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