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