Files
arthur-os/shell/Ui/MultiSelect.qml
T
2026-06-02 11:17:13 -04:00

618 lines
22 KiB
QML

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
readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth)
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
}
BorderSurface {
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
readonly property var _borderSpec: Border.controlSpec(trigger._focused ? "focus" : (trigger._hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent)
borderSpec: _borderSpec
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: trigger.borderLeft + Style.spacing.controlPaddingX
anchors.rightMargin: trigger.borderRight + 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: trigger.borderRight + 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. 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
leftPadding: Border.left(root.popupBorderSpec) + Style.spacing.hairline
rightPadding: Border.right(root.popupBorderSpec) + Style.spacing.hairline
topPadding: Border.top(root.popupBorderSpec) + Style.spacing.hairline
bottomPadding: Border.bottom(root.popupBorderSpec) + 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: BorderSurface {
color: root.background
borderSpec: root.popupBorderSpec
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
}
}
}
BorderSurface {
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)
borderSpec: refreshHover.hovered
? Border.controlSpec("hover-cursor", root.foreground, root.accent)
: Border.controlSpec("normal", root.foreground, root.accent)
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
BorderSurface {
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"
borderSpec: selected
? Border.controlSpec("selected", root.foreground, root.accent)
: Border.controlSpec("normal", root.foreground, root.accent)
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)
}
}
}
}
}
}
}
}
}