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) } } } } }