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
311 lines
11 KiB
JavaScript
311 lines
11 KiB
JavaScript
.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
|
|
}
|