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 } }