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:
Levi Woodard
2026-09-27 18:21:33 -06:00
parent 8cff4f8418
commit 2aa4c65e42
11 changed files with 2097 additions and 1 deletions

View File

@@ -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)

View File

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

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

200
omarchy-plugin/KeyGrid.qml Normal file
View File

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

21
omarchy-plugin/LICENSE Normal file
View File

@@ -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.

310
omarchy-plugin/Model.js Normal file
View File

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

501
omarchy-plugin/Panel.qml Normal file
View File

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

169
omarchy-plugin/README.md Normal file
View File

@@ -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).

311
omarchy-plugin/Service.qml Normal file
View File

@@ -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()
}

View File

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

View File

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