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