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
This commit is contained in:
407
omarchy-plugin/KeyEditor.qml
Normal file
407
omarchy-plugin/KeyEditor.qml
Normal file
@@ -0,0 +1,407 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user