Files
streamdeck-go/omarchy-plugin/KeyEditor.qml
Levi Woodard 2aa4c65e42 Add Omarchy shell plugin for the Stream Deck
QML bar widget + popout panel (dev.woodard.streamdeck) that drives
streamdeck-ctl: key grid shaped by the connected model, per-key editor,
brightness slider, and daemon controls. Themed via qs.Commons.

Makefile gains build-ctl, install-ctl, install-plugin, validate-plugin
and uninstall-plugin targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC
2026-09-27 18:21:33 -06:00

408 lines
13 KiB
QML

import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Editor for one key. It edits a local draft and only writes to config.yaml when
// Save is pressed, so a half-typed command never reaches the running deck.
//
// Inputs are deliberately *not* two-way bound — neither the text fields nor the
// dropdowns. Typing into a QML TextField (or Ui/Dropdown selecting internally)
// writes the property directly and destroys any declarative binding on it, which
// would leave Revert unable to repaint the control. Instead the draft is the
// single source of truth: edit handlers push user input into it, and
// syncFields()/paramsSynced() push the draft back out whenever it changes
// underneath the user. Controls with focus (or an open popup) are skipped so a
// background poll can never move the cursor mid-keystroke.
Item {
id: root
property var service: null
property var status: null
property int keyIndex: -1
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
signal closed()
signal saved()
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property var currentKey: {
if (!status || keyIndex < 0) return null
for (var i = 0; i < status.keys.length; i++) {
if (Number(status.keys[i].index) === keyIndex) return status.keys[i]
}
return null
}
readonly property var original: Model.editorDraft(currentKey)
readonly property bool isNew: currentKey === null
// The working copy.
property var draft: Model.editorDraft(null)
// `touched` is set by user edits rather than derived from draft-vs-original.
// A derived flag would be read by onCurrentKeyChanged while `original` was
// still evaluating, which Qt reports as a binding loop.
property bool touched: false
readonly property bool dirty: touched && Model.draftDiffers(draft, original)
readonly property bool usingModule: String(draft.module || "") !== ""
// The parameter Repeater's model. Rebuilt ONLY when the parameter name set
// changes (module/function switch), never on keystrokes or status polls —
// a model whose identity churned with `draft` or `status` would destroy and
// recreate the delegates, dropping focus after every typed character.
property var paramNames: []
// Fired when param VALUES in the draft changed underneath the delegates
// (reset, module switch). Delegates re-pull their value unless focused.
signal paramsSynced()
onKeyIndexChanged: reset()
// A background poll replaces the status object every few seconds. Adopt the
// refreshed key only when the user has nothing in flight.
//
// The adoption is deferred: `currentKey` is evaluated lazily, so the first
// read of `dirty` (via `original`) is what triggers this handler. Writing
// `draft` synchronously here would invalidate `dirty` while it is still being
// computed, which Qt reports as a binding loop. Qt.callLater moves the write
// to after the current evaluation pass.
onCurrentKeyChanged: if (!touched) Qt.callLater(adoptIfClean)
Component.onCompleted: reset()
// Escape closes the editor — it bubbles up here from whichever field has
// focus, since QQC2 TextField doesn't consume it.
Keys.onEscapePressed: closed()
// The shipped-panel idiom for inline editors: focus a real control the moment
// the editor appears (see network's password field). Landing focus on a plain
// Item would leave typed keys dead and Tab navigation stranded.
onVisibleChanged: if (visible) Qt.callLater(focusFirstField)
function focusFirstField() {
if (!visible) return
commandField.forceActiveFocus()
}
function adoptIfClean() {
if (!touched) reset()
}
function reset() {
draft = Model.editorDraft(currentKey)
touched = false
rebuildParams()
syncFields()
paramsSynced()
}
function rebuildParams() {
var rows = status ? Model.paramRows(status, draft.module, draft["function"], draft.params) : []
var names = rows.map(function(row) { return { name: row.name, placeholder: row.placeholder } })
// Identity guard: an unchanged name set must not touch the model, or the
// Repeater rebuilds delegates for nothing.
if (JSON.stringify(names) !== JSON.stringify(paramNames)) paramNames = names
}
// Push draft values into the controls. Focused fields and open popups are
// left alone so a sync landing mid-interaction cannot fight the user.
function syncFields() {
if (!commandField.activeFocus) commandField.text = draft.command
if (!labelField.activeFocus) labelField.text = draft.text
if (!colorField.activeFocus) colorField.text = draft.textColor
if (!moduleDropdown.popupOpen) moduleDropdown.value = draft.module
if (!functionDropdown.popupOpen) functionDropdown.value = draft["function"]
if (!iconDropdown.popupOpen) iconDropdown.value = draft.icon
}
function paramValueFor(name) {
var value = draft.params ? draft.params[name] : undefined
return (value === undefined || value === null) ? "" : String(value)
}
function setField(name, value) {
if (String(draft[name] || "") === String(value)) return
var next = Model.shallowCopy(draft)
next.params = Model.shallowCopy(draft.params)
next[name] = value
// Switching or clearing the module invalidates the function and its params.
if (name === "module") {
next["function"] = ""
next.params = {}
}
if (name === "function") next.params = {}
draft = next
touched = true
if (name === "module" || name === "function") {
rebuildParams()
paramsSynced()
}
syncFields()
}
function setParam(name, value) {
if (String(draft.params[name] || "") === String(value)) return
var next = Model.shallowCopy(draft)
next.params = Model.shallowCopy(draft.params)
next.params[name] = value
draft = next
touched = true
}
function save() {
if (!service) return
service.saveKey(keyIndex, Model.editArguments(draft, original))
touched = false
saved()
}
function removeKey() {
if (!service) return
service.clearKey(keyIndex)
touched = false
closed()
}
implicitHeight: column.implicitHeight
ColumnLayout {
id: column
anchors.left: parent.left
anchors.right: parent.right
spacing: Style.space(10)
// ── header ───────────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: (root.isNew ? "New key " : "Key ") + root.keyIndex
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
}
PanelActionButton {
iconText: "󰑓"
tooltipText: "Revert changes"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: root.dirty
opacity: enabled ? 1.0 : 0.35
onClicked: root.reset()
}
PanelActionButton {
iconText: "󰩹"
tooltipText: "Delete this key"
foreground: Color.urgent
fontFamily: root.fontFamily
enabled: !root.isNew
opacity: enabled ? 1.0 : 0.35
onClicked: root.removeKey()
}
PanelActionButton {
iconText: "󰅖"
tooltipText: "Close editor"
foreground: root.foreground
fontFamily: root.fontFamily
onClicked: root.closed()
}
}
// Parts of the key this editor doesn't cover are preserved on save (merge
// semantics), but say so rather than leaving them invisible.
Text {
Layout.fillWidth: true
visible: !!(root.currentKey && (root.currentKey.poll || root.currentKey.textCommand))
text: "This key also has " +
(root.currentKey && root.currentKey.poll ? "a poll block" : "live text") +
" — saved edits keep it, but edit it in config.yaml"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
// ── action: module function, or a plain shell command ────────────────────
FieldLabel {
text: "MODULE"
visible: root.status && root.status.modules.length > 0
}
Dropdown {
id: moduleDropdown
Layout.fillWidth: true
visible: root.status && root.status.modules.length > 0
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.moduleOptions(root.status) : []
onChanged: function(v) { root.setField("module", v) }
}
FieldLabel { text: "FUNCTION"; visible: root.usingModule }
Dropdown {
id: functionDropdown
Layout.fillWidth: true
visible: root.usingModule
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.functionOptions(root.status, root.draft.module) : []
onChanged: function(v) { root.setField("function", v) }
}
FieldLabel {
text: "PARAMETERS"
visible: root.usingModule && root.paramNames.length > 0
}
Repeater {
model: root.usingModule ? root.paramNames : []
RowLayout {
id: paramRow
required property var modelData
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.preferredWidth: Style.space(76)
text: paramRow.modelData.name
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
TextField {
id: paramField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: paramRow.modelData.placeholder
Component.onCompleted: text = root.paramValueFor(paramRow.modelData.name)
onTextEdited: root.setParam(paramRow.modelData.name, text)
}
Connections {
target: root
function onParamsSynced() {
if (!paramField.activeFocus) paramField.text = root.paramValueFor(paramRow.modelData.name)
}
}
}
}
FieldLabel { text: root.usingModule ? "COMMAND — OVERRIDES THE MODULE" : "COMMAND" }
TextField {
id: commandField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "shell command to run on press"
onTextEdited: root.setField("command", text)
}
PanelSeparator { Layout.fillWidth: true; foreground: root.foreground }
// ── appearance ───────────────────────────────────────────────────────────
FieldLabel { text: "ICON" }
Dropdown {
id: iconDropdown
Layout.fillWidth: true
showLabel: false
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
options: root.status ? Model.iconOptions(root.status) : []
onChanged: function(v) { root.setField("icon", v) }
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
ColumnLayout {
Layout.fillWidth: true
spacing: Style.spacing.labelGap
FieldLabel { text: "LABEL" }
TextField {
id: labelField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "text drawn on the key"
onTextEdited: root.setField("text", text)
}
}
ColumnLayout {
Layout.preferredWidth: Style.space(104)
spacing: Style.spacing.labelGap
FieldLabel { text: "COLOUR" }
TextField {
id: colorField
Layout.fillWidth: true
foreground: root.foreground
accent: root.accent
placeholderText: "white"
onTextEdited: root.setField("textColor", text)
}
}
}
// ── footer ───────────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Text {
Layout.fillWidth: true
text: root.dirty ? "Unsaved changes" : "Saved edits reload the deck immediately"
color: root.dirty ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
Button {
text: "Save"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
enabled: root.dirty
opacity: enabled ? 1.0 : 0.4
onClicked: root.save()
}
}
}
// FieldLabel is the small-caps heading above each control.
component FieldLabel: PanelSectionHeader {
Layout.fillWidth: true
foreground: root.foreground
fontFamily: root.fontFamily
}
}