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
201 lines
7.5 KiB
QML
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|