Files
streamdeck-go/omarchy-plugin/Panel.qml
Levi Woodard 2aa4c65e42 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
2026-09-27 18:21:33 -06:00

502 lines
17 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Bar widget and popout panel for streamdeck-go.
//
// Every colour and metric resolves through qs.Commons (Color/Style), so the
// widget repaints itself to match whatever Omarchy theme is active rather than
// carrying a palette of its own.
Panel {
id: root
moduleName: "dev.woodard.streamdeck"
ipcTarget: "dev.woodard.streamdeck"
manageIpc: false
// ── theme-derived palette ──────────────────────────────────────────────────
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color accent: Color.accent
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
// The bar icon dims when the deck is unreachable, the same convention the
// built-in network and bluetooth widgets use.
readonly property bool live: deck.connected && deck.daemonActive
readonly property color barIconColor: live ? barForeground : Qt.darker(barForeground, 1.55)
readonly property var slots: Model.keySlots(deck.status)
readonly property int cols: Number(deck.status.device.cols) || 8
readonly property int rows: Number(deck.status.device.rows) || 4
// ── panel navigation state ─────────────────────────────────────────────────
property int cursorIndex: 0
property bool cursorActive: false
property int editingIndex: -1
readonly property bool editing: editingIndex >= 0
// A device-shape change (XL -> MK.2, or the first real poll shrinking the
// fallback grid) can strand the cursor or the editor beyond the last slot.
onSlotsChanged: {
if (cursorIndex >= slots.length) cursorIndex = Math.max(0, slots.length - 1)
if (editing && editingIndex >= slots.length) closeEditor()
}
function moveCursor(dx, dy) {
cursorActive = true
if (editing) return
var next = cursorIndex + dx + dy * cols
if (next < 0 || next >= slots.length) return
cursorIndex = next
}
function activateCursor() {
if (editing) return
editKey(cursorIndex)
}
function focusKey(index) {
cursorActive = true
cursorIndex = index
}
function editKey(index) {
cursorIndex = index
editingIndex = index
// Focus lands inside the editor via its own onVisibleChanged handler —
// the key catcher is blocked while editing.
}
function closeEditor() {
editingIndex = -1
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
readonly property var focusedKey: {
for (var i = 0; i < slots.length; i++) {
if (Number(slots[i].index) === cursorIndex) return slots[i]
}
return null
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
visible: !(deck.setting("hideWhenDisconnected", false) === true && deck.loaded && !deck.connected)
onOpenedChanged: if (opened) {
cursorActive = false
editingIndex = -1
if (panelFlick) panelFlick.contentY = 0
deck.refresh()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
Service {
id: deck
settings: root.settings
panelOpen: root.opened
}
IpcHandler {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function refresh(): string { deck.refresh(); return "ok" }
function status(): string { return Model.statusLine(deck.status) }
function brightness(level: string): string {
var value = parseInt(level, 10)
if (!isFinite(value)) return "expected a number 0-100"
deck.setBrightness(value)
return "ok"
}
function press(index: string): string {
var value = parseInt(index, 10)
if (!isFinite(value)) return "expected a key index"
deck.pressKey(value)
return "ok"
}
}
// ── bar icon ───────────────────────────────────────────────────────────────
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
iconComponent: Component {
Item {
StreamDeckIcon {
anchors.centerIn: parent
iconSize: Style.space(13)
color: root.barIconColor
// The icon fills in proportionally to how much of the deck is mapped,
// so a glance at the bar says whether the deck is live and loaded.
litKeys: {
if (!deck.connected) return 0
var total = Number(deck.status.device.keyCount) || 0
if (total === 0) return 6
var used = Model.countConfigured(deck.status)
return Math.max(1, Math.round((used / total) * 6))
}
opacity: root.live ? 1.0 : 0.6
}
}
}
onPressed: function(buttonCode) {
if (buttonCode === Qt.RightButton) deck.refresh()
else if (buttonCode === Qt.MiddleButton) deck.toggleDaemon()
else root.toggle()
}
}
// ── panel ──────────────────────────────────────────────────────────────────
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
// Wide enough that an 8-column grid still gives each key a legible face.
contentWidth: panel.fittedContentWidth(Style.space(520))
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(640))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
// While the editor is up its fields own the keyboard — the shipped-panel
// idiom for inline editors. Escape then bubbles to the editor's own
// handler instead of tearing down the whole panel mid-edit.
blocked: root.editing
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
root.moveCursor(dx, dy)
}
onActivateRequested: if (root.cursorActive) root.activateCursor()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
if (t === "r" || t === "R") deck.refresh()
else if (t === "p" || t === "P") deck.toggleDaemon()
else if (t === "e" || t === "E") root.editKey(root.cursorIndex)
else if (t === " ") deck.pressKey(root.cursorIndex)
}
Flickable {
id: panelFlick
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
interactive: contentHeight > height
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
ColumnLayout {
id: column
width: panelFlick.width
spacing: Style.space(12)
// ── hero: title, status line, daemon switch ────────────────────────
Item {
id: header
Layout.fillWidth: true
implicitHeight: hero.implicitHeight
// Exposed for the hero's trailingControl, whose `root` resolves to
// PanelHero rather than this Panel.
readonly property bool ringVisible: false
PanelHero {
id: hero
width: parent.width
title: "Stream Deck"
meta: Model.statusLine(deck.status)
foreground: root.foreground
fontFamily: root.fontFamily
iconOpacity: root.live ? 1.0 : 0.5
iconComponent: Component {
StreamDeckIcon {
iconSize: Style.font.display
color: root.live ? root.foreground : root.dim
litKeys: deck.connected ? 6 : 0
}
}
trailingControl: Component {
ToggleSwitch {
id: powerSwitch
visible: !deck.ctlMissing && deck.status.daemon.state !== "unknown"
checked: deck.daemonActive
busy: deck.busy
foreground: hero.foreground
onToggled: deck.toggleDaemon()
PanelToolTip {
visible: powerSwitch.containsMouse
text: deck.daemonActive ? "Stop the daemon" : "Start the daemon"
fontFamily: hero.fontFamily
}
}
}
}
}
// ── messages ───────────────────────────────────────────────────────
Text {
Layout.fillWidth: true
visible: deck.actionStatus !== "" || deck.lastError !== ""
text: deck.actionStatus !== "" ? deck.actionStatus : deck.lastError
color: deck.lastError !== "" && deck.actionStatus === "" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
// streamdeck-ctl is what makes this widget work at all; if it isn't
// installed, say so plainly instead of showing dead controls.
ColumnLayout {
Layout.fillWidth: true
visible: deck.ctlMissing
spacing: Style.spacing.labelGap
Text {
Layout.fillWidth: true
text: "streamdeck-ctl was not found"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Text {
Layout.fillWidth: true
text: "Build and install it from the streamdeck-go repo:\nmake build-ctl && make install-ctl"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
// ── brightness ─────────────────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.spacing.labelGap
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
PanelSectionHeader {
Layout.fillWidth: true
text: "BRIGHTNESS"
foreground: root.foreground
fontFamily: root.fontFamily
}
Text {
text: deck.brightness + "%"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
PanelSlider {
Layout.fillWidth: true
bar: root.bar
minimum: 0
maximum: 100
step: 5
integer: true
value: deck.brightness
enabled: deck.connected
opacity: deck.connected ? 1.0 : 0.45
onMoved: function(v) { deck.setBrightness(v) }
}
}
// ── device / daemon facts ──────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.spacing.labelGap
InfoPair { label: "Device"; value: Model.deviceLine(deck.status) }
InfoPair {
label: "Connection"
value: deck.connected ? "Connected" : "Disconnected"
emphasised: !deck.connected
}
InfoPair { label: "Daemon"; value: Model.daemonLine(deck.status) }
}
PanelSeparator {
Layout.fillWidth: true
visible: !deck.ctlMissing
foreground: root.foreground
}
// ── key grid ───────────────────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.space(8)
PanelSectionHeader {
Layout.fillWidth: true
text: "KEYS"
foreground: root.foreground
fontFamily: root.fontFamily
}
KeyGrid {
Layout.fillWidth: true
slots: root.slots
cols: root.cols
rows: root.rows
cursorIndex: root.cursorIndex
cursorActive: root.cursorActive
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onKeyFocused: function(index) { root.focusKey(index) }
// Left-click mirrors a physical press; editing is the secondary action.
onKeyActivated: function(index) { deck.pressKey(index) }
// Right-click fires the key, mirroring a physical press.
onKeySecondary: function(index) { root.editKey(index) }
}
Text {
Layout.fillWidth: true
text: root.focusedKey
? "Key " + root.cursorIndex + " — " + Model.describeKey(root.focusedKey)
: ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: "Click a key to run it · right-click to edit"
color: root.dim
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// ── editor ─────────────────────────────────────────────────────────
KeyEditor {
id: editorPane
Layout.fillWidth: true
visible: root.editing
service: deck
status: deck.status
keyIndex: root.editingIndex
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClosed: root.closeEditor()
onSaved: root.closeEditor()
}
PanelSeparator {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
foreground: root.foreground
}
// ── actions ────────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
visible: !deck.ctlMissing && !root.editing
spacing: Style.space(6)
Button {
text: "Restart"
iconText: "󰑓"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.restartDaemon()
}
Button {
text: "Config"
iconText: "󰈔"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.openConfig()
}
Button {
text: "Icons"
iconText: "󰉋"
foreground: root.foreground
accent: root.accent
fontFamily: root.fontFamily
onClicked: deck.openIcons()
}
Item { Layout.fillWidth: true }
PanelActionButton {
iconText: "󰌱"
tooltipText: "Follow the daemon log"
foreground: root.foreground
fontFamily: root.fontFamily
onClicked: deck.openLogs()
}
}
}
}
}
}
// InfoPair is a label on the left, value on the right, with the gap between
// them absorbing the leftover width.
component InfoPair: RowLayout {
id: pair
property string label: ""
property string value: ""
property bool emphasised: false
Layout.fillWidth: true
spacing: Style.space(8)
Text {
text: pair.label
color: root.foreground
opacity: 0.6
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Item { Layout.fillWidth: true }
Text {
text: pair.value
color: pair.emphasised ? root.urgent : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
}
}