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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user