From 2aa4c65e4226ac69497701cafd4c8e50ccb9a17c Mon Sep 17 00:00:00 2001 From: Levi Woodard Date: Sun, 27 Sep 2026 18:21:33 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC --- Makefile | 54 +++- OMARCHY.md | 14 + omarchy-plugin/KeyEditor.qml | 407 ++++++++++++++++++++++++ omarchy-plugin/KeyGrid.qml | 200 ++++++++++++ omarchy-plugin/LICENSE | 21 ++ omarchy-plugin/Model.js | 310 ++++++++++++++++++ omarchy-plugin/Panel.qml | 501 ++++++++++++++++++++++++++++++ omarchy-plugin/README.md | 169 ++++++++++ omarchy-plugin/Service.qml | 311 +++++++++++++++++++ omarchy-plugin/StreamDeckIcon.qml | 52 ++++ omarchy-plugin/manifest.json | 59 ++++ 11 files changed, 2097 insertions(+), 1 deletion(-) create mode 100644 omarchy-plugin/KeyEditor.qml create mode 100644 omarchy-plugin/KeyGrid.qml create mode 100644 omarchy-plugin/LICENSE create mode 100644 omarchy-plugin/Model.js create mode 100644 omarchy-plugin/Panel.qml create mode 100644 omarchy-plugin/README.md create mode 100644 omarchy-plugin/Service.qml create mode 100644 omarchy-plugin/StreamDeckIcon.qml create mode 100644 omarchy-plugin/manifest.json diff --git a/Makefile b/Makefile index 828a685..da966a1 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,16 @@ BINARY := streamdeck-go HELPER := streamdeck-helper INIT := streamdeck-init +CTL := streamdeck-ctl PREFIX ?= $(HOME)/.local CONFIG_DIR := $(HOME)/.config/streamdeck-go GROUP := streamdeck +# ── Omarchy shell plugin ────────────────────────────────────────────────────── +PLUGIN_ID := dev.woodard.streamdeck +PLUGIN_SRC := $(CURDIR)/omarchy-plugin +PLUGIN_DIR := $(HOME)/.config/omarchy/plugins/$(PLUGIN_ID) + # ── OS detection ────────────────────────────────────────────────────────────── OS := $(shell uname -s) @@ -24,7 +30,9 @@ else UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules endif -.PHONY: build build-helper build-init install install-helper install-watchdog reinstall uninstall uninstall-helper uninstall-watchdog udev +.PHONY: build build-helper build-init build-ctl install install-ctl install-helper install-watchdog \ + install-plugin uninstall-plugin validate-plugin reinstall uninstall uninstall-helper \ + uninstall-watchdog udev # ── Build ───────────────────────────────────────────────────────────────────── @@ -37,6 +45,9 @@ build-helper: build-init: go build -o $(INIT) ./cmd/streamdeck-init/ +build-ctl: + go build -o $(CTL) ./cmd/streamdeck-ctl/ + # ── Install ─────────────────────────────────────────────────────────────────── # Interactive install — prompts for dotfiles directory, installs binary + service. @@ -223,6 +234,47 @@ reinstall: build fi endif +# ── streamdeck-ctl + Omarchy shell plugin ───────────────────────────────────── + +# The CLI the Omarchy plugin drives. Also useful on its own for scripting. +install-ctl: build-ctl + mkdir -p $(BIN_DIR) + install -m 755 $(CTL) $(BIN_DIR)/$(CTL) + @echo "Installed $(BIN_DIR)/$(CTL)" + +# Symlink rather than copy so edits to the QML are live after a shell rescan. +install-plugin: install-ctl + @if [ ! -d "$(HOME)/.config/omarchy" ]; then \ + echo "Omarchy shell config not found at ~/.config/omarchy — is Omarchy installed?"; \ + exit 1; \ + fi + mkdir -p $(HOME)/.config/omarchy/plugins + @if [ -e "$(PLUGIN_DIR)" ] && [ ! -L "$(PLUGIN_DIR)" ]; then \ + echo "$(PLUGIN_DIR) exists and is not a symlink — remove it first."; \ + exit 1; \ + fi + ln -sfn "$(PLUGIN_SRC)" "$(PLUGIN_DIR)" + # Validate the source tree, not the link: `omarchy plugin validate` refuses a + # symlinked plugin folder by design, but the shell resolves it happily. + @omarchy plugin validate "$(PLUGIN_SRC)" || true + @omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true + @echo "" + @echo "Plugin linked: $(PLUGIN_DIR) -> $(PLUGIN_SRC)" + @echo "Enable it with: omarchy plugin enable $(PLUGIN_ID) right" + +validate-plugin: + omarchy plugin validate "$(PLUGIN_SRC)" + @command -v qmllint >/dev/null 2>&1 && \ + qmllint -I $${OMARCHY_PATH:-/usr/share/omarchy}/shell "$(PLUGIN_SRC)"/*.qml || \ + echo "qmllint not installed — skipped QML lint" + +uninstall-plugin: + omarchy plugin disable $(PLUGIN_ID) 2>/dev/null || true + rm -f "$(PLUGIN_DIR)" + rm -f $(BIN_DIR)/$(CTL) + @omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true + @echo "Plugin unlinked and $(CTL) removed." + # ── Uninstall ───────────────────────────────────────────────────────────────── ifeq ($(OS),Darwin) diff --git a/OMARCHY.md b/OMARCHY.md index 838263c..9cea539 100644 --- a/OMARCHY.md +++ b/OMARCHY.md @@ -6,6 +6,20 @@ privileged helper — see [README § Privileged commands](README.md)). --- +## Shell plugin + +Beyond the key commands below, streamdeck-go ships an Omarchy **bar widget** that +puts deck status, brightness, and inline key editing in the bar itself: + +```bash +make install-plugin +omarchy plugin enable dev.woodard.streamdeck right +``` + +See [omarchy-plugin/README.md](omarchy-plugin/README.md). + +--- + ## Terminal (Ghostty) ```yaml diff --git a/omarchy-plugin/KeyEditor.qml b/omarchy-plugin/KeyEditor.qml new file mode 100644 index 0000000..d1901a2 --- /dev/null +++ b/omarchy-plugin/KeyEditor.qml @@ -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 + } +} diff --git a/omarchy-plugin/KeyGrid.qml b/omarchy-plugin/KeyGrid.qml new file mode 100644 index 0000000..376401f --- /dev/null +++ b/omarchy-plugin/KeyGrid.qml @@ -0,0 +1,200 @@ +import QtQuick +import qs.Commons +import "Model.js" as Model + +// The deck laid out as it sits on the desk. Grid shape comes from the device +// report (8x4 on an XL, 5x3 on an MK.2), so this is not hardcoded to one model. +Item { + id: root + + property var slots: [] + property int cols: 8 + property int rows: 4 + property int cursorIndex: -1 + property bool cursorActive: false + property color foreground: Color.foreground + property color accent: Color.accent + property string fontFamily: Style.font.family + // Icons are previewed from disk when the file is a raster format; SVG and GIF + // fall back to the kind glyph rather than risking a slow or failed decode in + // the panel's paint path. + property bool showIconPreviews: true + + signal keyActivated(int index) + signal keyFocused(int index) + signal keySecondary(int index) + + // Themes are not tagged light/dark, so derive it from the panel background's + // perceived luminance (Rec. 601 weights). + readonly property color panelBackground: Color.popups.background + readonly property bool lightTheme: (0.299 * panelBackground.r + + 0.587 * panelBackground.g + + 0.114 * panelBackground.b) > 0.5 + // Stands in for the deck's physical key face. + readonly property color keyFace: Qt.rgba(0.08, 0.08, 0.08, 1.0) + + readonly property real spacing: Style.space(4) + readonly property real cellWidth: cols > 0 ? (width - spacing * (cols - 1)) / cols : 0 + // Stream Deck keys are square; keeping the cells square makes the panel read + // as the physical device rather than an abstract table. + readonly property real cellHeight: cellWidth + + implicitHeight: rows > 0 ? cellHeight * rows + spacing * (rows - 1) : 0 + + Repeater { + // Modelled on the slot COUNT, not the slots array: `slots` gets a fresh + // identity on every status poll, and a Repeater over a JS array rebuilds + // every delegate on identity change — 32 cells of object churn and a frame + // of Image flicker per poll. With an int model the delegates persist and + // only the bindings that actually changed re-evaluate. + model: root.cols * root.rows + + Item { + id: cell + required property int index + + readonly property var slot: (index < root.slots.length) + ? root.slots[index] + : ({ index: index, configured: false, kind: "empty", label: "" }) + + readonly property int keyIndex: Number(slot.index) + readonly property bool configured: slot.configured === true + readonly property bool hasCursor: root.cursorActive && root.cursorIndex === keyIndex + readonly property bool hot: mouse.containsMouse || hasCursor + + x: (index % root.cols) * (root.cellWidth + root.spacing) + y: Math.floor(index / root.cols) * (root.cellHeight + root.spacing) + width: root.cellWidth + height: root.cellHeight + + Rectangle { + id: face + anchors.fill: parent + radius: Style.cornerRadius > 0 ? Style.space(3) : 0 + + // Every colour here resolves through Style/Color, so the grid repaints + // itself when the Omarchy theme changes. + color: cell.hot ? Style.hoverFillFor(root.foreground, root.accent) + : cell.configured ? Style.normalFillFor(root.foreground, root.accent) + : "transparent" + + border.width: cell.hot ? Style.hoverBorderWidth : Style.normalBorderWidth + border.color: cell.hot ? Style.hoverBorderFor(root.foreground, root.accent) + : Util.alpha(root.foreground, cell.configured ? 0.28 : 0.12) + + Behavior on color { ColorAnimation { duration: 90 } } + } + + // Deck icons are usually light artwork on transparency, drawn for the + // hardware's black key faces — on a light theme they would wash out. Under + // a light theme only, back the preview with a dark plate standing in for + // the physical key. Dark themes need no plate and get none. + Rectangle { + anchors.centerIn: preview + width: preview.width * 1.12 + height: preview.height * 1.12 + radius: Style.cornerRadius > 0 ? Style.space(2) : 0 + visible: preview.visible && root.lightTheme + color: root.keyFace + opacity: 0.9 + } + + // Icon preview, when the key has one and it is a format Image decodes + // cheaply. Falls back to the kind glyph below. + Image { + id: preview + anchors.centerIn: parent + // Lifted off centre so the artwork clears the label strip along the + // bottom edge of the cell. + anchors.verticalCenterOffset: -Style.space(4) + width: parent.width * 0.46 + height: width + visible: root.showIconPreviews && status === Image.Ready + fillMode: Image.PreserveAspectFit + smooth: true + asynchronous: true + cache: true + sourceSize.width: Math.max(24, Math.round(width)) + sourceSize.height: Math.max(24, Math.round(width)) + source: { + if (!root.showIconPreviews || !cell.configured) return "" + var path = String(cell.slot.iconPath || "") + if (path === "") return "" + var lower = path.toLowerCase() + if (!lower.endsWith(".png") && !lower.endsWith(".jpg") && !lower.endsWith(".jpeg")) return "" + return Util.fileUrl(path) + } + opacity: cell.configured ? 1.0 : 0.4 + } + + Text { + anchors.centerIn: parent + anchors.verticalCenterOffset: -Style.space(4) + visible: cell.configured && !preview.visible + text: Model.kindGlyph(String(cell.slot.kind || "")) + color: root.foreground + opacity: 0.75 + font.family: root.fontFamily + font.pixelSize: Math.max(Style.font.caption, cell.height * 0.34) + } + + // Slot number, always visible so the grid maps onto config.yaml indices. + Text { + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Style.space(2) + anchors.leftMargin: Style.space(3) + text: cell.keyIndex + color: root.foreground + opacity: cell.configured ? 0.45 : 0.28 + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottomMargin: Style.space(2) + anchors.leftMargin: Style.space(2) + anchors.rightMargin: Style.space(2) + visible: cell.configured && Model.gridLabel(cell.slot) !== "" + text: Model.gridLabel(cell.slot) + color: root.foreground + opacity: 0.8 + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + // A privileged key is worth flagging: pressing it from here is refused by + // the CLI, and it behaves differently on the deck itself. + Rectangle { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: Style.space(3) + anchors.rightMargin: Style.space(3) + visible: cell.slot.privileged === true + width: Style.space(4) + height: width + radius: width / 2 + color: Color.urgent + opacity: 0.85 + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onEntered: root.keyFocused(cell.keyIndex) + onClicked: function(event) { + if (event.button === Qt.RightButton) root.keySecondary(cell.keyIndex) + else root.keyActivated(cell.keyIndex) + } + } + } + } +} diff --git a/omarchy-plugin/LICENSE b/omarchy-plugin/LICENSE new file mode 100644 index 0000000..84b3a3c --- /dev/null +++ b/omarchy-plugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Levi Woodard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/omarchy-plugin/Model.js b/omarchy-plugin/Model.js new file mode 100644 index 0000000..546edd9 --- /dev/null +++ b/omarchy-plugin/Model.js @@ -0,0 +1,310 @@ +.pragma library + +// Pure helpers for the Stream Deck panel. Kept out of the QML so the parsing and +// formatting rules can be reasoned about (and changed) without touching layout. + +var EMPTY_STATUS = { + ok: false, + configPath: "", + modulesPath: "", + iconsDir: "", + brightness: 0, + configError: "", + daemon: { unit: "", active: false, enabled: false, state: "unknown", sub: "", sinceSec: 0 }, + device: { connected: false, known: false, model: "", keyCount: 0, cols: 0, rows: 0 }, + keys: [], + icons: [], + modules: [], + warnings: [] +} + +// parseStatus turns streamdeck-ctl's stdout into a status object, filling in +// anything the CLI omitted so bindings never dereference undefined. +function parseStatus(raw) { + var parsed + try { + parsed = JSON.parse(String(raw || "")) + } catch (e) { + return { ok: false, error: "Could not parse streamdeck-ctl output" } + } + if (!parsed || typeof parsed !== "object") { + return { ok: false, error: "Unexpected streamdeck-ctl output" } + } + + var status = {} + for (var field in EMPTY_STATUS) status[field] = EMPTY_STATUS[field] + for (var key in parsed) status[key] = parsed[key] + + status.daemon = Object.assign({}, EMPTY_STATUS.daemon, parsed.daemon || {}) + status.device = Object.assign({}, EMPTY_STATUS.device, parsed.device || {}) + status.keys = parsed.keys || [] + status.icons = parsed.icons || [] + status.modules = parsed.modules || [] + status.warnings = parsed.warnings || [] + return status +} + +// keySlots expands the sparse key list from the CLI into one entry per physical +// slot, so the grid can render empty positions without the view doing lookups. +function keySlots(status) { + var cols = Number(status.device.cols) || 8 + var rows = Number(status.device.rows) || 4 + var count = Number(status.device.keyCount) || (cols * rows) + + var configured = {} + for (var i = 0; i < status.keys.length; i++) { + configured[Number(status.keys[i].index)] = status.keys[i] + } + + var slots = [] + for (var index = 0; index < count; index++) { + if (configured[index]) { + // Copy rather than annotate — this library's helpers must not mutate the + // status object they were handed. + slots.push(Object.assign({ configured: true }, configured[index])) + } else { + slots.push({ index: index, configured: false, kind: "empty", label: "" }) + } + } + return slots +} + +// statusLine is the one-line summary under the panel title. +function statusLine(status) { + if (status.configError) return "Config error" + if (!status.device.connected) return status.daemon.active ? "Waiting for deck" : "Deck disconnected" + if (!status.daemon.active) return "Daemon stopped" + var used = countConfigured(status) + var total = Number(status.device.keyCount) || 0 + return used + " of " + total + " keys in use" +} + +function countConfigured(status) { + // Count only keys the connected device can actually show, so an XL config + // against an MK.2 can't read "17 of 15 keys in use". + var limit = Number(status.device.keyCount) || Infinity + var used = 0 + for (var i = 0; i < status.keys.length; i++) { + if (status.keys[i].kind !== "empty" && Number(status.keys[i].index) < limit) used++ + } + return used +} + +// deviceLine names the hardware, falling back to the raw USB IDs when the +// product ID isn't in the daemon's supported-models table. +function deviceLine(status) { + if (status.device.known && status.device.model) return status.device.model + if (status.device.productId) return "Unknown (" + status.device.productId + ")" + return "Unknown" +} + +function daemonLine(status) { + var daemon = status.daemon + if (daemon.active) { + var uptime = formatDuration(Number(daemon.sinceSec) || 0) + return uptime ? "Running · " + uptime : "Running" + } + if (daemon.state === "failed") return "Failed" + if (daemon.state === "unknown") return "Not installed" + return daemon.enabled ? "Stopped" : "Stopped (disabled)" +} + +function formatDuration(seconds) { + if (!seconds || seconds < 60) return seconds > 0 ? seconds + "s" : "" + var minutes = Math.floor(seconds / 60) + if (minutes < 60) return minutes + "m" + var hours = Math.floor(minutes / 60) + if (hours < 24) return hours + "h " + (minutes % 60) + "m" + var days = Math.floor(hours / 24) + return days + "d " + (hours % 24) + "h" +} + +// kindGlyph maps a key kind to a Nerd Font glyph for the grid cell. Kinds come +// from streamdeck-ctl, so this list tracks KeyStatus.Kind in status.go. +function kindGlyph(kind) { + switch (kind) { + case "toggle": return "󰄣" // toggle-switch + case "module": return "󰅵" // puzzle piece + case "text": return "󰉼" // text + case "static": return "󰃐" // image + default: return "" + } +} + +// describeKey is the detail line shown when a grid cell is focused. Module keys +// show only module · function — their rendered command can inline secrets, so +// streamdeck-ctl no longer emits it at all. +function describeKey(key) { + if (!key || !key.configured) return "Empty slot — click to configure" + var parts = [] + if (key.module && key.function) parts.push(key.module + " · " + key.function) + else if (key.command) parts.push(truncate(key.command, 70)) + if (key.poll) parts.push("polls every " + (key.poll.interval || "2s")) + if (key.privileged) parts.push("privileged") + return parts.length ? parts.join(" · ") : "No command" +} + +function truncate(text, limit) { + var value = String(text || "").replace(/\s+/g, " ").trim() + return value.length > limit ? value.substring(0, limit - 1) + "…" : value +} + +// gridLabel keeps cell text to something that fits an 8-column grid. +function gridLabel(key) { + if (!key || !key.configured) return "" + return truncate(key.label || "", 10) +} + +// iconOptions builds the dropdown model for icon selection: "(none)" plus every +// image file the CLI found in icons_dir. +function iconOptions(status) { + var options = [{ value: "", label: "(none)" }] + for (var i = 0; i < status.icons.length; i++) { + options.push({ value: status.icons[i], label: status.icons[i] }) + } + return options +} + +function moduleOptions(status) { + var options = [{ value: "", label: "(no module)" }] + for (var i = 0; i < status.modules.length; i++) { + options.push({ value: status.modules[i].name, label: status.modules[i].name }) + } + return options +} + +function functionOptions(status, moduleName) { + var options = [{ value: "", label: "(no function)" }] + if (!moduleName) return options + for (var i = 0; i < status.modules.length; i++) { + if (status.modules[i].name !== moduleName) continue + var functions = status.modules[i].functions || [] + for (var j = 0; j < functions.length; j++) { + options.push({ value: functions[j].name, label: functions[j].name }) + } + } + return options +} + +// functionParams returns the declared default params for a module function, used +// to seed the editor's parameter rows. +function functionParams(status, moduleName, functionName) { + for (var i = 0; i < status.modules.length; i++) { + if (status.modules[i].name !== moduleName) continue + var functions = status.modules[i].functions || [] + for (var j = 0; j < functions.length; j++) { + if (functions[j].name === functionName) return functions[j].params || {} + } + } + return {} +} + +// paramRows merges a function's declared defaults with the values already set on +// the key, so the editor shows every parameter the function accepts. +function paramRows(status, moduleName, functionName, current) { + var defaults = functionParams(status, moduleName, functionName) + var names = {} + var name + for (name in defaults) names[name] = true + for (name in (current || {})) names[name] = true + + var ordered = Object.keys(names).sort() + var rows = [] + for (var i = 0; i < ordered.length; i++) { + name = ordered[i] + var value = (current && current[name] !== undefined) ? current[name] : defaults[name] + rows.push({ + name: name, + value: value === undefined || value === null ? "" : String(value), + placeholder: defaults[name] === undefined ? "" : String(defaults[name]) + }) + } + return rows +} + +// editorDraft snapshots a key into the flat shape the editor binds to. +function editorDraft(key) { + return { + index: key ? Number(key.index) : 0, + icon: key && key.icon ? key.icon : "", + text: key && key.text ? key.text : "", + textColor: key && key.textColor ? key.textColor : "", + command: key && key.command ? key.command : "", + module: key && key.module ? key.module : "", + function: key && key.function ? key.function : "", + params: key && key.params ? shallowCopy(key.params) : {} + } +} + +function shallowCopy(source) { + var copy = {} + for (var name in source) copy[name] = source[name] + return copy +} + +// editArguments turns an editor draft into streamdeck-ctl `key set` flags. +// Fields the user emptied are passed as -unset so they leave the YAML entirely +// rather than lingering as empty strings. +function editArguments(draft, original) { + var args = [] + + function apply(flag, field, value) { + var text = String(value || "").trim() + var had = String((original && original[field]) || "").trim() !== "" + if (text !== "") args.push(flag, text) + else if (had) args.push("-unset", yamlField(field)) + } + + apply("-icon", "icon", draft.icon) + apply("-text", "text", draft.text) + apply("-text-color", "textColor", draft.textColor) + apply("-command", "command", draft.command) + apply("-module", "module", draft.module) + apply("-function", "function", draft.function) + + // Params only travel with a module function; without one they have nothing to + // substitute into, and the daemon would ignore them. + if (String(draft.module || "").trim() !== "" && String(draft.function || "").trim() !== "") { + var wrote = false + for (var name in draft.params) { + var value = draft.params[name] + if (value === undefined || value === null) continue + args.push("-param", name + "=" + value) + wrote = true + } + if (!wrote && original && original.params && Object.keys(original.params).length > 0) { + args.push("-unset", "params") + } + } else if (original && original.params && Object.keys(original.params).length > 0) { + args.push("-unset", "params") + } + + return args +} + +// yamlField maps the editor's camelCase field names to the YAML keys the CLI's +// -unset flag expects. +function yamlField(field) { + switch (field) { + case "textColor": return "text_color" + case "function": return "function" + default: return field + } +} + +// draftDiffers reports whether the editor has unsaved changes. +function draftDiffers(draft, original) { + var fields = ["icon", "text", "textColor", "command", "module", "function"] + for (var i = 0; i < fields.length; i++) { + var field = fields[i] + if (String(draft[field] || "") !== String(original[field] || "")) return true + } + var name + for (name in draft.params) { + if (String(draft.params[name] || "") !== String((original.params || {})[name] || "")) return true + } + for (name in (original.params || {})) { + if (draft.params[name] === undefined) return true + } + return false +} diff --git a/omarchy-plugin/Panel.qml b/omarchy-plugin/Panel.qml new file mode 100644 index 0000000..e5a6964 --- /dev/null +++ b/omarchy-plugin/Panel.qml @@ -0,0 +1,501 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io +import qs.Commons +import qs.Ui +import "Model.js" as Model + +// Bar widget and popout panel for streamdeck-go. +// +// Every colour and metric resolves through qs.Commons (Color/Style), so the +// widget repaints itself to match whatever Omarchy theme is active rather than +// carrying a palette of its own. +Panel { + id: root + moduleName: "dev.woodard.streamdeck" + ipcTarget: "dev.woodard.streamdeck" + manageIpc: false + + // ── theme-derived palette ────────────────────────────────────────────────── + readonly property color foreground: bar ? bar.foreground : Color.foreground + readonly property color accent: Color.accent + readonly property color urgent: bar ? bar.urgent : Color.urgent + readonly property color dim: Qt.darker(foreground, 1.55) + readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family + + // The bar icon dims when the deck is unreachable, the same convention the + // built-in network and bluetooth widgets use. + readonly property bool live: deck.connected && deck.daemonActive + readonly property color barIconColor: live ? barForeground : Qt.darker(barForeground, 1.55) + + readonly property var slots: Model.keySlots(deck.status) + readonly property int cols: Number(deck.status.device.cols) || 8 + readonly property int rows: Number(deck.status.device.rows) || 4 + + // ── panel navigation state ───────────────────────────────────────────────── + property int cursorIndex: 0 + property bool cursorActive: false + property int editingIndex: -1 + readonly property bool editing: editingIndex >= 0 + + // A device-shape change (XL -> MK.2, or the first real poll shrinking the + // fallback grid) can strand the cursor or the editor beyond the last slot. + onSlotsChanged: { + if (cursorIndex >= slots.length) cursorIndex = Math.max(0, slots.length - 1) + if (editing && editingIndex >= slots.length) closeEditor() + } + + function moveCursor(dx, dy) { + cursorActive = true + if (editing) return + var next = cursorIndex + dx + dy * cols + if (next < 0 || next >= slots.length) return + cursorIndex = next + } + + function activateCursor() { + if (editing) return + editKey(cursorIndex) + } + + function focusKey(index) { + cursorActive = true + cursorIndex = index + } + + function editKey(index) { + cursorIndex = index + editingIndex = index + // Focus lands inside the editor via its own onVisibleChanged handler — + // the key catcher is blocked while editing. + } + + function closeEditor() { + editingIndex = -1 + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + readonly property var focusedKey: { + for (var i = 0; i < slots.length; i++) { + if (Number(slots[i].index) === cursorIndex) return slots[i] + } + return null + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + visible: !(deck.setting("hideWhenDisconnected", false) === true && deck.loaded && !deck.connected) + + onOpenedChanged: if (opened) { + cursorActive = false + editingIndex = -1 + if (panelFlick) panelFlick.contentY = 0 + deck.refresh() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + Service { + id: deck + settings: root.settings + panelOpen: root.opened + } + + IpcHandler { + target: root.ipcTarget + function open(): void { root.open() } + function close(): void { root.close() } + function show(): void { root.open() } + function hide(): void { root.close() } + function toggle(): void { root.toggle() } + function refresh(): string { deck.refresh(); return "ok" } + function status(): string { return Model.statusLine(deck.status) } + function brightness(level: string): string { + var value = parseInt(level, 10) + if (!isFinite(value)) return "expected a number 0-100" + deck.setBrightness(value) + return "ok" + } + function press(index: string): string { + var value = parseInt(index, 10) + if (!isFinite(value)) return "expected a key index" + deck.pressKey(value) + return "ok" + } + } + + // ── bar icon ─────────────────────────────────────────────────────────────── + + BarIconButton { + id: button + anchors.fill: parent + bar: root.bar + iconComponent: Component { + Item { + StreamDeckIcon { + anchors.centerIn: parent + iconSize: Style.space(13) + color: root.barIconColor + // The icon fills in proportionally to how much of the deck is mapped, + // so a glance at the bar says whether the deck is live and loaded. + litKeys: { + if (!deck.connected) return 0 + var total = Number(deck.status.device.keyCount) || 0 + if (total === 0) return 6 + var used = Model.countConfigured(deck.status) + return Math.max(1, Math.round((used / total) * 6)) + } + opacity: root.live ? 1.0 : 0.6 + } + } + } + onPressed: function(buttonCode) { + if (buttonCode === Qt.RightButton) deck.refresh() + else if (buttonCode === Qt.MiddleButton) deck.toggleDaemon() + else root.toggle() + } + } + + // ── panel ────────────────────────────────────────────────────────────────── + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + // Wide enough that an 8-column grid still gives each key a legible face. + contentWidth: panel.fittedContentWidth(Style.space(520)) + contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(640)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + // While the editor is up its fields own the keyboard — the shipped-panel + // idiom for inline editors. Escape then bubbles to the editor's own + // handler instead of tearing down the whole panel mid-edit. + blocked: root.editing + onMoveRequested: function(dx, dy) { + if (!root.cursorActive) { root.cursorActive = true; return } + root.moveCursor(dx, dy) + } + onActivateRequested: if (root.cursorActive) root.activateCursor() + onCloseRequested: root.close() + onTabRequested: function(direction) { root.switchPanel(direction) } + onTextKey: function(t) { + if (t === "r" || t === "R") deck.refresh() + else if (t === "p" || t === "P") deck.toggleDaemon() + else if (t === "e" || t === "E") root.editKey(root.cursorIndex) + else if (t === " ") deck.pressKey(root.cursorIndex) + } + + Flickable { + id: panelFlick + anchors.fill: parent + contentWidth: width + contentHeight: column.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + ColumnLayout { + id: column + width: panelFlick.width + spacing: Style.space(12) + + // ── hero: title, status line, daemon switch ──────────────────────── + Item { + id: header + Layout.fillWidth: true + implicitHeight: hero.implicitHeight + + // Exposed for the hero's trailingControl, whose `root` resolves to + // PanelHero rather than this Panel. + readonly property bool ringVisible: false + + PanelHero { + id: hero + width: parent.width + title: "Stream Deck" + meta: Model.statusLine(deck.status) + foreground: root.foreground + fontFamily: root.fontFamily + iconOpacity: root.live ? 1.0 : 0.5 + iconComponent: Component { + StreamDeckIcon { + iconSize: Style.font.display + color: root.live ? root.foreground : root.dim + litKeys: deck.connected ? 6 : 0 + } + } + + trailingControl: Component { + ToggleSwitch { + id: powerSwitch + visible: !deck.ctlMissing && deck.status.daemon.state !== "unknown" + checked: deck.daemonActive + busy: deck.busy + foreground: hero.foreground + onToggled: deck.toggleDaemon() + + PanelToolTip { + visible: powerSwitch.containsMouse + text: deck.daemonActive ? "Stop the daemon" : "Start the daemon" + fontFamily: hero.fontFamily + } + } + } + } + } + + // ── messages ─────────────────────────────────────────────────────── + Text { + Layout.fillWidth: true + visible: deck.actionStatus !== "" || deck.lastError !== "" + text: deck.actionStatus !== "" ? deck.actionStatus : deck.lastError + color: deck.lastError !== "" && deck.actionStatus === "" ? root.urgent : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + } + + // streamdeck-ctl is what makes this widget work at all; if it isn't + // installed, say so plainly instead of showing dead controls. + ColumnLayout { + Layout.fillWidth: true + visible: deck.ctlMissing + spacing: Style.spacing.labelGap + + Text { + Layout.fillWidth: true + text: "streamdeck-ctl was not found" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + + Text { + Layout.fillWidth: true + text: "Build and install it from the streamdeck-go repo:\nmake build-ctl && make install-ctl" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + + // ── brightness ───────────────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + visible: !deck.ctlMissing && !root.editing + spacing: Style.spacing.labelGap + + RowLayout { + Layout.fillWidth: true + spacing: Style.space(8) + + PanelSectionHeader { + Layout.fillWidth: true + text: "BRIGHTNESS" + foreground: root.foreground + fontFamily: root.fontFamily + } + + Text { + text: deck.brightness + "%" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + + PanelSlider { + Layout.fillWidth: true + bar: root.bar + minimum: 0 + maximum: 100 + step: 5 + integer: true + value: deck.brightness + enabled: deck.connected + opacity: deck.connected ? 1.0 : 0.45 + onMoved: function(v) { deck.setBrightness(v) } + } + } + + // ── device / daemon facts ────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + visible: !deck.ctlMissing && !root.editing + spacing: Style.spacing.labelGap + + InfoPair { label: "Device"; value: Model.deviceLine(deck.status) } + InfoPair { + label: "Connection" + value: deck.connected ? "Connected" : "Disconnected" + emphasised: !deck.connected + } + InfoPair { label: "Daemon"; value: Model.daemonLine(deck.status) } + } + + PanelSeparator { + Layout.fillWidth: true + visible: !deck.ctlMissing + foreground: root.foreground + } + + // ── key grid ─────────────────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + visible: !deck.ctlMissing && !root.editing + spacing: Style.space(8) + + PanelSectionHeader { + Layout.fillWidth: true + text: "KEYS" + foreground: root.foreground + fontFamily: root.fontFamily + } + + KeyGrid { + Layout.fillWidth: true + slots: root.slots + cols: root.cols + rows: root.rows + cursorIndex: root.cursorIndex + cursorActive: root.cursorActive + foreground: root.foreground + accent: root.accent + fontFamily: root.fontFamily + onKeyFocused: function(index) { root.focusKey(index) } + // Left-click mirrors a physical press; editing is the secondary action. + onKeyActivated: function(index) { deck.pressKey(index) } + // Right-click fires the key, mirroring a physical press. + onKeySecondary: function(index) { root.editKey(index) } + } + + Text { + Layout.fillWidth: true + text: root.focusedKey + ? "Key " + root.cursorIndex + " — " + Model.describeKey(root.focusedKey) + : "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: "Click a key to run it · right-click to edit" + color: root.dim + opacity: 0.7 + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // ── editor ───────────────────────────────────────────────────────── + KeyEditor { + id: editorPane + Layout.fillWidth: true + visible: root.editing + service: deck + status: deck.status + keyIndex: root.editingIndex + foreground: root.foreground + accent: root.accent + fontFamily: root.fontFamily + onClosed: root.closeEditor() + onSaved: root.closeEditor() + } + + PanelSeparator { + Layout.fillWidth: true + visible: !deck.ctlMissing && !root.editing + foreground: root.foreground + } + + // ── actions ──────────────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + visible: !deck.ctlMissing && !root.editing + spacing: Style.space(6) + + Button { + text: "Restart" + iconText: "󰑓" + foreground: root.foreground + accent: root.accent + fontFamily: root.fontFamily + onClicked: deck.restartDaemon() + } + + Button { + text: "Config" + iconText: "󰈔" + foreground: root.foreground + accent: root.accent + fontFamily: root.fontFamily + onClicked: deck.openConfig() + } + + Button { + text: "Icons" + iconText: "󰉋" + foreground: root.foreground + accent: root.accent + fontFamily: root.fontFamily + onClicked: deck.openIcons() + } + + Item { Layout.fillWidth: true } + + PanelActionButton { + iconText: "󰌱" + tooltipText: "Follow the daemon log" + foreground: root.foreground + fontFamily: root.fontFamily + onClicked: deck.openLogs() + } + } + } + } + } + } + + // InfoPair is a label on the left, value on the right, with the gap between + // them absorbing the leftover width. + component InfoPair: RowLayout { + id: pair + property string label: "" + property string value: "" + property bool emphasised: false + + Layout.fillWidth: true + spacing: Style.space(8) + + Text { + text: pair.label + color: root.foreground + opacity: 0.6 + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Item { Layout.fillWidth: true } + + Text { + text: pair.value + color: pair.emphasised ? root.urgent : root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + } +} diff --git a/omarchy-plugin/README.md b/omarchy-plugin/README.md new file mode 100644 index 0000000..33a7948 --- /dev/null +++ b/omarchy-plugin/README.md @@ -0,0 +1,169 @@ +# Stream Deck — Omarchy shell plugin + +A bar widget for [Omarchy](https://omarchy.org) that controls the +[streamdeck-go](https://git.i0t.app/lwoodard/streamdeck-go) daemon: device and +daemon status, brightness, and a live 8×4 key grid you can edit in place. + +Every colour and metric resolves through Omarchy's `qs.Commons` theme tokens +(`Color.*`, `Style.*`), so the widget follows the active theme rather than +carrying a palette of its own. + +--- + +## What it does + +**Bar icon** — a miniature deck that fills in proportionally to how much of the +deck is mapped, and dims when the deck is unplugged or the daemon is stopped. + +**Panel** + +| Section | Behaviour | +|---|---| +| Header | Title, status line, and an on/off switch for the `streamdeck-go` user service | +| Brightness | Slider writing `brightness:` into `config.yaml`; the daemon hot-reloads, so the deck dims as you drag | +| Facts | Device model, connection state, daemon state and uptime | +| Key grid | One cell per physical key, with icon previews, slot indices, and labels. Empty slots are shown so you can fill them | +| Editor | Right-click a key to edit its module/function, parameters, shell command, icon, label, and label colour | +| Actions | Restart the daemon, open `config.yaml`, open the icons folder, follow the log | + +A red dot on a cell marks a `priv:` key. Those run through the privileged helper +and are deliberately **not** runnable from the panel — press them on the deck. + +### Mouse and keyboard + +| Input | Action | +|---|---| +| Left-click bar icon | Open/close the panel | +| Right-click bar icon | Refresh | +| Middle-click bar icon | Start/stop the daemon | +| Left-click a key | Run that key's command (fire-and-forget, like a physical press) | +| Right-click a key | Open the editor for that key | +| Arrow keys | Move the grid cursor | +| `Enter` | Edit the focused key | +| `Space` | Run the focused key | +| `e` | Edit the focused key | +| `r` | Refresh | +| `p` | Start/stop the daemon | +| `Esc` | Leave the field → close the editor → close the panel | + +--- + +## Requirements + +- Omarchy with `omarchy-shell` running +- `streamdeck-go` installed and its user service present +- **`streamdeck-ctl`** — the helper CLI the plugin drives. The panel refuses to + show controls without it and tells you how to install it. + +The plugin never edits YAML or probes USB itself; it shells out to +`streamdeck-ctl`, which reuses the daemon's own config and device code. That +keeps one implementation of the config format rather than two. + +--- + +## Install + +From a checkout of the streamdeck-go repo: + +```bash +make install-plugin # builds + installs streamdeck-ctl, links the plugin +omarchy plugin enable dev.woodard.streamdeck right +``` + +`make install-plugin` symlinks this directory into +`~/.config/omarchy/plugins/dev.woodard.streamdeck`, so edits to the QML are live +after a rescan: + +```bash +omarchy-shell shell rescanPlugins +``` + +> Qt caches compiled QML in `~/.cache/quickshell/qmlcache`, and a running shell +> can keep serving a stale compiled unit across a rescan. If an edit doesn't seem +> to take effect, `omarchy restart shell`. + +To validate before committing: + +```bash +make validate-plugin +``` + +`omarchy plugin validate` refuses a symlinked plugin folder by design, so this +target validates the source directory rather than the installed link. + +### Remove + +```bash +make uninstall-plugin +``` + +--- + +## Settings + +Configurable from the Omarchy bar widget settings: + +| Setting | Default | Meaning | +|---|---|---| +| `refreshIntervalSec` | 10 | Poll cadence while the panel is open; closed, it relaxes to at most once a minute | +| `ctlPath` | *(empty)* | Explicit path to `streamdeck-ctl`; empty searches `~/.local/bin`, `~/go/bin`, `/usr/local/bin`, then `$PATH` | +| `configPath` | *(empty)* | Explicit `config.yaml`; empty uses `~/.config/streamdeck-go/config.yaml` | +| `hideWhenDisconnected` | false | Hide the bar icon entirely when no deck is connected | + +--- + +## IPC + +```bash +omarchy-shell dev.woodard.streamdeck open +omarchy-shell dev.woodard.streamdeck toggle +omarchy-shell dev.woodard.streamdeck status # "20 of 32 keys in use" +omarchy-shell dev.woodard.streamdeck refresh +omarchy-shell dev.woodard.streamdeck brightness 60 +omarchy-shell dev.woodard.streamdeck press 3 +``` + +Useful for Hyprland binds — e.g. a key that dims the deck without opening the bar. + +--- + +## Security note + +Module keys can reference secrets via `{{env "TOKEN"}}` in their exec templates. +`streamdeck-ctl status` never emits the rendered command — the panel shows only +`module · function` for module keys — so tokens can't leak into the UI or into +anything scraping the CLI's JSON. On Linux, `key press` merges the systemd user +manager's environment over the caller's before running, so a token imported with +`systemctl --user import-environment` reaches panel presses exactly as it reaches +hardware presses. (macOS keeps the caller's environment — launchd has no +equivalent bulk query.) + +## A note on config formatting + +Saving from the editor rewrites `config.yaml` through a YAML node-level editor. +Comments and blank lines are preserved, but two normalisations happen on any +save: + +- trailing whitespace on values is trimmed +- a comment block dangling at the end of a key entry may be re-indented to match + the block it attaches to + +Both are cosmetic. Values, ordering, and comment text are preserved. + +--- + +## Files + +``` +manifest.json plugin metadata + settings schema +Panel.qml bar icon and popout panel (entry point) +Service.qml owns every streamdeck-ctl invocation +KeyGrid.qml the 8x4 (or 5x3) key grid +KeyEditor.qml per-key editor +StreamDeckIcon.qml drawn deck icon, takes its colour from the caller +Model.js parsing and formatting helpers +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/omarchy-plugin/Service.qml b/omarchy-plugin/Service.qml new file mode 100644 index 0000000..3f4ec02 --- /dev/null +++ b/omarchy-plugin/Service.qml @@ -0,0 +1,311 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "Model.js" as Model + +// Service owns every conversation with streamdeck-ctl. The panel binds to the +// properties here and calls the functions; it never spawns a process itself. +Item { + id: root + + property var settings: ({}) + + // Set by the panel. While the popout is closed only the bar icon consumes + // status, so polling relaxes to at most once a minute. + property bool panelOpen: false + + // Full snapshot from `streamdeck-ctl status`, normalised by Model.parseStatus. + property var status: Model.EMPTY_STATUS + property bool loaded: false + property bool refreshing: false + property string lastError: "" + property string actionStatus: "" + + // True once we have looked for the binary and failed — the panel shows install + // guidance instead of controls in that state. + property bool ctlMissing: false + property string ctlResolved: "" + + readonly property bool busy: statusProcess.running || actionProcess.running + readonly property bool connected: status.device.connected === true + readonly property bool daemonActive: status.daemon.active === true + + // Brightness is echoed optimistically so the slider tracks the drag instead of + // snapping back on the next poll, which lands up to refreshIntervalSec later. + property int pendingBrightness: -1 + readonly property int brightness: pendingBrightness >= 0 ? pendingBrightness : (Number(status.brightness) || 0) + + readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 10, 2, 600) + readonly property string configPath: String(setting("configPath", "") || "") + + function setting(name, fallback) { + var value = settings ? settings[name] : undefined + return value === undefined || value === null || value === "" ? fallback : value + } + + function intSetting(name, fallback, min, max) { + var n = parseInt(String(setting(name, fallback)), 10) + if (!isFinite(n)) n = fallback + return Math.max(min, Math.min(max, n)) + } + + // ── locating streamdeck-ctl ──────────────────────────────────────────────── + // + // The shell inherits a minimal PATH under systemd, so ~/.local/bin (Linux) and + // ~/go/bin (macOS, and Go's default install target) are checked explicitly + // before falling back to a PATH lookup. + readonly property string home: Quickshell.env("HOME") + readonly property var candidatePaths: [ + String(setting("ctlPath", "") || ""), + home + "/.local/bin/streamdeck-ctl", + home + "/go/bin/streamdeck-ctl", + "/usr/local/bin/streamdeck-ctl" + ] + + function resolveCtl() { + if (locateProcess.running) return + locateProcess.command = ["sh", "-c", locateScript()] + locateProcess.running = true + } + + // A changed ctlPath setting (or HOME, in theory) must invalidate the cached + // location — without this, ctlResolved is sticky for the life of the shell. + onCandidatePathsChanged: { + ctlResolved = "" + ctlMissing = false + resolveCtl() + } + + function locateScript() { + var checks = [] + for (var i = 0; i < candidatePaths.length; i++) { + var path = candidatePaths[i] + if (!path) continue + checks.push('[ -x ' + shellQuote(path) + ' ] && { printf %s ' + shellQuote(path) + '; exit 0; }') + } + checks.push('command -v streamdeck-ctl 2>/dev/null || true') + return checks.join("\n") + } + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + // baseArgs prefixes every invocation, carrying the -config override when the + // widget was pointed at a non-default config. + function baseArgs() { + var args = [] + if (configPath !== "") args.push("-config", configPath) + return args + } + + // ── reading ──────────────────────────────────────────────────────────────── + + function refresh() { + if (ctlResolved === "") { + if (!locateProcess.running) resolveCtl() + return + } + if (statusProcess.running) return + refreshing = true + statusProcess.command = [ctlResolved].concat(baseArgs()).concat(["status"]) + statusProcess.running = true + } + + // ── writing ──────────────────────────────────────────────────────────────── + + function setBrightness(value) { + var clamped = Math.max(0, Math.min(100, Math.round(value))) + pendingBrightness = clamped + brightnessDebounce.restart() + } + + function toggleDaemon() { + runAction(["daemon", "toggle"], daemonActive ? "Stopping daemon…" : "Starting daemon…") + } + + function restartDaemon() { + runAction(["daemon", "restart"], "Restarting daemon…") + } + + function pressKey(index) { + runAction(["key", "press", String(index)], "") + } + + function clearKey(index) { + runAction(["key", "clear", String(index)], "Cleared key " + index) + } + + // saveKey takes the flag list Model.editArguments built. Returning early on an + // empty list keeps a no-op "Save" from rewriting the config file. + function saveKey(index, flags) { + if (!flags || flags.length === 0) { + actionStatus = "No changes" + actionStatusTimer.restart() + return + } + runAction(["key", "set", String(index)].concat(flags), "Saved key " + index) + } + + // Writes are queued, not dropped: a save clicked while another action is in + // flight must still land. Brightness entries coalesce to the newest value so + // a slider drag queues one write, not a backlog of intermediate positions. + property var actionQueue: [] + + function runAction(args, message) { + if (ctlResolved === "") { lastError = "streamdeck-ctl not found"; return } + if (args[0] === "brightness") { + actionQueue = actionQueue.filter(function(entry) { return entry.args[0] !== "brightness" }) + } + actionQueue.push({ args: args, message: message }) + pumpActions() + } + + function pumpActions() { + if (actionProcess.running || actionQueue.length === 0) return + var next = actionQueue.shift() + actionProcess.pendingMessage = next.message + actionProcess.command = [ctlResolved].concat(baseArgs()).concat(next.args) + actionProcess.running = true + } + + function openConfig() { + var path = status.configPath || (home + "/.config/streamdeck-go/config.yaml") + Quickshell.execDetached(["uwsm-app", "--", "xdg-open", path]) + } + + function openLogs() { + Quickshell.execDetached(["uwsm-app", "--", "omarchy-launch-floating-terminal-with-presentation", + "journalctl", "--user", "-u", "streamdeck-go.service", "-f", "-n", "200"]) + } + + function openIcons() { + var dir = status.iconsDir || (home + "/.config/streamdeck-go/icons") + Quickshell.execDetached(["uwsm-app", "--", "xdg-open", dir]) + } + + // ── processes ────────────────────────────────────────────────────────────── + + Process { + id: locateProcess + running: false + command: [] + stdout: StdioCollector { id: locateStdout; waitForEnd: true } + onExited: function(exitCode) { + var found = String(locateStdout.text || "").trim().split("\n")[0].trim() + if (found !== "") { + root.ctlResolved = found + root.ctlMissing = false + root.refresh() + } else { + root.ctlMissing = true + root.loaded = true + } + } + } + + Process { + id: statusProcess + running: false + command: [] + stdout: StdioCollector { id: statusStdout; waitForEnd: true } + stderr: StdioCollector { id: statusStderr; waitForEnd: true } + onExited: function(exitCode) { + root.refreshing = false + root.loaded = true + // A failure with empty stdout is spawn-shaped (the CLI always emits JSON, + // even on error) — the binary may have been moved or removed. Drop the + // cached location so the next tick re-probes. + if (exitCode !== 0 && String(statusStdout.text || "").trim() === "") { + root.ctlResolved = "" + root.lastError = "streamdeck-ctl failed to run" + return + } + var parsed = Model.parseStatus(statusStdout.text) + if (parsed.ok === false && parsed.error) { + root.lastError = parsed.error + return + } + root.status = parsed + root.lastError = parsed.configError ? parsed.configError + : (exitCode !== 0 ? String(statusStderr.text || "").trim() : "") + // The poll caught up with the optimistic value — stop overriding it. + if (root.pendingBrightness >= 0 && Number(parsed.brightness) === root.pendingBrightness) { + root.pendingBrightness = -1 + } + } + } + + Process { + id: actionProcess + property string pendingMessage: "" + running: false + command: [] + stdout: StdioCollector { id: actionStdout; waitForEnd: true } + stderr: StdioCollector { id: actionStderr; waitForEnd: true } + onExited: function(exitCode) { + if (exitCode === 0) { + root.lastError = "" + if (actionProcess.pendingMessage !== "") { + root.actionStatus = actionProcess.pendingMessage + actionStatusTimer.restart() + } + } else { + var message = String(actionStderr.text || actionStdout.text || "").trim() + root.lastError = Model.truncate(message || "streamdeck-ctl failed", 160) + root.actionStatus = "" + // A failed brightness write must release the optimistic value, or the + // slider shows a level the deck never reached — forever, since no poll + // will ever confirm it. + root.pendingBrightness = -1 + } + root.pumpActions() + // Daemon transitions take a moment to settle; re-poll shortly after rather + // than waiting for the next scheduled refresh. + settleTimer.restart() + root.refresh() + } + } + + // ── timers ───────────────────────────────────────────────────────────────── + + Timer { + id: refreshTimer + // The bar icon doesn't need panel-grade freshness; the panel refreshes + // explicitly on open, so a slow background cadence costs nothing visible. + interval: (root.panelOpen ? root.refreshIntervalSec + : Math.max(root.refreshIntervalSec, 60)) * 1000 + repeat: true + running: true + triggeredOnStart: true + onTriggered: root.refresh() + } + + Timer { + // Dragging the slider would otherwise rewrite config.yaml on every pixel, + // and each write triggers a daemon reload. Coalesce to the last value. + id: brightnessDebounce + interval: 180 + repeat: false + onTriggered: { + if (root.pendingBrightness < 0) return + root.runAction(["brightness", String(root.pendingBrightness)], "") + } + } + + Timer { + id: settleTimer + interval: 1200 + repeat: false + onTriggered: root.refresh() + } + + Timer { + id: actionStatusTimer + interval: 2400 + repeat: false + onTriggered: root.actionStatus = "" + } + + Component.onCompleted: resolveCtl() +} diff --git a/omarchy-plugin/StreamDeckIcon.qml b/omarchy-plugin/StreamDeckIcon.qml new file mode 100644 index 0000000..909bece --- /dev/null +++ b/omarchy-plugin/StreamDeckIcon.qml @@ -0,0 +1,52 @@ +import QtQuick +import qs.Commons + +// A 3x2 grid of keys, drawn rather than glyphed so it scales cleanly and takes +// its colour straight from the caller (and therefore from the active theme). +Item { + id: root + + property real iconSize: Style.space(14) + property color color: Color.foreground + // Lit keys are drawn filled; the rest are outlined. The panel uses this to + // show the deck's state at a glance without a second icon. + property int litKeys: 6 + + implicitWidth: iconSize + implicitHeight: iconSize + + readonly property int cols: 3 + readonly property int rows: 2 + readonly property real gap: Math.max(1, iconSize * 0.1) + readonly property real cell: (iconSize - gap * (cols - 1)) / cols + readonly property real cellHeight: (iconSize * 0.72 - gap * (rows - 1)) / rows + + Item { + anchors.centerIn: parent + width: root.iconSize + height: root.cellHeight * root.rows + root.gap * (root.rows - 1) + + Repeater { + model: root.cols * root.rows + + Rectangle { + required property int index + + readonly property int column: index % root.cols + readonly property int row: Math.floor(index / root.cols) + readonly property bool lit: index < root.litKeys + + x: column * (root.cell + root.gap) + y: row * (root.cellHeight + root.gap) + width: root.cell + height: root.cellHeight + radius: Style.cornerRadius > 0 ? Math.max(1, root.cell * 0.22) : 0 + + color: lit ? root.color : "transparent" + border.width: lit ? 0 : Math.max(1, Math.round(root.iconSize * 0.07)) + border.color: root.color + opacity: lit ? 1.0 : 0.45 + } + } + } +} diff --git a/omarchy-plugin/manifest.json b/omarchy-plugin/manifest.json new file mode 100644 index 0000000..2668e3b --- /dev/null +++ b/omarchy-plugin/manifest.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "id": "dev.woodard.streamdeck", + "name": "Stream Deck", + "version": "1.0.0", + "author": "Levi Woodard", + "license": "MIT", + "description": "Control the streamdeck-go daemon from the Omarchy bar: device status, brightness, and a live key grid you can edit in place.", + "kinds": [ + "bar-widget" + ], + "entryPoints": { + "barWidget": "Panel.qml" + }, + "barWidget": { + "displayName": "Stream Deck", + "description": "Deck and daemon status, brightness, and inline key editing for streamdeck-go.", + "category": "Hardware", + "allowMultiple": false, + "defaultSection": "right", + "defaults": { + "refreshIntervalSec": 10, + "ctlPath": "", + "configPath": "", + "hideWhenDisconnected": false + }, + "schema": [ + { + "key": "refreshIntervalSec", + "type": "integer", + "label": "Refresh interval (seconds)", + "min": 2, + "max": 600, + "step": 1, + "defaultValue": 10 + }, + { + "key": "ctlPath", + "type": "string", + "label": "Path to streamdeck-ctl", + "description": "Leave empty to search ~/.local/bin, ~/go/bin, then $PATH.", + "defaultValue": "" + }, + { + "key": "configPath", + "type": "string", + "label": "Path to config.yaml", + "description": "Leave empty to use ~/.config/streamdeck-go/config.yaml.", + "defaultValue": "" + }, + { + "key": "hideWhenDisconnected", + "type": "boolean", + "label": "Hide the bar icon when no deck is connected", + "defaultValue": false + } + ] + } +}