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
312 lines
11 KiB
QML
312 lines
11 KiB
QML
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()
|
|
}
|