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:
501
omarchy-plugin/Panel.qml
Normal file
501
omarchy-plugin/Panel.qml
Normal file
@@ -0,0 +1,501 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user