Files
streamdeck-go/omarchy-plugin/Model.js
Levi Woodard 4158165410 Add folder support: keys that open sub-pages with a back key
A key with `folder: NAME` swaps the deck to the keys defined under
`folders.NAME`. Every folder shows a back key (slot 0 by default, drawn
with a built-in chevron) that pops back to the parent; folders nest.

The daemon now runs one page at a time: per-key setup moved into
setupKey(), openPage() draws a page under its own context, and
page.stop() tears it down before the next page is drawn (and on exit,
so device death no longer waits on uncancelled goroutines).

streamdeck-ctl gains `key set -folder`, reports kind "folder", lists
every folder in status, and refuses to `key press` a navigation-only
key. The Omarchy grid shows a folder glyph.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ARLa4moGb154fzc9TevgV
2026-10-01 18:39:24 -06:00

312 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 "folder": return "󰉋" // folder
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
}