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