Files
streamdeck-go/omarchy-plugin/KeyGrid.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

201 lines
7.5 KiB
QML

import QtQuick
import qs.Commons
import "Model.js" as Model
// The deck laid out as it sits on the desk. Grid shape comes from the device
// report (8x4 on an XL, 5x3 on an MK.2), so this is not hardcoded to one model.
Item {
id: root
property var slots: []
property int cols: 8
property int rows: 4
property int cursorIndex: -1
property bool cursorActive: false
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
// Icons are previewed from disk when the file is a raster format; SVG and GIF
// fall back to the kind glyph rather than risking a slow or failed decode in
// the panel's paint path.
property bool showIconPreviews: true
signal keyActivated(int index)
signal keyFocused(int index)
signal keySecondary(int index)
// Themes are not tagged light/dark, so derive it from the panel background's
// perceived luminance (Rec. 601 weights).
readonly property color panelBackground: Color.popups.background
readonly property bool lightTheme: (0.299 * panelBackground.r
+ 0.587 * panelBackground.g
+ 0.114 * panelBackground.b) > 0.5
// Stands in for the deck's physical key face.
readonly property color keyFace: Qt.rgba(0.08, 0.08, 0.08, 1.0)
readonly property real spacing: Style.space(4)
readonly property real cellWidth: cols > 0 ? (width - spacing * (cols - 1)) / cols : 0
// Stream Deck keys are square; keeping the cells square makes the panel read
// as the physical device rather than an abstract table.
readonly property real cellHeight: cellWidth
implicitHeight: rows > 0 ? cellHeight * rows + spacing * (rows - 1) : 0
Repeater {
// Modelled on the slot COUNT, not the slots array: `slots` gets a fresh
// identity on every status poll, and a Repeater over a JS array rebuilds
// every delegate on identity change — 32 cells of object churn and a frame
// of Image flicker per poll. With an int model the delegates persist and
// only the bindings that actually changed re-evaluate.
model: root.cols * root.rows
Item {
id: cell
required property int index
readonly property var slot: (index < root.slots.length)
? root.slots[index]
: ({ index: index, configured: false, kind: "empty", label: "" })
readonly property int keyIndex: Number(slot.index)
readonly property bool configured: slot.configured === true
readonly property bool hasCursor: root.cursorActive && root.cursorIndex === keyIndex
readonly property bool hot: mouse.containsMouse || hasCursor
x: (index % root.cols) * (root.cellWidth + root.spacing)
y: Math.floor(index / root.cols) * (root.cellHeight + root.spacing)
width: root.cellWidth
height: root.cellHeight
Rectangle {
id: face
anchors.fill: parent
radius: Style.cornerRadius > 0 ? Style.space(3) : 0
// Every colour here resolves through Style/Color, so the grid repaints
// itself when the Omarchy theme changes.
color: cell.hot ? Style.hoverFillFor(root.foreground, root.accent)
: cell.configured ? Style.normalFillFor(root.foreground, root.accent)
: "transparent"
border.width: cell.hot ? Style.hoverBorderWidth : Style.normalBorderWidth
border.color: cell.hot ? Style.hoverBorderFor(root.foreground, root.accent)
: Util.alpha(root.foreground, cell.configured ? 0.28 : 0.12)
Behavior on color { ColorAnimation { duration: 90 } }
}
// Deck icons are usually light artwork on transparency, drawn for the
// hardware's black key faces — on a light theme they would wash out. Under
// a light theme only, back the preview with a dark plate standing in for
// the physical key. Dark themes need no plate and get none.
Rectangle {
anchors.centerIn: preview
width: preview.width * 1.12
height: preview.height * 1.12
radius: Style.cornerRadius > 0 ? Style.space(2) : 0
visible: preview.visible && root.lightTheme
color: root.keyFace
opacity: 0.9
}
// Icon preview, when the key has one and it is a format Image decodes
// cheaply. Falls back to the kind glyph below.
Image {
id: preview
anchors.centerIn: parent
// Lifted off centre so the artwork clears the label strip along the
// bottom edge of the cell.
anchors.verticalCenterOffset: -Style.space(4)
width: parent.width * 0.46
height: width
visible: root.showIconPreviews && status === Image.Ready
fillMode: Image.PreserveAspectFit
smooth: true
asynchronous: true
cache: true
sourceSize.width: Math.max(24, Math.round(width))
sourceSize.height: Math.max(24, Math.round(width))
source: {
if (!root.showIconPreviews || !cell.configured) return ""
var path = String(cell.slot.iconPath || "")
if (path === "") return ""
var lower = path.toLowerCase()
if (!lower.endsWith(".png") && !lower.endsWith(".jpg") && !lower.endsWith(".jpeg")) return ""
return Util.fileUrl(path)
}
opacity: cell.configured ? 1.0 : 0.4
}
Text {
anchors.centerIn: parent
anchors.verticalCenterOffset: -Style.space(4)
visible: cell.configured && !preview.visible
text: Model.kindGlyph(String(cell.slot.kind || ""))
color: root.foreground
opacity: 0.75
font.family: root.fontFamily
font.pixelSize: Math.max(Style.font.caption, cell.height * 0.34)
}
// Slot number, always visible so the grid maps onto config.yaml indices.
Text {
anchors.top: parent.top
anchors.left: parent.left
anchors.topMargin: Style.space(2)
anchors.leftMargin: Style.space(3)
text: cell.keyIndex
color: root.foreground
opacity: cell.configured ? 0.45 : 0.28
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottomMargin: Style.space(2)
anchors.leftMargin: Style.space(2)
anchors.rightMargin: Style.space(2)
visible: cell.configured && Model.gridLabel(cell.slot) !== ""
text: Model.gridLabel(cell.slot)
color: root.foreground
opacity: 0.8
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
// A privileged key is worth flagging: pressing it from here is refused by
// the CLI, and it behaves differently on the deck itself.
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.space(3)
anchors.rightMargin: Style.space(3)
visible: cell.slot.privileged === true
width: Style.space(4)
height: width
radius: width / 2
color: Color.urgent
opacity: 0.85
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onEntered: root.keyFocused(cell.keyIndex)
onClicked: function(event) {
if (event.button === Qt.RightButton) root.keySecondary(cell.keyIndex)
else root.keyActivated(cell.keyIndex)
}
}
}
}
}