Add MultiSelect
This commit is contained in:
@@ -0,0 +1,615 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls as QQC
|
||||||
|
import QtQuick.Window
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
// Searchable multi-select dropdown. Trigger shape matches Dropdown /
|
||||||
|
// SearchableDropdown; the popup shows a search field, an optional refresh
|
||||||
|
// button, and a checkbox list. Click rows to toggle. Use when callers
|
||||||
|
// need to pick zero or more items from a (possibly long, possibly
|
||||||
|
// dynamic) list.
|
||||||
|
//
|
||||||
|
// Options are either:
|
||||||
|
// - static `options`: string[] or [{ value, label, description? }]
|
||||||
|
// - dynamic `optionsCommand`: argv array. The command's stdout is
|
||||||
|
// parsed as JSON when it trims to start with `[`, otherwise as
|
||||||
|
// one option per non-empty newline. Re-runs whenever the popup opens
|
||||||
|
// and via the refresh button.
|
||||||
|
//
|
||||||
|
// `values` is the persisted selection — always an array of strings.
|
||||||
|
// Emits `changed(values)` whenever the selection mutates.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property var values: []
|
||||||
|
property var options: []
|
||||||
|
property var optionsCommand: []
|
||||||
|
property string optionsCommandCwd: ""
|
||||||
|
property string placeholderText: "Search..."
|
||||||
|
property string emptyText: "No options"
|
||||||
|
property string noSelectionText: "None selected"
|
||||||
|
property string triggerLabel: ""
|
||||||
|
property bool showLabel: true
|
||||||
|
|
||||||
|
property color foreground: Color.popups.text
|
||||||
|
property color background: Color.popups.background
|
||||||
|
property color popupBorder: Color.popups.border
|
||||||
|
property color accent: Color.accent
|
||||||
|
property string fontFamily: Style.font.family
|
||||||
|
property int rowHeight: Style.spacing.controlHeight
|
||||||
|
property int popupRowHeight: Style.spacing.popupRowHeight
|
||||||
|
property int popupMinHeight: Style.spacing.searchablePopupMinHeight
|
||||||
|
|
||||||
|
property bool hasCursor: false
|
||||||
|
|
||||||
|
readonly property bool popupOpen: popup.opened
|
||||||
|
function open() { popup.open() }
|
||||||
|
function close() { popup.close() }
|
||||||
|
function toggle() { popup.opened ? popup.close() : popup.open() }
|
||||||
|
|
||||||
|
signal changed(var values)
|
||||||
|
signal hovered(bool isHovered)
|
||||||
|
|
||||||
|
// Loaded options after merging static + dynamic. Always normalized
|
||||||
|
// into the [{ value, label, description }] shape for delegate use.
|
||||||
|
property var resolvedOptions: []
|
||||||
|
property bool loadingOptions: false
|
||||||
|
property string optionsError: ""
|
||||||
|
|
||||||
|
function normalizeOption(o) {
|
||||||
|
if (o && typeof o === "object") {
|
||||||
|
return {
|
||||||
|
value: String(o.value),
|
||||||
|
label: String(o.label !== undefined ? o.label : o.value),
|
||||||
|
description: o.description ? String(o.description) : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var s = String(o)
|
||||||
|
return { value: s, label: s, description: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// QML schema arrays sometimes arrive as JSValue lists that fail
|
||||||
|
// `Array.isArray`. arrayFrom accepts anything array-like (`.length`
|
||||||
|
// numeric) and returns a real JS array so the rest of the component can
|
||||||
|
// rely on standard array operations.
|
||||||
|
function arrayFrom(v) {
|
||||||
|
if (!v || typeof v.length !== "number" || typeof v === "string") return []
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < v.length; i++) out.push(v[i])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAll(arr) {
|
||||||
|
var out = []
|
||||||
|
var src = arrayFrom(arr)
|
||||||
|
var seen = ({})
|
||||||
|
for (var i = 0; i < src.length; i++) {
|
||||||
|
var n = normalizeOption(src[i])
|
||||||
|
if (!n.value) continue
|
||||||
|
if (seen[n.value]) continue
|
||||||
|
seen[n.value] = true
|
||||||
|
out.push(n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueSet() {
|
||||||
|
var set = ({})
|
||||||
|
var arr = arrayFrom(values)
|
||||||
|
for (var i = 0; i < arr.length; i++) set[String(arr[i])] = true
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(value) {
|
||||||
|
var set = valueSet()
|
||||||
|
return !!set[String(value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleValue(value) {
|
||||||
|
var v = String(value)
|
||||||
|
var arr = arrayFrom(values)
|
||||||
|
var idx = arr.indexOf(v)
|
||||||
|
if (idx === -1) arr.push(v)
|
||||||
|
else arr.splice(idx, 1)
|
||||||
|
root.values = arr
|
||||||
|
root.changed(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionLabel() {
|
||||||
|
var arr = arrayFrom(values)
|
||||||
|
if (arr.length === 0) return ""
|
||||||
|
var labels = []
|
||||||
|
var byValue = ({})
|
||||||
|
for (var i = 0; i < resolvedOptions.length; i++)
|
||||||
|
byValue[resolvedOptions[i].value] = resolvedOptions[i].label
|
||||||
|
for (var j = 0; j < arr.length; j++)
|
||||||
|
labels.push(byValue[String(arr[j])] || String(arr[j]))
|
||||||
|
if (labels.length <= 3) return labels.join(", ")
|
||||||
|
return arr.length + " selected"
|
||||||
|
}
|
||||||
|
|
||||||
|
property var filtered: resolvedOptions
|
||||||
|
function recomputeFiltered() {
|
||||||
|
var q = searchField.text.toLowerCase()
|
||||||
|
if (!q) { filtered = resolvedOptions; return }
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < resolvedOptions.length; i++) {
|
||||||
|
var o = resolvedOptions[i]
|
||||||
|
if (o.label.toLowerCase().indexOf(q) !== -1
|
||||||
|
|| o.description.toLowerCase().indexOf(q) !== -1
|
||||||
|
|| o.value.toLowerCase().indexOf(q) !== -1) out.push(o)
|
||||||
|
}
|
||||||
|
filtered = out
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildFromStatic() {
|
||||||
|
resolvedOptions = normalizeAll(options)
|
||||||
|
recomputeFiltered()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stdout from a dynamic optionsCommand into `{ options, error }`.
|
||||||
|
// Output starting with `[` is parsed strictly as JSON — a malformed array
|
||||||
|
// surfaces as an error rather than silently falling back to newline
|
||||||
|
// parsing, which would render the broken text as a literal option label.
|
||||||
|
// Output not starting with `[` is treated as one value per non-empty line.
|
||||||
|
function parseCommandOutput(text) {
|
||||||
|
var raw = String(text || "").trim()
|
||||||
|
if (raw === "") return { options: [], error: "" }
|
||||||
|
if (raw.charAt(0) === "[") {
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(raw)
|
||||||
|
return { options: parsed, error: "" }
|
||||||
|
} catch (e) {
|
||||||
|
return { options: [], error: "Options command emitted invalid JSON" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out = []
|
||||||
|
var lines = raw.split(/\r?\n/)
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i].trim()
|
||||||
|
if (line !== "") out.push(line)
|
||||||
|
}
|
||||||
|
return { options: out, error: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monotonic request id so stale stdout/exit signals from a previous
|
||||||
|
// refresh can't clobber the resolvedOptions of a newer refresh, and
|
||||||
|
// a runaway command can be detected after a timeout.
|
||||||
|
property int refreshSeq: 0
|
||||||
|
readonly property int refreshTimeoutMs: 6000
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
var cmd = arrayFrom(optionsCommand)
|
||||||
|
if (cmd.length === 0) {
|
||||||
|
rebuildFromStatic()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshSeq++
|
||||||
|
loadingOptions = true
|
||||||
|
optionsError = ""
|
||||||
|
optionsProcess.command = cmd
|
||||||
|
optionsProcess.workingDirectory = optionsCommandCwd
|
||||||
|
optionsProcess.running = false
|
||||||
|
optionsProcess.running = true
|
||||||
|
refreshTimeoutTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshTimeoutTimer
|
||||||
|
interval: root.refreshTimeoutMs
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
if (!root.loadingOptions) return
|
||||||
|
optionsProcess.running = false
|
||||||
|
root.loadingOptions = false
|
||||||
|
root.optionsError = "Options command timed out"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOptionsChanged: if (arrayFrom(optionsCommand).length === 0) rebuildFromStatic()
|
||||||
|
onOptionsCommandChanged: refresh()
|
||||||
|
Component.onCompleted: refresh()
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: optionsProcess
|
||||||
|
running: false
|
||||||
|
command: []
|
||||||
|
|
||||||
|
property int seq: 0
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
waitForEnd: true
|
||||||
|
onStreamFinished: {
|
||||||
|
if (optionsProcess.seq !== root.refreshSeq) return
|
||||||
|
var result = root.parseCommandOutput(text)
|
||||||
|
if (result.error) {
|
||||||
|
root.optionsError = result.error
|
||||||
|
root.resolvedOptions = root.normalizeAll(root.options)
|
||||||
|
} else {
|
||||||
|
var combined = root.arrayFrom(root.options)
|
||||||
|
for (var j = 0; j < result.options.length; j++) combined.push(result.options[j])
|
||||||
|
root.resolvedOptions = root.normalizeAll(combined)
|
||||||
|
}
|
||||||
|
root.recomputeFiltered()
|
||||||
|
root.loadingOptions = false
|
||||||
|
refreshTimeoutTimer.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRunningChanged: if (running) seq = root.refreshSeq
|
||||||
|
|
||||||
|
onExited: function(exitCode, exitStatus) {
|
||||||
|
if (seq !== root.refreshSeq) return
|
||||||
|
refreshTimeoutTimer.stop()
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.loadingOptions = false
|
||||||
|
root.optionsError = "Options command exited " + exitCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitWidth: Style.spacing.searchableDropdownWidth
|
||||||
|
implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Style.spacing.labelGap
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: root.showLabel && root.label !== ""
|
||||||
|
text: root.label
|
||||||
|
color: Qt.darker(root.foreground, 1.4)
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: trigger
|
||||||
|
width: parent.width
|
||||||
|
height: root.rowHeight
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
|
||||||
|
readonly property bool _focused: trigger.activeFocus
|
||||||
|
readonly property bool _hot: triggerHover.hovered || root.hasCursor
|
||||||
|
|
||||||
|
color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent)
|
||||||
|
border.color: Style.controlBorder(trigger._focused, trigger._hot, root.foreground, root.accent)
|
||||||
|
border.width: Style.controlBorderWidth(trigger._focused, trigger._hot)
|
||||||
|
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: triggerHover
|
||||||
|
onHoveredChanged: root.hovered(hovered)
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onPressed: function(event) {
|
||||||
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|
||||||
|
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
|
||||||
|
popup.opened ? popup.close() : popup.open()
|
||||||
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Escape && popup.opened) {
|
||||||
|
popup.close(); event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: chevron.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: Style.spacing.controlPaddingX
|
||||||
|
anchors.rightMargin: Style.spacing.md
|
||||||
|
text: root.selectionLabel() || root.triggerLabel || root.noSelectionText
|
||||||
|
color: root.selectionLabel() ? root.foreground : Qt.darker(root.foreground, 1.5)
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: chevron
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.rightMargin: Style.spacing.controlGap
|
||||||
|
text: ""
|
||||||
|
color: Qt.darker(root.foreground, 1.2)
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
trigger.forceActiveFocus()
|
||||||
|
popup.opened ? popup.close() : popup.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QQC.Popup {
|
||||||
|
id: popup
|
||||||
|
// Reparent to the window's content item so the popup is free of any
|
||||||
|
// clipping ancestor (e.g. the bar settings dialog Flickable). Position
|
||||||
|
// and available height are recomputed on open and any time the
|
||||||
|
// trigger's geometry changes, since a binding on mapToItem alone
|
||||||
|
// won't reliably re-evaluate when ancestors scroll or resize.
|
||||||
|
parent: trigger.Window.window ? trigger.Window.window.contentItem : trigger
|
||||||
|
property real _anchorX: 0
|
||||||
|
property real _anchorY: 0
|
||||||
|
property real _availableBelow: 0
|
||||||
|
readonly property real _windowHeight: parent ? parent.height : 0
|
||||||
|
readonly property real _idealContent: resultList.contentHeight + Style.space(50)
|
||||||
|
readonly property real _maxRowsHeight: root.popupRowHeight * 6 + 5 * Style.spacing.labelGap + Style.space(50)
|
||||||
|
|
||||||
|
function reposition() {
|
||||||
|
if (!parent) return
|
||||||
|
var p = trigger.mapToItem(parent, 0, trigger.height + Style.spacing.xxs)
|
||||||
|
_anchorX = p.x
|
||||||
|
_anchorY = p.y
|
||||||
|
_availableBelow = Math.max(0, _windowHeight - _anchorY - Style.space(12))
|
||||||
|
}
|
||||||
|
|
||||||
|
x: _anchorX
|
||||||
|
y: _anchorY
|
||||||
|
width: trigger.width
|
||||||
|
// Clamp to whatever fits below the trigger; don't force popupMinHeight
|
||||||
|
// when there isn't room, otherwise the popup overflows the window.
|
||||||
|
implicitHeight: Math.min(_availableBelow, _idealContent, _maxRowsHeight)
|
||||||
|
padding: Style.spacing.hairline
|
||||||
|
focus: true
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: trigger
|
||||||
|
function onXChanged() { popup.reposition() }
|
||||||
|
function onYChanged() { popup.reposition() }
|
||||||
|
function onWidthChanged() { popup.reposition() }
|
||||||
|
function onHeightChanged() { popup.reposition() }
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: root.background
|
||||||
|
border.color: root.popupBorder
|
||||||
|
border.width: Style.normalBorderWidth
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: {
|
||||||
|
reposition()
|
||||||
|
searchField.text = ""
|
||||||
|
root.refresh()
|
||||||
|
root.recomputeFiltered()
|
||||||
|
Qt.callLater(function() { searchField.forceActiveFocus() })
|
||||||
|
}
|
||||||
|
onClosed: searchField.text = ""
|
||||||
|
|
||||||
|
contentItem: Column {
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: searchHeader
|
||||||
|
width: parent.width
|
||||||
|
height: root.popupRowHeight + Style.spacing.controlPaddingX
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Style.spacing.md
|
||||||
|
spacing: Style.spacing.rowGap
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: searchField
|
||||||
|
width: parent.width - refreshButton.width - parent.spacing
|
||||||
|
height: parent.height
|
||||||
|
placeholderText: root.placeholderText
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
|
||||||
|
onTextChanged: {
|
||||||
|
root.recomputeFiltered()
|
||||||
|
if (resultList.count > 0) resultList.currentIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onPressed: function(event) {
|
||||||
|
if (event.key === Qt.Key_Escape) {
|
||||||
|
popup.close(); event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Down) {
|
||||||
|
if (resultList.count > 0) {
|
||||||
|
resultList.currentIndex = 0
|
||||||
|
resultList.forceActiveFocus()
|
||||||
|
}
|
||||||
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||||
|
if (resultList.count > 0) {
|
||||||
|
resultList.currentIndex = 0
|
||||||
|
resultList.toggleCurrent()
|
||||||
|
}
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: refreshButton
|
||||||
|
visible: root.arrayFrom(root.optionsCommand).length > 0
|
||||||
|
enabled: !root.loadingOptions
|
||||||
|
width: parent.height
|
||||||
|
height: parent.height
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
color: refreshHover.hovered
|
||||||
|
? Style.hoverFillFor(root.foreground, root.accent)
|
||||||
|
: Style.normalFillFor(root.foreground, root.accent)
|
||||||
|
border.color: refreshHover.hovered
|
||||||
|
? Style.hoverBorderFor(root.foreground, root.accent)
|
||||||
|
: Style.normalBorderFor(root.foreground, root.accent)
|
||||||
|
border.width: refreshHover.hovered ? Style.hoverBorderWidth : Style.normalBorderWidth
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.loadingOptions ? "" : ""
|
||||||
|
color: root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
|
||||||
|
RotationAnimator on rotation {
|
||||||
|
running: root.loadingOptions
|
||||||
|
from: 0; to: 360
|
||||||
|
duration: 800
|
||||||
|
loops: Animation.Infinite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler { id: refreshHover }
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Util.alpha(root.foreground, 0.10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: popup.height - searchHeader.height - Style.spacing.xxs - 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: resultList.count === 0
|
||||||
|
text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText)
|
||||||
|
color: Qt.darker(root.foreground, 1.6)
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: resultList
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Style.spacing.labelGap
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
model: root.filtered
|
||||||
|
currentIndex: -1
|
||||||
|
keyNavigationEnabled: false
|
||||||
|
|
||||||
|
function toggleCurrent() {
|
||||||
|
if (currentIndex < 0 || currentIndex >= root.filtered.length) return
|
||||||
|
root.toggleValue(root.filtered[currentIndex].value)
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.priority: Keys.BeforeItem
|
||||||
|
Keys.onPressed: function(event) {
|
||||||
|
if (event.key === Qt.Key_Escape) {
|
||||||
|
popup.close(); event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Down || event.text === "j") {
|
||||||
|
if (resultList.currentIndex >= resultList.count - 1) {
|
||||||
|
event.accepted = true; return
|
||||||
|
}
|
||||||
|
resultList.currentIndex = resultList.currentIndex + 1
|
||||||
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Up || event.text === "k") {
|
||||||
|
if (resultList.currentIndex <= 0) {
|
||||||
|
searchField.forceActiveFocus()
|
||||||
|
event.accepted = true; return
|
||||||
|
}
|
||||||
|
resultList.currentIndex = resultList.currentIndex - 1
|
||||||
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|
||||||
|
|| event.key === Qt.Key_Space) {
|
||||||
|
resultList.toggleCurrent(); event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool selected: root.isSelected(modelData.value)
|
||||||
|
|
||||||
|
width: resultList.width
|
||||||
|
height: Math.max(root.popupRowHeight, rowContent.implicitHeight + Style.spacing.rowPaddingX)
|
||||||
|
color: index === resultList.currentIndex
|
||||||
|
? Style.hoverFillFor(root.foreground, root.accent)
|
||||||
|
: "transparent"
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: rowContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: Style.spacing.controlPaddingX
|
||||||
|
anchors.rightMargin: Style.spacing.controlPaddingX
|
||||||
|
spacing: Style.spacing.rowGap
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: checkbox
|
||||||
|
width: Style.space(16)
|
||||||
|
height: Style.space(16)
|
||||||
|
radius: Math.max(2, Style.cornerRadius / 2)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: selected ? Style.selectedFillFor(root.foreground, root.accent) : "transparent"
|
||||||
|
border.color: selected
|
||||||
|
? Style.selectedBorderFor(root.foreground, root.accent)
|
||||||
|
: Style.normalBorderFor(root.foreground, root.accent)
|
||||||
|
border.width: selected ? Style.selectedBorderWidth : Style.normalBorderWidth
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: selected
|
||||||
|
text: "✓"
|
||||||
|
color: Style.selectedStateColor(root.foreground, root.accent)
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Math.round(checkbox.height * 0.85)
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width - checkbox.width - parent.spacing
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.spacing.xxs
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: modelData.label
|
||||||
|
color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
elide: Text.ElideRight
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: text !== ""
|
||||||
|
text: modelData.description
|
||||||
|
color: Qt.darker(root.foreground, 1.5)
|
||||||
|
font.family: root.fontFamily
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
elide: Text.ElideRight
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPositionChanged: resultList.currentIndex = parent.index
|
||||||
|
onClicked: root.toggleValue(modelData.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ ButtonGroup 1.0 ButtonGroup.qml
|
|||||||
CursorSurface 1.0 CursorSurface.qml
|
CursorSurface 1.0 CursorSurface.qml
|
||||||
Dropdown 1.0 Dropdown.qml
|
Dropdown 1.0 Dropdown.qml
|
||||||
KeyboardPanel 1.0 KeyboardPanel.qml
|
KeyboardPanel 1.0 KeyboardPanel.qml
|
||||||
|
MultiSelect 1.0 MultiSelect.qml
|
||||||
NumberField 1.0 NumberField.qml
|
NumberField 1.0 NumberField.qml
|
||||||
Panel 1.0 Panel.qml
|
Panel 1.0 Panel.qml
|
||||||
PanelActionButton 1.0 PanelActionButton.qml
|
PanelActionButton 1.0 PanelActionButton.qml
|
||||||
|
|||||||
@@ -327,12 +327,18 @@ Item {
|
|||||||
allowMultiple: meta.allowMultiple === true,
|
allowMultiple: meta.allowMultiple === true,
|
||||||
settingsForm: meta.settingsForm || "",
|
settingsForm: meta.settingsForm || "",
|
||||||
schema: Array.isArray(meta.schema) ? meta.schema : [],
|
schema: Array.isArray(meta.schema) ? meta.schema : [],
|
||||||
|
sourceDir: manifest.__sourceDir || "",
|
||||||
source: "plugin"
|
source: "plugin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function widgetSourceDir(id) {
|
||||||
|
var meta = widgetMetadata(id)
|
||||||
|
return meta && meta.sourceDir ? String(meta.sourceDir) : ""
|
||||||
|
}
|
||||||
|
|
||||||
function widgetName(id) {
|
function widgetName(id) {
|
||||||
var rev = catalogRevision
|
var rev = catalogRevision
|
||||||
var meta = widgetMetadata(id)
|
var meta = widgetMetadata(id)
|
||||||
@@ -1133,6 +1139,7 @@ Item {
|
|||||||
id: dynamicSettingsComponent
|
id: dynamicSettingsComponent
|
||||||
Cmp.DynamicSettingsForm {
|
Cmp.DynamicSettingsForm {
|
||||||
schema: root.widgetSchema(entry.id || "")
|
schema: root.widgetSchema(entry.id || "")
|
||||||
|
pluginSourceDir: root.widgetSourceDir(entry.id || "")
|
||||||
foreground: root.foreground
|
foreground: root.foreground
|
||||||
fontFamily: root.fontFamily
|
fontFamily: root.fontFamily
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ Column {
|
|||||||
property color foreground: Color.popups.text
|
property color foreground: Color.popups.text
|
||||||
property color accent: Color.accent
|
property color accent: Color.accent
|
||||||
property string fontFamily: Style.font.family
|
property string fontFamily: Style.font.family
|
||||||
|
// Resolved on disk path of the plugin that owns this form, used to
|
||||||
|
// resolve relative argv entries in a multiselect's `optionsCommand`.
|
||||||
|
property string pluginSourceDir: ""
|
||||||
|
|
||||||
spacing: Style.spacing.xl
|
spacing: Style.spacing.xl
|
||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
@@ -26,10 +29,47 @@ Column {
|
|||||||
case "integer":
|
case "integer":
|
||||||
case "number": return field.min !== undefined ? field.min : 0
|
case "number": return field.min !== undefined ? field.min : 0
|
||||||
case "enum": return field.options && field.options.length > 0 ? field.options[0] : ""
|
case "enum": return field.options && field.options.length > 0 ? field.options[0] : ""
|
||||||
|
case "multiselect": return []
|
||||||
default: return ""
|
default: return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a multiselect's optionsCommand argv against the plugin's source
|
||||||
|
// directory. The first entry is treated as a path relative to the plugin
|
||||||
|
// dir unless it is absolute or begins with ".". Subsequent entries pass
|
||||||
|
// through unchanged.
|
||||||
|
// Resolve a multiselect's optionsCommand argv against the plugin's source
|
||||||
|
// directory. The first argv entry must be a relative path inside the
|
||||||
|
// plugin dir (no leading `/`, no `..` segments, no empty segments);
|
||||||
|
// anything else is rejected and the field falls back to whatever static
|
||||||
|
// `options` it has. Subsequent argv entries pass through unchanged. We
|
||||||
|
// avoid `Array.isArray` because schema-supplied arrays sometimes arrive
|
||||||
|
// as QML JSValue lists that don't satisfy it; iterating by `.length`
|
||||||
|
// works for both real arrays and JSValue lists.
|
||||||
|
function resolveOptionsCommand(field) {
|
||||||
|
var oc = field ? field.optionsCommand : undefined
|
||||||
|
if (!oc || typeof oc.length !== "number" || oc.length === 0) return []
|
||||||
|
var argv = []
|
||||||
|
for (var i = 0; i < oc.length; i++) argv.push(String(oc[i]))
|
||||||
|
if (!pluginSourceDir) return []
|
||||||
|
var head = argv[0]
|
||||||
|
if (head.length === 0 || head.charAt(0) === "/") {
|
||||||
|
console.warn("DynamicSettingsForm: optionsCommand must be a path relative to the plugin dir, got: " + head)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
var rel = head.replace(/^\.\//, "")
|
||||||
|
var segments = rel.split("/")
|
||||||
|
for (var s = 0; s < segments.length; s++) {
|
||||||
|
if (segments[s] === ".." || segments[s] === "") {
|
||||||
|
console.warn("DynamicSettingsForm: optionsCommand may not contain '..' or empty segments: " + head)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var dir = pluginSourceDir.replace(/\/$/, "")
|
||||||
|
argv[0] = dir + "/" + rel
|
||||||
|
return argv
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.schema
|
model: root.schema
|
||||||
|
|
||||||
@@ -68,6 +108,7 @@ Column {
|
|||||||
case "enum": return enumField
|
case "enum": return enumField
|
||||||
case "integer": return integerField
|
case "integer": return integerField
|
||||||
case "number": return numberField
|
case "number": return numberField
|
||||||
|
case "multiselect": return multiselectField
|
||||||
default: return stringField
|
default: return stringField
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +209,36 @@ Column {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: multiselectField
|
||||||
|
|
||||||
|
Ui.MultiSelect {
|
||||||
|
property string fieldKey: ""
|
||||||
|
property var field: ({})
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
foreground: root.foreground
|
||||||
|
accent: root.accent
|
||||||
|
fontFamily: root.fontFamily
|
||||||
|
showLabel: false
|
||||||
|
// MultiSelect's arrayFrom() tolerates JSValue-style schema arrays, so
|
||||||
|
// we can hand it `field.options` directly without an Array.isArray
|
||||||
|
// guard that would silently drop JSValue lists.
|
||||||
|
options: field ? (field.options || []) : []
|
||||||
|
optionsCommand: root.resolveOptionsCommand(field)
|
||||||
|
optionsCommandCwd: root.pluginSourceDir
|
||||||
|
placeholderText: field && field.placeholderText ? String(field.placeholderText) : "Search..."
|
||||||
|
emptyText: field && field.emptyText ? String(field.emptyText) : "No options"
|
||||||
|
noSelectionText: field && field.noSelectionText ? String(field.noSelectionText) : "None selected"
|
||||||
|
values: {
|
||||||
|
var v = root.currentValue(field)
|
||||||
|
return v ? v : []
|
||||||
|
}
|
||||||
|
|
||||||
|
onChanged: function(arr) { if (fieldKey) root.fieldChanged(fieldKey, arr) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
id: numberField
|
id: numberField
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -520,8 +520,6 @@ ShellRoot {
|
|||||||
console.warn("Plugin " + manifest.id + " has no barWidget entry point")
|
console.warn("Plugin " + manifest.id + " has no barWidget entry point")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (existing && existing.url === url && shell.barWidgetRegistry.has(registryKey)) continue
|
|
||||||
|
|
||||||
var meta = manifest.barWidget || {}
|
var meta = manifest.barWidget || {}
|
||||||
meta = {
|
meta = {
|
||||||
displayName: meta.displayName || manifest.name,
|
displayName: meta.displayName || manifest.name,
|
||||||
@@ -531,9 +529,19 @@ ShellRoot {
|
|||||||
defaults: meta.defaults || {},
|
defaults: meta.defaults || {},
|
||||||
schema: meta.schema || [],
|
schema: meta.schema || [],
|
||||||
pluginId: manifest.id,
|
pluginId: manifest.id,
|
||||||
|
sourceDir: manifest.__sourceDir || "",
|
||||||
source: "plugin"
|
source: "plugin"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the component URL is unchanged, just refresh the metadata in
|
||||||
|
// place. We can't skip this even when the URL matches: manifests can
|
||||||
|
// change schema, defaults, or sourceDir between rescans, and the
|
||||||
|
// settings panel reads metadata from the registry.
|
||||||
|
if (existing && existing.url === url && shell.barWidgetRegistry.has(registryKey)) {
|
||||||
|
shell.barWidgetRegistry.register(registryKey, existing.component, meta)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
loadPluginWidget(registryKey, url, meta)
|
loadPluginWidget(registryKey, url, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user