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
502 lines
17 KiB
QML
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
|
|
}
|
|
}
|
|
}
|