diff --git a/.gitignore b/.gitignore index c491e9b..d27fa76 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ keys.txt # local env (PATH etc.) .env + +# local visual-verification output +.artifacts/ diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 6d5fd22..c1dc319 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -10,6 +10,9 @@ import qs.widgets.systray import qs.widgets.vitals Scope { + // Dense multi-monitor status rail; visual core is headlessly renderable. + DenseBar {} + // Left sidebar in the Slant (V6) style — toggle with SUPER CTRL S. SideBar {} BarBottom {} diff --git a/dotfiles/quickshell/tests/DenseBarHeadless.qml b/dotfiles/quickshell/tests/DenseBarHeadless.qml new file mode 100644 index 0000000..500d43d --- /dev/null +++ b/dotfiles/quickshell/tests/DenseBarHeadless.qml @@ -0,0 +1,27 @@ +import QtQuick +import qs.widgets.bar + +DenseBarContent { + width: 1920 + height: 164 + + now: new Date(2026, 7, 27, 21, 47, 0) + hostName: "TERRA" + telemetryReady: true + ratesReady: true + cpuFraction: 0.62 + cpuThreads: 16 + memoryFraction: 0.78 + memoryUsedText: "24.9G" + temperatureCelsius: 54 + networkInterface: "enp7s0" + networkRxText: "842.6M/S" + networkTxText: "116.2M/S" + storageFraction: 0.69 + storageFreeText: "1.82T FREE" + workspaceIds: [1, 2, 3, 4] + activeWorkspaceId: 1 + trayCount: 3 + audioFraction: 0.50 + audioMuted: false +} diff --git a/dotfiles/quickshell/tests/HeadlessSmoke.qml b/dotfiles/quickshell/tests/HeadlessSmoke.qml new file mode 100644 index 0000000..909f742 --- /dev/null +++ b/dotfiles/quickshell/tests/HeadlessSmoke.qml @@ -0,0 +1,155 @@ +import QtQuick +import QtQuick.Shapes + +Item { + id: root + + implicitWidth: 720 + implicitHeight: 120 + + readonly property color voidColor: "#0a0a0a" + readonly property color inkColor: "#dedede" + readonly property color mutedColor: "#858585" + readonly property color accentColor: "#e8722a" + + Rectangle { + anchors.fill: parent + color: root.voidColor + } + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: root.voidColor + strokeColor: Qt.rgba(0.87, 0.87, 0.87, 0.28) + strokeWidth: 1 + startX: 1 + startY: 1 + PathLine { x: root.width - 14; y: 1 } + PathLine { x: root.width - 1; y: 14 } + PathLine { x: root.width - 1; y: root.height - 9 } + PathLine { x: root.width - 9; y: root.height - 1 } + PathLine { x: 17; y: root.height - 1 } + PathLine { x: 1; y: root.height - 17 } + PathLine { x: 1; y: 1 } + } + + ShapePath { + fillColor: root.accentColor + strokeWidth: 0 + startX: 1 + startY: 1 + PathLine { x: 92; y: 1 } + PathLine { x: 92; y: 3 } + PathLine { x: 1; y: 3 } + PathLine { x: 1; y: 1 } + } + } + + Rectangle { + x: 14 + y: 12 + width: 29 + height: 14 + color: root.accentColor + + Text { + anchors.centerIn: parent + text: "001" + color: root.voidColor + font.pixelSize: 8 + font.bold: true + } + } + + Text { + x: 52 + y: 13 + text: "HEADLESS RENDER ARRAY" + color: root.inkColor + font.family: "monospace" + font.pixelSize: 9 + font.bold: true + font.letterSpacing: 1.4 + } + + Rectangle { + x: 14 + y: 34 + width: root.width - 28 + height: 1 + color: root.inkColor + opacity: 0.18 + } + + Text { + x: 16 + y: 48 + text: "ORBITAL" + color: root.inkColor + font.family: "sans-serif-condensed" + font.pixelSize: 28 + font.bold: true + font.letterSpacing: 2 + } + + Text { + x: 18 + y: 80 + text: "QML // GRABTOIMAGE // SOFTWARE RHI" + color: root.accentColor + font.family: "monospace" + font.pixelSize: 7 + font.letterSpacing: 1.4 + } + + Row { + x: 250 + y: 56 + spacing: 4 + + Repeater { + model: 16 + + Rectangle { + required property int index + width: 20 + height: 12 + color: index < 11 ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.06) + border.width: 1 + border.color: index < 11 ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.2) + } + } + } + + Text { + x: 250 + y: 78 + text: "RENDER PIPELINE" + color: root.mutedColor + font.family: "monospace" + font.pixelSize: 7 + font.letterSpacing: 1.1 + } + + Text { + x: 585 + y: 77 + text: "68.75%" + color: root.inkColor + font.family: "monospace" + font.pixelSize: 15 + font.bold: true + } + + Rectangle { + x: 14 + y: root.height - 9 + width: root.width - 28 + height: 3 + color: root.accentColor + opacity: 0.6 + } +} diff --git a/dotfiles/quickshell/widgets/bar/DenseBar.qml b/dotfiles/quickshell/widgets/bar/DenseBar.qml new file mode 100644 index 0000000..fe1a41c --- /dev/null +++ b/dotfiles/quickshell/widgets/bar/DenseBar.qml @@ -0,0 +1,86 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.Pipewire +import Quickshell.Services.SystemTray +import QtQuick +import qs.widgets.vitals + +// Production/layer-shell adapter. All visual composition lives in the +// Item-rooted DenseBarContent so the exact bar can be rendered headlessly. +Scope { + id: root + + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property real volume: sink?.audio?.volume ?? 0 + readonly property bool muted: sink?.audio?.muted ?? false + + readonly property var workspaceIds: Hyprland.workspaces.values + .filter(workspace => workspace.id > 0) + .map(workspace => workspace.id) + readonly property int activeWorkspaceId: Hyprland.focusedWorkspace?.id ?? 1 + readonly property var rootDisk: vitals.disks.length > 0 ? vitals.disks[0] : null + + PwObjectTracker { + objects: [root.sink] + } + + VitalsData { + id: vitals + active: true + } + + Variants { + model: Quickshell.screens + + PanelWindow { + id: window + + required property var modelData + screen: modelData + color: "transparent" + implicitHeight: 164 + + anchors { + top: true + left: true + right: true + } + + DenseBarContent { + anchors.fill: parent + + hostName: vitals.host || "LOCAL" + telemetryReady: vitals.ready && !vitals.failed + ratesReady: vitals.ratesReady && !vitals.failed + cpuFraction: vitals.cpu + cpuThreads: vitals.cpuThreads + memoryFraction: vitals.memTotal > 0 ? vitals.memUsed / vitals.memTotal : 0 + memoryUsedText: vitals.fmtBytes(vitals.memUsed) + temperatureCelsius: vitals.cpuTemp + networkInterface: vitals.netIface || "NET" + networkRxText: vitals.fmtRate(vitals.netRx) + networkTxText: vitals.fmtRate(vitals.netTx) + storageFraction: root.rootDisk && root.rootDisk.size > 0 + ? root.rootDisk.used / root.rootDisk.size : 0 + storageFreeText: root.rootDisk + ? vitals.fmtBytes(root.rootDisk.size - root.rootDisk.used) + " FREE" + : "-- FREE" + workspaceIds: root.workspaceIds.length > 0 ? root.workspaceIds : [1, 2, 3, 4] + activeWorkspaceId: root.activeWorkspaceId + trayCount: SystemTray.items.values.length + audioFraction: Math.max(0, Math.min(1, root.volume)) + audioMuted: root.muted + + onWorkspaceActivated: workspaceId => { + const workspace = Hyprland.workspaces.values.find(item => item.id === workspaceId); + if (workspace) + workspace.activate(); + else + Hyprland.dispatch("workspace " + workspaceId); + } + } + } + } +} diff --git a/dotfiles/quickshell/widgets/bar/DenseBarContent.qml b/dotfiles/quickshell/widgets/bar/DenseBarContent.qml new file mode 100644 index 0000000..dd8d28c --- /dev/null +++ b/dotfiles/quickshell/widgets/bar/DenseBarContent.qml @@ -0,0 +1,799 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes + +// Renderable visual core for the desktop telemetry rail. Runtime services stay +// in DenseBar.qml so this Item can be exercised without Wayland or Hyprland. +Item { + id: root + + implicitWidth: 1920 + implicitHeight: 164 + + readonly property color voidColor: "#0a0a0a" + readonly property color inkColor: "#dedede" + readonly property color mutedColor: "#858585" + readonly property color accentColor: "#e8722a" + readonly property color hairColor: Qt.rgba(0.87, 0.87, 0.87, 0.28) + readonly property bool compact: width <= 1400 + readonly property string displayFont: "DepartureMono Nerd Font" + readonly property string microFont: "DejaVu Sans Mono" + + property bool autoClock: true + property date now: new Date() + property string hostName: "LOCAL" + property bool telemetryReady: false + property bool ratesReady: false + property real cpuFraction: 0 + property int cpuThreads: 0 + property real memoryFraction: 0 + property string memoryUsedText: "--" + property real temperatureCelsius: NaN + property string networkInterface: "NET" + property string networkRxText: "--" + property string networkTxText: "--" + property real storageFraction: 0 + property string storageFreeText: "-- FREE" + property var workspaceIds: [1, 2, 3, 4] + property int activeWorkspaceId: 1 + property int trayCount: 0 + property real audioFraction: 0 + property bool audioMuted: false + + signal workspaceActivated(int workspaceId) + + function pct(value, ready) { + return ready ? Math.round(Math.max(0, Math.min(1, value)) * 100) : 0; + } + + function two(value) { + return value < 10 ? "0" + value : String(value); + } + + function timeText(value) { + return root.two(value.getHours()) + ":" + root.two(value.getMinutes()); + } + + function dateText(value) { + const days = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; + const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + return days[value.getDay()] + " // " + root.two(value.getDate()) + " " + months[value.getMonth()]; + } + + Timer { + interval: 30000 + running: root.autoClock + repeat: true + triggeredOnStart: true + onTriggered: root.now = new Date() + } + + Rectangle { + anchors.fill: parent + color: root.voidColor + } + + // Faint drafting grid; no gradient and deliberately subordinate to data. + Repeater { + model: Math.ceil(root.width / 40) + Rectangle { + required property int index + x: index * 40 + width: 1 + height: root.height + color: root.inkColor + opacity: 0.018 + } + } + + Repeater { + model: Math.ceil(root.height / 40) + Rectangle { + required property int index + y: index * 40 + width: root.width + height: 1 + color: root.inkColor + opacity: 0.018 + } + } + + Item { + id: array + x: 12 + y: 8 + width: root.width - 24 + height: 92 + + readonly property real identityWidth: root.compact ? 250 : 320 + readonly property real radarWidth: root.compact ? 130 : 164 + readonly property real stateWidth: root.compact ? 210 : 250 + readonly property real flexWidth: Math.max(250, (width - identityWidth - radarWidth - stateWidth - 32) / 2) + + Row { + anchors.fill: parent + spacing: 8 + + TelemetryPanel { + width: array.identityWidth + height: array.height + panelId: "001" + title: "COMMAND LAYER" + + Text { + x: 12 + y: 34 + text: root.hostName.toUpperCase() + color: root.inkColor + font.family: root.displayFont + font.pixelSize: root.compact ? 20 : 25 + font.bold: true + font.letterSpacing: 2 + elide: Text.ElideRight + width: parent.width - 92 + } + + Text { + x: 13 + y: 63 + text: "STATUS ARRAY // " + (root.telemetryReady ? "LIVE" : "STANDBY") + color: root.accentColor + font.family: root.microFont + font.pixelSize: 7 + font.letterSpacing: 1.4 + } + + Rectangle { + x: parent.width - 75 + y: 36 + width: 1 + height: 43 + color: root.hairColor + } + + Rectangle { + x: parent.width - 62 + y: 42 + width: 5 + height: 5 + color: root.accentColor + opacity: root.telemetryReady ? 1 : 0.35 + } + + Text { + x: parent.width - 50 + y: 38 + text: root.telemetryReady ? "ONLINE" : "LOCAL" + color: root.accentColor + font.family: root.microFont + font.pixelSize: 7 + } + + Text { + x: parent.width - 63 + y: 57 + text: "NODE // " + (root.hostName || "--").toUpperCase().slice(0, 7) + color: root.mutedColor + font.family: root.microFont + font.pixelSize: 6 + } + + Shape { + x: parent.width - 63 + y: 71 + width: 48 + height: 10 + ShapePath { + fillColor: "transparent" + strokeColor: root.accentColor + strokeWidth: 1 + startX: 0; startY: 6 + PathLine { x: 11; y: 6 } + PathLine { x: 16; y: 1 } + PathLine { x: 22; y: 9 } + PathLine { x: 27; y: 6 } + PathLine { x: 48; y: 6 } + } + } + } + + TelemetryPanel { + width: array.flexWidth + height: array.height + panelId: "02" + title: "RESOURCE LATTICE" + meta: root.telemetryReady ? "REALTIME" : "FALLBACK" + + Row { + x: 11 + y: 32 + width: parent.width - 22 + height: 51 + spacing: root.compact ? 7 : 12 + + MetricBlock { + width: (parent.width - parent.spacing * (root.compact ? 1 : 2)) / (root.compact ? 2 : 3) + label: "CPU" + value: root.cpuFraction + ready: root.ratesReady + readout: root.ratesReady ? root.pct(root.cpuFraction, true) + "%" : "--" + detailLeft: "CORE:" + (root.cpuThreads || "--") + detailRight: root.ratesReady ? "BUSY" : "PRIME" + } + + MetricBlock { + width: (parent.width - parent.spacing * (root.compact ? 1 : 2)) / (root.compact ? 2 : 3) + label: "MEM" + value: root.memoryFraction + ready: root.telemetryReady + readout: root.telemetryReady ? root.pct(root.memoryFraction, true) + "%" : "--" + detailLeft: "ALLOC" + detailRight: root.memoryUsedText + } + + MetricBlock { + visible: !root.compact + width: (parent.width - parent.spacing * 2) / 3 + label: "THERM" + value: isFinite(root.temperatureCelsius) ? root.temperatureCelsius / 100 : 0 + ready: isFinite(root.temperatureCelsius) + readout: isFinite(root.temperatureCelsius) ? Math.round(root.temperatureCelsius) + "°C" : "--" + detailLeft: "ZONE:01" + detailRight: isFinite(root.temperatureCelsius) && root.temperatureCelsius >= 85 ? "HOT" : "NOMINAL" + } + } + } + + TelemetryPanel { + width: array.radarWidth + height: array.height + panelId: "03" + title: "SCAN" + + RadarGauge { + anchors.horizontalCenter: parent.horizontalCenter + y: 28 + size: 57 + level: root.cpuFraction + } + } + + TelemetryPanel { + width: array.flexWidth + height: array.height + panelId: "04" + title: "CARRIER UPLINK" + meta: root.networkInterface.toUpperCase() + + NetworkTrace { + x: 12 + y: 43 + width: parent.width - (root.compact ? 117 : 145) + height: 33 + level: root.ratesReady ? root.cpuFraction : 0.25 + } + + Column { + anchors.right: parent.right + anchors.rightMargin: 14 + y: 34 + width: root.compact ? 92 : 116 + spacing: 1 + + Text { + width: parent.width + text: root.networkRxText + horizontalAlignment: Text.AlignRight + color: root.accentColor + font.family: root.displayFont + font.pixelSize: root.compact ? 11 : 13 + font.bold: true + elide: Text.ElideLeft + } + MicroText { width: parent.width; text: "RX // DOWN"; horizontalAlignment: Text.AlignRight } + Text { + width: parent.width + text: root.networkTxText + horizontalAlignment: Text.AlignRight + color: root.inkColor + font.family: root.displayFont + font.pixelSize: root.compact ? 11 : 13 + font.bold: true + elide: Text.ElideLeft + } + MicroText { width: parent.width; text: "TX // UP"; horizontalAlignment: Text.AlignRight } + } + } + + TelemetryPanel { + width: array.stateWidth + height: array.height + panelId: "05" + title: "SYSTEM STATE" + + Row { + x: 10 + y: 35 + spacing: 6 + + StateCell { code: "N"; active: root.ratesReady; label: "NET" } + StateCell { code: root.audioMuted ? "M" : "A"; active: !root.audioMuted; label: "AUD" } + StateCell { visible: !root.compact; code: String(root.trayCount); active: root.trayCount > 0; label: "TRAY" } + } + + Rectangle { + x: root.compact ? 101 : 151 + y: 33 + width: 1 + height: 46 + color: root.hairColor + } + + Column { + anchors.right: parent.right + anchors.rightMargin: 12 + y: 34 + width: root.compact ? 91 : 82 + spacing: 2 + + Text { + width: parent.width + text: root.timeText(root.now) + horizontalAlignment: Text.AlignRight + color: root.inkColor + font.family: root.displayFont + font.pixelSize: root.compact ? 20 : 22 + font.bold: true + font.letterSpacing: 1 + } + Text { + width: parent.width + text: root.dateText(root.now) + horizontalAlignment: Text.AlignRight + color: root.accentColor + font.family: root.microFont + font.pixelSize: 6 + elide: Text.ElideLeft + } + MicroText { width: parent.width; text: "LOCAL TIME"; horizontalAlignment: Text.AlignRight } + } + } + } + } + + TelemetryPanel { + id: rail + x: 12 + y: 108 + width: root.width - 24 + height: 34 + showHeader: false + chamfer: 10 + + Row { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + + RailSection { + width: root.compact ? 252 : 310 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "06 // DESKTOP" + color: root.accentColor + font.family: root.microFont + font.pixelSize: 7 + font.letterSpacing: 1 + } + Row { + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Repeater { + model: root.workspaceIds.slice(0, root.compact ? 4 : 6) + Rectangle { + required property var modelData + width: 24 + height: 17 + color: Number(modelData) === root.activeWorkspaceId ? Qt.rgba(0.91, 0.45, 0.16, 0.18) : "transparent" + border.width: 1 + border.color: Number(modelData) === root.activeWorkspaceId ? root.accentColor : root.hairColor + Text { + anchors.centerIn: parent + text: root.two(Number(parent.modelData)) + color: Number(parent.modelData) === root.activeWorkspaceId ? root.accentColor : root.mutedColor + font.family: root.microFont + font.pixelSize: 7 + } + MouseArea { + anchors.fill: parent + onClicked: root.workspaceActivated(Number(parent.modelData)) + } + } + } + } + } + + RailSection { + width: root.compact ? 260 : 410 + MicroText { x: 9; anchors.verticalCenter: parent.verticalCenter; text: "PROCESS" } + Text { + x: 76 + anchors.verticalCenter: parent.verticalCenter + text: "COMPOSITOR // NODE_EXPORTER // SHELL" + color: root.telemetryReady ? root.inkColor : root.mutedColor + font.family: root.microFont + font.pixelSize: 7 + elide: Text.ElideRight + width: parent.width - 84 + } + } + + RailSection { + width: root.compact ? 300 : 385 + MicroText { x: 9; anchors.verticalCenter: parent.verticalCenter; text: "VOL // ROOT" } + Text { + x: 93 + anchors.verticalCenter: parent.verticalCenter + text: root.storageFreeText + color: root.inkColor + font.family: root.microFont + font.pixelSize: 8 + } + SegmentMeter { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: root.compact ? 95 : 170 + height: 7 + segments: root.compact ? 10 : 16 + value: root.storageFraction + ready: root.telemetryReady + } + } + + RailSection { + visible: !root.compact + width: 350 + MicroText { x: 9; anchors.verticalCenter: parent.verticalCenter; text: "AUDIO BUS" } + Text { + x: 89 + anchors.verticalCenter: parent.verticalCenter + text: root.audioMuted ? "MUTED" : Math.round(root.audioFraction * 100) + "%" + color: root.audioMuted ? root.mutedColor : root.accentColor + font.family: root.microFont + font.pixelSize: 8 + } + SegmentMeter { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 155 + height: 7 + segments: 16 + value: root.audioFraction + ready: true + } + } + + RailSection { + width: Math.max(150, rail.width - (root.compact ? 812 : 1455) - 20) + borderVisible: false + MicroText { x: 9; anchors.verticalCenter: parent.verticalCenter; text: root.compact ? "SYS // " + root.timeText(root.now) : "EVENTS" } + Text { + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: root.telemetryReady ? "WARN:00 // ERR:00" : "TELEMETRY WAIT" + color: root.telemetryReady ? root.mutedColor : root.accentColor + font.family: root.microFont + font.pixelSize: 7 + elide: Text.ElideLeft + width: parent.width - 64 + horizontalAlignment: Text.AlignRight + } + } + } + } + + // Bottom routing trace and caution hatch. + Shape { + x: 12 + y: 150 + width: root.width - 24 + height: 12 + ShapePath { + fillColor: "transparent" + strokeColor: Qt.rgba(0.91, 0.45, 0.16, 0.55) + strokeWidth: 1 + startX: 0; startY: 1 + PathLine { x: 220; y: 1 } + PathLine { x: 232; y: 11 } + PathLine { x: 330; y: 11 } + } + } + + Row { + x: 360 + y: 151 + width: root.width - 372 + height: 3 + spacing: 5 + clip: true + Repeater { + model: Math.ceil(parent.width / 10) + Rectangle { + required property int index + width: 5 + height: 3 + color: index % 2 === 0 ? root.accentColor : "transparent" + opacity: 0.58 + } + } + } + + component MicroText: Text { + color: root.mutedColor + font.family: root.microFont + font.pixelSize: 6 + font.letterSpacing: 0.7 + } + + component RailSection: Item { + property bool borderVisible: true + height: rail.height + Rectangle { + visible: parent.borderVisible + anchors.right: parent.right + width: 1 + height: parent.height + color: root.hairColor + opacity: 0.65 + } + } + + component TelemetryPanel: Item { + id: panel + property string panelId: "" + property string title: "" + property string meta: "" + property bool showHeader: true + property int chamfer: 13 + + Shape { + id: panelShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: root.voidColor + strokeColor: root.hairColor + strokeWidth: 1 + startX: 1; startY: 1 + PathLine { x: panelShape.width - panel.chamfer; y: 1 } + PathLine { x: panelShape.width - 1; y: panel.chamfer } + PathLine { x: panelShape.width - 1; y: panelShape.height - 8 } + PathLine { x: panelShape.width - 8; y: panelShape.height - 1 } + PathLine { x: 16; y: panelShape.height - 1 } + PathLine { x: 1; y: panelShape.height - 16 } + PathLine { x: 1; y: 1 } + } + ShapePath { + fillColor: root.accentColor + strokeWidth: 0 + startX: 1; startY: 1 + PathLine { x: Math.min(49, panelShape.width / 3); y: 1 } + PathLine { x: Math.min(49, panelShape.width / 3); y: 3 } + PathLine { x: 1; y: 3 } + PathLine { x: 1; y: 1 } + } + } + + Rectangle { + visible: panel.showHeader + x: 1; y: 20 + width: parent.width - 2 + height: 1 + color: root.inkColor + opacity: 0.12 + } + + Rectangle { + visible: panel.showHeader + x: 9; y: 5 + width: panel.panelId.length > 2 ? 29 : 24 + height: 11 + color: root.accentColor + Text { + anchors.centerIn: parent + text: panel.panelId + color: root.voidColor + font.family: root.microFont + font.pixelSize: 7 + font.bold: true + } + } + + Text { + visible: panel.showHeader + x: 40; y: 5 + width: parent.width - 105 + text: panel.title + color: root.inkColor + font.family: root.displayFont + font.pixelSize: 9 + font.bold: true + font.letterSpacing: 1.1 + elide: Text.ElideRight + } + + MicroText { + visible: panel.showHeader && panel.meta.length > 0 + anchors.right: parent.right + anchors.rightMargin: 12 + y: 6 + text: panel.meta + width: Math.min(80, parent.width / 4) + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + + Row { + visible: panel.showHeader + anchors.right: parent.right + anchors.rightMargin: 10 + y: 14 + spacing: 2 + Repeater { + model: 5 + Rectangle { required property int index; width: 3; height: 1; color: root.accentColor } + } + } + } + + component MetricBlock: Item { + id: metric + property string label: "" + property real value: 0 + property bool ready: false + property string readout: "--" + property string detailLeft: "" + property string detailRight: "" + height: 51 + + MicroText { x: 0; y: 1; text: metric.label; font.pixelSize: 7 } + Text { + anchors.right: parent.right + y: -3 + text: metric.readout + color: root.inkColor + font.family: root.displayFont + font.pixelSize: root.compact ? 14 : 17 + font.bold: true + } + SegmentMeter { + x: 0; y: 19 + width: parent.width + height: 9 + segments: 10 + value: metric.value + ready: metric.ready + } + MicroText { x: 0; y: 34; text: metric.detailLeft } + MicroText { anchors.right: parent.right; y: 34; text: metric.detailRight; horizontalAlignment: Text.AlignRight } + } + + component SegmentMeter: Row { + id: meter + property int segments: 10 + property real value: 0 + property bool ready: false + spacing: 2 + Repeater { + model: meter.segments + Rectangle { + required property int index + width: Math.max(2, (meter.width - (meter.segments - 1) * meter.spacing) / meter.segments) + height: meter.height + readonly property bool on: meter.ready && index < Math.round(Math.max(0, Math.min(1, meter.value)) * meter.segments) + color: on ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.06) + border.width: 1 + border.color: on ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.18) + } + } + } + + component StateCell: Item { + id: state + property string code: "--" + property string label: "" + property bool active: false + width: root.compact ? 39 : 43 + height: 42 + + Shape { + anchors.fill: parent + ShapePath { + fillColor: state.active ? Qt.rgba(0.91, 0.45, 0.16, 0.18) : "transparent" + strokeColor: state.active ? root.accentColor : root.hairColor + strokeWidth: 1 + startX: 1; startY: 1 + PathLine { x: parent.width - 7; y: 1 } + PathLine { x: parent.width - 1; y: 7 } + PathLine { x: parent.width - 1; y: parent.height - 1 } + PathLine { x: 7; y: parent.height - 1 } + PathLine { x: 1; y: parent.height - 7 } + PathLine { x: 1; y: 1 } + } + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + y: 7 + text: state.code + color: state.active ? root.accentColor : root.inkColor + font.family: root.displayFont + font.pixelSize: 13 + font.bold: true + } + MicroText { anchors.horizontalCenter: parent.horizontalCenter; y: 27; text: state.label } + } + + component RadarGauge: Item { + id: radar + property real size: 58 + property real level: 0 + width: size + height: size + + Repeater { + model: [1, 0.72, 0.36] + Rectangle { + required property int index + required property var modelData + anchors.centerIn: parent + width: radar.size * Number(modelData) + height: width + radius: width / 2 + color: "transparent" + border.width: 1 + border.color: index === 0 ? root.accentColor : root.hairColor + } + } + Rectangle { x: 0; y: parent.height / 2; width: parent.width; height: 1; color: root.hairColor } + Rectangle { x: parent.width / 2; y: 0; width: 1; height: parent.height; color: root.hairColor } + Rectangle { x: 12; y: 18; width: 4; height: 4; color: root.accentColor } + Rectangle { x: parent.width - 14; y: parent.height - 20; width: 4; height: 4; color: root.accentColor } + Rectangle { x: parent.width / 2; y: parent.height - 11; width: 4; height: 4; color: root.accentColor } + MicroText { anchors.right: parent.right; y: 3; text: "R:" + Math.round(radar.level * 99); color: root.accentColor } + } + + component NetworkTrace: Item { + id: trace + property real level: 0 + Rectangle { x: 0; y: parent.height - 1; width: parent.width; height: 1; color: root.hairColor } + Rectangle { x: 0; y: 0; width: 1; height: parent.height; color: root.hairColor } + Shape { + anchors.fill: parent + ShapePath { + fillColor: "transparent" + strokeColor: root.accentColor + strokeWidth: 1.2 + startX: 0; startY: trace.height * 0.65 + PathLine { x: trace.width * 0.10; y: trace.height * 0.65 } + PathLine { x: trace.width * 0.15; y: trace.height * (0.25 + trace.level * 0.15) } + PathLine { x: trace.width * 0.21; y: trace.height * 0.78 } + PathLine { x: trace.width * 0.31; y: trace.height * 0.56 } + PathLine { x: trace.width * 0.43; y: trace.height * 0.62 } + PathLine { x: trace.width * 0.49; y: trace.height * 0.18 } + PathLine { x: trace.width * 0.57; y: trace.height * 0.82 } + PathLine { x: trace.width * 0.68; y: trace.height * 0.58 } + PathLine { x: trace.width * 0.79; y: trace.height * 0.62 } + PathLine { x: trace.width * 0.85; y: trace.height * 0.34 } + PathLine { x: trace.width * 0.91; y: trace.height * 0.75 } + PathLine { x: trace.width; y: trace.height * 0.60 } + } + } + } +} diff --git a/tools/quickshell-preview/Dockerfile b/tools/quickshell-preview/Dockerfile new file mode 100644 index 0000000..7aac813 --- /dev/null +++ b/tools/quickshell-preview/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 +FROM nixos/nix:2.30.2 + +ARG NIXPKGS_REV=e5bdc4a41d4c072fe1e3787eaa0320a384741d44 +ARG QSMCP_REV=76b1b008f0352064121af9334065012e31d80fc2 + +ENV NIX_CONFIG="experimental-features = nix-command flakes" \ + PATH="/nix/var/nix/profiles/default/bin:${PATH}" + +RUN nix profile install --profile /nix/var/nix/profiles/default \ + "github:NixOS/nixpkgs/${NIXPKGS_REV}#quickshell" \ + "github:NixOS/nixpkgs/${NIXPKGS_REV}#nodejs_22" + +RUN mkdir -p /opt/qsmcp \ + && git init /opt/qsmcp \ + && git -C /opt/qsmcp remote add origin https://github.com/fedsfarm/qsmcp.git \ + && git -C /opt/qsmcp fetch --depth 1 origin "${QSMCP_REV}" \ + && git -C /opt/qsmcp checkout --detach FETCH_HEAD \ + && test "$(git -C /opt/qsmcp rev-parse HEAD)" = "${QSMCP_REV}" \ + && cd /opt/qsmcp \ + && npm ci --omit=dev \ + && rm -rf /opt/qsmcp/.git + +# qsmcp's CLI hard-codes a 1568px safety cap. Desktop bar previews need their +# true target width so responsive layout and text collisions are testable. +RUN node -e 'const fs=require("fs"); const p="/opt/qsmcp/src/index.ts"; const s=fs.readFileSync(p,"utf8"); const old="max_dimension: 1568"; if (!s.includes(old)) throw new Error("qsmcp max_dimension source changed"); fs.writeFileSync(p,s.replace(old,"max_dimension: 4096"))' + +ENV QSMCP_SHELL_ROOT=/workspace/quickshell \ + QSMCP_QS_BIN=/nix/var/nix/profiles/default/bin/qs \ + QT_QUICK_BACKEND=software + +COPY entrypoint.sh /usr/local/bin/quickshell-preview +RUN chmod +x /usr/local/bin/quickshell-preview + +RUN mkdir -p /workspace/quickshell +WORKDIR /workspace/quickshell + +ENTRYPOINT ["/usr/local/bin/quickshell-preview"] diff --git a/tools/quickshell-preview/README.md b/tools/quickshell-preview/README.md new file mode 100644 index 0000000..a922dec --- /dev/null +++ b/tools/quickshell-preview/README.md @@ -0,0 +1,39 @@ +# Headless Quickshell previews + +This directory provides a reproducible Docker wrapper around +[qsmcp](https://github.com/fedsfarm/qsmcp). It renders a Quickshell `Item` to a +PNG with Qt's `grabToImage()` without connecting to the host Wayland session. + +Both nixpkgs and qsmcp are pinned in `Dockerfile`. The runner uses +`docker create` plus `docker cp` rather than bind mounts, so it also works with +rootless/remote daemons whose mount namespace cannot see the checkout. + +## Smoke test + +Start Docker, then run from the repository root: + +```sh +./tools/quickshell-preview/render.sh +``` + +The default output is: + +```text +/tmp/homelab-quickshell-preview/smoke.png +``` + +## Render another Item-rooted component + +```sh +./tools/quickshell-preview/render.sh \ + tests/DenseBarHeadless.qml \ + .artifacts/quickshell-preview/dense-bar-1920.png \ + 1920 164 +``` + +The component path is relative to `dotfiles/quickshell`. Keep production +`PanelWindow` wrappers thin and put their visual content in an `Item` component +so it can be rendered through this offscreen path. + +`PanelWindow`-rooted previews require a nested compositor and are intentionally +outside this first smoke-test image. diff --git a/tools/quickshell-preview/entrypoint.sh b/tools/quickshell-preview/entrypoint.sh new file mode 100644 index 0000000..e3ce9dd --- /dev/null +++ b/tools/quickshell-preview/entrypoint.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p /tmp/qsmcp-runtime /tmp/qsmcp-home /output +chmod 700 /tmp/qsmcp-runtime + +export HOME=/tmp/qsmcp-home +export XDG_RUNTIME_DIR=/tmp/qsmcp-runtime + +exec /nix/var/nix/profiles/default/bin/node \ + /opt/qsmcp/src/index.ts render "$@" diff --git a/tools/quickshell-preview/render.sh b/tools/quickshell-preview/render.sh new file mode 100755 index 0000000..1c45a8f --- /dev/null +++ b/tools/quickshell-preview/render.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" +tool_dir="$repo_root/tools/quickshell-preview" +shell_root="$repo_root/dotfiles/quickshell" +image="homelab-quickshell-preview:qsmcp-76b1b008" + +component="${1:-tests/HeadlessSmoke.qml}" +output="${2:-${TMPDIR:-/tmp}/homelab-quickshell-preview/smoke.png}" +width="${3:-720}" +height="${4:-120}" + +if ! docker info >/dev/null 2>&1; then + echo "error: Docker-compatible daemon is unavailable" >&2 + exit 69 +fi + +case "$output" in + /*) ;; + *) output="$PWD/$output" ;; +esac + +output_dir="$(dirname "$output")" +output_name="$(basename "$output")" +mkdir -p "$output_dir" + +container_id="" +cleanup() { + if [[ -n "$container_id" ]]; then + docker rm --force "$container_id" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +docker build --tag "$image" --file "$tool_dir/Dockerfile" "$tool_dir" + +container_id="$(docker create \ + --network none \ + --tmpfs /tmp:rw,noexec,nosuid,size=256m \ + "$image" \ + "$component" \ + --width "$width" \ + --height "$height" \ + --dpr 1 \ + --padding 0 \ + --background solid \ + --bg '#0a0a0a' \ + --out "/output/$output_name")" + +# docker cp streams files through the API, so this works with rootless and +# remote daemons whose filesystem cannot see the client's repository path. +docker cp "$shell_root/." "$container_id:/workspace/quickshell" +docker start --attach "$container_id" +docker cp "$container_id:/output/$output_name" "$output" + +test -s "$output" +printf 'rendered=%s\n' "$output"