From 22fe8ab778f8f06e134ae2f79ba98cf6113e64a3 Mon Sep 17 00:00:00 2001 From: luna Date: Thu, 27 Aug 2026 23:52:47 +0000 Subject: [PATCH 01/24] feat(quickshell): add dense telemetry bar --- .gitignore | 3 + dotfiles/quickshell/shell.qml | 3 + .../quickshell/tests/DenseBarHeadless.qml | 27 + dotfiles/quickshell/tests/HeadlessSmoke.qml | 155 ++++ dotfiles/quickshell/widgets/bar/DenseBar.qml | 86 ++ .../widgets/bar/DenseBarContent.qml | 799 ++++++++++++++++++ tools/quickshell-preview/Dockerfile | 38 + tools/quickshell-preview/README.md | 39 + tools/quickshell-preview/entrypoint.sh | 11 + tools/quickshell-preview/render.sh | 58 ++ 10 files changed, 1219 insertions(+) create mode 100644 dotfiles/quickshell/tests/DenseBarHeadless.qml create mode 100644 dotfiles/quickshell/tests/HeadlessSmoke.qml create mode 100644 dotfiles/quickshell/widgets/bar/DenseBar.qml create mode 100644 dotfiles/quickshell/widgets/bar/DenseBarContent.qml create mode 100644 tools/quickshell-preview/Dockerfile create mode 100644 tools/quickshell-preview/README.md create mode 100644 tools/quickshell-preview/entrypoint.sh create mode 100755 tools/quickshell-preview/render.sh 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" From 54896033c6733dc6f9f0cf6daf13b4d7be98cd1b Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Fri, 28 Aug 2026 07:55:03 +0200 Subject: [PATCH 02/24] feat(flake): add a quickshell hot-reload devShell hosts/terra/home.nix ships dotfiles/quickshell via xdg.configFile, which copies the tree into the store: ~/.config/quickshell is a read-only symlink into /nix/store and every QML tweak costs a nixos-rebuild. quickshell does hot-reload on file save -- but only for the files it watches, which are those frozen store copies. `nix develop` now swaps the running shell to the working tree (`qs -p`) and swaps it back on exit, so QML edits need no rebuild at all. The swap starts the dev instance FIRST and kills the packaged one only once dev is confirmed up. A QML error in the working tree then leaves you on your normal bar instead of no bar, which matters because a broken save is exactly when you would be running this. Liveness is "did `qs list -j` return json" -- it exits 0 whether or not it found anything, so the exit code says nothing. Every kill is scoped to one config (`qs kill` = default, `qs kill -p` = that path). A blanket kill would also take out unrelated instances; pkgs/rishot.nix is one. Three guards on the auto-swap, all learned by testing it: - interactive only. `nix develop --command X` EXECs X, replacing the shell that set the `trap ... EXIT`, so the restore never runs and you are left on the dev instance. Non-interactive use gets `nix develop -c qs-dev`. - WAYLAND_DISPLAY, so entering the shell over ssh cannot kill the desktop's bar and leave nothing in its place. - a sentinel, so a nested `nix develop` does not swap and restore twice. Deliberately not wired to direnv (no .envrc): programs.direnv is enabled for this user, so a `use flake` would swap the running desktop shell on every `cd` into the checkout. Verified end to end on terra: swap, hot-reload of a working-tree edit, and restore, plus both the interactive and non-interactive paths. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgcWkm3t6BDQktYHQvb8Hx --- flake.nix | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/flake.nix b/flake.nix index fc1b2a8..457279b 100644 --- a/flake.nix +++ b/flake.nix @@ -552,5 +552,113 @@ machine.crash() ''; }; + + # `nix develop` — hot-reload loop for dotfiles/quickshell. + # + # hosts/terra/home.nix ships the shell via `xdg.configFile."quickshell"`, + # which COPIES the tree into the store, so ~/.config/quickshell is a + # read-only symlink into /nix/store and every QML tweak costs a + # nixos-rebuild. quickshell DOES hot-reload on file save — but only for + # the files it is watching, which are those frozen store copies. Pointing + # it at the working tree with `qs -p` restores edit-save-see, no rebuild. + # + # quickshell keys instance identity on the CONFIG PATH, so a working-tree + # instance and the store-backed one are two different instances that would + # both map layer-shell bars onto every output. Hence a swap, not a second + # instance — and the swap starts dev FIRST, killing the packaged shell + # only once dev is confirmed up, so a QML error in the working tree leaves + # you on your normal bar instead of no bar at all. + # + # Every kill is scoped to one config (`qs kill` = default only, `qs kill + # -p` = that path only). A blanket kill would also take out unrelated + # quickshell instances — pkgs/rishot.nix is one. + # + # Deliberately NOT wired to direnv (no .envrc in this repo): programs.direnv + # is enabled for this user, so a `use flake` would swap the running desktop + # shell on every `cd` into the checkout, including over ssh. + devShells.${system}.default = + let + pkgs = nixpkgs.legacyPackages.${system}; + # Same nixpkgs terra's home.nix takes pkgs.quickshell from, so the dev + # instance is the identical build to the packaged one. + qs = "${nixpkgs.legacyPackages.${system}.quickshell}/bin/qs"; + git = "${nixpkgs.legacyPackages.${system}.git}/bin/git"; + + # Resolved at RUN time, not build time: the entire point is to run the + # working tree, and `self` here is only a store snapshot of it. + preamble = '' + root="$(${git} rev-parse --show-toplevel 2>/dev/null || pwd)" + cfg="$root/dotfiles/quickshell" + if [ ! -f "$cfg/shell.qml" ]; then + echo "no shell.qml under $cfg — run this from the homelab checkout" >&2 + exit 1 + fi + # `qs list` exits 0 whether or not it found anything, and only emits + # json when it did — so "json came back" is the liveness test. + running() { ${qs} list -p "$1" -j 2>/dev/null | grep -q '"id"'; } + prod_running() { ${qs} list -j 2>/dev/null | grep -q '"id"'; } + ''; + + qs-dev = pkgs.writeShellScriptBin "qs-dev" '' + set -uo pipefail + ${preamble} + + if [ -z "''${WAYLAND_DISPLAY:-}" ]; then + echo "qs-dev: no WAYLAND_DISPLAY — refusing to swap the desktop shell" >&2 + exit 1 + fi + + if running "$cfg"; then + echo "qs-dev: already running from $cfg" + exit 0 + fi + + ${qs} -d -p "$cfg" + + # Confirm it came up before touching the packaged shell. + for _ in $(seq 1 50); do + running "$cfg" && break + sleep 0.1 + done + + if ! running "$cfg"; then + echo "qs-dev: dev shell failed to start — packaged shell left alone" >&2 + echo "qs-dev: run 'qs -p $cfg' in the foreground to see the QML error" >&2 + exit 1 + fi + + ${qs} kill || true + echo "qs-dev: live on $cfg — edits there now hot-reload" + ''; + + qs-prod = pkgs.writeShellScriptBin "qs-prod" '' + set -uo pipefail + ${preamble} + + running "$cfg" && ${qs} kill -p "$cfg" || true + prod_running || ${qs} -d + echo "qs-prod: back on ~/.config/quickshell" + ''; + in + pkgs.mkShell { + packages = [ pkgs.quickshell qs-dev qs-prod ]; + + # Swap on entry, swap back on exit. Three guards: + # - interactive only ($- has i). `nix develop --command X` EXECs X, + # replacing the shell that set the trap, so the restore would + # never run and you'd be left on the dev instance. Non-interactive + # use gets the explicit `nix develop -c qs-dev` instead. + # - WAYLAND_DISPLAY, so entering the shell over ssh cannot kill the + # desktop's bar and leave nothing in its place. + # - a sentinel, so a nested `nix develop` does not swap (and then + # restore) a second time. + shellHook = '' + if [[ $- == *i* ]] && [ -n "''${WAYLAND_DISPLAY:-}" ] && [ -z "''${HOMELAB_QS_DEV:-}" ]; then + export HOMELAB_QS_DEV=1 + qs-dev && trap qs-prod EXIT + fi + echo "homelab devshell — qs-dev (working tree) / qs-prod (packaged); exit restores" + ''; + }; }; } From 01bc16918084a0cb5cd0ac8f0456f92b4b042dff Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Fri, 28 Aug 2026 07:55:59 +0200 Subject: [PATCH 03/24] refactor(quickshell): rework the panel chrome, extract it as StatusBarPanel The dense bar's panel chrome gets a squarer outline (top-right and bottom-left chamfers only, square on the other two corners) and a second accent line in the lower right to balance the existing upper-left one. The chrome then moves out of DenseBarContent's inline `component TelemetryPanel` into its own file. The call sites are unchanged apart from the name -- children still come from the default property -- and the widgets they pass in (RadarGauge, NetworkTrace, MetricBlock) stay declared in DenseBarContent, so their scope is unaffected by moving only the definition. Two things could not come along and had to be reproduced locally, because a component in its own file has no access to the enclosing scope: - the palette and the two font families, previously read off `root`. They are properties with defaults matching DenseBarContent's, which is this repo's per-component convention. Duplicated on purpose for now; a shared theme singleton is the place to collapse it. - MicroText, which is an *inline* component of DenseBarContent and so invisible from another file. Expanded to the Text it desugars to. Also qualified the bare offsetY/chamfer/accentLineThickness references as panel.*; they resolved through the component scope before, but being explicit avoids ComponentBehavior: Bound warnings in the new file. Verified the move was verbatim by normalising the old inline block and the new file body and diffing them -- the only differences are the relocated property block, the panel.* qualification and the MicroText expansion. Loads clean both headlessly and in the real layer-shell shell. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgcWkm3t6BDQktYHQvb8Hx --- .../widgets/bar/DenseBarContent.qml | 115 +------------- .../quickshell/widgets/bar/StatusBarPanel.qml | 141 ++++++++++++++++++ 2 files changed, 147 insertions(+), 109 deletions(-) create mode 100644 dotfiles/quickshell/widgets/bar/StatusBarPanel.qml diff --git a/dotfiles/quickshell/widgets/bar/DenseBarContent.qml b/dotfiles/quickshell/widgets/bar/DenseBarContent.qml index dd8d28c..38e598d 100644 --- a/dotfiles/quickshell/widgets/bar/DenseBarContent.qml +++ b/dotfiles/quickshell/widgets/bar/DenseBarContent.qml @@ -69,11 +69,6 @@ Item { 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) @@ -115,7 +110,7 @@ Item { anchors.fill: parent spacing: 8 - TelemetryPanel { + StatusBarPanel { width: array.identityWidth height: array.height panelId: "001" @@ -198,7 +193,7 @@ Item { } } - TelemetryPanel { + StatusBarPanel { width: array.flexWidth height: array.height panelId: "02" @@ -245,7 +240,7 @@ Item { } } - TelemetryPanel { + StatusBarPanel { width: array.radarWidth height: array.height panelId: "03" @@ -259,7 +254,7 @@ Item { } } - TelemetryPanel { + StatusBarPanel { width: array.flexWidth height: array.height panelId: "04" @@ -306,7 +301,7 @@ Item { } } - TelemetryPanel { + StatusBarPanel { width: array.stateWidth height: array.height panelId: "05" @@ -362,7 +357,7 @@ Item { } } - TelemetryPanel { + StatusBarPanel { id: rail x: 12 y: 108 @@ -555,104 +550,6 @@ Item { } } - 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: "" diff --git a/dotfiles/quickshell/widgets/bar/StatusBarPanel.qml b/dotfiles/quickshell/widgets/bar/StatusBarPanel.qml new file mode 100644 index 0000000..2da2200 --- /dev/null +++ b/dotfiles/quickshell/widgets/bar/StatusBarPanel.qml @@ -0,0 +1,141 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes + +// Chamfered panel chrome for the dense status rail: outline, corner accent +// lines, and the optional header strip (id chip / title / meta / tick marks). +// Content is supplied as children by the call site. +// +// Palette and fonts are properties with defaults rather than references to the +// parent, because a component in its own file has no access to the enclosing +// scope. The defaults match DenseBarContent's, which is the repo's existing +// per-component convention (see quickshell/CLAUDE.md) — a shared theme +// singleton would be the place to collapse the duplication. +Item { + id: panel + + property string panelId: "" + property string title: "" + property string meta: "" + property bool showHeader: true + property int chamfer: 13 + property int offsetY: 2 + property int accentLineThickness: 3 + + property color voidColor: "#0a0a0a" + property color inkColor: "#dedede" + property color mutedColor: "#858585" + property color accentColor: "#e8722a" + property color hairColor: Qt.rgba(0.87, 0.87, 0.87, 0.28) + property string displayFont: "DepartureMono Nerd Font" + property string microFont: "DejaVu Sans Mono" + + Shape { + id: panelShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: panel.voidColor + strokeColor: panel.hairColor + strokeWidth: 1 + startX: 0; startY: panel.offsetY + PathLine { x: panelShape.width - panel.chamfer; y: panel.offsetY } + PathLine { x: panelShape.width; y: panel.chamfer } + PathLine { x: panelShape.width; y: panelShape.height } + PathLine { x: panel.chamfer; y: panelShape.height } + PathLine { x: 0; y: panelShape.height - panel.chamfer } + PathLine { x: 0; y: panel.offsetY } + } + + // Upper left accent line + ShapePath { + fillColor: panel.accentColor + strokeWidth: 0 + startX: 0; startY: 0 + PathLine { x: Math.min(49, panelShape.width / 3); y: 0 } + PathLine { x: Math.min(49, panelShape.width / 3); y: panel.accentLineThickness } + PathLine { x: 0; y: panel.accentLineThickness } + PathLine { x: 0; y: 0 } + } + + // Lower right accent line + ShapePath { + fillColor: panel.accentColor + strokeWidth: 0 + startX: panelShape.width; startY: panelShape.height + PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height } + PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width; y: panelShape.height } + } + } + + Rectangle { + visible: panel.showHeader + x: 1; y: 22 + width: parent.width - 2 + height: 1 + color: panel.inkColor + opacity: 0.12 + } + + Rectangle { + visible: panel.showHeader + x: 5; y: 7 + width: panel.panelId.length > 2 ? 29 : 24 + height: 11 + color: panel.accentColor + Text { + anchors.centerIn: parent + text: panel.panelId + color: panel.voidColor + font.family: panel.microFont + font.pixelSize: 8 + font.bold: true + } + } + + Text { + visible: panel.showHeader + x: 40; y: 7 + width: parent.width - 105 + text: panel.title + color: panel.inkColor + font.family: panel.displayFont + font.pixelSize: 9 + font.bold: true + font.letterSpacing: 1.1 + elide: Text.ElideRight + } + + // Inlined rather than reusing DenseBarContent's MicroText, which is an + // inline component and therefore not visible from another file. + Text { + 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) + color: panel.mutedColor + font.family: panel.microFont + font.pixelSize: 6 + font.letterSpacing: 0.7 + 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: 4; height: 2; color: panel.accentColor } + } + } +} From b16cc93ff6629a6a08c156fa0cf709784fd1fe4b Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Fri, 28 Aug 2026 07:56:25 +0200 Subject: [PATCH 04/24] refactor(quickshell): move every color and font into a Theme singleton The shell was running two unrelated palettes: an amber one (#FFD063 accent, #EEEEEE text, #0F1012 panels) hardcoded as ~200 raw hex literals across the launchers, sidebar, systray, vitals and notifications, and an orange one (#e8722a) that only the dense bar had, tokenized as per-file properties. This unifies on the ORANGE values under the AMBER naming scheme, and moves the lot into widgets/theme/Theme.qml. `surface` takes the dense bar's void (#0a0a0a) rather than the old panel background. Zero color and font literals remain anywhere under widgets/ outside Theme.qml. Collisions resolved, all near-duplicates that wanted to be one token: - #0F1012 + #0A0A0C + #0a0a0a -> surface - #EEEEEE + #dedede -> text - #7A7B7D + #858585 -> muted - #292C30 + #22262C -> raised - #FFD063 + #e8722a -> accent Two derived things rather than literals. accentSoft (the pale flash the top/bottom bars show while a launcher is open) was a hand-picked #FFF3C0 against amber, which is simply wrong against orange; it is now Qt.tint(accent, white 55%), a ratio checked against the original (amber tinted 55% gives #FFE9B8 vs the hand-picked #FFF3C0). And the dense bar had been hand-encoding Qt.rgba(0.87,0.87,0.87,a) and Qt.rgba(0.91,0.45,0.16,a), which are just text and accent at alpha -- now textAlpha(a)/accentAlpha(a), so they track a palette change instead of silently drifting. Fonts came along too. Digital-7 Mono is dropped for DepartureMono: it was never packaged, relying on a manual ~/.dots/fonts/digital_7 install that does not exist on terra, so `fc-match "Digital-7 Mono"` resolved to DejaVu Sans and all 38 of those sites -- the launcher lists, sidebar clock, systray labels, every vitals readout -- were silently rendering in a PROPORTIONAL fallback. Numeric columns should visibly improve. readoutFont is an alias of displayFont rather than a second literal so the two roles cannot drift apart. quickshell/CLAUDE.md updated: it said "No shared theme/tokens file yet" and told contributors to grep for the existing hex color, which would now reintroduce exactly what this removes. Verified: no file references Theme. without the import, none imports it unused, and the whole shell -- launchers, sidebar, vitals, systray, notifications, not just the harness -- hot-reloaded clean on terra. tests/HeadlessSmoke.qml deliberately keeps its own copies; its value is having no dependencies. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgcWkm3t6BDQktYHQvb8Hx --- dotfiles/quickshell/CLAUDE.md | 9 +- dotfiles/quickshell/widgets/bar/BarBottom.qml | 5 +- dotfiles/quickshell/widgets/bar/BarTop.qml | 5 +- .../widgets/bar/DenseBarContent.qml | 126 +++++++++--------- .../quickshell/widgets/bar/StatusBarPanel.qml | 41 +++--- .../widgets/decoration/TopLeftPanelShape.qml | 3 +- .../quickshell/widgets/input/TextField.qml | 7 +- .../widgets/launcher/LauncherConsole.qml | 29 ++-- .../widgets/launcher/LauncherCorner.qml | 63 ++++----- .../widgets/launcher/LauncherDock.qml | 29 ++-- .../widgets/launcher/LauncherGrid.qml | 31 ++--- .../widgets/launcher/LauncherSlant.qml | 47 +++---- .../widgets/launcher/LauncherSpotlight.qml | 31 ++--- .../widgets/launcher/LauncherStack.qml | 31 ++--- .../notifications/NotificationPopup.qml | 19 +-- dotfiles/quickshell/widgets/osd/VolumeOsd.qml | 7 +- .../quickshell/widgets/sidebar/SideBar.qml | 45 ++++--- .../quickshell/widgets/systray/SysTray.qml | 27 ++-- .../widgets/systray/SysTrayItem.qml | 5 +- dotfiles/quickshell/widgets/theme/Theme.qml | 61 +++++++++ .../quickshell/widgets/vitals/VitalBar.qml | 7 +- dotfiles/quickshell/widgets/vitals/Vitals.qml | 85 ++++++------ 22 files changed, 390 insertions(+), 323 deletions(-) create mode 100644 dotfiles/quickshell/widgets/theme/Theme.qml diff --git a/dotfiles/quickshell/CLAUDE.md b/dotfiles/quickshell/CLAUDE.md index 1e561d0..e1f938a 100644 --- a/dotfiles/quickshell/CLAUDE.md +++ b/dotfiles/quickshell/CLAUDE.md @@ -33,8 +33,13 @@ Quickshell hot-reloads QML on file save when already running, so for most edits - `widgets/layout/` — `HorizontalStack`/`VerticalStack`: `RowLayout`/`ColumnLayout` wrappers that expose `default property alias content` for terser call sites, with a trailing filler `Item` that soaks up remaining space. - `assets/` — SVG icons referenced via `file://${Quickshell.shellDir}/assets/...`. -**Styling**: No shared theme/tokens file yet — colors (`#FFD063` accent, `#0F1012`/`#292C30` backgrounds, `#EEEEEE` text) and metrics are hardcoded per-component. When touching visuals, grep for the existing hex color across `widgets/` to keep new elements consistent rather than introducing new values. +**Styling**: All colors and font families come from the `Theme` singleton +(`widgets/theme/Theme.qml`, `import qs.widgets.theme`) — there are no color or +font literals left anywhere under `widgets/`. Add a token there rather than +hardcoding a value; alpha variants of the two main colors go through +`Theme.textAlpha(a)` / `Theme.accentAlpha(a)` instead of a hand-written +`Qt.rgba(...)`. Metrics (sizes, spacing) are still per-component. **Component base pattern**: `BarWidget.qml` (`widgets/bar/modules/BarWidget.qml`) is a `WrapperMouseArea` + `WrapperRectangle` combo providing hover-triggered border highlight (`Behavior on border.color` animation) and `Layout.fillWidth`. Bar modules extend it via `default property alias content` rather than duplicating the hover/border chrome. -**Fonts**: Clock/Date use the `Digital-7 Mono` font family — expected to be installed system-wide (see sibling `~/.dots/fonts/digital_7`), not bundled in this repo. +**Fonts**: Two families, both via `Theme`. `Theme.displayFont` / `Theme.readoutFont` (an alias of it) are `DepartureMono Nerd Font`, installed by `services/desktop/desktop-apps.nix`; `Theme.microFont` is `DejaVu Sans Mono`. The shell no longer uses `Digital-7 Mono`, which was never packaged and depended on a manual `~/.dots/fonts/digital_7` install. diff --git a/dotfiles/quickshell/widgets/bar/BarBottom.qml b/dotfiles/quickshell/widgets/bar/BarBottom.qml index 414d52b..a2e5033 100644 --- a/dotfiles/quickshell/widgets/bar/BarBottom.qml +++ b/dotfiles/quickshell/widgets/bar/BarBottom.qml @@ -6,6 +6,7 @@ import QtQuick import QtQuick.Layouts import qs.widgets.launcher +import qs.widgets.theme Scope { id: root @@ -42,7 +43,7 @@ Scope { implicitHeight: 12 // Brightens when the Dock launcher (variant 5) opens. - color: LauncherState.dockOpen ? "#FFF3C0" : "#FFD063" + color: LauncherState.dockOpen ? Theme.accentSoft : Theme.accent Behavior on color { ColorAnimation { @@ -53,7 +54,7 @@ Scope { // Gentle "listening" pulse while the dock is open. Rectangle { anchors.fill: parent - color: "#FFFFFF" + color: Theme.highlight opacity: 0 visible: LauncherState.dockOpen diff --git a/dotfiles/quickshell/widgets/bar/BarTop.qml b/dotfiles/quickshell/widgets/bar/BarTop.qml index 6b8c3d6..31dfee8 100644 --- a/dotfiles/quickshell/widgets/bar/BarTop.qml +++ b/dotfiles/quickshell/widgets/bar/BarTop.qml @@ -5,6 +5,7 @@ import Quickshell.Widgets import QtQuick import qs.widgets.launcher +import qs.widgets.theme Scope { id: root @@ -42,7 +43,7 @@ Scope { implicitHeight: 6 // Brightens when the Console launcher (variant 4) opens. - color: LauncherState.consoleOpen ? "#FFF3C0" : "#FFD063" + color: LauncherState.consoleOpen ? Theme.accentSoft : Theme.accent Behavior on color { ColorAnimation { @@ -53,7 +54,7 @@ Scope { // Gentle "listening" pulse while the console is open. Rectangle { anchors.fill: parent - color: "#FFFFFF" + color: Theme.highlight opacity: 0 visible: LauncherState.consoleOpen diff --git a/dotfiles/quickshell/widgets/bar/DenseBarContent.qml b/dotfiles/quickshell/widgets/bar/DenseBarContent.qml index 38e598d..38a41da 100644 --- a/dotfiles/quickshell/widgets/bar/DenseBarContent.qml +++ b/dotfiles/quickshell/widgets/bar/DenseBarContent.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Shapes +import qs.widgets.theme // Renderable visual core for the desktop telemetry rail. Runtime services stay // in DenseBar.qml so this Item can be exercised without Wayland or Hyprland. @@ -11,14 +12,7 @@ Item { 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() @@ -77,7 +71,7 @@ Item { x: index * 40 width: 1 height: root.height - color: root.inkColor + color: Theme.text opacity: 0.018 } } @@ -89,7 +83,7 @@ Item { y: index * 40 width: root.width height: 1 - color: root.inkColor + color: Theme.text opacity: 0.018 } } @@ -120,8 +114,8 @@ Item { x: 12 y: 34 text: root.hostName.toUpperCase() - color: root.inkColor - font.family: root.displayFont + color: Theme.text + font.family: Theme.displayFont font.pixelSize: root.compact ? 20 : 25 font.bold: true font.letterSpacing: 2 @@ -133,8 +127,8 @@ Item { x: 13 y: 63 text: "STATUS ARRAY // " + (root.telemetryReady ? "LIVE" : "STANDBY") - color: root.accentColor - font.family: root.microFont + color: Theme.accent + font.family: Theme.microFont font.pixelSize: 7 font.letterSpacing: 1.4 } @@ -144,7 +138,7 @@ Item { y: 36 width: 1 height: 43 - color: root.hairColor + color: Theme.hair } Rectangle { @@ -152,7 +146,7 @@ Item { y: 42 width: 5 height: 5 - color: root.accentColor + color: Theme.accent opacity: root.telemetryReady ? 1 : 0.35 } @@ -160,8 +154,8 @@ Item { x: parent.width - 50 y: 38 text: root.telemetryReady ? "ONLINE" : "LOCAL" - color: root.accentColor - font.family: root.microFont + color: Theme.accent + font.family: Theme.microFont font.pixelSize: 7 } @@ -169,8 +163,8 @@ Item { x: parent.width - 63 y: 57 text: "NODE // " + (root.hostName || "--").toUpperCase().slice(0, 7) - color: root.mutedColor - font.family: root.microFont + color: Theme.muted + font.family: Theme.microFont font.pixelSize: 6 } @@ -181,7 +175,7 @@ Item { height: 10 ShapePath { fillColor: "transparent" - strokeColor: root.accentColor + strokeColor: Theme.accent strokeWidth: 1 startX: 0; startY: 6 PathLine { x: 11; y: 6 } @@ -280,8 +274,8 @@ Item { width: parent.width text: root.networkRxText horizontalAlignment: Text.AlignRight - color: root.accentColor - font.family: root.displayFont + color: Theme.accent + font.family: Theme.displayFont font.pixelSize: root.compact ? 11 : 13 font.bold: true elide: Text.ElideLeft @@ -291,8 +285,8 @@ Item { width: parent.width text: root.networkTxText horizontalAlignment: Text.AlignRight - color: root.inkColor - font.family: root.displayFont + color: Theme.text + font.family: Theme.displayFont font.pixelSize: root.compact ? 11 : 13 font.bold: true elide: Text.ElideLeft @@ -322,7 +316,7 @@ Item { y: 33 width: 1 height: 46 - color: root.hairColor + color: Theme.hair } Column { @@ -336,8 +330,8 @@ Item { width: parent.width text: root.timeText(root.now) horizontalAlignment: Text.AlignRight - color: root.inkColor - font.family: root.displayFont + color: Theme.text + font.family: Theme.displayFont font.pixelSize: root.compact ? 20 : 22 font.bold: true font.letterSpacing: 1 @@ -346,8 +340,8 @@ Item { width: parent.width text: root.dateText(root.now) horizontalAlignment: Text.AlignRight - color: root.accentColor - font.family: root.microFont + color: Theme.accent + font.family: Theme.microFont font.pixelSize: 6 elide: Text.ElideLeft } @@ -377,8 +371,8 @@ Item { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: "06 // DESKTOP" - color: root.accentColor - font.family: root.microFont + color: Theme.accent + font.family: Theme.microFont font.pixelSize: 7 font.letterSpacing: 1 } @@ -393,14 +387,14 @@ Item { required property var modelData width: 24 height: 17 - color: Number(modelData) === root.activeWorkspaceId ? Qt.rgba(0.91, 0.45, 0.16, 0.18) : "transparent" + color: Number(modelData) === root.activeWorkspaceId ? Theme.accentAlpha(0.18) : "transparent" border.width: 1 - border.color: Number(modelData) === root.activeWorkspaceId ? root.accentColor : root.hairColor + border.color: Number(modelData) === root.activeWorkspaceId ? Theme.accent : Theme.hair Text { anchors.centerIn: parent text: root.two(Number(parent.modelData)) - color: Number(parent.modelData) === root.activeWorkspaceId ? root.accentColor : root.mutedColor - font.family: root.microFont + color: Number(parent.modelData) === root.activeWorkspaceId ? Theme.accent : Theme.muted + font.family: Theme.microFont font.pixelSize: 7 } MouseArea { @@ -419,8 +413,8 @@ Item { x: 76 anchors.verticalCenter: parent.verticalCenter text: "COMPOSITOR // NODE_EXPORTER // SHELL" - color: root.telemetryReady ? root.inkColor : root.mutedColor - font.family: root.microFont + color: root.telemetryReady ? Theme.text : Theme.muted + font.family: Theme.microFont font.pixelSize: 7 elide: Text.ElideRight width: parent.width - 84 @@ -434,8 +428,8 @@ Item { x: 93 anchors.verticalCenter: parent.verticalCenter text: root.storageFreeText - color: root.inkColor - font.family: root.microFont + color: Theme.text + font.family: Theme.microFont font.pixelSize: 8 } SegmentMeter { @@ -458,8 +452,8 @@ Item { 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 + color: root.audioMuted ? Theme.muted : Theme.accent + font.family: Theme.microFont font.pixelSize: 8 } SegmentMeter { @@ -483,8 +477,8 @@ Item { 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 + color: root.telemetryReady ? Theme.muted : Theme.accent + font.family: Theme.microFont font.pixelSize: 7 elide: Text.ElideLeft width: parent.width - 64 @@ -502,7 +496,7 @@ Item { height: 12 ShapePath { fillColor: "transparent" - strokeColor: Qt.rgba(0.91, 0.45, 0.16, 0.55) + strokeColor: Theme.accentAlpha(0.55) strokeWidth: 1 startX: 0; startY: 1 PathLine { x: 220; y: 1 } @@ -524,15 +518,15 @@ Item { required property int index width: 5 height: 3 - color: index % 2 === 0 ? root.accentColor : "transparent" + color: index % 2 === 0 ? Theme.accent : "transparent" opacity: 0.58 } } } component MicroText: Text { - color: root.mutedColor - font.family: root.microFont + color: Theme.muted + font.family: Theme.microFont font.pixelSize: 6 font.letterSpacing: 0.7 } @@ -545,7 +539,7 @@ Item { anchors.right: parent.right width: 1 height: parent.height - color: root.hairColor + color: Theme.hair opacity: 0.65 } } @@ -565,8 +559,8 @@ Item { anchors.right: parent.right y: -3 text: metric.readout - color: root.inkColor - font.family: root.displayFont + color: Theme.text + font.family: Theme.displayFont font.pixelSize: root.compact ? 14 : 17 font.bold: true } @@ -595,9 +589,9 @@ Item { 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) + color: on ? Theme.accent : Theme.textAlpha(0.06) border.width: 1 - border.color: on ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.18) + border.color: on ? Theme.accent : Theme.textAlpha(0.18) } } } @@ -613,8 +607,8 @@ Item { 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 + fillColor: state.active ? Theme.accentAlpha(0.18) : "transparent" + strokeColor: state.active ? Theme.accent : Theme.hair strokeWidth: 1 startX: 1; startY: 1 PathLine { x: parent.width - 7; y: 1 } @@ -629,8 +623,8 @@ Item { anchors.horizontalCenter: parent.horizontalCenter y: 7 text: state.code - color: state.active ? root.accentColor : root.inkColor - font.family: root.displayFont + color: state.active ? Theme.accent : Theme.text + font.family: Theme.displayFont font.pixelSize: 13 font.bold: true } @@ -655,27 +649,27 @@ Item { radius: width / 2 color: "transparent" border.width: 1 - border.color: index === 0 ? root.accentColor : root.hairColor + border.color: index === 0 ? Theme.accent : Theme.hair } } - 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 } + Rectangle { x: 0; y: parent.height / 2; width: parent.width; height: 1; color: Theme.hair } + Rectangle { x: parent.width / 2; y: 0; width: 1; height: parent.height; color: Theme.hair } + Rectangle { x: 12; y: 18; width: 4; height: 4; color: Theme.accent } + Rectangle { x: parent.width - 14; y: parent.height - 20; width: 4; height: 4; color: Theme.accent } + Rectangle { x: parent.width / 2; y: parent.height - 11; width: 4; height: 4; color: Theme.accent } + MicroText { anchors.right: parent.right; y: 3; text: "R:" + Math.round(radar.level * 99); color: Theme.accent } } 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 } + Rectangle { x: 0; y: parent.height - 1; width: parent.width; height: 1; color: Theme.hair } + Rectangle { x: 0; y: 0; width: 1; height: parent.height; color: Theme.hair } Shape { anchors.fill: parent ShapePath { fillColor: "transparent" - strokeColor: root.accentColor + strokeColor: Theme.accent strokeWidth: 1.2 startX: 0; startY: trace.height * 0.65 PathLine { x: trace.width * 0.10; y: trace.height * 0.65 } diff --git a/dotfiles/quickshell/widgets/bar/StatusBarPanel.qml b/dotfiles/quickshell/widgets/bar/StatusBarPanel.qml index 2da2200..5c736e2 100644 --- a/dotfiles/quickshell/widgets/bar/StatusBarPanel.qml +++ b/dotfiles/quickshell/widgets/bar/StatusBarPanel.qml @@ -2,16 +2,13 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Shapes +import qs.widgets.theme // Chamfered panel chrome for the dense status rail: outline, corner accent // lines, and the optional header strip (id chip / title / meta / tick marks). // Content is supplied as children by the call site. // -// Palette and fonts are properties with defaults rather than references to the -// parent, because a component in its own file has no access to the enclosing -// scope. The defaults match DenseBarContent's, which is the repo's existing -// per-component convention (see quickshell/CLAUDE.md) — a shared theme -// singleton would be the place to collapse the duplication. +// Colors and fonts both come from the Theme singleton. Item { id: panel @@ -23,22 +20,14 @@ Item { property int offsetY: 2 property int accentLineThickness: 3 - property color voidColor: "#0a0a0a" - property color inkColor: "#dedede" - property color mutedColor: "#858585" - property color accentColor: "#e8722a" - property color hairColor: Qt.rgba(0.87, 0.87, 0.87, 0.28) - property string displayFont: "DepartureMono Nerd Font" - property string microFont: "DejaVu Sans Mono" - Shape { id: panelShape anchors.fill: parent preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: panel.voidColor - strokeColor: panel.hairColor + fillColor: Theme.surface + strokeColor: Theme.hair strokeWidth: 1 startX: 0; startY: panel.offsetY PathLine { x: panelShape.width - panel.chamfer; y: panel.offsetY } @@ -51,7 +40,7 @@ Item { // Upper left accent line ShapePath { - fillColor: panel.accentColor + fillColor: Theme.accent strokeWidth: 0 startX: 0; startY: 0 PathLine { x: Math.min(49, panelShape.width / 3); y: 0 } @@ -62,7 +51,7 @@ Item { // Lower right accent line ShapePath { - fillColor: panel.accentColor + fillColor: Theme.accent strokeWidth: 0 startX: panelShape.width; startY: panelShape.height PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height } @@ -77,7 +66,7 @@ Item { x: 1; y: 22 width: parent.width - 2 height: 1 - color: panel.inkColor + color: Theme.text opacity: 0.12 } @@ -86,12 +75,12 @@ Item { x: 5; y: 7 width: panel.panelId.length > 2 ? 29 : 24 height: 11 - color: panel.accentColor + color: Theme.accent Text { anchors.centerIn: parent text: panel.panelId - color: panel.voidColor - font.family: panel.microFont + color: Theme.surface + font.family: Theme.microFont font.pixelSize: 8 font.bold: true } @@ -102,8 +91,8 @@ Item { x: 40; y: 7 width: parent.width - 105 text: panel.title - color: panel.inkColor - font.family: panel.displayFont + color: Theme.text + font.family: Theme.displayFont font.pixelSize: 9 font.bold: true font.letterSpacing: 1.1 @@ -119,8 +108,8 @@ Item { y: 6 text: panel.meta width: Math.min(80, parent.width / 4) - color: panel.mutedColor - font.family: panel.microFont + color: Theme.muted + font.family: Theme.microFont font.pixelSize: 6 font.letterSpacing: 0.7 horizontalAlignment: Text.AlignRight @@ -135,7 +124,7 @@ Item { spacing: 2 Repeater { model: 5 - Rectangle { required property int index; width: 4; height: 2; color: panel.accentColor } + Rectangle { required property int index; width: 4; height: 2; color: Theme.accent } } } } diff --git a/dotfiles/quickshell/widgets/decoration/TopLeftPanelShape.qml b/dotfiles/quickshell/widgets/decoration/TopLeftPanelShape.qml index 0d991ab..fbd3d5d 100644 --- a/dotfiles/quickshell/widgets/decoration/TopLeftPanelShape.qml +++ b/dotfiles/quickshell/widgets/decoration/TopLeftPanelShape.qml @@ -1,10 +1,11 @@ import QtQuick import QtQuick.Shapes +import qs.widgets.theme Shape { preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: "#FFD063" + fillColor: Theme.accent strokeWidth: 0 startX: 0 diff --git a/dotfiles/quickshell/widgets/input/TextField.qml b/dotfiles/quickshell/widgets/input/TextField.qml index 629d018..420000a 100644 --- a/dotfiles/quickshell/widgets/input/TextField.qml +++ b/dotfiles/quickshell/widgets/input/TextField.qml @@ -5,6 +5,7 @@ import QtQuick.Layouts import QtQuick.Shapes import qs.widgets.decoration +import qs.widgets.theme WrapperItem { RowLayout { @@ -24,7 +25,7 @@ WrapperItem { ShapePath { strokeWidth: 0 - fillColor: "#0F1012" + fillColor: Theme.surface startX: 8 startY: 0 @@ -52,7 +53,7 @@ WrapperItem { ShapePath { strokeWidth: 0 - fillColor: "#0F1012" + fillColor: Theme.surface startX: 16 startY: 0 @@ -83,7 +84,7 @@ WrapperItem { startY: 0 strokeWidth: 0 - fillColor: "#0F1012" + fillColor: Theme.surface PathLine { x: shape.width diff --git a/dotfiles/quickshell/widgets/launcher/LauncherConsole.qml b/dotfiles/quickshell/widgets/launcher/LauncherConsole.qml index a2563d8..5048d28 100644 --- a/dotfiles/quickshell/widgets/launcher/LauncherConsole.qml +++ b/dotfiles/quickshell/widgets/launcher/LauncherConsole.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import qs.widgets.theme // Variant 4 — "Console": a full-width command deck that unfolds down out of // the top bar, with a horizontal carousel of app cards. While open it drives @@ -99,7 +100,7 @@ Scope { anchors.right: parent.right clip: true - color: "#0F1012" + color: Theme.surface height: root.active ? win.openHeight : 0 @@ -116,7 +117,7 @@ Scope { // Accent lid — mirrors the top bar strip, reads as its extension. Rectangle { - color: "#FFD063" + color: Theme.accent implicitHeight: 4 Layout.fillWidth: true } @@ -132,8 +133,8 @@ Scope { Text { text: ">_" - color: "#FFD063" - font.family: "Digital-7 Mono" + color: Theme.accent + font.family: Theme.readoutFont font.pointSize: 22 font.bold: true } @@ -147,7 +148,7 @@ Scope { anchors.fill: parent verticalAlignment: TextInput.AlignVCenter - color: "#EEEEEE" + color: Theme.text font.pointSize: 16 clip: true @@ -182,7 +183,7 @@ Scope { verticalAlignment: Text.AlignVCenter visible: input.text.length === 0 text: "launch application…" - color: "#7A7B7D" + color: Theme.muted font: input.font } } @@ -190,14 +191,14 @@ Scope { Text { text: model.apps.length + " apps" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 15 } } Rectangle { - color: "#292C30" + color: Theme.raised implicitHeight: 1 Layout.fillWidth: true } @@ -236,9 +237,9 @@ Scope { Rectangle { anchors.fill: parent - color: card.selected ? "#292C30" : "transparent" + color: card.selected ? Theme.raised : "transparent" border.width: 1 - border.color: card.selected ? "#FFD063" : "#292C30" + border.color: card.selected ? Theme.accent : Theme.raised Behavior on border.color { ColorAnimation { @@ -261,7 +262,7 @@ Scope { Text { Layout.fillWidth: true text: card.modelData.name - color: card.selected ? "#FFD063" : "#EEEEEE" + color: card.selected ? Theme.accent : Theme.text font.pointSize: 9 horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight @@ -277,8 +278,8 @@ Scope { anchors.centerIn: parent visible: model.apps.length === 0 text: "no matches" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 16 } } diff --git a/dotfiles/quickshell/widgets/launcher/LauncherCorner.qml b/dotfiles/quickshell/widgets/launcher/LauncherCorner.qml index ba565dd..aa118a5 100644 --- a/dotfiles/quickshell/widgets/launcher/LauncherCorner.qml +++ b/dotfiles/quickshell/widgets/launcher/LauncherCorner.qml @@ -7,6 +7,7 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts import QtQuick.Shapes +import qs.widgets.theme // Variant 7 — a copy of the "Slant" (V6) launcher, plus a small floating // power panel docked to its right with Shutdown / Reboot buttons. @@ -77,7 +78,7 @@ Scope { // Dim backdrop — click to dismiss. Rectangle { anchors.fill: parent - color: "#0A0A0C" + color: Theme.surface opacity: 0.55 MouseArea { @@ -107,8 +108,8 @@ Scope { preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: "#0F1012" - strokeColor: "#FFD063" + fillColor: Theme.surface + strokeColor: Theme.accent strokeWidth: 2 startX: frame.chamfer @@ -153,7 +154,7 @@ Scope { // run out to the top and left edges to meet the straight borders. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 0 startY: frame.chamfer @@ -179,7 +180,7 @@ Scope { // bottom and right edges. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width startY: panelShape.height - frame.chamfer @@ -205,7 +206,7 @@ Scope { // detached from the panel by the width of the cut. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width startY: panelShape.height @@ -228,7 +229,7 @@ Scope { // slanted end pieces on both arms. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent // inner, right end of the bottom arm startX: panelShape.width / 3 @@ -277,7 +278,7 @@ Scope { // slanted end pieces on both arms. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent // inner, bottom of the right arm startX: panelShape.width @@ -345,7 +346,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 6 startY: 0 PathLine { @@ -367,7 +368,7 @@ Scope { } ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 15 startY: 0 PathLine { @@ -389,7 +390,7 @@ Scope { } ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 24 startY: 0 PathLine { @@ -420,8 +421,8 @@ Scope { anchors.fill: parent verticalAlignment: TextInput.AlignVCenter - color: "#EEEEEE" - font.family: "Digital-7 Mono" + color: Theme.text + font.family: Theme.readoutFont font.pointSize: 20 font.letterSpacing: 1 clip: true @@ -457,7 +458,7 @@ Scope { verticalAlignment: Text.AlignVCenter visible: input.text.length === 0 text: "run" - color: "#7A7B7D" + color: Theme.muted font: input.font } } @@ -465,8 +466,8 @@ Scope { Text { text: model.apps.length - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 18 } } @@ -489,7 +490,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: divider.slant startY: 0 PathLine { @@ -512,7 +513,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 0 startY: divider.height PathLine { @@ -535,7 +536,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: divider.width startY: 0 PathLine { @@ -597,7 +598,7 @@ Scope { // Body ShapePath { strokeWidth: 0 - fillColor: "#22262C" + fillColor: Theme.raised startX: appRow.shear startY: 0 PathLine { @@ -620,7 +621,7 @@ Scope { // Left accent edge ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: appRow.shear startY: 0 PathLine { @@ -655,7 +656,7 @@ Scope { Text { text: appRow.modelData.name - color: appRow.selected ? "#FFD063" : "#EEEEEE" + color: appRow.selected ? Theme.accent : Theme.text font.pointSize: 12 elide: Text.ElideRight Layout.fillWidth: true @@ -663,7 +664,7 @@ Scope { Text { text: appRow.modelData.genericName || "" - color: "#7A7B7D" + color: Theme.muted font.pointSize: 10 elide: Text.ElideRight Layout.maximumWidth: 220 @@ -694,8 +695,8 @@ Scope { preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: "#FFD063" - strokeColor: "#FFD063" + fillColor: Theme.accent + strokeColor: Theme.accent strokeWidth: 2 startX: 0 @@ -732,9 +733,9 @@ Scope { Text { Layout.alignment: Qt.AlignHCenter text: "POWER" - font.family: "Digital-7 Mono" + font.family: Theme.readoutFont font.pointSize: 11 - color: "#0F1012" + color: Theme.surface } Repeater { @@ -762,8 +763,8 @@ Scope { ShapePath { strokeWidth: 1 - strokeColor: powerButton.containsMouse ? "#FFD063" : "#7A7B7D" - fillColor: "#292C30" + strokeColor: powerButton.containsMouse ? Theme.accent : Theme.muted + fillColor: Theme.raised startX: powerButton.chamfer startY: 0 @@ -785,8 +786,8 @@ Scope { Text { anchors.centerIn: parent text: powerButton.modelData.label - color: powerButton.containsMouse ? "#FFD063" : "#EEEEEE" - font.family: "Digital-7 Mono" + color: powerButton.containsMouse ? Theme.accent : Theme.text + font.family: Theme.readoutFont font.pointSize: 11 } } diff --git a/dotfiles/quickshell/widgets/launcher/LauncherDock.qml b/dotfiles/quickshell/widgets/launcher/LauncherDock.qml index a2e65a8..c10bd07 100644 --- a/dotfiles/quickshell/widgets/launcher/LauncherDock.qml +++ b/dotfiles/quickshell/widgets/launcher/LauncherDock.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import qs.widgets.theme // Variant 5 — "Dock": the Console concept mirrored to the bottom edge. A // full-width deck rises up out of the bottom bar, which energizes via @@ -98,7 +99,7 @@ Scope { anchors.right: parent.right clip: true - color: "#0F1012" + color: Theme.surface height: root.active ? win.openHeight : 0 @@ -153,9 +154,9 @@ Scope { Rectangle { anchors.fill: parent - color: card.selected ? "#292C30" : "transparent" + color: card.selected ? Theme.raised : "transparent" border.width: 1 - border.color: card.selected ? "#FFD063" : "#292C30" + border.color: card.selected ? Theme.accent : Theme.raised Behavior on border.color { ColorAnimation { @@ -178,7 +179,7 @@ Scope { Text { Layout.fillWidth: true text: card.modelData.name - color: card.selected ? "#FFD063" : "#EEEEEE" + color: card.selected ? Theme.accent : Theme.text font.pointSize: 9 horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight @@ -194,14 +195,14 @@ Scope { anchors.centerIn: parent visible: model.apps.length === 0 text: "no matches" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 16 } } Rectangle { - color: "#292C30" + color: Theme.raised implicitHeight: 1 Layout.fillWidth: true } @@ -217,8 +218,8 @@ Scope { Text { text: ">_" - color: "#FFD063" - font.family: "Digital-7 Mono" + color: Theme.accent + font.family: Theme.readoutFont font.pointSize: 22 font.bold: true } @@ -232,7 +233,7 @@ Scope { anchors.fill: parent verticalAlignment: TextInput.AlignVCenter - color: "#EEEEEE" + color: Theme.text font.pointSize: 16 clip: true @@ -267,7 +268,7 @@ Scope { verticalAlignment: Text.AlignVCenter visible: input.text.length === 0 text: "launch application…" - color: "#7A7B7D" + color: Theme.muted font: input.font } } @@ -275,15 +276,15 @@ Scope { Text { text: model.apps.length + " apps" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 15 } } // Accent lid — mirrors the bottom bar strip. Rectangle { - color: "#FFD063" + color: Theme.accent implicitHeight: 4 Layout.fillWidth: true } diff --git a/dotfiles/quickshell/widgets/launcher/LauncherGrid.qml b/dotfiles/quickshell/widgets/launcher/LauncherGrid.qml index ec54629..3dc8e57 100644 --- a/dotfiles/quickshell/widgets/launcher/LauncherGrid.qml +++ b/dotfiles/quickshell/widgets/launcher/LauncherGrid.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import qs.widgets.theme // Variant 2 — "Grid": centered app-drawer with a dim backdrop and icon tiles. Scope { @@ -78,7 +79,7 @@ Scope { // Dim backdrop — click anywhere outside to dismiss. Rectangle { anchors.fill: parent - color: "#0A0A0C" + color: Theme.surface opacity: 0.55 MouseArea { @@ -93,9 +94,9 @@ Scope { width: 760 height: 560 - color: "#0F1012" + color: Theme.surface border.width: 1 - border.color: "#FFD063" + border.color: Theme.accent opacity: 0.98 // Accent corner brackets for the cyberpunk frame. @@ -104,14 +105,14 @@ Scope { anchors.left: parent.left width: 5 height: 28 - color: "#FFD063" + color: Theme.accent } Rectangle { anchors.top: parent.top anchors.right: parent.right width: 28 height: 5 - color: "#FFD063" + color: Theme.accent } ColumnLayout { @@ -126,8 +127,8 @@ Scope { Text { text: "APPS" - color: "#FFD063" - font.family: "Digital-7 Mono" + color: Theme.accent + font.family: Theme.readoutFont font.pointSize: 22 font.letterSpacing: 3 } @@ -138,9 +139,9 @@ Scope { Rectangle { anchors.fill: parent - color: "#292C30" + color: Theme.raised border.width: 1 - border.color: input.activeFocus ? "#FFD063" : "#7A7B7D" + border.color: input.activeFocus ? Theme.accent : Theme.muted Behavior on border.color { ColorAnimation { @@ -155,7 +156,7 @@ Scope { anchors.rightMargin: 12 verticalAlignment: TextInput.AlignVCenter - color: "#EEEEEE" + color: Theme.text font.pointSize: 13 clip: true @@ -198,7 +199,7 @@ Scope { anchors.verticalCenter: parent.verticalCenter visible: input.text.length === 0 text: "Type to search…" - color: "#7A7B7D" + color: Theme.muted font: input.font } } @@ -207,7 +208,7 @@ Scope { } Rectangle { - color: "#FFD063" + color: Theme.accent implicitHeight: 3 Layout.fillWidth: true } @@ -242,9 +243,9 @@ Scope { Rectangle { anchors.fill: parent anchors.margins: 6 - color: tile.selected ? "#292C30" : "transparent" + color: tile.selected ? Theme.raised : "transparent" border.width: 1 - border.color: tile.selected ? "#FFD063" : "transparent" + border.color: tile.selected ? Theme.accent : "transparent" Behavior on border.color { ColorAnimation { @@ -266,7 +267,7 @@ Scope { Text { Layout.fillWidth: true text: tile.modelData.name - color: tile.selected ? "#FFD063" : "#EEEEEE" + color: tile.selected ? Theme.accent : Theme.text font.pointSize: 10 horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight diff --git a/dotfiles/quickshell/widgets/launcher/LauncherSlant.qml b/dotfiles/quickshell/widgets/launcher/LauncherSlant.qml index 8445fd6..f851d08 100644 --- a/dotfiles/quickshell/widgets/launcher/LauncherSlant.qml +++ b/dotfiles/quickshell/widgets/launcher/LauncherSlant.qml @@ -7,6 +7,7 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts import QtQuick.Shapes +import qs.widgets.theme // Variant 6 — "Slant": breaks up the rectangular layout with angled geometry — // a chamfered panel, a slanted header divider and sheared row highlights. @@ -77,7 +78,7 @@ Scope { // Dim backdrop — click to dismiss. Rectangle { anchors.fill: parent - color: "#0A0A0C" + color: Theme.surface opacity: 0.55 MouseArea { @@ -107,8 +108,8 @@ Scope { preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: "#0F1012" - strokeColor: "#FFD063" + fillColor: Theme.surface + strokeColor: Theme.accent strokeWidth: 2 startX: frame.chamfer @@ -153,7 +154,7 @@ Scope { // run out to the top and left edges to meet the straight borders. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 0 startY: frame.chamfer @@ -179,7 +180,7 @@ Scope { // bottom and right edges. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width startY: panelShape.height - frame.chamfer @@ -205,7 +206,7 @@ Scope { // detached from the panel by the width of the cut. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width startY: panelShape.height @@ -228,7 +229,7 @@ Scope { // slanted end pieces on both arms. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent // inner, right end of the bottom arm startX: panelShape.width / 3 @@ -277,7 +278,7 @@ Scope { // slanted end pieces on both arms. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent // inner, bottom of the right arm startX: panelShape.width @@ -345,7 +346,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 6 startY: 0 PathLine { @@ -367,7 +368,7 @@ Scope { } ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 15 startY: 0 PathLine { @@ -389,7 +390,7 @@ Scope { } ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 24 startY: 0 PathLine { @@ -420,8 +421,8 @@ Scope { anchors.fill: parent verticalAlignment: TextInput.AlignVCenter - color: "#EEEEEE" - font.family: "Digital-7 Mono" + color: Theme.text + font.family: Theme.readoutFont font.pointSize: 20 font.letterSpacing: 1 clip: true @@ -457,7 +458,7 @@ Scope { verticalAlignment: Text.AlignVCenter visible: input.text.length === 0 text: "run" - color: "#7A7B7D" + color: Theme.muted font: input.font } } @@ -465,8 +466,8 @@ Scope { Text { text: model.apps.length - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 18 } } @@ -489,7 +490,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: divider.slant startY: 0 PathLine { @@ -512,7 +513,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 0 startY: divider.height PathLine { @@ -535,7 +536,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: divider.width startY: 0 PathLine { @@ -597,7 +598,7 @@ Scope { // Body ShapePath { strokeWidth: 0 - fillColor: "#22262C" + fillColor: Theme.raised startX: appRow.shear startY: 0 PathLine { @@ -620,7 +621,7 @@ Scope { // Left accent edge ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: appRow.shear startY: 0 PathLine { @@ -655,7 +656,7 @@ Scope { Text { text: appRow.modelData.name - color: appRow.selected ? "#FFD063" : "#EEEEEE" + color: appRow.selected ? Theme.accent : Theme.text font.pointSize: 12 elide: Text.ElideRight Layout.fillWidth: true @@ -663,7 +664,7 @@ Scope { Text { text: appRow.modelData.genericName || "" - color: "#7A7B7D" + color: Theme.muted font.pointSize: 10 elide: Text.ElideRight Layout.maximumWidth: 220 diff --git a/dotfiles/quickshell/widgets/launcher/LauncherSpotlight.qml b/dotfiles/quickshell/widgets/launcher/LauncherSpotlight.qml index 97dda8b..4658cd0 100644 --- a/dotfiles/quickshell/widgets/launcher/LauncherSpotlight.qml +++ b/dotfiles/quickshell/widgets/launcher/LauncherSpotlight.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import qs.widgets.theme // Variant 3 — "Spotlight": top-center command bar with a compact result list. Scope { @@ -91,9 +92,9 @@ Scope { Rectangle { Layout.fillWidth: true implicitHeight: 60 - color: "#0F1012" + color: Theme.surface border.width: 1 - border.color: "#FFD063" + border.color: Theme.accent RowLayout { anchors.fill: parent @@ -103,8 +104,8 @@ Scope { Text { text: ">" - color: "#FFD063" - font.family: "Digital-7 Mono" + color: Theme.accent + font.family: Theme.readoutFont font.pointSize: 26 font.bold: true } @@ -118,7 +119,7 @@ Scope { anchors.fill: parent verticalAlignment: TextInput.AlignVCenter - color: "#EEEEEE" + color: Theme.text font.pointSize: 17 clip: true @@ -153,7 +154,7 @@ Scope { verticalAlignment: Text.AlignVCenter visible: input.text.length === 0 text: "run application" - color: "#7A7B7D" + color: Theme.muted font: input.font } } @@ -161,8 +162,8 @@ Scope { Text { text: model.apps.length + " ▸" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 16 } } @@ -172,7 +173,7 @@ Scope { Rectangle { Layout.fillWidth: true implicitHeight: 3 - color: "#FFD063" + color: Theme.accent visible: model.apps.length > 0 } @@ -181,11 +182,11 @@ Scope { Layout.fillWidth: true // Cap the visible height to maxRows; scroll beyond that. implicitHeight: Math.min(model.apps.length, win.maxRows) * 44 + 2 - color: "#0F1012" + color: Theme.surface opacity: 0.97 visible: model.apps.length > 0 border.width: 1 - border.color: "#292C30" + border.color: Theme.raised ListView { id: list @@ -215,14 +216,14 @@ Scope { Rectangle { anchors.fill: parent - color: entry.selected ? "#1A1C1F" : "transparent" + color: entry.selected ? Theme.selection : "transparent" Rectangle { anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom width: 3 - color: "#FFD063" + color: Theme.accent visible: entry.selected } @@ -239,7 +240,7 @@ Scope { Text { text: entry.modelData.name - color: entry.selected ? "#FFD063" : "#EEEEEE" + color: entry.selected ? Theme.accent : Theme.text font.pointSize: 12 elide: Text.ElideRight Layout.fillWidth: true @@ -247,7 +248,7 @@ Scope { Text { text: entry.modelData.genericName || "" - color: "#7A7B7D" + color: Theme.muted font.pointSize: 10 elide: Text.ElideRight Layout.maximumWidth: 200 diff --git a/dotfiles/quickshell/widgets/launcher/LauncherStack.qml b/dotfiles/quickshell/widgets/launcher/LauncherStack.qml index fb62098..41d3ed8 100644 --- a/dotfiles/quickshell/widgets/launcher/LauncherStack.qml +++ b/dotfiles/quickshell/widgets/launcher/LauncherStack.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import qs.widgets.theme // Variant 1 — "Stack": left-anchored vertical list launcher. // Framed with the top/bottom accent strips used by the main Bar. @@ -82,7 +83,7 @@ Scope { spacing: 0 Rectangle { - color: "#FFD063" + color: Theme.accent implicitHeight: 5 Layout.fillWidth: true } @@ -93,8 +94,8 @@ Scope { margin: 12 border.width: 1 - border.color: "#FFD063" - color: "#0F1012" + border.color: Theme.accent + color: Theme.surface opacity: 0.95 ColumnLayout { @@ -107,7 +108,7 @@ Scope { Text { text: "▚" - color: "#FFD063" + color: Theme.accent font.pointSize: 14 font.bold: true } @@ -121,8 +122,8 @@ Scope { anchors.fill: parent verticalAlignment: TextInput.AlignVCenter - color: "#EEEEEE" - font.family: "Digital-7 Mono" + color: Theme.text + font.family: Theme.readoutFont font.pointSize: 16 clip: true @@ -158,7 +159,7 @@ Scope { verticalAlignment: Text.AlignVCenter visible: input.text.length === 0 text: "search" - color: "#7A7B7D" + color: Theme.muted font: input.font } } @@ -166,14 +167,14 @@ Scope { Text { text: model.apps.length - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 14 } } Rectangle { - color: "#FFD063" + color: Theme.accent implicitHeight: 2 Layout.fillWidth: true } @@ -207,7 +208,7 @@ Scope { Rectangle { anchors.fill: parent - color: appRow.selected ? "#292C30" : "transparent" + color: appRow.selected ? Theme.raised : "transparent" // Accent bar on the selected row. Rectangle { @@ -215,7 +216,7 @@ Scope { anchors.top: parent.top anchors.bottom: parent.bottom width: 3 - color: "#FFD063" + color: Theme.accent visible: appRow.selected } @@ -236,7 +237,7 @@ Scope { Text { text: appRow.modelData.name - color: appRow.selected ? "#FFD063" : "#EEEEEE" + color: appRow.selected ? Theme.accent : Theme.text font.pointSize: 12 elide: Text.ElideRight Layout.fillWidth: true @@ -245,7 +246,7 @@ Scope { Text { visible: text.length > 0 text: appRow.modelData.genericName || appRow.modelData.comment || "" - color: "#7A7B7D" + color: Theme.muted font.pointSize: 9 elide: Text.ElideRight Layout.fillWidth: true @@ -259,7 +260,7 @@ Scope { } Rectangle { - color: "#FFD063" + color: Theme.accent implicitHeight: 5 Layout.fillWidth: true } diff --git a/dotfiles/quickshell/widgets/notifications/NotificationPopup.qml b/dotfiles/quickshell/widgets/notifications/NotificationPopup.qml index 01e3d28..31a9c9f 100644 --- a/dotfiles/quickshell/widgets/notifications/NotificationPopup.qml +++ b/dotfiles/quickshell/widgets/notifications/NotificationPopup.qml @@ -2,6 +2,7 @@ import Quickshell.Services.Notifications import Quickshell.Widgets import QtQuick import QtQuick.Layouts +import qs.widgets.theme ColumnLayout { id: root @@ -12,7 +13,7 @@ ColumnLayout { Layout.fillWidth: true Rectangle { - color: "#FFD063" + color: Theme.accent implicitHeight: 5 Layout.fillWidth: true @@ -23,8 +24,8 @@ ColumnLayout { margin: 12 border.width: 1 - border.color: "#FFD063" - color: "#0F1012" + border.color: Theme.accent + color: Theme.surface opacity: .9 ColumnLayout { @@ -45,7 +46,7 @@ ColumnLayout { Text { text: root.modelData.summary - color: "#EEEEEE" + color: Theme.text font.pointSize: 14 font.bold: true elide: Text.ElideRight @@ -57,7 +58,7 @@ ColumnLayout { id: dismissButton text: "×" - color: "#FFD063" + color: Theme.accent font.pointSize: 18 MouseArea { @@ -71,7 +72,7 @@ ColumnLayout { Text { visible: root.modelData.body !== "" text: root.modelData.body - color: "#EEEEEE" + color: Theme.text font.pointSize: 12 wrapMode: Text.Wrap @@ -92,9 +93,9 @@ ColumnLayout { required property NotificationAction modelData - color: "#292C30" + color: Theme.raised border.width: 1 - border.color: "#FFD063" + border.color: Theme.accent implicitHeight: 28 implicitWidth: actionText.implicitWidth + 16 @@ -102,7 +103,7 @@ ColumnLayout { id: actionText anchors.centerIn: parent text: actionButton.modelData.text - color: "#EEEEEE" + color: Theme.text font.pointSize: 12 } diff --git a/dotfiles/quickshell/widgets/osd/VolumeOsd.qml b/dotfiles/quickshell/widgets/osd/VolumeOsd.qml index 6200114..326545b 100644 --- a/dotfiles/quickshell/widgets/osd/VolumeOsd.qml +++ b/dotfiles/quickshell/widgets/osd/VolumeOsd.qml @@ -2,6 +2,7 @@ import Quickshell import Quickshell.Services.Pipewire import Quickshell.Wayland import QtQuick +import qs.widgets.theme Scope { id: root @@ -68,7 +69,7 @@ Scope { anchors.bottomMargin: 4 text: Math.round(Math.min(root.volume, root.maxVolume) * 100) + "%" - color: !root.muted && root.volume > 1 ? "#FF6B4A" : "#FFD063" + color: !root.muted && root.volume > 1 ? Theme.hot : Theme.accent font.pointSize: 12 font.bold: true } @@ -84,7 +85,7 @@ Scope { anchors.centerIn: parent implicitHeight: 6 width: parent.width * Math.min(root.volume, root.maxVolume) / root.maxVolume - color: "#FF6B4A" + color: Theme.hot visible: !root.muted && root.volume > 1 Behavior on width { @@ -98,7 +99,7 @@ Scope { anchors.centerIn: parent implicitHeight: 6 width: parent.width * Math.min(root.volume, 1) / root.maxVolume - color: root.muted ? "#7A7B7D" : "#FFD063" + color: root.muted ? Theme.muted : Theme.accent Behavior on width { NumberAnimation { diff --git a/dotfiles/quickshell/widgets/sidebar/SideBar.qml b/dotfiles/quickshell/widgets/sidebar/SideBar.qml index c094486..fa43147 100644 --- a/dotfiles/quickshell/widgets/sidebar/SideBar.qml +++ b/dotfiles/quickshell/widgets/sidebar/SideBar.qml @@ -8,6 +8,7 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts import QtQuick.Shapes +import qs.widgets.theme // A vertical sidebar carrying the "Slant" (launcher V6) visual language — // chamfered panel, thick trapezoid bevel accents, an outward bracket, a @@ -81,8 +82,8 @@ Scope { // Panel: big chamfers on top-right & bottom-left, small bevels // on top-left & bottom-right (mirror of the left-anchored panel). ShapePath { - fillColor: "#0F1012" - strokeColor: "#FFD063" + fillColor: Theme.surface + strokeColor: Theme.accent strokeWidth: 2 startX: panelShape.width - frame.chamfer @@ -124,7 +125,7 @@ Scope { // Thick top-right bevel accent (trapezoid to the edges). ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width startY: frame.chamfer @@ -149,7 +150,7 @@ Scope { // Thick bottom-left bevel accent. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 0 startY: panelShape.height - frame.chamfer @@ -174,7 +175,7 @@ Scope { // Floating triangle cap in the bottom-left notch. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 0 startY: panelShape.height @@ -197,7 +198,7 @@ Scope { // corner following the small chamfer. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 0 startY: frame.armSide @@ -245,15 +246,15 @@ Scope { anchors.rightMargin: 8 spacing: 10 - // Clock — Digital-7, hh over mm + // Clock — readout font, hh over mm Text { Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter text: Qt.formatDateTime(clock.date, "hh\nmm") - font.family: "Digital-7 Mono" + font.family: Theme.readoutFont font.pointSize: 22 font.letterSpacing: 1 - color: "#EEEEEE" + color: Theme.text SystemClock { id: clock @@ -266,9 +267,9 @@ Scope { Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter text: Qt.formatDateTime(clock.date, "dd\nMMM").toUpperCase() - font.family: "Digital-7 Mono" + font.family: Theme.readoutFont font.pointSize: 13 - color: "#FFD063" + color: Theme.accent } // Slanted divider @@ -282,7 +283,7 @@ Scope { preferredRendererType: Shape.CurveRenderer ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 8 startY: 0 PathLine { x: divider.width; y: 0 } @@ -301,9 +302,9 @@ Scope { Text { Layout.alignment: Qt.AlignHCenter text: "VOL" - font.family: "Digital-7 Mono" + font.family: Theme.readoutFont font.pointSize: 10 - color: "#7A7B7D" + color: Theme.muted } ColumnLayout { @@ -336,8 +337,8 @@ Scope { ShapePath { strokeWidth: 1 - strokeColor: "#7A7B7D" - fillColor: "#292C30" + strokeColor: Theme.muted + fillColor: Theme.raised startX: seg.chamfer startY: 0 @@ -375,7 +376,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FF6B4A" + fillColor: Theme.hot startX: segFill.chamfer startY: 0 PathLine { x: segFill.width; y: 0 } @@ -407,8 +408,8 @@ Scope { ShapePath { strokeWidth: 1 - strokeColor: "#7A7B7D" - fillColor: "#292C30" + strokeColor: Theme.muted + fillColor: Theme.raised startX: volMeter.chamfer startY: 0 @@ -448,7 +449,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: root.muted ? "#7A7B7D" : "#FFD063" + fillColor: root.muted ? Theme.muted : Theme.accent startX: vol.chamfer startY: 0 PathLine { x: vol.width; y: 0 } @@ -466,9 +467,9 @@ Scope { Text { Layout.alignment: Qt.AlignHCenter text: root.muted ? "--" : Math.round(root.volume * 100) - font.family: "Digital-7 Mono" + font.family: Theme.readoutFont font.pointSize: 14 - color: root.muted ? "#7A7B7D" : "#EEEEEE" + color: root.muted ? Theme.muted : Theme.text } } } diff --git a/dotfiles/quickshell/widgets/systray/SysTray.qml b/dotfiles/quickshell/widgets/systray/SysTray.qml index 2b2ef0a..ddecdc4 100644 --- a/dotfiles/quickshell/widgets/systray/SysTray.qml +++ b/dotfiles/quickshell/widgets/systray/SysTray.qml @@ -6,10 +6,11 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts import QtQuick.Shapes +import qs.widgets.theme // The right bar: a compact utility strip for the SystemTray icons, styled // in the same family as the Launcher (V6 "Slant") and SideBar — accent -// color, chamfered corners, Digital-7 Mono, the slash-trio motif — but with +// color, chamfered corners, the readout font, the slash-trio motif — but with // its own plain, evenly-chamfered panel rather than their ornate // bracket-and-cap silhouette, since this is a secondary/utility bar rather // than a hero surface. @@ -54,8 +55,8 @@ Scope { preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: "#0F1012" - strokeColor: "#FFD063" + fillColor: Theme.surface + strokeColor: Theme.accent strokeWidth: 2 startX: frame.chamfer @@ -114,7 +115,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 6 startY: 0 PathLine { x: 10; y: 0 } @@ -124,7 +125,7 @@ Scope { } ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 15 startY: 0 PathLine { x: 19; y: 0 } @@ -134,7 +135,7 @@ Scope { } ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 24 startY: 0 PathLine { x: 28; y: 0 } @@ -155,7 +156,7 @@ Scope { preferredRendererType: Shape.CurveRenderer ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 8 startY: 0 PathLine { x: divider.width; y: 0 } @@ -169,9 +170,9 @@ Scope { Text { Layout.alignment: Qt.AlignHCenter text: "TRAY" - font.family: "Digital-7 Mono" + font.family: Theme.readoutFont font.pointSize: 12 - color: "#7A7B7D" + color: Theme.muted } ListView { @@ -201,17 +202,17 @@ Scope { Layout.alignment: Qt.AlignHCenter visible: SystemTray.items.values.length === 0 text: "--" - font.family: "Digital-7 Mono" + font.family: Theme.readoutFont font.pointSize: 14 - color: "#7A7B7D" + color: Theme.muted } Text { Layout.alignment: Qt.AlignHCenter text: SystemTray.items.values.length - font.family: "Digital-7 Mono" + font.family: Theme.readoutFont font.pointSize: 16 - color: "#EEEEEE" + color: Theme.text } } } diff --git a/dotfiles/quickshell/widgets/systray/SysTrayItem.qml b/dotfiles/quickshell/widgets/systray/SysTrayItem.qml index 9e1ac85..ece3ce0 100644 --- a/dotfiles/quickshell/widgets/systray/SysTrayItem.qml +++ b/dotfiles/quickshell/widgets/systray/SysTrayItem.qml @@ -2,6 +2,7 @@ import Quickshell import Quickshell.Services.SystemTray import QtQuick import QtQuick.Shapes +import qs.widgets.theme // A single tray icon, chamfered to match the Slant (launcher V6 / SideBar) // visual language instead of a plain rectangular hit target. @@ -35,8 +36,8 @@ MouseArea { ShapePath { strokeWidth: 1 - strokeColor: root.containsMouse ? "#FFD063" : "#7A7B7D" - fillColor: "#292C30" + strokeColor: root.containsMouse ? Theme.accent : Theme.muted + fillColor: Theme.raised startX: root.chamfer startY: 0 diff --git a/dotfiles/quickshell/widgets/theme/Theme.qml b/dotfiles/quickshell/widgets/theme/Theme.qml new file mode 100644 index 0000000..49b94a0 --- /dev/null +++ b/dotfiles/quickshell/widgets/theme/Theme.qml @@ -0,0 +1,61 @@ +pragma Singleton + +import Quickshell +import QtQuick + +// Single source of truth for the shell's palette and font families. +// +// The shell previously ran two unrelated palettes: an amber one (#FFD063) used +// by the launchers, sidebar, systray, vitals and notifications, and an orange +// one (#e8722a) that only the dense bar had, tokenized as per-file properties. +// This unifies on the ORANGE values under the AMBER naming scheme. +// +// `surface` deliberately takes the dense bar's void (#0a0a0a) rather than the +// old panel background (#0F1012), which also absorbs the near-identical +// #0A0A0C scrim. +Singleton { + // ---- core ---- + readonly property color accent: "#e8722a" // was #FFD063 (amber) / #e8722a (bar) + readonly property color text: "#dedede" // was #EEEEEE / #dedede + readonly property color muted: "#858585" // was #7A7B7D / #858585 + readonly property color surface: "#0a0a0a" // was #0F1012 + #0A0A0C + #0a0a0a + readonly property color hot: "#ff6b4a" // alert/hot; no bar equivalent, kept + + // ---- supporting darks ---- + // A three-step ramp above `surface`. `raised` also absorbs #22262C, which + // differed from #292C30 by an imperceptible amount across two call sites. + readonly property color selection: "#1a1c1f" // selected row fill + readonly property color raised: "#292c30" // raised surface / border + readonly property color disabled: "#3a3d42" // unknown / disabled stroke + + // ---- accents ---- + // The pale "flash" the top/bottom bars show while a launcher is open. Was a + // hand-picked #FFF3C0 against amber; derived here so it tracks `accent`. + // 55% toward white reproduces the original amber relationship closely + // (#FFD063 -> #FFE9B8 vs the hand-picked #FFF3C0). + readonly property color accentSoft: Qt.tint(accent, Qt.rgba(1, 1, 1, 0.55)) + readonly property color highlight: "#ffffff" + + // ---- fonts ---- + // Two faces. `readoutFont` is an alias rather than a second literal so the + // two roles cannot silently drift apart; point it at a different family if + // the readouts should ever diverge from the headings again. + // + // Installed by services/desktop/desktop-apps.nix (nerd-fonts.departure-mono). + // The former readout face, Digital-7 Mono, was never packaged — it relied on + // a manual ~/.dots/fonts/digital_7 install, so dropping it also removes an + // undeclared external dependency. + readonly property string displayFont: "DepartureMono Nerd Font" // headings, large values + readonly property string readoutFont: displayFont // seven-segment readouts: launchers, sidebar, systray, vitals + readonly property string microFont: "DejaVu Sans Mono" // dense bar micro labels + + // ---- derived alpha variants ---- + // The dense bar hand-encoded these as Qt.rgba(0.87,0.87,0.87,a) = text and + // Qt.rgba(0.91,0.45,0.16,a) = accent. Expressed as functions so the + // relationship survives a palette change. + function textAlpha(a) { return Qt.rgba(text.r, text.g, text.b, a); } + function accentAlpha(a) { return Qt.rgba(accent.r, accent.g, accent.b, a); } + + // Hairline rule / panel outline: text at 28%. + readonly property color hair: textAlpha(0.28) +} diff --git a/dotfiles/quickshell/widgets/vitals/VitalBar.qml b/dotfiles/quickshell/widgets/vitals/VitalBar.qml index 32f378f..89e1d56 100644 --- a/dotfiles/quickshell/widgets/vitals/VitalBar.qml +++ b/dotfiles/quickshell/widgets/vitals/VitalBar.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Shapes +import qs.widgets.theme // Horizontal meter in the Slant language: a chamfered track whose fill is a // clipped copy of the SAME hexagon, so empty and full always share one @@ -26,8 +27,8 @@ Item { ShapePath { strokeWidth: 1 - strokeColor: bar.unknown ? "#3A3D42" : "#7A7B7D" - fillColor: "#292C30" + strokeColor: bar.unknown ? Theme.disabled : Theme.muted + fillColor: Theme.raised startX: bar.chamfer startY: 0 @@ -72,7 +73,7 @@ Item { ShapePath { strokeWidth: 0 - fillColor: bar.hot ? "#FF6B4A" : "#FFD063" + fillColor: bar.hot ? Theme.hot : Theme.accent Behavior on fillColor { ColorAnimation { diff --git a/dotfiles/quickshell/widgets/vitals/Vitals.qml b/dotfiles/quickshell/widgets/vitals/Vitals.qml index 76ca292..30c8120 100644 --- a/dotfiles/quickshell/widgets/vitals/Vitals.qml +++ b/dotfiles/quickshell/widgets/vitals/Vitals.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import QtQuick import QtQuick.Layouts import QtQuick.Shapes +import qs.widgets.theme // Host vitals HUD — the "Slant" language (chamfered panel, trapezoid bevels, // outward corner chunks, floating cap, slanted dividers) carried over from the @@ -59,7 +60,7 @@ Scope { // Dim backdrop — click anywhere to dismiss. Rectangle { anchors.fill: parent - color: "#0A0A0C" + color: Theme.surface opacity: 0.5 MouseArea { @@ -102,8 +103,8 @@ Scope { preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: "#0F1012" - strokeColor: "#FFD063" + fillColor: Theme.surface + strokeColor: Theme.accent strokeWidth: 2 startX: frame.chamfer @@ -121,7 +122,7 @@ Scope { // Thick top-left bevel accent. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 0 startY: frame.chamfer @@ -134,7 +135,7 @@ Scope { // Thick bottom-right bevel accent. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width startY: panelShape.height - frame.chamfer @@ -147,7 +148,7 @@ Scope { // Floating triangle cap in the bottom-right notch. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width startY: panelShape.height @@ -159,7 +160,7 @@ Scope { // Heavy outward chunk wrapping the bottom-left corner. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width / 3 startY: panelShape.height @@ -176,7 +177,7 @@ Scope { // Heavy outward chunk wrapping the top-right corner. ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: panelShape.width startY: panelShape.height / 3 @@ -217,7 +218,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 6 startY: 0 PathLine { x: 10; y: 0 } @@ -227,7 +228,7 @@ Scope { } ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 15 startY: 0 PathLine { x: 19; y: 0 } @@ -237,7 +238,7 @@ Scope { } ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 24 startY: 0 PathLine { x: 28; y: 0 } @@ -249,8 +250,8 @@ Scope { Text { text: (vitals.host || "vitals").toUpperCase() - color: "#EEEEEE" - font.family: "Digital-7 Mono" + color: Theme.text + font.family: Theme.readoutFont font.pointSize: 20 font.letterSpacing: 2 } @@ -261,15 +262,15 @@ Scope { Text { text: "UP" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 11 } Text { text: vitals.fmtUptime(vitals.uptime) - color: "#FFD063" - font.family: "Digital-7 Mono" + color: Theme.accent + font.family: Theme.readoutFont font.pointSize: 16 } } @@ -281,8 +282,8 @@ Scope { Layout.fillWidth: true visible: vitals.failed text: "NODE_EXPORTER UNREACHABLE ON :" + vitals.port - color: "#FF6B4A" - font.family: "Digital-7 Mono" + color: Theme.hot + font.family: Theme.readoutFont font.pointSize: 13 } @@ -353,7 +354,7 @@ Scope { Text { Layout.preferredWidth: 96 text: diskRow.modelData.mount - color: "#7A7B7D" + color: Theme.muted font.pointSize: 9 elide: Text.ElideMiddle } @@ -369,8 +370,8 @@ Scope { Layout.preferredWidth: 48 horizontalAlignment: Text.AlignRight text: Math.round(diskRow.frac * 100) + "%" - color: diskRow.frac >= 0.9 ? "#FF6B4A" : "#EEEEEE" - font.family: "Digital-7 Mono" + color: diskRow.frac >= 0.9 ? Theme.hot : Theme.text + font.family: Theme.readoutFont font.pointSize: 13 } @@ -378,8 +379,8 @@ Scope { Layout.preferredWidth: 104 horizontalAlignment: Text.AlignRight text: vitals.fmtBytes(diskRow.modelData.size - diskRow.modelData.used) + " FREE" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 11 } } @@ -399,21 +400,21 @@ Scope { Text { Layout.preferredWidth: 96 text: vitals.netIface || "NET" - color: "#7A7B7D" + color: Theme.muted font.pointSize: 9 } Text { text: "RX" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 11 } Text { text: vitals.fmtRate(vitals.netRx) - color: "#FFD063" - font.family: "Digital-7 Mono" + color: Theme.accent + font.family: Theme.readoutFont font.pointSize: 14 } @@ -423,15 +424,15 @@ Scope { Text { text: "TX" - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 11 } Text { text: vitals.fmtRate(vitals.netTx) - color: "#FFD063" - font.family: "Digital-7 Mono" + color: Theme.accent + font.family: Theme.readoutFont font.pointSize: 14 // Right-align the TX readout against the panel edge the @@ -469,8 +470,8 @@ Scope { Text { Layout.preferredWidth: 46 text: metric.label - color: "#FFD063" - font.family: "Digital-7 Mono" + color: Theme.accent + font.family: Theme.readoutFont font.pointSize: 15 font.letterSpacing: 1 } @@ -486,8 +487,8 @@ Scope { Layout.preferredWidth: 54 horizontalAlignment: Text.AlignRight text: metric.readout - color: "#EEEEEE" - font.family: "Digital-7 Mono" + color: Theme.text + font.family: Theme.readoutFont font.pointSize: 16 } } @@ -499,8 +500,8 @@ Scope { Text { text: metric.detail - color: "#7A7B7D" - font.family: "Digital-7 Mono" + color: Theme.muted + font.family: Theme.readoutFont font.pointSize: 11 } @@ -511,8 +512,8 @@ Scope { Text { visible: metric.aside.length > 0 text: metric.aside - color: metric.asideHot ? "#FF6B4A" : "#7A7B7D" - font.family: "Digital-7 Mono" + color: metric.asideHot ? Theme.hot : Theme.muted + font.family: Theme.readoutFont font.pointSize: 11 } } @@ -530,7 +531,7 @@ Scope { ShapePath { strokeWidth: 0 - fillColor: "#FFD063" + fillColor: Theme.accent startX: 6 startY: 0 PathLine { x: divider.width; y: 0 } From fdc00a85746924930d5db1cf4c47262fb97a10f9 Mon Sep 17 00:00:00 2001 From: luna Date: Fri, 28 Aug 2026 07:35:06 +0000 Subject: [PATCH 05/24] [verified] feat(quickshell): add dense application launcher --- dotfiles/quickshell/CLAUDE.md | 2 +- dotfiles/quickshell/open_launcher.sh | 2 +- dotfiles/quickshell/shell.qml | 4 +- .../tests/ApplicationLauncherHeadless.qml | 62 ++ .../widgets/launcher/ApplicationLauncher.qml | 101 +++ .../launcher/ApplicationLauncherContent.qml | 602 ++++++++++++++++++ hosts/terra/home/hyprland.nix | 2 +- 7 files changed, 770 insertions(+), 5 deletions(-) create mode 100644 dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml create mode 100644 dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml create mode 100644 dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml diff --git a/dotfiles/quickshell/CLAUDE.md b/dotfiles/quickshell/CLAUDE.md index e1f938a..1d5c6cb 100644 --- a/dotfiles/quickshell/CLAUDE.md +++ b/dotfiles/quickshell/CLAUDE.md @@ -27,7 +27,7 @@ Quickshell hot-reloads QML on file save when already running, so for most edits **Directory layout**: - `widgets/bar/` — `Bar.qml` is the main sidebar (right-anchored, full height) hosting the module stack (date, clock, tray, decorative dividers); `BarTop.qml`/`BarBottom.qml` are thin accent-colored strips anchored to the top/bottom edges. - `widgets/bar/modules/` — individual bar widgets (`Clock`, `Date`, `Tray`/`TrayItem`, `Volume`) built on the shared `BarWidget` base component. -- `widgets/launcher/` — app launcher panel (`Launcher` → `LauncherPanel` → `SearchBar`/`TextField`), currently a WIP skeleton (search box has no backing logic yet, `Launcher.qml` is instantiated with `visible: false`). +- `widgets/launcher/` — shared `AppModel` search/execution plus eight launcher variants. `ApplicationLauncher` (variant 8 and the primary `SUPER` launcher) keeps its visual core in the headlessly renderable `ApplicationLauncherContent`; variants 1–7 remain available on `SUPER CTRL 1–7` for comparison. - `widgets/decoration/` — reusable QtQuick `Shape`-based visual accents (angled panel edges, slashes) used to give bar panels their non-rectangular look. `Dummy.qml` is a placeholder/test rectangle. - `widgets/input/` — thin wrappers around `QtQuick.Controls` inputs (currently just `TextField`). - `widgets/layout/` — `HorizontalStack`/`VerticalStack`: `RowLayout`/`ColumnLayout` wrappers that expose `default property alias content` for terser call sites, with a trailing filler `Item` that soaks up remaining space. diff --git a/dotfiles/quickshell/open_launcher.sh b/dotfiles/quickshell/open_launcher.sh index a4ec215..8329ea9 100755 --- a/dotfiles/quickshell/open_launcher.sh +++ b/dotfiles/quickshell/open_launcher.sh @@ -1,3 +1,3 @@ #!/bin/bash -hyprctl dispatch 'hl.dsp.global ("quickshell:launcher7")' +hyprctl dispatch 'hl.dsp.global ("quickshell:launcher8")' diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index c1dc319..fb2222a 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -17,8 +17,7 @@ Scope { SideBar {} BarBottom {} - // App launcher variants — toggled via Hyprland global shortcuts - // (SUPER CTRL 1/2/3). Try each and keep the one you like. + // App launcher variants — SUPER CTRL 1–8; variant 8 is the primary HUD. LauncherStack {} // 1 — left vertical list LauncherGrid {} // 2 — centered icon grid LauncherSpotlight {} // 3 — top-center command bar @@ -26,6 +25,7 @@ Scope { LauncherDock {} // 5 — deck rising from the bottom bar LauncherSlant {} // 6 — angular / sheared panel LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot) + ApplicationLauncher {} // 8 — dense HUD command index (primary) Notifications {} VolumeOsd {} diff --git a/dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml b/dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml new file mode 100644 index 0000000..71ca1d9 --- /dev/null +++ b/dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml @@ -0,0 +1,62 @@ +import QtQuick +import qs.widgets.launcher + +ApplicationLauncherContent { + width: 1080 + height: 620 + + query: "term" + selectedIndex: 1 + showIcons: false + apps: [ + { + name: "Alacritty", + genericName: "Terminal Emulator", + comment: "GPU accelerated command interface", + icon: "utilities-terminal", + categories: ["System", "TerminalEmulator"] + }, + { + name: "Ghostty", + genericName: "Terminal", + comment: "Fast native terminal for local operations", + icon: "com.mitchellh.ghostty", + categories: ["System", "TerminalEmulator"] + }, + { + name: "Kitty", + genericName: "Terminal Emulator", + comment: "GPU based terminal with multiplexing", + icon: "kitty", + categories: ["System", "TerminalEmulator"] + }, + { + name: "Vivaldi", + genericName: "Web Browser", + comment: "Access network and web applications", + icon: "vivaldi", + categories: ["Network", "WebBrowser"] + }, + { + name: "Cosmic Files", + genericName: "File Manager", + comment: "Browse and manage local storage", + icon: "com.system76.CosmicFiles", + categories: ["System", "FileManager"] + }, + { + name: "Obsidian", + genericName: "Knowledge Base", + comment: "Local-first markdown workspace", + icon: "obsidian", + categories: ["Office"] + }, + { + name: "Steam", + genericName: "Game Platform", + comment: "Launch and manage games", + icon: "steam", + categories: ["Game"] + } + ] +} diff --git a/dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml b/dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml new file mode 100644 index 0000000..cc1a063 --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml @@ -0,0 +1,101 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import qs.widgets.theme + +// Primary application launcher (variant 8). The full-screen layer-shell adapter +// owns focus, DesktopEntries and execution; ApplicationLauncherContent remains +// an Item so the complete visual state can be rendered headlessly. +Scope { + id: root + + property bool active: false + + function toggle() { root.active = !root.active; } + + GlobalShortcut { + name: "launcher8" + description: "Toggle dense application command index" + onPressed: root.toggle() + } + + PanelWindow { + id: win + + visible: root.active + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + color: Theme.textAlpha(0) + + anchors { + top: true + left: true + right: true + bottom: true + } + + property int selectedIndex: 0 + + function clampSelection(index) { + if (model.apps.length === 0) + return 0; + return Math.max(0, Math.min(model.apps.length - 1, index)); + } + + function move(delta) { + const count = model.apps.length; + if (count === 0) + return; + selectedIndex = ((selectedIndex + delta) % count + count) % count; + } + + function launch(index) { + if (model.launch(index)) + root.active = false; + } + + onVisibleChanged: { + if (visible) { + content.clearSearch(); + selectedIndex = 0; + content.focusSearch(); + } + } + + AppModel { + id: model + search: content.query + } + + Rectangle { + anchors.fill: parent + color: Theme.surface + opacity: 0.72 + + MouseArea { + anchors.fill: parent + onClicked: root.active = false + } + } + + ApplicationLauncherContent { + id: content + anchors.centerIn: parent + width: 1080 + height: 620 + scale: Math.min(1, (parent.width - 64) / width, (parent.height - 64) / height) + transformOrigin: Item.Center + apps: model.apps + selectedIndex: win.selectedIndex + + onSelectionRequested: index => win.selectedIndex = win.clampSelection(index) + onMoveRequested: delta => win.move(delta) + onLaunchRequested: index => win.launch(index) + onDismissRequested: root.active = false + } + } +} diff --git a/dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml b/dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml new file mode 100644 index 0000000..cb11326 --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml @@ -0,0 +1,602 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import qs.widgets.bar +import qs.widgets.theme + +// Headlessly renderable visual core for the primary application launcher. +// Runtime concerns (DesktopEntries, layer shell, launching) stay in +// ApplicationLauncher.qml; this component only renders state and emits intent. +Item { + id: root + + property var apps: [] + property int selectedIndex: 0 + property bool showIcons: true + property alias query: searchInput.text + + readonly property var selectedApp: apps.length > 0 && selectedIndex >= 0 && selectedIndex < apps.length + ? apps[selectedIndex] : null + + signal selectionRequested(int index) + signal moveRequested(int delta) + signal launchRequested(int index) + signal dismissRequested + + function focusSearch() { searchInput.forceActiveFocus(); } + function clearSearch() { searchInput.text = ""; } + + implicitWidth: 1080 + implicitHeight: 620 + + component MicroText: Text { + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 7 + font.letterSpacing: 0.9 + elide: Text.ElideRight + } + + component KeyHint: Row { + property string keyText: "" + property string actionText: "" + spacing: 6 + + Rectangle { + width: keyLabel.implicitWidth + 10 + height: 18 + color: Theme.accentAlpha(0.14) + border.width: 1 + border.color: Theme.accent + + Text { + id: keyLabel + anchors.centerIn: parent + text: parent.parent.keyText + color: Theme.accent + font.family: Theme.microFont + font.pixelSize: 7 + font.bold: true + } + } + + MicroText { + anchors.verticalCenter: parent.verticalCenter + text: parent.actionText + } + } + + StatusBarPanel { + anchors.fill: parent + panelId: "008" + title: "APPLICATION COMMAND INDEX" + meta: root.apps.length + " TARGETS" + chamfer: 18 + + // Query module. + StatusBarPanel { + id: queryPanel + x: 18 + y: 34 + width: 670 + height: 76 + panelId: "QRY" + title: "SEARCH VECTOR" + meta: "FUZZY / PREFIX" + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 31 + height: 34 + spacing: 12 + + Text { + text: ">_" + color: Theme.accent + font.family: Theme.displayFont + font.pixelSize: 20 + font.bold: true + } + + TextInput { + id: searchInput + Layout.fillWidth: true + Layout.fillHeight: true + verticalAlignment: TextInput.AlignVCenter + color: Theme.text + selectionColor: Theme.accent + selectedTextColor: Theme.surface + font.family: Theme.displayFont + font.pixelSize: 18 + font.letterSpacing: 1 + clip: true + + onTextChanged: root.selectionRequested(0) + + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Down: + case Qt.Key_Tab: + root.moveRequested(1); + event.accepted = true; + break; + case Qt.Key_Up: + case Qt.Key_Backtab: + root.moveRequested(-1); + event.accepted = true; + break; + case Qt.Key_PageDown: + root.moveRequested(5); + event.accepted = true; + break; + case Qt.Key_PageUp: + root.moveRequested(-5); + event.accepted = true; + break; + case Qt.Key_Return: + case Qt.Key_Enter: + root.launchRequested(root.selectedIndex); + event.accepted = true; + break; + case Qt.Key_Escape: + root.dismissRequested(); + event.accepted = true; + break; + } + } + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + visible: searchInput.text.length === 0 + text: "TYPE APPLICATION DESIGNATION" + color: Theme.muted + font: searchInput.font + } + } + + Rectangle { + Layout.preferredWidth: 84 + Layout.preferredHeight: 22 + color: Theme.accentAlpha(0.12) + border.width: 1 + border.color: Theme.hair + + Text { + anchors.centerIn: parent + text: "LIVE INDEX" + color: Theme.accent + font.family: Theme.microFont + font.pixelSize: 7 + font.bold: true + font.letterSpacing: 0.8 + } + } + } + } + + // Search result table. + StatusBarPanel { + id: resultPanel + x: 18 + y: 118 + width: 670 + height: 424 + panelId: "IDX" + title: "APPLICATION TABLE" + meta: root.query.length > 0 ? "FILTER ACTIVE" : "A–Z / LOCAL" + + ListView { + id: appList + x: 9 + y: 29 + width: parent.width - 18 + height: parent.height - 38 + clip: true + spacing: 3 + model: root.apps + currentIndex: root.selectedIndex + boundsBehavior: Flickable.StopAtBounds + onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + + delegate: MouseArea { + id: row + required property int index + required property var modelData + + width: ListView.view.width + height: 48 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + readonly property bool selected: index === root.selectedIndex + + onEntered: root.selectionRequested(index) + onClicked: root.launchRequested(index) + + Shape { + id: rowShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: row.selected ? Theme.selection : Theme.textAlpha(0.025) + strokeColor: row.selected ? Theme.accent : Theme.textAlpha(0.10) + strokeWidth: 1 + startX: 9; startY: 0 + PathLine { x: rowShape.width; y: 0 } + PathLine { x: rowShape.width - 9; y: rowShape.height } + PathLine { x: 0; y: rowShape.height } + PathLine { x: 9; y: 0 } + } + + ShapePath { + fillColor: row.selected ? Theme.accent : Theme.textAlpha(0.12) + strokeWidth: 0 + startX: 9; startY: 0 + PathLine { x: 13; y: 0 } + PathLine { x: 4; y: rowShape.height } + PathLine { x: 0; y: rowShape.height } + PathLine { x: 9; y: 0 } + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + spacing: 12 + + Text { + Layout.preferredWidth: 28 + text: String(row.index + 1).padStart(2, "0") + color: row.selected ? Theme.accent : Theme.muted + font.family: Theme.microFont + font.pixelSize: 8 + font.bold: row.selected + } + + Rectangle { + Layout.preferredWidth: 34 + Layout.preferredHeight: 34 + color: row.selected ? Theme.accentAlpha(0.14) : Theme.textAlpha(0.035) + border.width: 1 + border.color: row.selected ? Theme.accent : Theme.hair + + IconImage { + visible: root.showIcons + anchors.centerIn: parent + implicitSize: 24 + source: root.showIcons + ? Quickshell.iconPath(row.modelData.icon || "application-x-executable", "application-x-executable") + : "" + } + + Text { + visible: !root.showIcons + anchors.centerIn: parent + text: String(row.modelData.name || "?").charAt(0).toUpperCase() + color: row.selected ? Theme.accent : Theme.text + font.family: Theme.displayFont + font.pixelSize: 14 + font.bold: true + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + + Text { + Layout.fillWidth: true + text: row.modelData.name || "UNKNOWN TARGET" + color: row.selected ? Theme.accent : Theme.text + font.family: Theme.displayFont + font.pixelSize: 11 + font.bold: row.selected + font.letterSpacing: 0.5 + elide: Text.ElideRight + } + + MicroText { + Layout.fillWidth: true + text: row.modelData.genericName || row.modelData.comment || "DESKTOP APPLICATION" + } + } + + MicroText { + Layout.preferredWidth: 100 + horizontalAlignment: Text.AlignRight + text: row.index === root.selectedIndex ? "LOCKED" : "AVAILABLE" + color: row.selected ? Theme.accent : Theme.muted + } + + Rectangle { + Layout.preferredWidth: 38 + Layout.preferredHeight: 6 + color: Theme.textAlpha(0.05) + border.width: 1 + border.color: Theme.hair + + Rectangle { + width: row.selected ? parent.width : Math.max(5, parent.width * (1 - row.index / Math.max(1, root.apps.length))) + height: parent.height + color: row.selected ? Theme.accent : Theme.textAlpha(0.20) + } + } + } + } + + Text { + anchors.centerIn: parent + visible: root.apps.length === 0 + text: "NO EXECUTABLE TARGETS MATCH QUERY" + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 1.2 + } + } + } + + // Selected application inspector. + StatusBarPanel { + id: inspector + x: 700 + y: 34 + width: 362 + height: 508 + panelId: "SEL" + title: "TARGET INSPECTOR" + meta: root.selectedApp ? "LOCKED" : "NO TARGET" + + Item { + id: reticle + anchors.horizontalCenter: parent.horizontalCenter + y: 39 + width: 126 + height: 126 + + Rectangle { + anchors.centerIn: parent + width: 112 + height: 112 + radius: 56 + color: Theme.textAlpha(0.015) + border.width: 1 + border.color: Theme.accentAlpha(0.52) + } + Rectangle { + anchors.centerIn: parent + width: 84 + height: 84 + radius: 42 + color: Theme.textAlpha(0) + border.width: 1 + border.color: Theme.hair + } + Rectangle { anchors.centerIn: parent; width: 126; height: 1; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 1; height: 126; color: Theme.hair } + + Rectangle { + anchors.centerIn: parent + width: 60 + height: 60 + color: Theme.surface + border.width: 1 + border.color: Theme.accent + + IconImage { + visible: root.showIcons + anchors.centerIn: parent + implicitSize: 44 + source: root.showIcons && root.selectedApp + ? Quickshell.iconPath(root.selectedApp.icon || "application-x-executable", "application-x-executable") + : "" + } + + Text { + visible: !root.showIcons + anchors.centerIn: parent + text: root.selectedApp ? String(root.selectedApp.name || "?").charAt(0).toUpperCase() : "?" + color: Theme.accent + font.family: Theme.displayFont + font.pixelSize: 28 + font.bold: true + } + } + + Repeater { + model: 4 + Rectangle { + required property int index + width: 5; height: 5 + x: index % 2 === 0 ? 8 : reticle.width - 13 + y: index < 2 ? 8 : reticle.height - 13 + color: Theme.accent + } + } + } + + Text { + anchors.top: reticle.bottom + anchors.topMargin: 14 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 34 + horizontalAlignment: Text.AlignHCenter + text: root.selectedApp ? root.selectedApp.name : "NO TARGET" + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 19 + font.bold: true + font.letterSpacing: 1 + elide: Text.ElideRight + } + + MicroText { + id: genericLabel + anchors.top: reticle.bottom + anchors.topMargin: 42 + x: 18 + width: parent.width - 36 + horizontalAlignment: Text.AlignHCenter + text: root.selectedApp ? (root.selectedApp.genericName || "DESKTOP APPLICATION") : "AWAITING SEARCH VECTOR" + color: Theme.accent + } + + Rectangle { + x: 18 + anchors.top: genericLabel.bottom + anchors.topMargin: 12 + width: parent.width - 36 + height: 1 + color: Theme.hair + } + + MicroText { + id: description + x: 22 + anchors.top: genericLabel.bottom + anchors.topMargin: 27 + width: parent.width - 44 + height: 42 + text: root.selectedApp ? (root.selectedApp.comment || "No application description supplied by desktop entry.") : "Enter a designation to acquire an executable target." + wrapMode: Text.Wrap + elide: Text.ElideRight + maximumLineCount: 3 + horizontalAlignment: Text.AlignHCenter + } + + GridLayout { + id: telemetryGrid + x: 18 + anchors.top: description.bottom + anchors.topMargin: 15 + width: parent.width - 36 + columns: 2 + rowSpacing: 7 + columnSpacing: 7 + + Repeater { + model: [ + { label: "ENTRY", value: "DESKTOP" }, + { label: "MODE", value: "DETACHED" }, + { label: "AUTH", value: "LOCAL USER" }, + { label: "INDEX", value: String(Math.max(0, root.selectedIndex + 1)).padStart(3, "0") } + ] + + Rectangle { + required property var modelData + Layout.fillWidth: true + Layout.preferredHeight: 46 + color: Theme.textAlpha(0.025) + border.width: 1 + border.color: Theme.hair + + MicroText { x: 8; y: 7; width: parent.width - 16; text: modelData.label } + Text { + x: 8; y: 22; width: parent.width - 16 + text: modelData.value + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 9 + font.bold: true + font.letterSpacing: 0.6 + elide: Text.ElideRight + } + } + } + } + + Item { + x: 18 + anchors.bottom: executeTile.top + anchors.bottomMargin: 12 + width: parent.width - 36 + height: 18 + + Row { + spacing: 5 + Repeater { + model: 24 + Rectangle { + required property int index + width: 7; height: 3 + y: index % 3 === 0 ? 0 : 4 + color: index < 16 ? Theme.accent : Theme.hair + transform: Rotation { angle: -35 } + } + } + } + } + + MouseArea { + id: executeTile + x: 18 + anchors.bottom: parent.bottom + anchors.bottomMargin: 14 + width: parent.width - 36 + height: 42 + enabled: root.selectedApp !== null + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.launchRequested(root.selectedIndex) + + Rectangle { + anchors.fill: parent + color: executeTile.containsMouse ? Theme.accent : Theme.accentAlpha(0.16) + border.width: 1 + border.color: Theme.accent + + Text { + anchors.centerIn: parent + text: root.selectedApp ? "EXECUTE SELECTED TARGET" : "NO TARGET ACQUIRED" + color: executeTile.containsMouse ? Theme.surface : Theme.accent + font.family: Theme.displayFont + font.pixelSize: 10 + font.bold: true + font.letterSpacing: 1.2 + } + } + } + } + + // Dense command footer. + StatusBarPanel { + x: 18 + y: 550 + width: 1044 + height: 52 + showHeader: false + chamfer: 9 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 18 + + MicroText { text: "INPUT CHANNEL // KEYBOARD"; color: Theme.accent } + Rectangle { Layout.preferredWidth: 1; Layout.preferredHeight: 22; color: Theme.hair } + KeyHint { keyText: "↑ ↓"; actionText: "SELECT" } + KeyHint { keyText: "ENTER"; actionText: "EXECUTE" } + KeyHint { keyText: "ESC"; actionText: "ABORT" } + Item { Layout.fillWidth: true } + MicroText { text: "QRY:" + (root.query.length > 0 ? "ACTIVE" : "IDLE") } + MicroText { text: "CRC // A7F2" } + Rectangle { + Layout.preferredWidth: 76 + Layout.preferredHeight: 4 + color: Theme.accent + } + } + } + } +} diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index 5dd1d16..593ce5e 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -198,7 +198,7 @@ in # quickshell app-launcher variants (evaluating — pick one) ] - ++ (map (n: bind "SUPER + CTRL + ${toString n}" (dsp.global "quickshell:launcher${toString n}")) (lib.range 1 7)) + ++ (map (n: bind "SUPER + CTRL + ${toString n}" (dsp.global "quickshell:launcher${toString n}")) (lib.range 1 8)) ++ [ # restart quickshell (also starts it if not running) (bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs")) From 5ecc73f08488fdf03ee8fd949eaeec148a9e9e1d Mon Sep 17 00:00:00 2001 From: luna Date: Fri, 28 Aug 2026 20:48:23 +0000 Subject: [PATCH 06/24] [verified] feat(quickshell): add blade launcher variant --- dotfiles/quickshell/shell.qml | 3 +- .../tests/BladeLauncherHeadless.qml | 20 + .../widgets/launcher/BladeLauncher.qml | 80 +++ .../widgets/launcher/BladeLauncherContent.qml | 473 ++++++++++++++++++ hosts/terra/home/hyprland.nix | 2 +- 5 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 dotfiles/quickshell/tests/BladeLauncherHeadless.qml create mode 100644 dotfiles/quickshell/widgets/launcher/BladeLauncher.qml create mode 100644 dotfiles/quickshell/widgets/launcher/BladeLauncherContent.qml diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index fb2222a..278e5ff 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -17,7 +17,7 @@ Scope { SideBar {} BarBottom {} - // App launcher variants — SUPER CTRL 1–8; variant 8 is the primary HUD. + // App launcher variants — SUPER CTRL 1–9; variant 8 remains the primary HUD. LauncherStack {} // 1 — left vertical list LauncherGrid {} // 2 — centered icon grid LauncherSpotlight {} // 3 — top-center command bar @@ -26,6 +26,7 @@ Scope { LauncherSlant {} // 6 — angular / sheared panel LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot) ApplicationLauncher {} // 8 — dense HUD command index (primary) + BladeLauncher {} // 9 — asymmetric blade matrix Notifications {} VolumeOsd {} diff --git a/dotfiles/quickshell/tests/BladeLauncherHeadless.qml b/dotfiles/quickshell/tests/BladeLauncherHeadless.qml new file mode 100644 index 0000000..509f9fe --- /dev/null +++ b/dotfiles/quickshell/tests/BladeLauncherHeadless.qml @@ -0,0 +1,20 @@ +import QtQuick +import qs.widgets.launcher + +BladeLauncherContent { + width: 1120 + height: 640 + query: "dev" + selectedIndex: 2 + showIcons: false + apps: [ + { name: "Visual Studio Code", genericName: "Code Editor", comment: "Edit and debug software projects", icon: "visual-studio-code", categories: ["Development"] }, + { name: "GitKraken", genericName: "Git Client", comment: "Inspect branches and repository history", icon: "gitkraken", categories: ["Development"] }, + { name: "OpenCode", genericName: "AI Development", comment: "Agentic terminal coding environment", icon: "utilities-terminal", categories: ["Development", "Utility"] }, + { name: "Alacritty", genericName: "Terminal Emulator", comment: "GPU accelerated command interface", icon: "utilities-terminal", categories: ["System"] }, + { name: "Vivaldi", genericName: "Web Browser", comment: "Access network and web applications", icon: "vivaldi", categories: ["Network"] }, + { name: "Obsidian", genericName: "Knowledge Base", comment: "Local-first markdown workspace", icon: "obsidian", categories: ["Office"] }, + { name: "Blender", genericName: "3D Creation", comment: "Model, animate and render 3D scenes", icon: "blender", categories: ["Graphics"] }, + { name: "Steam", genericName: "Game Platform", comment: "Launch and manage games", icon: "steam", categories: ["Game"] } + ] +} diff --git a/dotfiles/quickshell/widgets/launcher/BladeLauncher.qml b/dotfiles/quickshell/widgets/launcher/BladeLauncher.qml new file mode 100644 index 0000000..c29d7b5 --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/BladeLauncher.qml @@ -0,0 +1,80 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import qs.widgets.theme + +// Variant 9 — asymmetric Blade application launcher. +Scope { + id: root + + property bool active: false + function toggle() { root.active = !root.active; } + + GlobalShortcut { + name: "launcher9" + description: "Toggle app launcher (Blade matrix)" + onPressed: root.toggle() + } + + PanelWindow { + id: win + visible: root.active + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + color: Theme.textAlpha(0) + + anchors { top: true; left: true; right: true; bottom: true } + + property int selectedIndex: 0 + + function clampSelection(index) { + return model.apps.length === 0 ? 0 : Math.max(0, Math.min(model.apps.length - 1, index)); + } + function move(delta) { + const count = model.apps.length; + if (count === 0) + return; + selectedIndex = ((selectedIndex + delta) % count + count) % count; + } + function launch(index) { + if (model.launch(index)) + root.active = false; + } + + onVisibleChanged: { + if (visible) { + content.clearSearch(); + selectedIndex = 0; + content.focusSearch(); + } + } + + AppModel { id: model; search: content.query } + + Rectangle { + anchors.fill: parent + color: Theme.surface + opacity: 0.78 + MouseArea { anchors.fill: parent; onClicked: root.active = false } + } + + BladeLauncherContent { + id: content + anchors.centerIn: parent + width: 1120 + height: 640 + scale: Math.min(1, (parent.width - 56) / width, (parent.height - 56) / height) + transformOrigin: Item.Center + apps: model.apps + selectedIndex: win.selectedIndex + onSelectionRequested: index => win.selectedIndex = win.clampSelection(index) + onMoveRequested: delta => win.move(delta) + onLaunchRequested: index => win.launch(index) + onDismissRequested: root.active = false + } + } +} diff --git a/dotfiles/quickshell/widgets/launcher/BladeLauncherContent.qml b/dotfiles/quickshell/widgets/launcher/BladeLauncherContent.qml new file mode 100644 index 0000000..3521d90 --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/BladeLauncherContent.qml @@ -0,0 +1,473 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import qs.widgets.theme + +// Variant 9 visual core: asymmetric "Blade" composition. It shares the shell +// Theme and AppModel contract, but intentionally does not reuse StatusBarPanel +// or the nested-panel structure of ApplicationLauncherContent. +Item { + id: root + + property var apps: [] + property int selectedIndex: 0 + property bool showIcons: true + property alias query: commandInput.text + + readonly property var selectedApp: apps.length > 0 && selectedIndex >= 0 && selectedIndex < apps.length + ? apps[selectedIndex] : null + + signal selectionRequested(int index) + signal moveRequested(int delta) + signal launchRequested(int index) + signal dismissRequested + + function focusSearch() { commandInput.forceActiveFocus(); } + function clearSearch() { commandInput.text = ""; } + + implicitWidth: 1120 + implicitHeight: 640 + + component MicroText: Text { + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 7 + font.letterSpacing: 0.9 + elide: Text.ElideRight + } + + Rectangle { anchors.fill: parent; color: Theme.surface } + + // Sparse circuit traces behind the active surfaces. + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accentAlpha(0.24) + strokeWidth: 1 + startX: 24; startY: 92 + PathLine { x: 115; y: 92 } + PathLine { x: 138; y: 69 } + PathLine { x: 480; y: 69 } + PathLine { x: 494; y: 55 } + PathLine { x: 840; y: 55 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.hair + strokeWidth: 1 + startX: 115; startY: 570 + PathLine { x: 146; y: 601 } + PathLine { x: 775; y: 601 } + PathLine { x: 801; y: 575 } + PathLine { x: 1095; y: 575 } + } + } + + // Top command mast — an open angular frame rather than panel chrome. + Shape { + id: mast + x: 26; y: 18 + width: parent.width - 52; height: 72 + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0.018) + strokeColor: Theme.hair + strokeWidth: 1 + startX: 0; startY: 17 + PathLine { x: 17; y: 0 } + PathLine { x: mast.width - 110; y: 0 } + PathLine { x: mast.width - 88; y: 22 } + PathLine { x: mast.width; y: 22 } + PathLine { x: mast.width; y: mast.height } + PathLine { x: 0; y: mast.height } + PathLine { x: 0; y: 17 } + } + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: 0; startY: 17 + PathLine { x: 17; y: 0 } + PathLine { x: 122; y: 0 } + PathLine { x: 116; y: 4 } + PathLine { x: 20; y: 4 } + PathLine { x: 4; y: 20 } + PathLine { x: 4; y: mast.height } + PathLine { x: 0; y: mast.height } + PathLine { x: 0; y: 17 } + } + } + + Text { + x: 54; y: 31 + text: "BLADE // EXECUTION MATRIX" + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 16 + font.bold: true + font.letterSpacing: 1.4 + } + MicroText { x: 56; y: 57; text: "09 / APPLICATION ROUTER / LOCAL DESKTOP ENTRIES"; color: Theme.accent } + + Row { + anchors.right: parent.right + anchors.rightMargin: 48 + y: 37 + spacing: 4 + Repeater { + model: 16 + Rectangle { + required property int index + width: 9; height: index % 4 === 0 ? 16 : 8 + y: index % 4 === 0 ? 0 : 8 + color: index < Math.min(16, root.apps.length + 6) ? Theme.accent : Theme.hair + transform: Rotation { angle: -28 } + } + } + } + + // Left category rotor and mode spine. + Item { + id: rotorZone + x: 26; y: 108 + width: 188; height: 444 + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0.018) + strokeColor: Theme.hair + strokeWidth: 1 + startX: 0; startY: 0 + PathLine { x: 154; y: 0 } + PathLine { x: 188; y: 34 } + PathLine { x: 188; y: 408 } + PathLine { x: 152; y: 444 } + PathLine { x: 0; y: 444 } + PathLine { x: 0; y: 0 } + } + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: 0; startY: 0 + PathLine { x: 7; y: 0 } + PathLine { x: 7; y: 444 } + PathLine { x: 0; y: 444 } + PathLine { x: 0; y: 0 } + } + } + + Text { + x: 20; y: 18 + text: "VECTOR" + color: Theme.accent + font.family: Theme.displayFont + font.pixelSize: 11 + font.bold: true + font.letterSpacing: 1.4 + } + MicroText { x: 20; y: 38; text: "CATEGORY BUS" } + + Item { + id: rotor + anchors.horizontalCenter: parent.horizontalCenter + y: 70; width: 126; height: 126 + Rectangle { anchors.centerIn: parent; width: 116; height: 116; radius: 58; color: Theme.textAlpha(0); border.width: 1; border.color: Theme.accentAlpha(0.55) } + Rectangle { anchors.centerIn: parent; width: 82; height: 82; radius: 41; color: Theme.textAlpha(0); border.width: 1; border.color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 126; height: 1; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 1; height: 126; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 32; height: 32; color: Theme.accentAlpha(0.18); border.width: 1; border.color: Theme.accent; transform: Rotation { angle: 45 } } + Text { anchors.centerIn: parent; text: "A"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 16; font.bold: true } + Repeater { + model: 4 + Rectangle { + required property int index + width: 6; height: 6; radius: 3 + x: [16, 101, 101, 16][index] + y: [16, 16, 101, 101][index] + color: Theme.accent + } + } + } + + Column { + x: 18; y: 218 + width: parent.width - 38 + spacing: 7 + Repeater { + model: ["ALL TARGETS", "SYSTEM", "DEVELOP", "NETWORK", "MEDIA"] + Rectangle { + required property int index + required property string modelData + width: parent.width - index * 5 + height: 31 + x: index * 5 + color: index === 0 ? Theme.accentAlpha(0.14) : Theme.textAlpha(0.025) + border.width: 1 + border.color: index === 0 ? Theme.accent : Theme.hair + Text { + x: 9; anchors.verticalCenter: parent.verticalCenter + text: String(index).padStart(2, "0") + " " + modelData + color: index === 0 ? Theme.accent : Theme.muted + font.family: Theme.microFont + font.pixelSize: 7 + font.bold: index === 0 + font.letterSpacing: 0.7 + } + } + } + } + + MicroText { x: 18; anchors.bottom: parent.bottom; anchors.bottomMargin: 14; text: "BUS // UNFILTERED"; color: Theme.accent } + } + + // Central command spine. + Item { + id: commandZone + x: 230; y: 108 + width: 594; height: 486 + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0.012) + strokeColor: Theme.hair + strokeWidth: 1 + startX: 20; startY: 0 + PathLine { x: commandZone.width; y: 0 } + PathLine { x: commandZone.width - 20; y: commandZone.height } + PathLine { x: 0; y: commandZone.height } + PathLine { x: 20; y: 0 } + } + } + + // Search blade. + Shape { + id: searchBlade + x: 12; y: 12; width: parent.width - 32; height: 58 + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.accentAlpha(0.10) + strokeColor: Theme.accent + strokeWidth: 1 + startX: 18; startY: 0 + PathLine { x: searchBlade.width; y: 0 } + PathLine { x: searchBlade.width - 18; y: searchBlade.height } + PathLine { x: 0; y: searchBlade.height } + PathLine { x: 18; y: 0 } + } + } + Text { x: 32; y: 28; text: ">"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 20; font.bold: true } + TextInput { + id: commandInput + x: 62; y: 22; width: 430; height: 38 + verticalAlignment: TextInput.AlignVCenter + color: Theme.text + selectionColor: Theme.accent + selectedTextColor: Theme.surface + font.family: Theme.displayFont + font.pixelSize: 17 + font.letterSpacing: 1 + clip: true + onTextChanged: root.selectionRequested(0) + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Down: case Qt.Key_Tab: root.moveRequested(1); event.accepted = true; break; + case Qt.Key_Up: case Qt.Key_Backtab: root.moveRequested(-1); event.accepted = true; break; + case Qt.Key_PageDown: root.moveRequested(5); event.accepted = true; break; + case Qt.Key_PageUp: root.moveRequested(-5); event.accepted = true; break; + case Qt.Key_Return: case Qt.Key_Enter: root.launchRequested(root.selectedIndex); event.accepted = true; break; + case Qt.Key_Escape: root.dismissRequested(); event.accepted = true; break; + } + } + Text { anchors.fill: parent; verticalAlignment: Text.AlignVCenter; visible: commandInput.text.length === 0; text: "ACQUIRE TARGET"; color: Theme.muted; font: commandInput.font } + } + MicroText { anchors.right: parent.right; anchors.rightMargin: 38; y: 35; text: root.apps.length + " HIT"; color: Theme.accent } + + ListView { + id: bladeList + x: 10; y: 84; width: parent.width - 26; height: 382 + model: root.apps + currentIndex: root.selectedIndex + clip: true + spacing: 4 + boundsBehavior: Flickable.StopAtBounds + onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + + delegate: MouseArea { + id: blade + required property int index + required property var modelData + readonly property bool selected: index === root.selectedIndex + width: ListView.view.width - (index % 2 === 0 ? 0 : 16) + height: 43 + x: index % 2 === 0 ? 0 : 16 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.selectionRequested(index) + onClicked: root.launchRequested(index) + + Shape { + id: bladeShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: blade.selected ? Theme.selection : Theme.textAlpha(0.025) + strokeColor: blade.selected ? Theme.accent : Theme.textAlpha(0.12) + strokeWidth: 1 + startX: 16; startY: 0 + PathLine { x: bladeShape.width; y: 0 } + PathLine { x: bladeShape.width - 24; y: bladeShape.height } + PathLine { x: 0; y: bladeShape.height } + PathLine { x: 16; y: 0 } + } + ShapePath { + fillColor: blade.selected ? Theme.accent : Theme.textAlpha(0.12) + strokeWidth: 0 + startX: 16; startY: 0 + PathLine { x: 22; y: 0 } + PathLine { x: 6; y: bladeShape.height } + PathLine { x: 0; y: bladeShape.height } + PathLine { x: 16; y: 0 } + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 26 + anchors.rightMargin: 28 + spacing: 10 + Text { Layout.preferredWidth: 26; text: String(blade.index + 1).padStart(2, "0"); color: blade.selected ? Theme.accent : Theme.muted; font.family: Theme.microFont; font.pixelSize: 7 } + Rectangle { + Layout.preferredWidth: 28; Layout.preferredHeight: 28 + color: blade.selected ? Theme.accentAlpha(0.14) : Theme.textAlpha(0.025) + border.width: 1; border.color: blade.selected ? Theme.accent : Theme.hair + IconImage { visible: root.showIcons; anchors.centerIn: parent; implicitSize: 20; source: root.showIcons ? Quickshell.iconPath(blade.modelData.icon || "application-x-executable", "application-x-executable") : "" } + Text { visible: !root.showIcons; anchors.centerIn: parent; text: String(blade.modelData.name || "?").charAt(0).toUpperCase(); color: blade.selected ? Theme.accent : Theme.text; font.family: Theme.displayFont; font.pixelSize: 11; font.bold: true } + } + Text { Layout.fillWidth: true; text: blade.modelData.name || "UNKNOWN"; color: blade.selected ? Theme.accent : Theme.text; font.family: Theme.displayFont; font.pixelSize: 10; font.bold: blade.selected; elide: Text.ElideRight } + MicroText { Layout.preferredWidth: 125; horizontalAlignment: Text.AlignRight; text: blade.modelData.genericName || "APPLICATION" } + Rectangle { Layout.preferredWidth: blade.selected ? 32 : 12; Layout.preferredHeight: 3; color: blade.selected ? Theme.accent : Theme.hair } + } + } + + Text { anchors.centerIn: parent; visible: root.apps.length === 0; text: "NO TARGET VECTOR"; color: Theme.muted; font.family: Theme.microFont; font.pixelSize: 9; font.letterSpacing: 1.2 } + } + } + + // Right dossier: open brackets and data lines, deliberately not a panel. + Item { + id: dossier + x: 842; y: 108 + width: 252; height: 444 + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: 36; startY: 0 + PathLine { x: 0; y: 0 } + PathLine { x: 0; y: 86 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: dossier.width - 36; startY: dossier.height + PathLine { x: dossier.width; y: dossier.height } + PathLine { x: dossier.width; y: dossier.height - 86 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.hair + strokeWidth: 1 + startX: 20; startY: 20 + PathLine { x: dossier.width; y: 20 } + PathLine { x: dossier.width; y: dossier.height - 20 } + PathLine { x: 0; y: dossier.height - 20 } + PathLine { x: 0; y: 104 } + } + } + + MicroText { x: 18; y: 12; text: "ACTIVE DOSSIER"; color: Theme.accent } + Text { + x: 18; y: 45; width: parent.width - 36 + text: root.selectedApp ? root.selectedApp.name : "NO TARGET" + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 18 + font.bold: true + font.letterSpacing: 0.8 + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + MicroText { x: 18; y: 94; width: parent.width - 36; text: root.selectedApp ? (root.selectedApp.genericName || "DESKTOP APPLICATION") : "AWAITING ACQUISITION"; color: Theme.accent } + + Rectangle { x: 18; y: 119; width: parent.width - 36; height: 1; color: Theme.hair } + MicroText { x: 18; y: 137; width: parent.width - 36; height: 52; wrapMode: Text.Wrap; maximumLineCount: 4; text: root.selectedApp ? (root.selectedApp.comment || "No metadata supplied by desktop entry.") : "Enter a search vector and select an executable target." } + + Column { + x: 18; y: 210; width: parent.width - 36; spacing: 8 + Repeater { + model: [ + { k: "INDEX", v: String(Math.max(0, root.selectedIndex + 1)).padStart(3, "0") }, + { k: "TYPE", v: "DESKTOP ENTRY" }, + { k: "ROUTE", v: "DETACHED" }, + { k: "STATE", v: root.selectedApp ? "ARMED" : "IDLE" } + ] + Item { + required property var modelData + width: parent.width; height: 27 + Rectangle { x: 0; y: parent.height - 1; width: parent.width; height: 1; color: Theme.hair } + MicroText { x: 0; anchors.verticalCenter: parent.verticalCenter; text: modelData.k } + Text { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: modelData.v; color: modelData.k === "STATE" ? Theme.accent : Theme.text; font.family: Theme.displayFont; font.pixelSize: 8; font.bold: true; font.letterSpacing: 0.5 } + } + } + } + + MouseArea { + id: fire + x: 18; anchors.bottom: parent.bottom; anchors.bottomMargin: 34 + width: parent.width - 36; height: 44 + enabled: root.selectedApp !== null + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.launchRequested(root.selectedIndex) + Shape { + id: fireShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: fire.containsMouse ? Theme.accent : Theme.accentAlpha(0.16) + strokeColor: Theme.accent + strokeWidth: 1 + startX: 14; startY: 0 + PathLine { x: fireShape.width; y: 0 } + PathLine { x: fireShape.width - 14; y: fireShape.height } + PathLine { x: 0; y: fireShape.height } + PathLine { x: 14; y: 0 } + } + } + Text { anchors.centerIn: parent; text: "LAUNCH VECTOR"; color: fire.containsMouse ? Theme.surface : Theme.accent; font.family: Theme.displayFont; font.pixelSize: 10; font.bold: true; font.letterSpacing: 1.2 } + } + } + + // Bottom caution lane and key map. + Row { + x: 28; y: 612; spacing: 4 + Repeater { model: 30; Rectangle { required property int index; width: 12; height: 4; color: index % 3 === 0 ? Theme.accent : Theme.hair; transform: Rotation { angle: -32 } } } + } + MicroText { x: 420; y: 606; text: "↑↓ SELECT // ENTER LAUNCH // ESC ABORT // PGUP/PGDN STEP"; color: Theme.accent } + MicroText { anchors.right: parent.right; anchors.rightMargin: 28; y: 606; text: "BLADE-09 / CRC A7F2" } +} diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index 593ce5e..e0042b4 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -198,7 +198,7 @@ in # quickshell app-launcher variants (evaluating — pick one) ] - ++ (map (n: bind "SUPER + CTRL + ${toString n}" (dsp.global "quickshell:launcher${toString n}")) (lib.range 1 8)) + ++ (map (n: bind "SUPER + CTRL + ${toString n}" (dsp.global "quickshell:launcher${toString n}")) (lib.range 1 9)) ++ [ # restart quickshell (also starts it if not running) (bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs")) From 9b6b799a5d12e534f10cf38db7bb25bdb76effe1 Mon Sep 17 00:00:00 2001 From: luna Date: Fri, 28 Aug 2026 20:58:37 +0000 Subject: [PATCH 07/24] [verified] feat(quickshell): add orbit launcher variant --- dotfiles/quickshell/shell.qml | 3 +- .../tests/OrbitLauncherHeadless.qml | 20 ++ .../widgets/launcher/OrbitLauncher.qml | 79 ++++ .../widgets/launcher/OrbitLauncherContent.qml | 338 ++++++++++++++++++ hosts/terra/home/hyprland.nix | 2 + 5 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 dotfiles/quickshell/tests/OrbitLauncherHeadless.qml create mode 100644 dotfiles/quickshell/widgets/launcher/OrbitLauncher.qml create mode 100644 dotfiles/quickshell/widgets/launcher/OrbitLauncherContent.qml diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 278e5ff..d4bde52 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -17,7 +17,7 @@ Scope { SideBar {} BarBottom {} - // App launcher variants — SUPER CTRL 1–9; variant 8 remains the primary HUD. + // App launcher variants — 1–10; variant 8 remains the primary HUD. LauncherStack {} // 1 — left vertical list LauncherGrid {} // 2 — centered icon grid LauncherSpotlight {} // 3 — top-center command bar @@ -27,6 +27,7 @@ Scope { LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot) ApplicationLauncher {} // 8 — dense HUD command index (primary) BladeLauncher {} // 9 — asymmetric blade matrix + OrbitLauncher {} // 10 — radial targeting arena Notifications {} VolumeOsd {} diff --git a/dotfiles/quickshell/tests/OrbitLauncherHeadless.qml b/dotfiles/quickshell/tests/OrbitLauncherHeadless.qml new file mode 100644 index 0000000..007486f --- /dev/null +++ b/dotfiles/quickshell/tests/OrbitLauncherHeadless.qml @@ -0,0 +1,20 @@ +import QtQuick +import qs.widgets.launcher + +OrbitLauncherContent { + width: 1120 + height: 660 + query: "media" + selectedIndex: 3 + showIcons: false + apps: [ + { name: "Vivaldi", genericName: "Web Browser", comment: "Access network and web applications", icon: "vivaldi" }, + { name: "Obsidian", genericName: "Knowledge Base", comment: "Local-first markdown workspace", icon: "obsidian" }, + { name: "Blender", genericName: "3D Creation", comment: "Model, animate and render 3D scenes", icon: "blender" }, + { name: "Jellyfin Media Player", genericName: "Media Player", comment: "Stream media from the homelab library", icon: "jellyfin-media-player" }, + { name: "Spotify", genericName: "Music Player", comment: "Browse and play music", icon: "spotify" }, + { name: "Steam", genericName: "Game Platform", comment: "Launch and manage games", icon: "steam" }, + { name: "Cosmic Files", genericName: "File Manager", comment: "Browse and manage local storage", icon: "com.system76.CosmicFiles" }, + { name: "Alacritty", genericName: "Terminal Emulator", comment: "GPU accelerated command interface", icon: "utilities-terminal" } + ] +} diff --git a/dotfiles/quickshell/widgets/launcher/OrbitLauncher.qml b/dotfiles/quickshell/widgets/launcher/OrbitLauncher.qml new file mode 100644 index 0000000..9a5d833 --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/OrbitLauncher.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import qs.widgets.theme + +// Variant 10 — radial Orbit application launcher. +Scope { + id: root + + property bool active: false + function toggle() { root.active = !root.active; } + + GlobalShortcut { + name: "launcher10" + description: "Toggle app launcher (Orbit targeting)" + onPressed: root.toggle() + } + + PanelWindow { + id: win + visible: root.active + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + color: Theme.textAlpha(0) + anchors { top: true; left: true; right: true; bottom: true } + + property int selectedIndex: 0 + + function clampSelection(index) { + return model.apps.length === 0 ? 0 : Math.max(0, Math.min(model.apps.length - 1, index)); + } + function move(delta) { + const count = model.apps.length; + if (count === 0) + return; + selectedIndex = ((selectedIndex + delta) % count + count) % count; + } + function launch(index) { + if (model.launch(index)) + root.active = false; + } + + onVisibleChanged: { + if (visible) { + content.clearSearch(); + selectedIndex = 0; + content.focusSearch(); + } + } + + AppModel { id: model; search: content.query } + + Rectangle { + anchors.fill: parent + color: Theme.surface + opacity: 0.80 + MouseArea { anchors.fill: parent; onClicked: root.active = false } + } + + OrbitLauncherContent { + id: content + anchors.centerIn: parent + width: 1120 + height: 660 + scale: Math.min(1, (parent.width - 56) / width, (parent.height - 56) / height) + transformOrigin: Item.Center + apps: model.apps + selectedIndex: win.selectedIndex + onSelectionRequested: index => win.selectedIndex = win.clampSelection(index) + onMoveRequested: delta => win.move(delta) + onLaunchRequested: index => win.launch(index) + onDismissRequested: root.active = false + } + } +} diff --git a/dotfiles/quickshell/widgets/launcher/OrbitLauncherContent.qml b/dotfiles/quickshell/widgets/launcher/OrbitLauncherContent.qml new file mode 100644 index 0000000..496ff19 --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/OrbitLauncherContent.qml @@ -0,0 +1,338 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import qs.widgets.theme + +// Variant 10 visual core: radial application targeting arena plus execution +// tape. Shares Theme/AppModel contracts but no launcher panel primitives. +Item { + id: root + + property var apps: [] + property int selectedIndex: 0 + property bool showIcons: true + property alias query: orbitInput.text + + readonly property var selectedApp: apps.length > 0 && selectedIndex >= 0 && selectedIndex < apps.length + ? apps[selectedIndex] : null + readonly property int orbitCount: Math.min(8, apps.length) + + signal selectionRequested(int index) + signal moveRequested(int delta) + signal launchRequested(int index) + signal dismissRequested + + function focusSearch() { orbitInput.forceActiveFocus(); } + function clearSearch() { orbitInput.text = ""; } + + implicitWidth: 1120 + implicitHeight: 660 + + component MicroText: Text { + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 7 + font.letterSpacing: 0.9 + elide: Text.ElideRight + } + + Rectangle { anchors.fill: parent; color: Theme.surface } + + // Perimeter bracket frame, deliberately open on all four sides. + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: 24; startY: 92 + PathLine { x: 24; y: 24 } + PathLine { x: 128; y: 24 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: root.width - 128; startY: 24 + PathLine { x: root.width - 24; y: 24 } + PathLine { x: root.width - 24; y: 92 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: 24; startY: root.height - 92 + PathLine { x: 24; y: root.height - 24 } + PathLine { x: 128; y: root.height - 24 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: root.width - 128; startY: root.height - 24 + PathLine { x: root.width - 24; y: root.height - 24 } + PathLine { x: root.width - 24; y: root.height - 92 } + } + } + + Text { + x: 44; y: 38 + text: "ORBIT // APPLICATION TARGETING" + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 17 + font.bold: true + font.letterSpacing: 1.5 + } + MicroText { x: 46; y: 64; text: "10 / RADIAL INDEX / EXECUTION CONTROL"; color: Theme.accent } + MicroText { anchors.right: parent.right; anchors.rightMargin: 44; y: 48; text: root.apps.length + " TRACKED OBJECTS" } + + // Radial targeting arena. + Item { + id: arena + x: 36; y: 92 + width: 716; height: 500 + + // Axis traces. + Rectangle { anchors.centerIn: parent; width: parent.width - 28; height: 1; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 1; height: parent.height - 16; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 414; height: 414; radius: 207; color: Theme.textAlpha(0.008); border.width: 1; border.color: Theme.accentAlpha(0.50) } + Rectangle { anchors.centerIn: parent; width: 326; height: 326; radius: 163; color: Theme.textAlpha(0); border.width: 1; border.color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 220; height: 220; radius: 110; color: Theme.textAlpha(0); border.width: 1; border.color: Theme.accentAlpha(0.28) } + + // Cardinal labels. + MicroText { anchors.horizontalCenter: parent.horizontalCenter; y: 8; text: "N // INDEX 00" } + MicroText { anchors.horizontalCenter: parent.horizontalCenter; anchors.bottom: parent.bottom; anchors.bottomMargin: 5; text: "S // COMMAND BUS" } + MicroText { anchors.verticalCenter: parent.verticalCenter; x: 8; text: "W"; color: Theme.accent } + MicroText { anchors.verticalCenter: parent.verticalCenter; anchors.right: parent.right; anchors.rightMargin: 8; text: "E"; color: Theme.accent } + + // App nodes distributed around the outer orbit. + Repeater { + model: root.orbitCount + + MouseArea { + id: node + required property int index + readonly property var app: root.apps[index] + readonly property real angle: -Math.PI / 2 + index * (2 * Math.PI / Math.max(1, root.orbitCount)) + readonly property bool selected: index === root.selectedIndex + width: selected ? 92 : 74 + height: selected ? 52 : 44 + x: arena.width / 2 + Math.cos(angle) * 205 - width / 2 + y: arena.height / 2 + Math.sin(angle) * 205 - height / 2 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.selectionRequested(index) + onClicked: root.launchRequested(index) + + Shape { + id: nodeShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: node.selected ? Theme.selection : Theme.surface + strokeColor: node.selected ? Theme.accent : Theme.hair + strokeWidth: node.selected ? 2 : 1 + startX: 10; startY: 0 + PathLine { x: nodeShape.width; y: 0 } + PathLine { x: nodeShape.width - 10; y: nodeShape.height } + PathLine { x: 0; y: nodeShape.height } + PathLine { x: 10; y: 0 } + } + } + + Rectangle { + x: 7; anchors.verticalCenter: parent.verticalCenter + width: 27; height: 27 + color: node.selected ? Theme.accentAlpha(0.18) : Theme.textAlpha(0.025) + border.width: 1 + border.color: node.selected ? Theme.accent : Theme.hair + IconImage { visible: root.showIcons; anchors.centerIn: parent; implicitSize: 20; source: root.showIcons ? Quickshell.iconPath(node.app.icon || "application-x-executable", "application-x-executable") : "" } + Text { visible: !root.showIcons; anchors.centerIn: parent; text: String(node.app.name || "?").charAt(0).toUpperCase(); color: node.selected ? Theme.accent : Theme.text; font.family: Theme.displayFont; font.pixelSize: 11; font.bold: true } + } + Text { + x: 40; anchors.verticalCenter: parent.verticalCenter + width: parent.width - 48 + text: node.app.name || "UNKNOWN" + color: node.selected ? Theme.accent : Theme.text + font.family: Theme.displayFont + font.pixelSize: 8 + font.bold: node.selected + elide: Text.ElideRight + } + } + } + + // Core target, not a conventional card. + Item { + id: core + anchors.centerIn: parent + width: 196; height: 196 + + Rectangle { anchors.centerIn: parent; width: 186; height: 186; radius: 93; color: Theme.surface; border.width: 2; border.color: Theme.accent } + Rectangle { anchors.centerIn: parent; width: 156; height: 156; radius: 78; color: Theme.accentAlpha(0.04); border.width: 1; border.color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 118; height: 118; color: Theme.textAlpha(0.012); border.width: 1; border.color: Theme.accentAlpha(0.42); transform: Rotation { angle: 45 } } + Rectangle { anchors.centerIn: parent; width: 196; height: 1; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 1; height: 196; color: Theme.hair } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: 31; width: 58; height: 58 + color: Theme.surface + border.width: 1; border.color: Theme.accent + IconImage { visible: root.showIcons; anchors.centerIn: parent; implicitSize: 42; source: root.showIcons && root.selectedApp ? Quickshell.iconPath(root.selectedApp.icon || "application-x-executable", "application-x-executable") : "" } + Text { visible: !root.showIcons; anchors.centerIn: parent; text: root.selectedApp ? String(root.selectedApp.name || "?").charAt(0).toUpperCase() : "?"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 26; font.bold: true } + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + y: 100; width: 156 + text: root.selectedApp ? root.selectedApp.name : "NO TARGET" + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 11 + font.bold: true + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + MicroText { anchors.horizontalCenter: parent.horizontalCenter; y: 124; width: 150; horizontalAlignment: Text.AlignHCenter; text: root.selectedApp ? (root.selectedApp.genericName || "APPLICATION") : "AWAITING LOCK"; color: Theme.accent } + MicroText { anchors.horizontalCenter: parent.horizontalCenter; y: 146; text: "LOCK // " + String(Math.max(0, root.selectedIndex + 1)).padStart(3, "0") } + } + + // Search rail crosses the bottom of the arena. + Shape { + id: queryRail + x: 98; anchors.bottom: parent.bottom; anchors.bottomMargin: 24 + width: 520; height: 48 + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.accentAlpha(0.10) + strokeColor: Theme.accent + strokeWidth: 1 + startX: 16; startY: 0 + PathLine { x: queryRail.width; y: 0 } + PathLine { x: queryRail.width - 16; y: queryRail.height } + PathLine { x: 0; y: queryRail.height } + PathLine { x: 16; y: 0 } + } + } + Text { x: 118; anchors.bottom: parent.bottom; anchors.bottomMargin: 33; text: "⌕"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 19; font.bold: true } + TextInput { + id: orbitInput + x: 152; anchors.bottom: parent.bottom; anchors.bottomMargin: 31 + width: 385; height: 32 + verticalAlignment: TextInput.AlignVCenter + color: Theme.text + selectionColor: Theme.accent + selectedTextColor: Theme.surface + font.family: Theme.displayFont + font.pixelSize: 15 + font.letterSpacing: 1 + clip: true + onTextChanged: root.selectionRequested(0) + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Right: case Qt.Key_Down: case Qt.Key_Tab: root.moveRequested(1); event.accepted = true; break; + case Qt.Key_Left: case Qt.Key_Up: case Qt.Key_Backtab: root.moveRequested(-1); event.accepted = true; break; + case Qt.Key_PageDown: root.moveRequested(5); event.accepted = true; break; + case Qt.Key_PageUp: root.moveRequested(-5); event.accepted = true; break; + case Qt.Key_Return: case Qt.Key_Enter: root.launchRequested(root.selectedIndex); event.accepted = true; break; + case Qt.Key_Escape: root.dismissRequested(); event.accepted = true; break; + } + } + Text { anchors.fill: parent; verticalAlignment: Text.AlignVCenter; visible: orbitInput.text.length === 0; text: "SCAN APPLICATION INDEX"; color: Theme.muted; font: orbitInput.font } + } + } + + // Right execution tape. + Item { + id: tape + x: 778; y: 92 + width: 308; height: 500 + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0.014) + strokeColor: Theme.hair + strokeWidth: 1 + startX: 0; startY: 0 + PathLine { x: tape.width - 28; y: 0 } + PathLine { x: tape.width; y: 28 } + PathLine { x: tape.width; y: tape.height } + PathLine { x: 28; y: tape.height } + PathLine { x: 0; y: tape.height - 28 } + PathLine { x: 0; y: 0 } + } + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: 0; startY: 0 + PathLine { x: 82; y: 0 } + PathLine { x: 82; y: 4 } + PathLine { x: 0; y: 4 } + PathLine { x: 0; y: 0 } + } + } + + Text { x: 16; y: 15; text: "EXECUTION TAPE"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 10; font.bold: true; font.letterSpacing: 1.2 } + MicroText { anchors.right: parent.right; anchors.rightMargin: 17; y: 17; text: root.apps.length + " ROWS" } + Rectangle { x: 14; y: 40; width: parent.width - 28; height: 1; color: Theme.hair } + + ListView { + id: tapeList + x: 12; y: 51; width: parent.width - 24; height: 350 + model: root.apps + currentIndex: root.selectedIndex + spacing: 3 + clip: true + boundsBehavior: Flickable.StopAtBounds + onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + + delegate: MouseArea { + id: tapeRow + required property int index + required property var modelData + readonly property bool selected: index === root.selectedIndex + width: ListView.view.width + height: 37 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.selectionRequested(index) + onClicked: root.launchRequested(index) + + Rectangle { anchors.fill: parent; color: tapeRow.selected ? Theme.selection : Theme.textAlpha(0.018); border.width: 1; border.color: tapeRow.selected ? Theme.accent : Theme.textAlpha(0.09) } + Rectangle { x: 0; width: tapeRow.selected ? 6 : 2; height: parent.height; color: tapeRow.selected ? Theme.accent : Theme.hair } + Text { x: 13; anchors.verticalCenter: parent.verticalCenter; width: 24; text: String(tapeRow.index + 1).padStart(2, "0"); color: tapeRow.selected ? Theme.accent : Theme.muted; font.family: Theme.microFont; font.pixelSize: 7 } + Text { x: 43; anchors.verticalCenter: parent.verticalCenter; width: parent.width - 116; text: tapeRow.modelData.name || "UNKNOWN"; color: tapeRow.selected ? Theme.accent : Theme.text; font.family: Theme.displayFont; font.pixelSize: 9; font.bold: tapeRow.selected; elide: Text.ElideRight } + MicroText { anchors.right: parent.right; anchors.rightMargin: 10; anchors.verticalCenter: parent.verticalCenter; text: tapeRow.selected ? "LOCK" : "PASS"; color: tapeRow.selected ? Theme.accent : Theme.muted } + } + } + + MicroText { x: 16; y: 417; width: parent.width - 32; height: 28; wrapMode: Text.Wrap; maximumLineCount: 2; text: root.selectedApp ? (root.selectedApp.comment || "No target metadata supplied.") : "No target acquired." } + + MouseArea { + id: execute + x: 14; anchors.bottom: parent.bottom; anchors.bottomMargin: 15 + width: parent.width - 28; height: 42 + enabled: root.selectedApp !== null + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.launchRequested(root.selectedIndex) + Rectangle { anchors.fill: parent; color: execute.containsMouse ? Theme.accent : Theme.accentAlpha(0.16); border.width: 1; border.color: Theme.accent } + Text { anchors.centerIn: parent; text: "COMMIT ORBITAL LAUNCH"; color: execute.containsMouse ? Theme.surface : Theme.accent; font.family: Theme.displayFont; font.pixelSize: 9; font.bold: true; font.letterSpacing: 1.1 } + } + } + + Row { + x: 42; y: 622; spacing: 4 + Repeater { model: 28; Rectangle { required property int index; width: 11; height: 3; color: index % 4 === 0 ? Theme.accent : Theme.hair; transform: Rotation { angle: -30 } } } + } + MicroText { x: 390; y: 617; text: "←↑↓→ TRACK // ENTER COMMIT // ESC RELEASE"; color: Theme.accent } + MicroText { anchors.right: parent.right; anchors.rightMargin: 40; y: 617; text: "ORBIT-10 // CRC 9C31" } +} diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index e0042b4..5f4ee2d 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -200,6 +200,8 @@ in ] ++ (map (n: bind "SUPER + CTRL + ${toString n}" (dsp.global "quickshell:launcher${toString n}")) (lib.range 1 9)) ++ [ + # variant 10 (CTRL+0 is already the quickshell restart binding below) + (bind "SUPER + CTRL + SHIFT + 0" (dsp.global "quickshell:launcher10")) # restart quickshell (also starts it if not running) (bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs")) # toggle the Slant sidebar From 6406e06330d26024b5ea82b457110ffc0eedb42a Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Fri, 28 Aug 2026 23:03:36 +0200 Subject: [PATCH 08/24] Auto stash before merge of "feat/quickshell-dense-bar" and "origin/feat/quickshell-dense-bar" --- dotfiles/quickshell/shell.qml | 7 +- dotfiles/quickshell/widgets/bar/BarBottom.qml | 81 --- dotfiles/quickshell/widgets/bar/BarTop.qml | 81 --- .../widgets/bar/DenseBarContent.qml | 23 +- .../quickshell/widgets/sidebar/SideBar.qml | 477 ------------------ flake.lock | 36 +- hosts/terra/home.nix | 1 - hosts/terra/home/hyprland.nix | 2 +- 8 files changed, 24 insertions(+), 684 deletions(-) delete mode 100644 dotfiles/quickshell/widgets/bar/BarBottom.qml delete mode 100644 dotfiles/quickshell/widgets/bar/BarTop.qml delete mode 100644 dotfiles/quickshell/widgets/sidebar/SideBar.qml diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index d4bde52..1336017 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -5,7 +5,6 @@ import qs.widgets.bar import qs.widgets.launcher import qs.widgets.notifications import qs.widgets.osd -import qs.widgets.sidebar import qs.widgets.systray import qs.widgets.vitals @@ -13,11 +12,7 @@ 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 {} - - // App launcher variants — 1–10; variant 8 remains the primary HUD. + // App launcher variants — SUPER CTRL 1–8; variant 8 is the primary HUD. LauncherStack {} // 1 — left vertical list LauncherGrid {} // 2 — centered icon grid LauncherSpotlight {} // 3 — top-center command bar diff --git a/dotfiles/quickshell/widgets/bar/BarBottom.qml b/dotfiles/quickshell/widgets/bar/BarBottom.qml deleted file mode 100644 index a2e5033..0000000 --- a/dotfiles/quickshell/widgets/bar/BarBottom.qml +++ /dev/null @@ -1,81 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts - -import qs.widgets.launcher -import qs.widgets.theme - -Scope { - id: root - - Variants { - model: Quickshell.screens - - PanelWindow { - required property var modelData - screen: modelData - - color: "transparent" - - anchors { - bottom: true - left: true - right: true - } - - implicitHeight: wrapper.implicitHeight - - WrapperRectangle { - id: wrapper - - color: "transparent" - - anchors.fill: parent - - leftMargin: 12 - rightMargin: 12 - bottomMargin: 12 - - Rectangle { - implicitHeight: 12 - - // Brightens when the Dock launcher (variant 5) opens. - color: LauncherState.dockOpen ? Theme.accentSoft : Theme.accent - - Behavior on color { - ColorAnimation { - duration: 250 - } - } - - // Gentle "listening" pulse while the dock is open. - Rectangle { - anchors.fill: parent - color: Theme.highlight - opacity: 0 - visible: LauncherState.dockOpen - - SequentialAnimation on opacity { - running: LauncherState.dockOpen - loops: Animation.Infinite - - NumberAnimation { - to: 0.4 - duration: 700 - easing.type: Easing.InOutSine - } - NumberAnimation { - to: 0.0 - duration: 700 - easing.type: Easing.InOutSine - } - } - } - } - } - } - } -} diff --git a/dotfiles/quickshell/widgets/bar/BarTop.qml b/dotfiles/quickshell/widgets/bar/BarTop.qml deleted file mode 100644 index 31dfee8..0000000 --- a/dotfiles/quickshell/widgets/bar/BarTop.qml +++ /dev/null @@ -1,81 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Widgets -import QtQuick - -import qs.widgets.launcher -import qs.widgets.theme - -Scope { - id: root - - Variants { - model: Quickshell.screens - - PanelWindow { - id: topBar - - required property var modelData - screen: modelData - - color: "transparent" - - anchors { - top: true - left: true - right: true - } - - implicitHeight: wrapper.implicitHeight - - WrapperRectangle { - id: wrapper - - color: "transparent" - - anchors.fill: parent - - margin: 12 - bottomMargin: 0 - - Rectangle { - implicitHeight: 6 - - // Brightens when the Console launcher (variant 4) opens. - color: LauncherState.consoleOpen ? Theme.accentSoft : Theme.accent - - Behavior on color { - ColorAnimation { - duration: 250 - } - } - - // Gentle "listening" pulse while the console is open. - Rectangle { - anchors.fill: parent - color: Theme.highlight - opacity: 0 - visible: LauncherState.consoleOpen - - SequentialAnimation on opacity { - running: LauncherState.consoleOpen - loops: Animation.Infinite - - NumberAnimation { - to: 0.4 - duration: 700 - easing.type: Easing.InOutSine - } - NumberAnimation { - to: 0.0 - duration: 700 - easing.type: Easing.InOutSine - } - } - } - } - } - } - } -} diff --git a/dotfiles/quickshell/widgets/bar/DenseBarContent.qml b/dotfiles/quickshell/widgets/bar/DenseBarContent.qml index 38a41da..470eaac 100644 --- a/dotfiles/quickshell/widgets/bar/DenseBarContent.qml +++ b/dotfiles/quickshell/widgets/bar/DenseBarContent.qml @@ -96,9 +96,8 @@ Item { 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) + readonly property real stateWidth: root.compact ? 300 : 350 + readonly property real flexWidth: Math.max(250, (width - identityWidth - stateWidth - 32) / 2) Row { anchors.fill: parent @@ -234,24 +233,10 @@ Item { } } - StatusBarPanel { - width: array.radarWidth - height: array.height - panelId: "03" - title: "SCAN" - - RadarGauge { - anchors.horizontalCenter: parent.horizontalCenter - y: 28 - size: 57 - level: root.cpuFraction - } - } - StatusBarPanel { width: array.flexWidth height: array.height - panelId: "04" + panelId: "03" title: "CARRIER UPLINK" meta: root.networkInterface.toUpperCase() @@ -298,7 +283,7 @@ Item { StatusBarPanel { width: array.stateWidth height: array.height - panelId: "05" + panelId: "04" title: "SYSTEM STATE" Row { diff --git a/dotfiles/quickshell/widgets/sidebar/SideBar.qml b/dotfiles/quickshell/widgets/sidebar/SideBar.qml deleted file mode 100644 index fa43147..0000000 --- a/dotfiles/quickshell/widgets/sidebar/SideBar.qml +++ /dev/null @@ -1,477 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Hyprland -import Quickshell.Services.Pipewire -import Quickshell.Wayland -import Quickshell.Widgets -import QtQuick -import QtQuick.Layouts -import QtQuick.Shapes -import qs.widgets.theme - -// A vertical sidebar carrying the "Slant" (launcher V6) visual language — -// chamfered panel, thick trapezoid bevel accents, an outward bracket, a -// floating triangle cap and a slanted divider. Right-anchored, mirrored. -Scope { - id: root - - property bool active: true - - function toggle() { - root.active = !root.active; - } - - GlobalShortcut { - name: "sidebar" - description: "Toggle the Slant sidebar" - onPressed: root.toggle() - } - - readonly property PwNode sink: Pipewire.defaultAudioSink - readonly property real volume: sink?.audio?.volume ?? 0 - readonly property bool muted: sink?.audio?.muted ?? false - readonly property real maxVolume: 1.5 - - PwObjectTracker { - objects: [root.sink] - } - - PanelWindow { - id: win - - visible: root.active - - color: "transparent" - - anchors { - top: true - right: true - bottom: true - } - - margins { - top: 8 - } - - // Frame width plus the gutter the outward bracket grows into. - readonly property int pad: 10 - implicitWidth: 72 + 2 * pad - - Item { - id: frame - - // Inset so the outward bracket has room in the gutter. - anchors.fill: parent - anchors.margins: win.pad - - readonly property int chamfer: 18 // big cut corners (top-right, bottom-left) - readonly property int smallChamfer: 7 // small bevel (top-left, bottom-right) - readonly property int bevel: 4 // inward thickness of the trapezoid accents - readonly property int chunkThick: 7 // outward thickness of the bracket - readonly property int chunkSlant: 10 // slant of the bracket end pieces - readonly property int capSize: 10 // floating triangle cap - readonly property int armSide: 58 // bracket arm down the left edge - readonly property int armTop: 34 // bracket arm along the top edge - - Shape { - id: panelShape - anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - - // Panel: big chamfers on top-right & bottom-left, small bevels - // on top-left & bottom-right (mirror of the left-anchored panel). - ShapePath { - fillColor: Theme.surface - strokeColor: Theme.accent - strokeWidth: 2 - - startX: panelShape.width - frame.chamfer - startY: 0 - PathLine { - x: frame.smallChamfer - y: 0 - } - PathLine { - x: 0 - y: frame.smallChamfer - } - PathLine { - x: 0 - y: panelShape.height - frame.chamfer - } - PathLine { - x: frame.chamfer - y: panelShape.height - } - PathLine { - x: panelShape.width - frame.smallChamfer - y: panelShape.height - } - PathLine { - x: panelShape.width - y: panelShape.height - frame.smallChamfer - } - PathLine { - x: panelShape.width - y: frame.chamfer - } - PathLine { - x: panelShape.width - frame.chamfer - y: 0 - } - } - - // Thick top-right bevel accent (trapezoid to the edges). - ShapePath { - strokeWidth: 0 - fillColor: Theme.accent - - startX: panelShape.width - startY: frame.chamfer - PathLine { - x: panelShape.width - frame.chamfer - y: 0 - } - PathLine { - x: panelShape.width - frame.chamfer - 2 * frame.bevel - y: 0 - } - PathLine { - x: panelShape.width - y: frame.chamfer + 2 * frame.bevel - } - PathLine { - x: panelShape.width - y: frame.chamfer - } - } - - // Thick bottom-left bevel accent. - ShapePath { - strokeWidth: 0 - fillColor: Theme.accent - - startX: 0 - startY: panelShape.height - frame.chamfer - PathLine { - x: frame.chamfer - y: panelShape.height - } - PathLine { - x: frame.chamfer + 2 * frame.bevel - y: panelShape.height - } - PathLine { - x: 0 - y: panelShape.height - frame.chamfer - 2 * frame.bevel - } - PathLine { - x: 0 - y: panelShape.height - frame.chamfer - } - } - - // Floating triangle cap in the bottom-left notch. - ShapePath { - strokeWidth: 0 - fillColor: Theme.accent - - startX: 0 - startY: panelShape.height - PathLine { - x: 0 - y: panelShape.height - frame.capSize - } - PathLine { - x: frame.capSize - y: panelShape.height - } - PathLine { - x: 0 - y: panelShape.height - } - } - - // Outward bracket wrapping the top-left corner: down the left - // edge and along the top edge, with slanted ends and a beveled - // corner following the small chamfer. - ShapePath { - strokeWidth: 0 - fillColor: Theme.accent - - startX: 0 - startY: frame.armSide - PathLine { - x: -frame.chunkThick - y: frame.armSide - frame.chunkSlant - } - PathLine { - x: -frame.chunkThick - y: frame.smallChamfer - } - PathLine { - x: frame.smallChamfer - y: -frame.chunkThick - } - PathLine { - x: frame.armTop - frame.chunkSlant - y: -frame.chunkThick - } - PathLine { - x: frame.armTop - y: 0 - } - PathLine { - x: frame.smallChamfer - y: 0 - } - PathLine { - x: 0 - y: frame.smallChamfer - } - PathLine { - x: 0 - y: frame.armSide - } - } - } - - // ── Content ──────────────────────────────────────────────────── - ColumnLayout { - anchors.fill: parent - anchors.topMargin: 16 - anchors.bottomMargin: 16 - anchors.leftMargin: 10 - anchors.rightMargin: 8 - spacing: 10 - - // Clock — readout font, hh over mm - Text { - Layout.alignment: Qt.AlignHCenter - horizontalAlignment: Text.AlignHCenter - text: Qt.formatDateTime(clock.date, "hh\nmm") - font.family: Theme.readoutFont - font.pointSize: 22 - font.letterSpacing: 1 - color: Theme.text - - SystemClock { - id: clock - precision: SystemClock.Minutes - } - } - - // Date - Text { - Layout.alignment: Qt.AlignHCenter - horizontalAlignment: Text.AlignHCenter - text: Qt.formatDateTime(clock.date, "dd\nMMM").toUpperCase() - font.family: Theme.readoutFont - font.pointSize: 13 - color: Theme.accent - } - - // Slanted divider - Item { - Layout.fillWidth: true - implicitHeight: 4 - - Shape { - id: divider - anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - ShapePath { - strokeWidth: 0 - fillColor: Theme.accent - startX: 8 - startY: 0 - PathLine { x: divider.width; y: 0 } - PathLine { x: divider.width - 8; y: divider.height } - PathLine { x: 0; y: divider.height } - PathLine { x: 8; y: 0 } - } - } - } - - Item { - Layout.fillHeight: true - } - - // Volume — vertical meter with a sheared fill and a % readout - Text { - Layout.alignment: Qt.AlignHCenter - text: "VOL" - font.family: Theme.readoutFont - font.pointSize: 10 - color: Theme.muted - } - - ColumnLayout { - Layout.alignment: Qt.AlignHCenter - spacing: 3 - - // Overflow — three floating slanted segments for volume - // pushed above 100%, lit with the same overload color as - // the volume OSD. - Repeater { - model: 3 - - delegate: Item { - id: seg - required property int index - - readonly property real segStart: 1 + (2 - index) / 3 * (root.maxVolume - 1) - readonly property real segEnd: 1 + (3 - index) / 3 * (root.maxVolume - 1) - readonly property real frac: root.muted ? 0 : Math.max(0, Math.min(1, (root.volume - segStart) / (segEnd - segStart))) - readonly property int chamfer: 4 - - Layout.alignment: Qt.AlignHCenter - implicitWidth: 16 - implicitHeight: 12 - - // Empty track, chamfered to match the main meter. - Shape { - anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - - ShapePath { - strokeWidth: 1 - strokeColor: Theme.muted - fillColor: Theme.raised - - startX: seg.chamfer - startY: 0 - PathLine { x: seg.width; y: 0 } - PathLine { x: seg.width; y: seg.height - seg.chamfer } - PathLine { x: seg.width - seg.chamfer; y: seg.height } - PathLine { x: 0; y: seg.height } - PathLine { x: 0; y: seg.chamfer } - PathLine { x: seg.chamfer; y: 0 } - } - } - - // Overload fill — reveals a fixed copy of the same - // chamfered hexagon from the bottom, so filled and - // empty states always share one silhouette. - Item { - id: segFillClip - anchors.left: parent.left - anchors.leftMargin: 2 - anchors.right: parent.right - anchors.rightMargin: 2 - anchors.bottom: parent.bottom - anchors.bottomMargin: 2 - clip: true - height: seg.frac * (seg.height - 4) - - Shape { - id: segFill - width: seg.width - 4 - height: seg.height - 4 - y: segFillClip.height - height - preferredRendererType: Shape.CurveRenderer - - readonly property int chamfer: seg.chamfer - 2 - - ShapePath { - strokeWidth: 0 - fillColor: Theme.hot - startX: segFill.chamfer - startY: 0 - PathLine { x: segFill.width; y: 0 } - PathLine { x: segFill.width; y: segFill.height - segFill.chamfer } - PathLine { x: segFill.width - segFill.chamfer; y: segFill.height } - PathLine { x: 0; y: segFill.height } - PathLine { x: 0; y: segFill.chamfer } - PathLine { x: segFill.chamfer; y: 0 } - } - } - } - } - } - - Item { - id: volMeter - Layout.alignment: Qt.AlignHCenter - implicitWidth: 16 - implicitHeight: 96 - - readonly property int chamfer: 6 - - // Track — chamfered top-left/bottom-right to match the - // panel's slant, so the sheared fill sits in a shape that - // agrees with it rather than a plain rectangle. - Shape { - anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - - ShapePath { - strokeWidth: 1 - strokeColor: Theme.muted - fillColor: Theme.raised - - startX: volMeter.chamfer - startY: 0 - PathLine { x: volMeter.width; y: 0 } - PathLine { x: volMeter.width; y: volMeter.height - volMeter.chamfer } - PathLine { x: volMeter.width - volMeter.chamfer; y: volMeter.height } - PathLine { x: 0; y: volMeter.height } - PathLine { x: 0; y: volMeter.chamfer } - PathLine { x: volMeter.chamfer; y: 0 } - } - } - - // Accent fill — reveals a fixed copy of the same - // chamfered hexagon as the track from the bottom, so - // the fill and background always share one silhouette. - Item { - id: fillClip - anchors.left: parent.left - anchors.leftMargin: 2 - anchors.right: parent.right - anchors.rightMargin: 2 - anchors.bottom: parent.bottom - anchors.bottomMargin: 2 - clip: true - - readonly property real frac: root.muted ? 0 : Math.min(root.volume, 1) - height: frac * (volMeter.height - 4) - - Shape { - id: vol - width: volMeter.width - 4 - height: volMeter.height - 4 - y: fillClip.height - height - preferredRendererType: Shape.CurveRenderer - - readonly property int chamfer: volMeter.chamfer - 2 - - ShapePath { - strokeWidth: 0 - fillColor: root.muted ? Theme.muted : Theme.accent - startX: vol.chamfer - startY: 0 - PathLine { x: vol.width; y: 0 } - PathLine { x: vol.width; y: vol.height - vol.chamfer } - PathLine { x: vol.width - vol.chamfer; y: vol.height } - PathLine { x: 0; y: vol.height } - PathLine { x: 0; y: vol.chamfer } - PathLine { x: vol.chamfer; y: 0 } - } - } - } - } - } - - Text { - Layout.alignment: Qt.AlignHCenter - text: root.muted ? "--" : Math.round(root.volume * 100) - font.family: Theme.readoutFont - font.pointSize: 14 - color: root.muted ? Theme.muted : Theme.text - } - } - } - } -} diff --git a/flake.lock b/flake.lock index 9240eb0..ab1ac67 100644 --- a/flake.lock +++ b/flake.lock @@ -14,11 +14,11 @@ "uv2nix": "uv2nix" }, "locked": { - "lastModified": 1786986906, - "narHash": "sha256-DJ1oU9szQJNdEM0dysh4NnKOB1HwOKtNukrUYKpawVs=", + "lastModified": 1787577519, + "narHash": "sha256-YNAXQTgR26RJiX2vtYjk6OtBu2jMWeq4qUN6sUrt6Lc=", "owner": "nix-community", "repo": "authentik-nix", - "rev": "afdb2eeca1e0b38fabb93c4a8944be73d3581268", + "rev": "30c37930450d7a5fefa8ffec613f037fc75c3071", "type": "github" }, "original": { @@ -270,11 +270,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1787124618, - "narHash": "sha256-aKf1k2hvYgaxP9oxDPRiv9npEJLODC9eKxk7nR69lzQ=", + "lastModified": 1787728766, + "narHash": "sha256-g2oZlrBU3AI2ubCiY/UyE9ALTIDueTcF//QP3vaY9IQ=", "owner": "nix-community", "repo": "nixos-anywhere", - "rev": "ad8fa24e11eef167fd72d49fafefa3f840312d71", + "rev": "6b77f26ec4538ced04bf1d02f374b0ec02e9c27e", "type": "github" }, "original": { @@ -291,11 +291,11 @@ "nixos-unstable": "nixos-unstable" }, "locked": { - "lastModified": 1787222173, - "narHash": "sha256-acp6QJnWVnLvnanC59CMkiDC/i0ZhdFKiB7prru9SHw=", + "lastModified": 1787826771, + "narHash": "sha256-gWkyr3I/cg4SHWGkAoUo24+BQaqoi+S0T2JxDjsA+pw=", "owner": "nix-community", "repo": "nixos-images", - "rev": "e17386d9193d6d5a90f1b4b6a8a5cd2620d34b56", + "rev": "f6714acc84ce92df7286c89a571ad1e946057a5b", "type": "github" }, "original": { @@ -354,11 +354,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1787209939, - "narHash": "sha256-WvvHR4kSQLAbtouMC/ruZ5UpLwlUcY3K4FAllMN+yGk=", + "lastModified": 1787814960, + "narHash": "sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "391b592eb44808b3bd0cb80bb71b63a5a118b8bb", + "rev": "c27cdad491a991b11ed731760aa2ef8db0cb0410", "type": "github" }, "original": { @@ -370,11 +370,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1787204541, - "narHash": "sha256-OURZPknrTjQrlNyxPdqzyqmU/81Wes1CUP/Ft1Rv/YI=", + "lastModified": 1787753485, + "narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5880666fd9eb563038431edb35c2d0aa595884e6", + "rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1", "type": "github" }, "original": { @@ -400,11 +400,11 @@ ] }, "locked": { - "lastModified": 1785730568, - "narHash": "sha256-NjSPsgjJ7MSpBtTkUcmNhRe6AFZ96+zsca2M8YuQi8Y=", + "lastModified": 1786936886, + "narHash": "sha256-8SFyOdmcG6Nsh/JzlH812lTI/v+ur06fpQhj/acNn8k=", "owner": "pyproject-nix", "repo": "build-system-pkgs", - "rev": "90fde00db3687922d39d95fc591475fd0bbbcd72", + "rev": "90ffdeee1a4929b231913df067448cd9803d3e07", "type": "github" }, "original": { diff --git a/hosts/terra/home.nix b/hosts/terra/home.nix index 9f7bb5b..8eb295d 100644 --- a/hosts/terra/home.nix +++ b/hosts/terra/home.nix @@ -54,7 +54,6 @@ in pkgs.hyprcursor pkgs.bibata-cursors pkgs.papirus-icon-theme - tome ]; xdg.desktopEntries.btop = { diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index 5f4ee2d..e5b6eb1 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -36,7 +36,7 @@ let # here rather than at runtime, since hyprpaper has no built-in "random" # mode; re-pick and rebuild (or swap in real per-monitor selection) when # this stops being a placeholder. - wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-4yjyd4.png"; + wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png"; # Dispatchers → the new hl.dsp.* API (signatures verified against hyprland # 0.55's src/config/lua/bindings/LuaBindingsDispatchers.cpp). From e38a8403ace0bbaaad268dad13380e2b155a9f6f Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 02:38:00 +0200 Subject: [PATCH 09/24] feat(quickshell): add hyprchrome bar with collapsible panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second shell chrome under dotfiles/quickshell/hyprchrome, built around a BarPanel that carries TWO renderings of its data: the default children are the expanded detail view, `summary` the terse one shown while collapsed. Both stay bound to the same sources, so the densities cannot disagree, and the panel cross-fades between them while its height animates. Panels: HostPanel (hostname, user, timezone, clock), VitalsPanel (CPU load and temperature, memory, GPU load and temperature, all metered), TrayPanel (system tray, self-sizing). HyprChromeBar pins them to DP-2 and owns `expanded` for the whole rail — SUPER A, via GlobalShortcut "chrome". GPU busy comes off sysfs rather than the node_exporter scrape VitalsData already does: the hwmon collector carries the card's temps, power and clocks but not its utilisation. The bar's height binds to each panel's `targetHeight` — where it will settle, not where the animation currently is — because the exclusive zone is window-sized by default, and binding to the animated height relayouts every tiled window on the output twelve times per toggle. The zone follows the target immediately so the desktop reflows once, at the start; the surface itself shrinks only after the panels finish, or it would clip them mid-animation. DebugWindow stages a widget in the middle of the secondary monitor (SUPER CTRL D), masked so only the staged widget takes pointer input. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- dotfiles/quickshell/CLAUDE.md | 2 +- .../quickshell/hyprchrome/DebugWindow.qml | 106 +++++ .../quickshell/hyprchrome/theme/Theme.qml | 61 +++ .../hyprchrome/widgets/HyprChromeBar.qml | 134 ++++++ .../hyprchrome/widgets/debug/LoremPanel.qml | 39 ++ .../hyprchrome/widgets/host/HostPanel.qml | 217 ++++++++++ .../hyprchrome/widgets/panels/BarPanel.qml | 387 ++++++++++++++++++ .../hyprchrome/widgets/tray/TrayIcon.qml | 72 ++++ .../hyprchrome/widgets/tray/TrayPanel.qml | 80 ++++ .../hyprchrome/widgets/vitals/GpuBusy.qml | 60 +++ .../widgets/vitals/SegmentMeter.qml | 40 ++ .../hyprchrome/widgets/vitals/VitalRow.qml | 58 +++ .../hyprchrome/widgets/vitals/VitalsPanel.qml | 142 +++++++ dotfiles/quickshell/shell.qml | 44 +- hosts/terra/home/hyprland.nix | 4 + 15 files changed, 1444 insertions(+), 2 deletions(-) create mode 100644 dotfiles/quickshell/hyprchrome/DebugWindow.qml create mode 100644 dotfiles/quickshell/hyprchrome/theme/Theme.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/debug/LoremPanel.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/tray/TrayIcon.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/tray/TrayPanel.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/vitals/GpuBusy.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/vitals/SegmentMeter.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/vitals/VitalRow.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/vitals/VitalsPanel.qml diff --git a/dotfiles/quickshell/CLAUDE.md b/dotfiles/quickshell/CLAUDE.md index 1d5c6cb..63995f2 100644 --- a/dotfiles/quickshell/CLAUDE.md +++ b/dotfiles/quickshell/CLAUDE.md @@ -14,7 +14,7 @@ qs -p . # run this directory explicitly regardless of symlink qs -n # exit immediately if another instance is already running (use to avoid duplicate shells while iterating) ``` -Quickshell hot-reloads QML on file save when already running, so for most edits just save and check the running instance rather than restarting `qs`. There is no separate build/lint/test tooling in this repo — verification is visual/behavioral via the running shell. `qmlls` (QML language server) is configured via `.qmlls.ini` for editor diagnostics. +Quickshell hot-reloads QML on file save when already running, so for most edits just save and check the running instance rather than restarting `qs`. The watcher follows the file's inode, so an edit that REPLACES the file (`perl -i`, `sed -i`, `mv`) silently detaches it — the shell keeps rendering the previous config and `qs log` still says "Configuration Loaded" for the last real reload. Edit in place, or `touch` a still-watched file to force a full reload. There is no separate build/lint/test tooling in this repo — verification is visual/behavioral via the running shell. `qmlls` (QML language server) is configured via `.qmlls.ini` for editor diagnostics. ## Architecture diff --git a/dotfiles/quickshell/hyprchrome/DebugWindow.qml b/dotfiles/quickshell/hyprchrome/DebugWindow.qml new file mode 100644 index 0000000..3bd543c --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/DebugWindow.qml @@ -0,0 +1,106 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import qs.widgets.theme + +// Debug stage: a bare, chrome-less staging area in the middle of ONE monitor +// (the secondary by default), used to look at a widget in isolation before it +// has a home in the bar or a launcher. +// +// DebugWindow { +// VitalBar { width: 220; value: 0.4 } +// } +// +// Children are reparented into the centred slot, which sizes itself to them — +// so they must carry their own size (implicit or explicit). Do NOT anchor a +// child to the slot (`anchors.fill: parent`): the slot measures its children, +// so that is a binding loop. +// +// Nothing is drawn around them — no panel, no background, no dim: whatever is +// staged is exactly what appears. Only the staged widgets take pointer input +// (`mask`), so the rest of the monitor stays clickable, and the window never +// takes keyboard focus. Toggle: SUPER CTRL D. +Scope { + id: root + + // Monitor to stage on. Falls back to the LAST connected screen when the + // name matches nothing, so a single-monitor session still gets a stage. + property string screenName: "HDMI-A-1" + property bool active: true + + default property alias content: slot.data + + // Quickshell.screens is a QML list, not a JS array — no .find() on it. + readonly property var targetScreen: { + const screens = Quickshell.screens; + if (screens.length === 0) + return null; + for (let i = 0; i < screens.length; i++) { + if (screens[i].name === root.screenName) + return screens[i]; + } + return screens[screens.length - 1]; + } + + function toggle() { + root.active = !root.active; + } + + GlobalShortcut { + name: "debug" + description: "Toggle the debug widget stage" + onPressed: root.toggle() + } + + PanelWindow { + id: win + + screen: root.targetScreen + visible: root.active && root.targetScreen !== null + + WlrLayershell.layer: WlrLayer.Overlay + // A HUD, not a modal: never steal the keyboard from the focused window. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + // Ignore the dense bar's exclusive zone so "centred" means the centre of + // the monitor, not the centre of what is left below the bar. + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + anchors { + top: true + left: true + right: true + bottom: true + } + + // Everything outside the staged widgets is click-through. + mask: Region { + item: slot + } + + Item { + id: slot + + anchors.centerIn: parent + width: Math.max(childrenRect.width, placeholder.visible ? placeholder.implicitWidth : 0) + height: Math.max(childrenRect.height, placeholder.visible ? placeholder.implicitHeight : 0) + } + + // Sits beside the slot, not inside it, so it never counts itself. Without + // it an unsized child looks identical to a broken window. + Text { + id: placeholder + + visible: slot.children.length === 0 + anchors.centerIn: parent + text: "NO WIDGETS STAGED" + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 8 + font.letterSpacing: 1.2 + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/theme/Theme.qml b/dotfiles/quickshell/hyprchrome/theme/Theme.qml new file mode 100644 index 0000000..49b94a0 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/theme/Theme.qml @@ -0,0 +1,61 @@ +pragma Singleton + +import Quickshell +import QtQuick + +// Single source of truth for the shell's palette and font families. +// +// The shell previously ran two unrelated palettes: an amber one (#FFD063) used +// by the launchers, sidebar, systray, vitals and notifications, and an orange +// one (#e8722a) that only the dense bar had, tokenized as per-file properties. +// This unifies on the ORANGE values under the AMBER naming scheme. +// +// `surface` deliberately takes the dense bar's void (#0a0a0a) rather than the +// old panel background (#0F1012), which also absorbs the near-identical +// #0A0A0C scrim. +Singleton { + // ---- core ---- + readonly property color accent: "#e8722a" // was #FFD063 (amber) / #e8722a (bar) + readonly property color text: "#dedede" // was #EEEEEE / #dedede + readonly property color muted: "#858585" // was #7A7B7D / #858585 + readonly property color surface: "#0a0a0a" // was #0F1012 + #0A0A0C + #0a0a0a + readonly property color hot: "#ff6b4a" // alert/hot; no bar equivalent, kept + + // ---- supporting darks ---- + // A three-step ramp above `surface`. `raised` also absorbs #22262C, which + // differed from #292C30 by an imperceptible amount across two call sites. + readonly property color selection: "#1a1c1f" // selected row fill + readonly property color raised: "#292c30" // raised surface / border + readonly property color disabled: "#3a3d42" // unknown / disabled stroke + + // ---- accents ---- + // The pale "flash" the top/bottom bars show while a launcher is open. Was a + // hand-picked #FFF3C0 against amber; derived here so it tracks `accent`. + // 55% toward white reproduces the original amber relationship closely + // (#FFD063 -> #FFE9B8 vs the hand-picked #FFF3C0). + readonly property color accentSoft: Qt.tint(accent, Qt.rgba(1, 1, 1, 0.55)) + readonly property color highlight: "#ffffff" + + // ---- fonts ---- + // Two faces. `readoutFont` is an alias rather than a second literal so the + // two roles cannot silently drift apart; point it at a different family if + // the readouts should ever diverge from the headings again. + // + // Installed by services/desktop/desktop-apps.nix (nerd-fonts.departure-mono). + // The former readout face, Digital-7 Mono, was never packaged — it relied on + // a manual ~/.dots/fonts/digital_7 install, so dropping it also removes an + // undeclared external dependency. + readonly property string displayFont: "DepartureMono Nerd Font" // headings, large values + readonly property string readoutFont: displayFont // seven-segment readouts: launchers, sidebar, systray, vitals + readonly property string microFont: "DejaVu Sans Mono" // dense bar micro labels + + // ---- derived alpha variants ---- + // The dense bar hand-encoded these as Qt.rgba(0.87,0.87,0.87,a) = text and + // Qt.rgba(0.91,0.45,0.16,a) = accent. Expressed as functions so the + // relationship survives a palette change. + function textAlpha(a) { return Qt.rgba(text.r, text.g, text.b, a); } + function accentAlpha(a) { return Qt.rgba(accent.r, accent.g, accent.b, a); } + + // Hairline rule / panel outline: text at 28%. + readonly property color hair: textAlpha(0.28) +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml new file mode 100644 index 0000000..b407eb3 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml @@ -0,0 +1,134 @@ +import QtQuick +import QtQuick.Shapes +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import qs.hyprchrome.widgets +import qs.hyprchrome.widgets.host +import qs.hyprchrome.widgets.vitals +import qs.hyprchrome.widgets.tray + +Scope { + id: root + + // Monitor the rail lives on. Falls back to the FIRST connected screen when + // the name matches nothing, so the bar still appears on a single-monitor + // session or after a cable swap (DebugWindow falls back to the last one + // instead — it wants the secondary). + property string screenName: "DP-2" + + // Quickshell.screens is a QML list, not a JS array — no .find() on it. + readonly property var targetScreen: { + const screens = Quickshell.screens; + if (screens.length === 0) + return null; + for (let i = 0; i < screens.length; i++) { + if (screens[i].name === root.screenName) + return screens[i]; + } + return screens[0]; + } + + // Density is a property of the BAR: every panel follows it, so the whole rail + // expands and collapses as one. Panels keep their own animation; only the + // decision is centralised here. + property bool expanded: true + + function toggle() { + root.expanded = !root.expanded; + } + + // SUPER A — see hosts/terra/home/hyprland.nix. + GlobalShortcut { + name: "chrome" + description: "Expand or collapse the hyprchrome bar" + onPressed: root.toggle() + } + + PanelWindow { + id: window + + screen: root.targetScreen + visible: root.targetScreen !== null + + property int margin: 12 + + // Where the panels will SETTLE, not where they are mid-transition. Binding + // the surface to the animated height instead resizes the layer surface — + // and, with the automatic exclusive zone, relayouts every tiled window on + // this output — on every frame of the animation. + readonly property real contentHeight: Math.max(hostPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2 + + // The surface and the exclusive zone move on different clocks. The zone is + // the desktop-visible half: set it to the target immediately, so the tiled + // windows reflow ONCE, at the start, and slide while the bar animates. + // The surface itself grows before the panels do but shrinks only after they + // have finished, because a surface that shrank immediately would clip the + // panels still animating inside it. + property real barHeight: 0 + + exclusionMode: ExclusionMode.Normal + exclusiveZone: Math.round(window.contentHeight) + + implicitHeight: window.barHeight + + onContentHeightChanged: { + if (window.contentHeight > window.barHeight) + window.barHeight = window.contentHeight; + else + shrink.restart(); + } + + Component.onCompleted: window.barHeight = window.contentHeight + + Timer { + id: shrink + + // Longer than the panel's own collapse (200ms body + 110ms fade-in). + interval: 340 + onTriggered: window.barHeight = window.contentHeight + } + + anchors { top: true; left: true; right: true; } + + color: "transparent" + + RowLayout { + id: panelRow + x: window.margin + y: window.margin + width: window.width - window.margin * 2 + spacing: 8 + + HostPanel { + id: hostPanel + + expanded: root.expanded + toggleOnClick: false + Layout.preferredWidth: 350 + Layout.fillHeight: true + } + + VitalsPanel { + id: vitalsPanel + + expanded: root.expanded + toggleOnClick: false + Layout.preferredWidth: 500 + Layout.fillHeight: true + } + + // Slack lives between the left group and the tray, so the tray sits + // flush right whatever the other panels measure. + Item { Layout.fillWidth: true } + + TrayPanel { + id: trayPanel + + expanded: root.expanded + toggleOnClick: false + Layout.fillHeight: true + } + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/debug/LoremPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/debug/LoremPanel.qml new file mode 100644 index 0000000..96998fb --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/debug/LoremPanel.qml @@ -0,0 +1,39 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.hyprchrome.theme +import qs.hyprchrome.widgets.panels + +// Staging widget for the debug window: a BarPanel carrying filler copy in both +// of the panel's densities — the full block when expanded, one elided line when +// collapsed. Both read the same `text`, so the two modes cannot disagree. +// +// Give it a width; the height follows whichever body is showing. +BarPanel { + id: lorem + + property string text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since 1966, when designers at Letraset and James Mosley, the librarian at St Bride Printing Library in London, took a 1914 Cicero translation and scrambled it to make dummy text for Letraset's Body Type sheets. It has survived not only many decades, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised thanks to these sheets and more recently with desktop publishing software like Aldus PageMaker and Microsoft Word including versions of Lorem Ipsum." + + title: "LOREM IPSUM" + + // Collapsed: one line, cut off where the panel ends. + summary: Text { + width: parent.width + text: lorem.text + color: Theme.muted + font.family: Theme.displayFont + font.pixelSize: 12 + maximumLineCount: 1 + elide: Text.ElideRight + } + + // Expanded: the whole thing, wrapped. + Text { + width: parent.width + text: lorem.text + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 12 + wrapMode: Text.WordWrap + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml new file mode 100644 index 0000000..829fb55 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml @@ -0,0 +1,217 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Layouts +import qs.hyprchrome.theme +import qs.hyprchrome.widgets.panels + +// Host identity in the hyprchrome panel chrome: the machine's name set large, +// with its timezone and the current date and time. +// +// Expanded: name on the left, clock stack on the right. +// Collapsed: name, time and zone abbreviation on the header line. +// +// The name and the zone come from one shell call at startup — neither changes +// while the shell runs, so there is nothing to poll. The clock is a plain +// Timer; `now` is the single source both densities read, so they always agree +// down to the second. +BarPanel { + id: panel + + panelId: "HST" + title: "HOST" + meta: panel.zoneAbbrev + + property string hostName: "LOCAL" + property string zoneName: "" // IANA, e.g. EUROPE/BERLIN + property string userName: "" // whoever is logged in, e.g. DARMAN + property date now: new Date() + + // Qt resolves the abbreviation ("CEST") against the same zone the offset + // comes from, so the two can never disagree. + readonly property string zoneAbbrev: Qt.formatDateTime(panel.now, "t") + readonly property string utcOffset: { + const hours = -panel.now.getTimezoneOffset() / 60; + return "UTC" + (hours >= 0 ? "+" : "") + (Number.isInteger(hours) ? hours : hours.toFixed(1)); + } + + function two(value) { + return value < 10 ? "0" + value : String(value); + } + + function timeText(value) { + return panel.two(value.getHours()) + ":" + panel.two(value.getMinutes()) + ":" + panel.two(value.getSeconds()); + } + + 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()] + " // " + panel.two(value.getDate()) + " " + months[value.getMonth()] + " " + value.getFullYear(); + } + + Timer { + interval: 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: panel.now = new Date() + } + + Process { + running: true + + // /etc/localtime is a symlink into the zoneinfo tree; its tail is the + // IANA name, which no environment variable reliably carries. + command: ["sh", "-c", "cat /proc/sys/kernel/hostname; readlink -f /etc/localtime; id -un"] + + stdout: StdioCollector { + onStreamFinished: { + const lines = this.text.trim().split("\n"); + if (lines.length > 0 && lines[0].trim().length > 0) + panel.hostName = lines[0].trim().toUpperCase(); + if (lines.length > 1) { + const zone = lines[1].match(/zoneinfo\/(.+)$/); + if (zone) + panel.zoneName = zone[1].toUpperCase(); + } + if (lines.length > 2 && lines[2].trim().length > 0) + panel.userName = lines[2].trim().toUpperCase(); + } + } + } + + // Collapsed: who and when, nothing else. + summary: Row { + spacing: 10 + + Text { + id: hostLabel + + text: panel.hostName + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 13 + font.bold: true + font.letterSpacing: 1.2 + } + + // The pieces are set at two sizes. A Row positions its children at the + // top and has no item alignment of its own (that is Grid), and a + // vertical anchor inside a positioner is ignored — so the smaller + // pieces take the tallest one's height and centre their text in it. + Text { + text: panel.timeText(panel.now) + color: Theme.accent + font.family: Theme.displayFont + font.pixelSize: 13 + font.bold: true + height: hostLabel.implicitHeight + verticalAlignment: Text.AlignVCenter + } + + Text { + text: panel.dateText(panel.now) + color: Theme.text + font.family: Theme.microFont + font.pixelSize: 11 + height: hostLabel.implicitHeight + verticalAlignment: Text.AlignVCenter + } + + Text { + text: panel.zoneAbbrev + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 11 + height: hostLabel.implicitHeight + verticalAlignment: Text.AlignVCenter + } + } + + // Expanded: name left, clock stack right. + RowLayout { + width: parent.width + height: 48 + spacing: 16 + + Column { + Layout.fillWidth: true + Layout.fillHeight: true + + Text { + id: hostText + + text: panel.hostName + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 25 + font.bold: true + font.letterSpacing: 2 + elide: Text.ElideRight + } + + Text { + y: 32 + text: (panel.userName || "NODE") + " // " + (panel.zoneName || "LOCAL") + color: Theme.accent + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 1.4 + elide: Text.ElideRight + } + } + + // Rule between identity and clock, the same hairline the dense bar + // puts between its host name and node readout. + Rectangle { + Layout.alignment: Qt.AlignCenter + y: 2 + width: 2 + height: parent.height - 8 + color: Theme.hair + } + + Column { + id: clock + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignRight + + width: 210 + spacing: 2 + + Text { + width: parent.width + text: panel.timeText(panel.now) + horizontalAlignment: Text.AlignRight + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 18 + font.bold: true + font.letterSpacing: 1 + } + + Text { + width: parent.width + text: panel.dateText(panel.now) + horizontalAlignment: Text.AlignRight + color: Theme.accent + font.family: Theme.microFont + font.pixelSize: 11 + } + + Text { + width: parent.width + text: panel.zoneAbbrev + " // " + panel.utcOffset + horizontalAlignment: Text.AlignRight + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 0.7 + } + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml new file mode 100644 index 0000000..422462f --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml @@ -0,0 +1,387 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes +import qs.hyprchrome.theme + +// Chamfered panel chrome for the dense status rail: outline, corner accent +// lines, header strip (id chip / title / meta / tick marks) and a collapsing +// body. +// +// The body holds TWO renderings of the same data: the default children are the +// expanded detail view, `summary` the terse one shown while collapsed. Both +// stay instantiated and bound to the same sources — two renderings of one +// truth, not two truths — and the panel cross-fades between them while its +// height animates to whichever is showing. +// +// BarPanel { +// panelId: "02" +// summary: Text { text: "CPU 43%" } +// MetricRow { /* the full view */ } +// } +// +// Expanded, the detail view sits under the header rule. Collapsed, the summary +// moves up ONTO the header line, starting just right of the slug chip and +// centred in the strip, so the whole panel becomes a single line. The slug +// in both modes; title, meta and tick deco fade out with the detail body. +// +// Each slot keeps its own fixed geometry — only the panel's height animates, +// and the body is clipped — so neither rendering reflows while the other one +// is fading. Slot children are measured (`childrenRect`), so they must carry +// their own size and must NOT anchor to the slot. +// +// Colors and fonts both come from the Theme singleton. +Item { + id: panel + + property string panelId: "" + property string title: "" + property string meta: "" + property bool expanded: true + property int chamfer: 13 + property int offsetY: 2 + property int accentLineThickness: 3 + + readonly property int headerHeight: 28 + + // Breathing room between the header rule and the detail view. The + // collapsed summary is unaffected — it sits on the header line itself. + property int headerGap: 8 + // Left offset of the header row, and the inset the upper-left accent line + // is sized against. + readonly property int headerPadding: 8 + + // Upper-left accent line, sized to the slug chip it underlines. Lives on + // the panel rather than on the ShapePath: a PathLine does not see its + // ShapePath's own properties by bare name (they resolve through the + // component scope, not the parent object). + // Unclamped: what the header chrome WANTS to span. Everything that has to + // stay independent of the panel's final width reads this one — a width + // that clamps against panel.width cannot also decide it. + readonly property real headerContentWidth: slugChip.width + panel.headerPadding * 2 + readonly property real accentLineWidth: Math.min(panel.width, panel.headerContentWidth) + + // Narrowest the panel can be before the title runs into the meta text and + // tick deco. A panel that sizes itself to its content (the tray) has to + // take this as a floor; none of it depends on panel.width, so it can. + readonly property real headerMinWidth: panel.headerContentWidth + panelTitle.width + + headerRow.spacing + headerEnd.width + 12 + panel.headerPadding + property int padding: 12 + // Floor for the animated height, so a collapsed strip with a short (or + // empty) summary still reads as a panel rather than a hairline. + property int minimumHeight: 38 + + // Self-toggling is a convenience for staging a panel on its own. A host + // that drives `expanded` for a whole group turns it off: assigning to a + // bound property from a click would destroy that binding for good. + property bool toggleOnClick: true + + default property alias content: detail.data + property alias summary: brief.data + + // Where the summary sits when collapsed: just past the slug chip, and + // centred in the strip the panel collapses to, which is sized to the + // summary itself (or the height floor, whichever is taller). + readonly property real briefLeft: panel.headerContentWidth + readonly property real collapsedHeight: Math.max(panel.minimumHeight, brief.height + panel.headerPadding * 2) + readonly property real briefTop: Math.round((panel.collapsedHeight - brief.height) / 2) + + // Bottom of the visible body, and the gap kept below it. The states bind + // these to whichever rendering is showing and the transitions animate them, + // so they are plain properties rather than ternaries on implicitHeight — + // an animation cannot drive a binding. + property real bodyBottom: detail.y + detail.height + property real bodyEndPadding: panel.padding + + implicitHeight: Math.max(panel.minimumHeight, panel.bodyBottom + panel.bodyEndPadding) + + // Where the panel will settle in its current state, skipping the values + // the transition passes through. A window sized to this reconfigures once + // per toggle rather than once per animation frame — which, for a + // layer-shell bar with an automatic exclusive zone, is the difference + // between one relayout of the desktop and a dozen. + readonly property real targetHeight: Math.max(panel.minimumHeight, panel.expanded + ? detail.y + detail.height + panel.padding + : panel.collapsedHeight) + + + // The two chamfer cuts (top-right at y=chamfer, bottom-left at + // height-chamfer) cross once the panel is shorter than twice the chamfer, + // which turns the outline inside out for the last frames of a collapse. + readonly property real activeChamfer: Math.max(2, Math.min(panel.chamfer, panel.height / 2 - 1)) + + Shape { + id: panelShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + // Main panel shape + ShapePath { + fillColor: Theme.surface + strokeColor: Theme.hair + strokeWidth: 1 + startX: 0; startY: panel.offsetY + PathLine { x: panelShape.width - panel.activeChamfer; y: panel.offsetY } + PathLine { x: panelShape.width; y: panel.activeChamfer } + PathLine { x: panelShape.width; y: panelShape.height } + PathLine { x: panel.activeChamfer; y: panelShape.height } + PathLine { x: 0; y: panelShape.height - panel.activeChamfer } + PathLine { x: 0; y: panel.offsetY } + } + + // Upper left accent line + ShapePath { + + fillColor: Theme.accent + strokeWidth: 0 + startX: 0; startY: 0 + PathLine { x: panel.accentLineWidth; y: 0 } + PathLine { x: panel.accentLineWidth; y: panel.accentLineThickness } + PathLine { x: 0; y: panel.accentLineThickness } + PathLine { x: 0; y: 0 } + } + + // Lower right accent line + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: panelShape.width; startY: panelShape.height + PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height } + PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width; y: panelShape.height } + } + } + + // Header + Row { + id: headerRow + + x: panel.headerPadding + // Centred in the header band rather than pinned, so the chip keeps + // clear of the rule when the slug font changes size. + y: Math.round((panel.headerHeight - height) / 2) + // Puts the title where the accent line ends: chip + headerPadding on + // both sides of it. + spacing: panel.headerPadding + + // Header slug — the one piece that survives a collapse, so the strip + // still says which panel it is. + Rectangle { + id: slugChip + + width: panelSlugText.implicitWidth + 6 + height: panelSlugText.implicitHeight + 3 + color: Theme.accent + + Text { + id: panelSlugText + anchors.centerIn: parent + text: panel.panelId + color: Theme.surface + font.family: Theme.microFont + font.pixelSize: 9 + font.bold: true + } + } + + // Header title + Item { + id: panelTitle + + width: panelTitleText.implicitWidth + 6 + height: panelTitleText.implicitHeight + 3 + + Text { + id: panelTitleText + anchors.centerIn: parent + text: panel.title + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 11 + font.bold: true + font.letterSpacing: 1.1 + elide: Text.ElideRight + } + } + } + + // Everything else in the header fades as one, so a collapse is a single + // coordinated move rather than four independently timed ones. + Item { + id: headerExtras + + anchors.fill: parent + + // Header separator + Rectangle { + x: 1; y: panel.headerHeight + width: parent.width - 2 + height: 1 + color: Theme.text + opacity: 0.12 + } + + // Header end deco + Row { + id: headerEnd + + anchors.right: parent.right + anchors.rightMargin: 12 + y: 12 + spacing: 4 + + Text { + id: metaText + + visible: panel.meta.length > 0 + text: panel.meta + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 8 + font.letterSpacing: 0.7 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + + // Same height as the meta text, so the Row aligning both by their + // tops also aligns them by their bottoms — no wrapper needed. + Row { + spacing: 2 + + Repeater { + model: 5 + Rectangle { required property int index; width: 4; height: metaText.implicitHeight; color: Theme.accent } + } + } + } + } + + // Body. Spans the panel and clips, because mid-collapse the panel is + // already shorter than the detail view that is still fading out. + Item { + id: bodyClip + + anchors.fill: parent + clip: true + + Item { + id: detail + + x: panel.padding + y: panel.headerHeight + panel.headerGap + width: Math.max(0, panel.width - panel.padding * 2) + height: childrenRect.height + visible: opacity > 0 + } + + Item { + id: brief + + x: panel.briefLeft + y: panel.briefTop + width: Math.max(0, panel.width - panel.briefLeft - panel.padding) + height: childrenRect.height + opacity: 0 + visible: opacity > 0 + } + } + + // Click anywhere on the panel to switch densities. Sits above the body, so + // interactive content in a slot would need its own handler on top of this. + MouseArea { + anchors.fill: parent + onClicked: { + if (panel.toggleOnClick) + panel.expanded = !panel.expanded; + } + } + + states: [ + State { + name: "expanded" + when: panel.expanded + PropertyChanges { + target: panel + bodyBottom: detail.y + detail.height + bodyEndPadding: panel.padding + } + PropertyChanges { target: detail; opacity: 1 } + PropertyChanges { target: brief; opacity: 0 } + PropertyChanges { target: panelTitle; opacity: 1 } + PropertyChanges { target: headerExtras; opacity: 1 } + }, + State { + name: "collapsed" + when: !panel.expanded + // Symmetric about the slug's midline: the same gap the summary has + // above it is kept below, so the strip reads as one line. + PropertyChanges { + target: panel + bodyBottom: brief.y + brief.height + bodyEndPadding: panel.collapsedHeight - brief.y - brief.height + } + PropertyChanges { target: detail; opacity: 0 } + PropertyChanges { target: brief; opacity: 1 } + PropertyChanges { target: panelTitle; opacity: 0 } + PropertyChanges { target: headerExtras; opacity: 0 } + } + ] + + // Out fast, resize, in late. Fading both bodies on the same clock would + // show them at half opacity on top of each other in the middle frames. + transitions: [ + Transition { + to: "collapsed" + ParallelAnimation { + NumberAnimation { + targets: [detail, panelTitle, headerExtras] + property: "opacity" + duration: 90 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: panel + properties: "bodyBottom,bodyEndPadding" + duration: 200 + easing.type: Easing.OutCubic + } + SequentialAnimation { + PauseAnimation { duration: 110 } + NumberAnimation { + target: brief + property: "opacity" + duration: 120 + easing.type: Easing.OutCubic + } + } + } + }, + Transition { + to: "expanded" + ParallelAnimation { + NumberAnimation { + target: brief + property: "opacity" + duration: 90 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: panel + properties: "bodyBottom,bodyEndPadding" + duration: 200 + easing.type: Easing.OutCubic + } + SequentialAnimation { + PauseAnimation { duration: 110 } + NumberAnimation { + targets: [detail, panelTitle, headerExtras] + property: "opacity" + duration: 120 + easing.type: Easing.OutCubic + } + } + } + } + ] +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/tray/TrayIcon.qml b/dotfiles/quickshell/hyprchrome/widgets/tray/TrayIcon.qml new file mode 100644 index 0000000..9ad4542 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/tray/TrayIcon.qml @@ -0,0 +1,72 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Services.SystemTray +import QtQuick +import QtQuick.Shapes +import qs.hyprchrome.theme + +// One tray item as a chamfered cell. Declares `modelData` required so it can be +// a Repeater delegate directly, without an Item wrapper in between. +// +// Left click activates, right click opens the item's own menu — anchored under +// the cell rather than at the window edge, since this rail sits along the top. +MouseArea { + id: cell + + required property SystemTrayItem modelData + + property int iconSize: 18 + property int chamfer: 4 + + implicitWidth: cell.iconSize + 8 + implicitHeight: cell.iconSize + 8 + + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: event => { + if (event.button === Qt.LeftButton) { + cell.modelData.activate(); + } else if (cell.modelData.hasMenu) { + const window = cell.QsWindow?.window; + if (window) { + const anchor = cell.mapToItem(null, 0, cell.height); + cell.modelData.display(window, anchor.x, anchor.y); + } + } + event.accepted = true; + } + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeWidth: 1 + strokeColor: cell.containsMouse ? Theme.accent : Theme.textAlpha(0.18) + fillColor: cell.containsMouse ? Theme.selection : Theme.textAlpha(0.06) + + startX: cell.chamfer + startY: 0 + PathLine { x: cell.width; y: 0 } + PathLine { x: cell.width; y: cell.height - cell.chamfer } + PathLine { x: cell.width - cell.chamfer; y: cell.height } + PathLine { x: 0; y: cell.height } + PathLine { x: 0; y: cell.chamfer } + PathLine { x: cell.chamfer; y: 0 } + + Behavior on strokeColor { ColorAnimation { duration: 150 } } + } + } + + Image { + anchors.centerIn: parent + source: cell.modelData.icon + width: cell.iconSize + height: cell.iconSize + fillMode: Image.PreserveAspectFit + smooth: true + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/tray/TrayPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/tray/TrayPanel.qml new file mode 100644 index 0000000..01c80b2 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/tray/TrayPanel.qml @@ -0,0 +1,80 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Services.SystemTray +import QtQuick +import qs.hyprchrome.theme +import qs.hyprchrome.widgets.panels + +// System tray in the hyprchrome panel chrome, in both densities: the same +// items, drawn large enough to hit when expanded and shrunk onto the header +// line when collapsed. +// +// Unlike the other panels this one sizes itself horizontally — the item count +// is whatever the session happens to be running — so a layout can just give it +// `Layout.fillHeight` and let its implicit width stand. +BarPanel { + id: panel + + panelId: "TRY" + title: "SYSTEM TRAY" + meta: panel.itemCount + (panel.itemCount === 1 ? " ITEM" : " ITEMS") + + readonly property int itemCount: SystemTray.items.values.length + + implicitWidth: Math.max(panel.headerMinWidth, + panel.briefLeft + brief.implicitWidth + panel.padding, + panel.padding * 2 + icons.implicitWidth) + + // Collapsed: the same icons, small, on the header line. + summary: Row { + id: brief + + spacing: 5 + + Repeater { + model: SystemTray.items + + TrayIcon { + iconSize: 13 + } + } + + Text { + visible: panel.itemCount === 0 + text: "NO ITEMS" + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 1.2 + height: 21 + verticalAlignment: Text.AlignVCenter + } + } + + // Expanded: full-size cells. + Row { + id: icons + + spacing: 8 + + Repeater { + model: SystemTray.items + + TrayIcon { + iconSize: 18 + } + } + + Text { + visible: panel.itemCount === 0 + text: "NO ITEMS" + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 1.2 + height: 26 + verticalAlignment: Text.AlignVCenter + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/vitals/GpuBusy.qml b/dotfiles/quickshell/hyprchrome/widgets/vitals/GpuBusy.qml new file mode 100644 index 0000000..23ed27f --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/vitals/GpuBusy.qml @@ -0,0 +1,60 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick + +// amdgpu utilisation, straight off sysfs. +// +// node_exporter's hwmon collector carries the card's temperatures, power and +// clocks — which is where VitalsData gets them — but not its busy percentage, +// so this is the one vital that cannot come from the same scrape. +// +// The card number is globbed rather than pinned: it is card1 on terra today, +// but it depends on probe order and moves when a GPU is added or removed. +Scope { + id: root + + // Poll only while something is showing it, like VitalsData. + property bool active: false + property int interval: 2000 + + property real value: 0 // 0..1 busy + property bool ready: false + + onActiveChanged: { + if (!root.active) + root.ready = false; + } + + Process { + id: probe + + command: ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | head -n1"] + + stdout: StdioCollector { + onStreamFinished: { + const busy = parseInt(this.text.trim(), 10); + if (isFinite(busy)) { + root.value = Math.max(0, Math.min(1, busy / 100)); + root.ready = true; + } else { + // No amdgpu (or no permission) — leave the meter blank + // rather than pinning it at zero, which would read as idle. + root.ready = false; + } + } + } + } + + Timer { + interval: root.interval + running: root.active + repeat: true + triggeredOnStart: true + onTriggered: { + if (!probe.running) + probe.running = true; + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/vitals/SegmentMeter.qml b/dotfiles/quickshell/hyprchrome/widgets/vitals/SegmentMeter.qml new file mode 100644 index 0000000..aee364e --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/vitals/SegmentMeter.qml @@ -0,0 +1,40 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.hyprchrome.theme + +// Segmented horizontal meter: a row of cells lit up to `value`. +// +// A copy of the dense bar's meter rather than a reuse of it — that one is an +// inline component inside DenseBarContent.qml and so is not visible from any +// other file (the same reason BarPanel inlines its own MicroText). +Row { + id: meter + + property int segments: 16 + property real value: 0 // 0..1 + property bool ready: false + property real warn: 0.85 // fraction at which the lit cells go hot + + readonly property real fraction: Math.max(0, Math.min(1, meter.value)) + readonly property bool hot: meter.ready && meter.fraction >= meter.warn + readonly property color litColor: meter.hot ? Theme.hot : Theme.accent + + spacing: 2 + + Repeater { + model: meter.segments + + Rectangle { + required property int index + + readonly property bool lit: meter.ready && index < Math.round(meter.fraction * meter.segments) + + width: Math.max(2, (meter.width - (meter.segments - 1) * meter.spacing) / meter.segments) + height: meter.height + color: lit ? meter.litColor : Theme.textAlpha(0.06) + border.width: 1 + border.color: lit ? meter.litColor : Theme.textAlpha(0.18) + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalRow.qml b/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalRow.qml new file mode 100644 index 0000000..47069e5 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalRow.qml @@ -0,0 +1,58 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.hyprchrome.theme + +// One metric of the expanded vitals panel: label, meter, readout on a line. +// +// The label and readout columns are fixed so the meters of stacked rows line +// up on both edges regardless of how long any one readout gets. +Item { + id: row + + property string label: "" + property real value: 0 // 0..1 + property string readout: "--" + property bool ready: false + property real warn: 0.85 + + property int labelWidth: 52 + property int readoutWidth: 46 + + readonly property bool hot: row.ready && row.value >= row.warn + + implicitHeight: 11 + + Text { + anchors.verticalCenter: parent.verticalCenter + width: row.labelWidth + text: row.label + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 10 + font.letterSpacing: 0.7 + elide: Text.ElideRight + } + + SegmentMeter { + x: row.labelWidth + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, row.width - row.labelWidth - row.readoutWidth) + height: 9 + value: row.value + ready: row.ready + warn: row.warn + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: row.readoutWidth + text: row.readout + color: row.hot ? Theme.hot : Theme.text + font.family: Theme.displayFont + font.pixelSize: 10 + font.bold: true + horizontalAlignment: Text.AlignRight + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalsPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalsPanel.qml new file mode 100644 index 0000000..00c1cca --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalsPanel.qml @@ -0,0 +1,142 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts + +import qs.hyprchrome.theme +import qs.hyprchrome.widgets.panels +import qs.widgets.vitals + +// Host vitals in the hyprchrome panel chrome, in both of BarPanel's densities. +// +// Expanded: CPU load and temperature, memory usage, GPU load and temperature, +// each as a segmented meter with its readout. +// Collapsed: the same five numbers as percentages behind Nerd Font glyphs. +// +// Both bodies read the same properties below, so the two densities cannot +// disagree — they are one set of numbers rendered twice. +// +// The scrape comes from the shared VitalsData (node_exporter over loopback); +// GPU busy is the one reading that scrape does not carry, so it comes off +// sysfs through GpuBusy. +BarPanel { + id: panel + + panelId: "MON" + title: "RESOURCE MONITOR" + meta: vitals.failed ? "OFFLINE" : vitals.ready ? "REALTIME" : "PRIMING" + + // Poll only while the panel exists on screen; both sources idle otherwise. + property bool polling: true + + // Temperatures are metered against a 0–100 °C span so a bar means the same + // thing on every row. + readonly property real tempCeiling: 100 + + readonly property real memoryFraction: vitals.memTotal > 0 ? vitals.memUsed / vitals.memTotal : 0 + readonly property bool cpuTempReady: isFinite(vitals.cpuTemp) + readonly property bool gpuTempReady: isFinite(vitals.gpuTemp) + + function pct(value, ready) { + return ready ? Math.round(Math.max(0, Math.min(1, value)) * 100) + "%" : "--"; + } + + function tempFraction(celsius) { + return isFinite(celsius) ? Math.max(0, Math.min(1, celsius / panel.tempCeiling)) : 0; + } + + VitalsData { + id: vitals + active: panel.polling + } + + GpuBusy { + id: gpu + active: panel.polling + } + + // Collapsed: glyph + percentage, in the same order as the rows below. + // Codepoints are Nerd Fonts v3 — oct-cpu, fa-thermometer-half, + // md-memory, md-expansion-card-variant — all present in DepartureMono. + summary: RowLayout { + spacing: 12 + + Item { Layout.fillWidth: true } + Readout { icon: "CPU:"; value: panel.pct(vitals.cpu, vitals.ratesReady) } + Readout { icon: "CPU Temp:"; value: vitals.fmtTemp(vitals.cpuTemp) } + Readout { icon: "Mem:"; value: panel.pct(panel.memoryFraction, vitals.ready) } + Readout { icon: "GPU:"; value: panel.pct(gpu.value, gpu.ready) } + Readout { icon: "GPU Temp:"; value: vitals.fmtTemp(vitals.gpuTemp) } + Item { Layout.fillWidth: true } + } + + // Expanded: the same five, metered. + Column { + width: parent.width + spacing: 5 + + VitalRow { + width: parent.width + label: "CPU" + value: vitals.cpu + ready: vitals.ratesReady && !vitals.failed + readout: panel.pct(vitals.cpu, vitals.ratesReady) + } + + VitalRow { + width: parent.width + label: "CPU TMP" + value: panel.tempFraction(vitals.cpuTemp) + ready: panel.cpuTempReady + readout: vitals.fmtTemp(vitals.cpuTemp) + warn: 0.85 + } + + VitalRow { + width: parent.width + label: "MEM" + value: panel.memoryFraction + ready: vitals.ready && !vitals.failed + readout: panel.pct(panel.memoryFraction, vitals.ready) + } + + VitalRow { + width: parent.width + label: "GPU" + value: gpu.value + ready: gpu.ready + readout: panel.pct(gpu.value, gpu.ready) + } + + VitalRow { + width: parent.width + label: "GPU TMP" + value: panel.tempFraction(vitals.gpuTemp) + ready: panel.gpuTempReady + readout: vitals.fmtTemp(vitals.gpuTemp) + warn: 0.85 + } + } + + component Readout: Row { + property string icon: "" + property string value: "--" + + spacing: 4 + + Text { + text: parent.icon + color: Theme.accent + font.family: Theme.displayFont + font.pixelSize: 12 + } + + Text { + text: parent.value + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 12 + font.bold: true + } + } +} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 1336017..b290450 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -1,16 +1,24 @@ //@ pragma UseQApplication +import QtQuick import Quickshell +import Quickshell.Widgets import qs.widgets.bar import qs.widgets.launcher import qs.widgets.notifications import qs.widgets.osd import qs.widgets.systray +import qs.widgets.theme import qs.widgets.vitals +import qs.hyprchrome +import qs.hyprchrome.widgets +import qs.hyprchrome.widgets.debug +import qs.hyprchrome.widgets.host +import qs.hyprchrome.widgets.vitals Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. - DenseBar {} + HyprChromeBar {} // App launcher variants — SUPER CTRL 1–8; variant 8 is the primary HUD. LauncherStack {} // 1 — left vertical list @@ -29,4 +37,38 @@ Scope { // Host vitals HUD — toggle with SUPER CTRL V. Vitals {} + + // Widget staging area, centered on the secondary monitor (HDMI-A-1), + // toggled with SUPER CTRL D. Swap the children below for whatever widget + // is being worked on; they must carry their own size (see DebugWindow). + // DebugWindow { + // id: debugStage + + // // VitalsPanel has no implicit width — the slot measures its children, so + // // each staged panel states its own. Height follows the mode it is in. + // // Click a panel to collapse or expand it. + // Column { + // spacing: 12 + // padding: 12 + + // HostPanel { + // width: 560 + // } + + // HostPanel { + // width: 560 + // expanded: false + // } + + // VitalsPanel { + // width: 560 + // } + + // VitalsPanel { + // width: 560 + // expanded: false + // } + + // } + // } } diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index e5b6eb1..01b3e49 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -208,6 +208,10 @@ in (bind "SUPER + CTRL + S" (dsp.global "quickshell:sidebar")) # toggle the host vitals HUD (bind "SUPER + CTRL + V" (dsp.global "quickshell:vitals")) + # toggle the debug widget stage (centred on the secondary monitor) + (bind "SUPER + CTRL + D" (dsp.global "quickshell:debug")) + # expand / collapse the hyprchrome bar as a whole + (bind "SUPER + A" (dsp.global "quickshell:chrome")) (bind "SUPER + B" (dsp.exec "vivaldi")) (bind "SUPER + E" (dsp.exec "cosmic-files")) From 7268221a511e0ef7d3d65bfd25986796d42233e9 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 02:41:39 +0200 Subject: [PATCH 10/24] fix(quickshell): drop layout-overridden geometry in HostPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded body is a RowLayout, so the divider's `y`/`width`/`height`, the clock column's `width: 210`, and the user/zone text's `y: 32` were all being discarded silently — a layout assigns its children's geometry, and a Column positions its own. Only the anchors among these ever warned. The divider's 8px inset becomes Layout margins, and `Layout.alignment` goes from the clock column, where it is ignored while the item fills. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- .../hyprchrome/widgets/host/HostPanel.qml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml index 829fb55..7cdd2ce 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml @@ -153,7 +153,6 @@ BarPanel { } Text { - y: 32 text: (panel.userName || "NODE") + " // " + (panel.zoneName || "LOCAL") color: Theme.accent font.family: Theme.microFont @@ -166,21 +165,25 @@ BarPanel { // Rule between identity and clock, the same hairline the dense bar // puts between its host name and node readout. Rectangle { - Layout.alignment: Qt.AlignCenter - y: 2 - width: 2 - height: parent.height - 8 + // The layout owns x/y/width/height: the 8px inset that was + // `height: parent.height - 8` becomes margins, and a bare `width: 2` + // would be overridden. + Layout.preferredWidth: 2 + Layout.fillHeight: true + Layout.topMargin: 4 + Layout.bottomMargin: 4 color: Theme.hair } Column { id: clock + // Both columns share the width evenly, as before. `width: 210` here was + // dead — a layout assigns width — and `Layout.alignment` is ignored + // while an item fills. Layout.fillWidth: true Layout.fillHeight: true - Layout.alignment: Qt.AlignRight - width: 210 spacing: 2 Text { From 7eb7b948e865aabb7077a335b3be798b008fded2 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 03:25:14 +0200 Subject: [PATCH 11/24] feat(quickshell): dim the desktop behind the expanded hyprchrome bar A full-screen scrim carrying the dense bar's drafting grid, shown while the rail is expanded and faded out with it. It sits on the TOP layer while the bar moves to OVERLAY. Both on one layer would stack by surface creation order, which is not something to rely on; a layer apart makes "above windows, below the bar" a guarantee. The mask is an empty Region, so the scrim takes no clicks and reserves nothing. The grid is the dense bar's, at twice the spacing and with registration crosses on every other intersection. Its 0.018 opacity was tuned against a near-black panel and is invisible over a scrim on lit windows, so grid and crosses are both properties rather than constants, and the lines take a desaturated accent derived from the palette instead of plain text colour. Cross geometry rounds with Math.floor on both the mark's offset and the bars inside it. anchors.*Center halves the box unfloored, which put an even-sized mark half a pixel off the 1px rule it registers against. crossThickness is in steps for the same reason: 1 -> 1px, 2 -> 3px, 3 -> 5px, since only an odd width straddles a rule symmetrically. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- .../hyprchrome/widgets/ChromeBackdrop.qml | 162 ++++++++++++++++++ .../hyprchrome/widgets/HyprChromeBar.qml | 13 ++ 2 files changed, 175 insertions(+) create mode 100644 dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml diff --git a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml new file mode 100644 index 0000000..4de1703 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml @@ -0,0 +1,162 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.hyprchrome.theme + +// Full-screen scrim behind the bar: dims the desktop and lays the dense bar's +// drafting grid over it while the rail is expanded. +// +// Sits on the TOP layer while the bar itself is on OVERLAY. Two surfaces on the +// same layer stack by creation order, which is not something to rely on; one +// layer apart is a guarantee — above ordinary windows, below the bar. +// +// It reserves nothing and takes no input: the mask is an empty Region, so +// clicks land on whatever is underneath rather than on the scrim. +PanelWindow { + id: backdrop + + property bool active: true + property real dim: 0.55 + property int gridSpacing: 120 + + // The dense bar drew this grid at 0.018 against its own near-black panel. + // Over a 55% scrim on top of lit windows that is invisible, so it is a + // knob rather than a constant. + property real gridOpacity: 0.1 + + // The accent with its saturation pulled back: warm enough to read as part + // of the palette, not so loud that a full-screen grid competes with the + // bar. Derived rather than a literal so it tracks a palette change. + // Registration crosses sit on every other intersection of the grid. + property color crossColor: Theme.muted + property real crossOpacity: 0.35 + property int crossSize: 20 + + // Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd + // widths can straddle a 1px rule symmetrically, so an even pixel count + // would push every mark half a pixel off the grid it registers against. + property int crossThickness: 2 + readonly property int crossWeight: Math.max(1, backdrop.crossThickness) * 2 - 1 + + property color gridColor: Qt.hsla(Theme.accent.hslHue, + Theme.accent.hslSaturation * 0.45, + Theme.accent.hslLightness, + 1) + + visible: scrim.opacity > 0 + + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + readonly property int crossColumns: Math.ceil(backdrop.width / (backdrop.gridSpacing * 2)) + 1 + readonly property int crossRows: Math.ceil(backdrop.height / (backdrop.gridSpacing * 2)) + 1 + + anchors { + top: true + left: true + right: true + bottom: true + } + + mask: Region {} + + Item { + id: scrim + + anchors.fill: parent + opacity: backdrop.active ? 1 : 0 + + // Matched to the bar's own collapse so the scrim and the panels resolve + // together rather than one trailing the other. + Behavior on opacity { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + Rectangle { + anchors.fill: parent + color: Theme.surface + opacity: backdrop.dim + } + + // Faint drafting grid; no gradient and deliberately subordinate to + // whatever is showing through it. + Repeater { + model: Math.ceil(scrim.width / backdrop.gridSpacing) + + Rectangle { + required property int index + + x: index * backdrop.gridSpacing + width: 1 + height: scrim.height + color: backdrop.gridColor + opacity: backdrop.gridOpacity + } + } + + Repeater { + model: Math.ceil(scrim.height / backdrop.gridSpacing) + + Rectangle { + required property int index + + y: index * backdrop.gridSpacing + width: scrim.width + height: 1 + color: backdrop.gridColor + opacity: backdrop.gridOpacity + } + } + + // Register marks on every other line, so they land on a 2x grid rather + // than on every crossing — sparse enough to read as drafting registration + // rather than as texture. + Repeater { + model: backdrop.crossColumns * backdrop.crossRows + + Item { + required property int index + + readonly property int column: index % backdrop.crossColumns + readonly property int row: Math.floor(index / backdrop.crossColumns) + + // Marks on every other rule in both directions, so they line up + // in columns as well as rows. Both offsets are whole multiples of + // gridSpacing, so every mark lands on a real intersection. + // + // Math.floor, not /2: the item offset and the bars inside it must + // round the same way, or an even crossSize sits half a pixel off the + // rule it marks. + x: column * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2) + y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2) + width: backdrop.crossSize + height: backdrop.crossSize + opacity: backdrop.crossOpacity + + Rectangle { + // Placed with the same Math.floor the item's own offset uses. + // anchors.verticalCenter halves the height unfloored, so an even + // crossSize put the 1px bar half a pixel off the rule it marks. + y: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2) + width: parent.width + height: backdrop.crossWeight + color: backdrop.crossColor + } + + Rectangle { + x: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2) + width: backdrop.crossWeight + height: parent.height + color: backdrop.crossColor + } + } + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml index b407eb3..1177859 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml @@ -3,6 +3,7 @@ import QtQuick.Shapes import QtQuick.Layouts import Quickshell import Quickshell.Hyprland +import Quickshell.Wayland import qs.hyprchrome.widgets import qs.hyprchrome.widgets.host import qs.hyprchrome.widgets.vitals @@ -45,12 +46,24 @@ Scope { onPressed: root.toggle() } + // Scrim first: it is a layer below the bar, so stacking does not depend on + // creation order, but keeping the declaration order the same as the visual + // order costs nothing. + ChromeBackdrop { + screen: root.targetScreen + active: root.expanded + } + PanelWindow { id: window screen: root.targetScreen visible: root.targetScreen !== null + // One layer above the scrim, so the stacking is guaranteed rather than + // dependent on surface creation order. See ChromeBackdrop. + WlrLayershell.layer: WlrLayer.Overlay + property int margin: 12 // Where the panels will SETTLE, not where they are mid-transition. Binding From 15698d3a7887703c992fd1ae3058f430e5391f3d Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 03:25:26 +0200 Subject: [PATCH 12/24] feat(devshell): stream quickshell warnings and errors in nix develop qs-log follows the working-tree instance's log filtered to WARN|ERROR (-a for everything), and the shellHook starts it in the background once qs-dev is up, taking it down again in the exit trap alongside qs-prod. A binding loop or a failed binding is a WARN, and easy to miss when it scrolls past unwatched. It starts at the end of the log rather than replaying the backlog, and re-attaches in a loop: `qs log -f` ends when the instance it attached to exits, and the dev shell outlives individual instances. Also corrects the hot-reload note added in 7268221, which was wrong on both counts. Tested against the running shell: `touch` never reloads (mtime is not a content change) and inode-replacing edits like `sed -i` are picked up fine. The reliable check is whether `qs log` shows a "Reloading configuration..." line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- dotfiles/quickshell/CLAUDE.md | 2 +- flake.nix | 38 +++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/dotfiles/quickshell/CLAUDE.md b/dotfiles/quickshell/CLAUDE.md index 63995f2..c840656 100644 --- a/dotfiles/quickshell/CLAUDE.md +++ b/dotfiles/quickshell/CLAUDE.md @@ -14,7 +14,7 @@ qs -p . # run this directory explicitly regardless of symlink qs -n # exit immediately if another instance is already running (use to avoid duplicate shells while iterating) ``` -Quickshell hot-reloads QML on file save when already running, so for most edits just save and check the running instance rather than restarting `qs`. The watcher follows the file's inode, so an edit that REPLACES the file (`perl -i`, `sed -i`, `mv`) silently detaches it — the shell keeps rendering the previous config and `qs log` still says "Configuration Loaded" for the last real reload. Edit in place, or `touch` a still-watched file to force a full reload. There is no separate build/lint/test tooling in this repo — verification is visual/behavioral via the running shell. `qmlls` (QML language server) is configured via `.qmlls.ini` for editor diagnostics. +Quickshell hot-reloads QML on file save when already running, so for most edits just save and check the running instance rather than restarting `qs`. It watches file CONTENT: `touch` alone never reloads (mtime is not a change), while any real edit does, including inode-replacing ones (`sed -i`, `perl -i`). A save that is not picked up leaves the shell rendering the previous config with no error — `qs log` shows a `Reloading configuration...` line for every save it saw, so that is the check. There is no separate build/lint/test tooling in this repo — verification is visual/behavioral via the running shell. `qmlls` (QML language server) is configured via `.qmlls.ini` for editor diagnostics. ## Architecture diff --git a/flake.nix b/flake.nix index 457279b..7501c10 100644 --- a/flake.nix +++ b/flake.nix @@ -583,6 +583,7 @@ # instance is the identical build to the packaged one. qs = "${nixpkgs.legacyPackages.${system}.quickshell}/bin/qs"; git = "${nixpkgs.legacyPackages.${system}.git}/bin/git"; + grep = "${nixpkgs.legacyPackages.${system}.gnugrep}/bin/grep"; # Resolved at RUN time, not build time: the entire point is to run the # working tree, and `self` here is only a store snapshot of it. @@ -631,6 +632,33 @@ echo "qs-dev: live on $cfg — edits there now hot-reload" ''; + # qs log -f prints everything the instance logs; WARN and ERROR are the + # two that mean something is wrong with the QML in front of you. A + # binding loop or a failed binding is a WARN and easy to miss when it + # scrolls past inside a reload's worth of chatter. + qs-log = pkgs.writeShellScriptBin "qs-log" '' + set -uo pipefail + ${preamble} + + filter='WARN|ERROR' + case "''${1:-}" in + -a|--all) filter='.' ;; + esac + + # -t 1: `qs log -f` replays the whole backlog first, which would dump + # every historical warning into the terminal on shell entry. + # + # `qs log -f` ends when the instance it attached to exits, and the dev + # shell outlives individual instances — a QML error kills one, `qs-dev` + # starts another. Re-attach instead of going quiet for the session. + while :; do + if running "$cfg"; then + ${qs} log -p "$cfg" -t 1 -f 2>/dev/null | ${grep} --line-buffered -E "$filter" >&2 + fi + sleep 1 + done + ''; + qs-prod = pkgs.writeShellScriptBin "qs-prod" '' set -uo pipefail ${preamble} @@ -641,7 +669,7 @@ ''; in pkgs.mkShell { - packages = [ pkgs.quickshell qs-dev qs-prod ]; + packages = [ pkgs.quickshell qs-dev qs-prod qs-log ]; # Swap on entry, swap back on exit. Three guards: # - interactive only ($- has i). `nix develop --command X` EXECs X, @@ -655,9 +683,15 @@ shellHook = '' if [[ $- == *i* ]] && [ -n "''${WAYLAND_DISPLAY:-}" ] && [ -z "''${HOMELAB_QS_DEV:-}" ]; then export HOMELAB_QS_DEV=1 - qs-dev && trap qs-prod EXIT + if qs-dev; then + # Stream the dev instance's warnings and errors into this + # terminal, and take the follower down with the shell. + qs-log & HOMELAB_QS_LOG=$! + trap 'kill "$HOMELAB_QS_LOG" 2>/dev/null; qs-prod' EXIT + fi fi echo "homelab devshell — qs-dev (working tree) / qs-prod (packaged); exit restores" + echo "homelab devshell — quickshell WARN/ERROR stream here; qs-log -a for everything" ''; }; }; From e7c30fd39d283f688a8c4708730c01f21a10f25b Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 03:30:35 +0200 Subject: [PATCH 13/24] feat(quickshell): show local and tailnet address in HostPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third line under user // timezone, muted so the identity block stays a descending three tiers. Unlike the name, user and zone — none of which can change under a running shell — addresses can, so this polls on a 30s timer instead of joining the startup one-shot. The local one is read off the interface holding the default route with tailscale0 excluded: as an exit node tailscale0 holds that route itself, and the panel would show the tailnet address on both sides. Drops the expanded body's `height: 48`. A third line has to grow the panel, and BarPanel measures the body to decide how tall it is, so a fixed height would have clipped the new row instead. The bar follows through targetHeight. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- .../hyprchrome/widgets/host/HostPanel.qml | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml index 7cdd2ce..ec2ad7a 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml @@ -27,6 +27,8 @@ BarPanel { property string hostName: "LOCAL" property string zoneName: "" // IANA, e.g. EUROPE/BERLIN property string userName: "" // whoever is logged in, e.g. DARMAN + property string localIp: "" // interface holding the default route + property string tailnetIp: "" // tailscale0, when the tailnet is up property date now: new Date() // Qt resolves the abbreviation ("CEST") against the same zone the offset @@ -82,6 +84,40 @@ BarPanel { } } + // Addresses, unlike the name and the zone, can change under a running + // shell — a lease renewal, `tailscale up`/`down` — so this one polls. + // + // The local address is taken from the interface carrying the default + // route, with tailscale0 excluded: as an exit node it holds the default + // route itself, and the panel would then show the tailnet address twice. + Process { + id: addresses + + command: ["sh", "-c", + "dev=$(ip -4 route show default | grep -v tailscale0 | awk '{print $5; exit}');" + + " ip -4 -o addr show dev \"$dev\" scope global 2>/dev/null | awk '{split($4,a,\"/\"); print a[1]; exit}';" + + " ip -4 -o addr show dev tailscale0 scope global 2>/dev/null | awk '{split($4,a,\"/\"); print a[1]; exit}'"] + + stdout: StdioCollector { + onStreamFinished: { + const lines = this.text.trim().split("\n"); + panel.localIp = lines.length > 0 ? lines[0].trim() : ""; + panel.tailnetIp = lines.length > 1 ? lines[1].trim() : ""; + } + } + } + + Timer { + interval: 30000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { + if (!addresses.running) + addresses.running = true; + } + } + // Collapsed: who and when, nothing else. summary: Row { spacing: 10 @@ -133,7 +169,8 @@ BarPanel { // Expanded: name left, clock stack right. RowLayout { width: parent.width - height: 48 + // No explicit height: a third line in the identity column has to grow + // the panel, and BarPanel measures the body to decide how tall it is. spacing: 16 Column { @@ -160,6 +197,15 @@ BarPanel { font.letterSpacing: 1.4 elide: Text.ElideRight } + + Text { + text: (panel.localIp || "--") + " // " + (panel.tailnetIp || "NO TAILNET") + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 1.4 + elide: Text.ElideRight + } } // Rule between identity and clock, the same hairline the dense bar From c69aa4fea29f6f941a839a5b37e82e8215af65dd Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 03:44:40 +0200 Subject: [PATCH 14/24] fix(quickshell): stop the bar twitching when a collapse finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two causes, both outside the panel animation itself. bodyBottom and bodyEndPadding are animated reals, so their sum spends the tail of every transition on a fraction. A layout rounds that up, then drops a pixel the moment the animation lands on its exact value — a 1px hop after the motion has visibly finished. implicitHeight and targetHeight now round. The rest was the surface. Hyprland animates layer-surface resizes (animations enabled, `layers` left at its default), and the deferred shrink put that resize exactly where the panel motion ended. The bar is now sized once to the expanded rail via the new BarPanel.expandedHeight and never resizes; only exclusiveZone tracks the state, so the desktop still reflows once per toggle, at the start. That retires barHeight, the content-height handler and the 340ms shrink timer. A surface that stays tall would swallow clicks across the screen while the rail is collapsed, so input is masked to the panel row. Instrumenting BarPanel per frame ruled the panels themselves out first: slug and summary hold the same absolute y through an entire collapse. The bar and scrim also get their own layer namespaces. Nothing depends on them yet; they are the handle for a layerrule that would exempt the rail from compositor animations without catching the launchers, which share the default "quickshell" namespace and do want their fade. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- .../hyprchrome/widgets/ChromeBackdrop.qml | 1 + .../hyprchrome/widgets/HyprChromeBar.qml | 45 +++++++------------ .../hyprchrome/widgets/panels/BarPanel.qml | 26 +++++++---- 3 files changed, 35 insertions(+), 37 deletions(-) diff --git a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml index 4de1703..da5857a 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml @@ -47,6 +47,7 @@ PanelWindow { visible: scrim.opacity > 0 + WlrLayershell.namespace: "hyprchrome-scrim" WlrLayershell.layer: WlrLayer.Top WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml index 1177859..341a28f 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml @@ -62,44 +62,33 @@ Scope { // One layer above the scrim, so the stacking is guaranteed rather than // dependent on surface creation order. See ChromeBackdrop. + // Own namespace so a layerrule can exempt the rail from Hyprland's layer + // animation without also catching the launchers, which share the default + // "quickshell" namespace and do want their fade. + WlrLayershell.namespace: "hyprchrome-bar" WlrLayershell.layer: WlrLayer.Overlay property int margin: 12 - // Where the panels will SETTLE, not where they are mid-transition. Binding - // the surface to the animated height instead resizes the layer surface — - // and, with the automatic exclusive zone, relayouts every tiled window on - // this output — on every frame of the animation. + // The surface never resizes: it is always tall enough for the expanded + // rail, and only the exclusive zone tracks the current state. Resizing a + // layer surface makes Hyprland animate the change, which showed up as the + // panels twitching a pixel or two the moment the collapse finished. + // + // The zone still follows the target height, so tiled windows reflow once + // per toggle, at the start, and slide while the panels animate. + readonly property real expandedContent: Math.max(hostPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2 readonly property real contentHeight: Math.max(hostPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2 - // The surface and the exclusive zone move on different clocks. The zone is - // the desktop-visible half: set it to the target immediately, so the tiled - // windows reflow ONCE, at the start, and slide while the bar animates. - // The surface itself grows before the panels do but shrinks only after they - // have finished, because a surface that shrank immediately would clip the - // panels still animating inside it. - property real barHeight: 0 + implicitHeight: Math.round(window.expandedContent) exclusionMode: ExclusionMode.Normal exclusiveZone: Math.round(window.contentHeight) - implicitHeight: window.barHeight - - onContentHeightChanged: { - if (window.contentHeight > window.barHeight) - window.barHeight = window.contentHeight; - else - shrink.restart(); - } - - Component.onCompleted: window.barHeight = window.contentHeight - - Timer { - id: shrink - - // Longer than the panel's own collapse (200ms body + 110ms fade-in). - interval: 340 - onTriggered: window.barHeight = window.contentHeight + // Only the panels take input. Without this the surface would keep eating + // clicks across its full height while the rail is collapsed. + mask: Region { + item: panelRow } anchors { top: true; left: true; right: true; } diff --git a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml index 422462f..bcedb43 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml @@ -93,16 +93,24 @@ Item { property real bodyBottom: detail.y + detail.height property real bodyEndPadding: panel.padding - implicitHeight: Math.max(panel.minimumHeight, panel.bodyBottom + panel.bodyEndPadding) + // Rounded: bodyBottom and bodyEndPadding are animated reals, so the sum + // spends the tail of every transition on a fraction. A layout rounds that + // UP, then drops a pixel the moment the animation lands on its exact + // value — a 1px hop after the motion has visibly finished. + implicitHeight: Math.round(Math.max(panel.minimumHeight, panel.bodyBottom + panel.bodyEndPadding)) - // Where the panel will settle in its current state, skipping the values - // the transition passes through. A window sized to this reconfigures once - // per toggle rather than once per animation frame — which, for a - // layer-shell bar with an automatic exclusive zone, is the difference - // between one relayout of the desktop and a dozen. - readonly property real targetHeight: Math.max(panel.minimumHeight, panel.expanded - ? detail.y + detail.height + panel.padding - : panel.collapsedHeight) + // Where the panel settles in each state, skipping the values the transition + // passes through: a host sizes its surface and its exclusive zone from these + // rather than from the animated height, so the desktop is relaid out once per + // toggle instead of once per animation frame. + // Height of the expanded body regardless of the current state — what a + // host needs to size a surface that must not resize when panels collapse. + readonly property real expandedHeight: Math.round(Math.max(panel.minimumHeight, + detail.y + detail.height + panel.padding)) + + readonly property real targetHeight: panel.expanded + ? panel.expandedHeight + : Math.round(Math.max(panel.minimumHeight, panel.collapsedHeight)) // The two chamfer cuts (top-right at y=chamfer, bottom-left at From bbe35dd72ebffbedc1863dbc99b94068aa6bfe32 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 04:13:43 +0200 Subject: [PATCH 15/24] feat(quickshell): join adjacent panels into a continuous rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At spacing 0 a row of panels read as separate tiles, each closing its own silhouette. BarPanel can now drop either cut corner — rightChamfer (top-right) and leftChamfer (bottom-left), the only two the shape cuts — so an edge that a neighbour butts against runs square into it. The bar turns off the host's right and the vitals' left; the tray keeps both, since the spacer between them is not a panel and that edge is free. A side with its chamfer off also draws a connector: the shared edge restroked in accent at outlineWidth + 2, so the join reads as a deliberate seam rather than two outlines that happen to touch. The outline's own width becomes a property so the connector can be defined against it instead of as a second literal. The lower-right accent strip takes accentLineWidth, the same slug-derived width the upper-left one already used, instead of its own Math.min(49, width / 3). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- .../hyprchrome/widgets/HyprChromeBar.qml | 9 +++- .../hyprchrome/widgets/panels/BarPanel.qml | 49 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml index 341a28f..b3c810b 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml @@ -100,12 +100,15 @@ Scope { x: window.margin y: window.margin width: window.width - window.margin * 2 - spacing: 8 + spacing: 0 HostPanel { id: hostPanel expanded: root.expanded + // Vitals butts against its right edge; the left end of the row is free. + rightChamfer: false + toggleOnClick: false Layout.preferredWidth: 350 Layout.fillHeight: true @@ -115,6 +118,10 @@ Scope { id: vitalsPanel expanded: root.expanded + // Host on the left, the spacer on the right — and a spacer is not a + // panel, so that edge keeps its cut. + leftChamfer: false + toggleOnClick: false Layout.preferredWidth: 500 Layout.fillHeight: true diff --git a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml index bcedb43..1c711b9 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml @@ -118,6 +118,23 @@ Item { // which turns the outline inside out for the last frames of a collapse. readonly property real activeChamfer: Math.max(2, Math.min(panel.chamfer, panel.height / 2 - 1)) + // The silhouette has exactly two cut corners: top-right and bottom-left. + // Turn one off where another panel butts against that side, so a row laid + // out with no spacing reads as one continuous strip instead of a line of + // separate tiles. A cut corner costs its side nothing when disabled — the + // edge simply runs square into the neighbour. + property bool rightChamfer: true // top-right cut + property bool leftChamfer: true // bottom-left cut + + readonly property real rightCut: panel.rightChamfer ? panel.activeChamfer : 0 + readonly property real leftCut: panel.leftChamfer ? panel.activeChamfer : 0 + + // The seam where two panels meet is drawn a step heavier and in accent, so + // a chamfer-less join reads as a deliberate connector rather than as two + // outlines that happen to touch. + property int outlineWidth: 1 + readonly property int connectorWidth: panel.outlineWidth + 2 + Shape { id: panelShape anchors.fill: parent @@ -127,16 +144,34 @@ Item { ShapePath { fillColor: Theme.surface strokeColor: Theme.hair - strokeWidth: 1 + strokeWidth: panel.outlineWidth startX: 0; startY: panel.offsetY - PathLine { x: panelShape.width - panel.activeChamfer; y: panel.offsetY } - PathLine { x: panelShape.width; y: panel.activeChamfer } + PathLine { x: panelShape.width - panel.rightCut; y: panel.offsetY } + PathLine { x: panelShape.width; y: panel.rightChamfer ? panel.activeChamfer : panel.offsetY } PathLine { x: panelShape.width; y: panelShape.height } - PathLine { x: panel.activeChamfer; y: panelShape.height } - PathLine { x: 0; y: panelShape.height - panel.activeChamfer } + PathLine { x: panel.leftCut; y: panelShape.height } + PathLine { x: 0; y: panelShape.height - panel.leftCut } PathLine { x: 0; y: panel.offsetY } } + // Connecting edges — drawn only on a side whose chamfer is off, which is + // exactly where a neighbour butts against this panel. + ShapePath { + fillColor: "transparent" + strokeColor: Theme.accent + strokeWidth: panel.rightChamfer ? 0 : panel.connectorWidth + startX: panelShape.width; startY: panel.offsetY + PathLine { x: panelShape.width; y: panelShape.height } + } + + ShapePath { + fillColor: "transparent" + strokeColor: Theme.accent + strokeWidth: panel.leftChamfer ? 0 : panel.connectorWidth + startX: 0; startY: panel.offsetY + PathLine { x: 0; y: panelShape.height } + } + // Upper left accent line ShapePath { @@ -154,8 +189,8 @@ Item { fillColor: Theme.accent strokeWidth: 0 startX: panelShape.width; startY: panelShape.height - PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height } - PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width - panel.accentLineWidth; y: panelShape.height } + PathLine { x: panelShape.width - panel.accentLineWidth; y: panelShape.height - panel.accentLineThickness } PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness } PathLine { x: panelShape.width; y: panelShape.height } } From 61da7748afd18583c5129a81b1855313ed49a58f Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 04:13:54 +0200 Subject: [PATCH 16/24] chore(terra): unload hypr-chrome, round corners at 25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments out both the plugin and its `plugin.hyprchrome` settings — Hyprland rejects plugin config for a plugin that is not loaded, so the two have to go together. The flake input stays, so re-enabling is two uncommented lines. With the window frames gone, decoration rounding goes 10 -> 25 and rounding_power 2.0 -> 1.0 (previously unset). Both were applied live first via `hyprctl eval 'hl.config{...}'`; plain `hyprctl keyword` is refused by the lua config's non-legacy parser. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- hosts/terra/home/hyprland.nix | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index 01b3e49..04d8a94 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -89,7 +89,8 @@ in wayland.windowManager.hyprland = { enable = true; - plugins = [ inputs.hypr-chrome.packages.${pkgs.stdenv.hostPlatform.system}.default ]; + # Unloaded for now; re-add together with the plugin.hyprchrome settings below. + # plugins = [ inputs.hypr-chrome.packages.${pkgs.stdenv.hostPlatform.system}.default ]; settings = { # ---- colours (from colors.conf) ---- fg_color = { _var = "rgba(eeeeeeff)"; }; @@ -131,7 +132,8 @@ in decoration = { dim_special = 0.3; - rounding = 10; + rounding = 25; + rounding_power = 1.0; blur = { enabled = true; special = true; # blur behind the special workspace @@ -158,16 +160,18 @@ in allow_workspace_cycles = true; }; - plugin.hyprchrome = { - enabled = true; - glow_size = 12; - glow_strength = 0.85; - shadow_size = 24; - shadow_color = lua "bg_color"; - shadow_offset = lua "{ 4, 8 }"; - outline_size = lua "2"; - outline_color = lua "fg_color"; - }; + # Unloaded for now — Hyprland rejects plugin config for a plugin that is + # not loaded, so this stays commented until it goes back in `plugins` above. + # plugin.hyprchrome = { + # enabled = true; + # glow_size = 12; + # glow_strength = 0.85; + # shadow_size = 24; + # shadow_color = lua "bg_color"; + # shadow_offset = lua "{ 4, 8 }"; + # outline_size = lua "2"; + # outline_color = lua "fg_color"; + # }; }; # ---- animations ---- From abccf536f63b7659998f6d19a4fd626b1d36c43d Mon Sep 17 00:00:00 2001 From: luna Date: Sun, 30 Aug 2026 16:54:19 +0000 Subject: [PATCH 17/24] [verified] feat(quickshell): add cyber dock launcher --- dotfiles/quickshell/shell.qml | 3 +- .../quickshell/tests/CyberDockHeadless.qml | 21 ++ .../quickshell/widgets/launcher/CyberDock.qml | 88 +++++ .../widgets/launcher/CyberDockContent.qml | 300 ++++++++++++++++++ hosts/terra/home/hyprland.nix | 2 + 5 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 dotfiles/quickshell/tests/CyberDockHeadless.qml create mode 100644 dotfiles/quickshell/widgets/launcher/CyberDock.qml create mode 100644 dotfiles/quickshell/widgets/launcher/CyberDockContent.qml diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 1336017..3da98de 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -12,7 +12,7 @@ Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. DenseBar {} - // App launcher variants — SUPER CTRL 1–8; variant 8 is the primary HUD. + // App launcher variants — 1–11; variant 8 remains the primary HUD. LauncherStack {} // 1 — left vertical list LauncherGrid {} // 2 — centered icon grid LauncherSpotlight {} // 3 — top-center command bar @@ -23,6 +23,7 @@ Scope { ApplicationLauncher {} // 8 — dense HUD command index (primary) BladeLauncher {} // 9 — asymmetric blade matrix OrbitLauncher {} // 10 — radial targeting arena + CyberDock {} // 11 — cyberpunk bottom cartridge dock Notifications {} VolumeOsd {} diff --git a/dotfiles/quickshell/tests/CyberDockHeadless.qml b/dotfiles/quickshell/tests/CyberDockHeadless.qml new file mode 100644 index 0000000..430b1d8 --- /dev/null +++ b/dotfiles/quickshell/tests/CyberDockHeadless.qml @@ -0,0 +1,21 @@ +import QtQuick +import qs.widgets.launcher + +CyberDockContent { + width: 1440 + height: 272 + query: "" + selectedIndex: 3 + showIcons: false + apps: [ + { name: "Alacritty", genericName: "Terminal", comment: "GPU accelerated command interface", icon: "utilities-terminal" }, + { name: "Vivaldi", genericName: "Browser", comment: "Access web applications", icon: "vivaldi" }, + { name: "Obsidian", genericName: "Knowledge", comment: "Local-first markdown workspace", icon: "obsidian" }, + { name: "Cosmic Files", genericName: "Files", comment: "Browse local storage", icon: "com.system76.CosmicFiles" }, + { name: "Jellyfin", genericName: "Media", comment: "Stream the homelab library", icon: "jellyfin-media-player" }, + { name: "Spotify", genericName: "Music", comment: "Browse and play music", icon: "spotify" }, + { name: "Steam", genericName: "Games", comment: "Launch and manage games", icon: "steam" }, + { name: "Blender", genericName: "3D", comment: "Model and render scenes", icon: "blender" }, + { name: "Visual Studio Code", genericName: "Editor", comment: "Edit and debug projects", icon: "visual-studio-code" } + ] +} diff --git a/dotfiles/quickshell/widgets/launcher/CyberDock.qml b/dotfiles/quickshell/widgets/launcher/CyberDock.qml new file mode 100644 index 0000000..b81ee7c --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/CyberDock.qml @@ -0,0 +1,88 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import qs.widgets.theme + +// Variant 11 — cyberpunk bottom dock inspired by V5's rising carousel. +Scope { + id: root + + property bool active: false + function toggle() { root.active = !root.active; } + + onActiveChanged: { + LauncherState.dockOpen = root.active; + if (root.active) + win.prepareOpen(); + } + + GlobalShortcut { + name: "launcher11" + description: "Toggle app launcher (Cyber Dock)" + onPressed: root.toggle() + } + + PanelWindow { + id: win + visible: root.active || deck.y < height + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: root.active ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + color: Theme.textAlpha(0) + + anchors { bottom: true; left: true; right: true } + margins { bottom: 10; left: 12; right: 12 } + implicitHeight: 290 + + property int selectedIndex: 0 + + function prepareOpen() { + deck.clearSearch(); + selectedIndex = 0; + deck.focusSearch(); + } + + function clampSelection(index) { + return model.apps.length === 0 ? 0 : Math.max(0, Math.min(model.apps.length - 1, index)); + } + function move(delta) { + const count = model.apps.length; + if (count === 0) + return; + selectedIndex = ((selectedIndex + delta) % count + count) % count; + } + function launch(index) { + if (model.launch(index)) + root.active = false; + } + + AppModel { id: model; search: deck.query } + + CyberDockContent { + id: deck + width: 1440 + height: 272 + x: (parent.width - width) / 2 + y: root.active ? parent.height - height : parent.height + 4 + scale: Math.min(1, (parent.width - 16) / width) + transformOrigin: Item.Bottom + apps: model.apps + selectedIndex: win.selectedIndex + + Behavior on y { + NumberAnimation { + duration: 240 + easing.type: root.active ? Easing.OutCubic : Easing.InCubic + } + } + + onSelectionRequested: index => win.selectedIndex = win.clampSelection(index) + onMoveRequested: delta => win.move(delta) + onLaunchRequested: index => win.launch(index) + onDismissRequested: root.active = false + } + } +} diff --git a/dotfiles/quickshell/widgets/launcher/CyberDockContent.qml b/dotfiles/quickshell/widgets/launcher/CyberDockContent.qml new file mode 100644 index 0000000..fff582b --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/CyberDockContent.qml @@ -0,0 +1,300 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import qs.widgets.theme + +// Headlessly renderable visual core for variant 11. Inspired by V5's bottom +// deck and horizontal carousel, but owns a denser industrial cyberpunk chassis. +Item { + id: root + + property var apps: [] + property int selectedIndex: 0 + property bool showIcons: true + property alias query: dockInput.text + + readonly property var selectedApp: apps.length > 0 && selectedIndex >= 0 && selectedIndex < apps.length + ? apps[selectedIndex] : null + + signal selectionRequested(int index) + signal moveRequested(int delta) + signal launchRequested(int index) + signal dismissRequested + + function focusSearch() { dockInput.forceActiveFocus(); } + function clearSearch() { dockInput.text = ""; } + + implicitWidth: 1440 + implicitHeight: 272 + + component MicroText: Text { + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 7 + font.letterSpacing: 0.9 + elide: Text.ElideRight + } + + Rectangle { anchors.fill: parent; color: Theme.textAlpha(0) } + + // Main dock chassis with clipped upper corners and heavier lower edge. + Shape { + id: chassis + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: Theme.surface + strokeColor: Theme.hair + strokeWidth: 1 + startX: 28; startY: 0 + PathLine { x: chassis.width - 28; y: 0 } + PathLine { x: chassis.width; y: 28 } + PathLine { x: chassis.width; y: chassis.height } + PathLine { x: 0; y: chassis.height } + PathLine { x: 0; y: 28 } + PathLine { x: 28; y: 0 } + } + + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: 28; startY: 0 + PathLine { x: 250; y: 0 } + PathLine { x: 244; y: 4 } + PathLine { x: 32; y: 4 } + PathLine { x: 4; y: 32 } + PathLine { x: 4; y: 96 } + PathLine { x: 0; y: 96 } + PathLine { x: 0; y: 28 } + PathLine { x: 28; y: 0 } + } + + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: 0; startY: chassis.height - 6 + PathLine { x: chassis.width; y: chassis.height - 6 } + PathLine { x: chassis.width; y: chassis.height } + PathLine { x: 0; y: chassis.height } + PathLine { x: 0; y: chassis.height - 6 } + } + } + + // Top caution seam. + Row { + x: 270; y: 1; spacing: 5 + Repeater { + model: 58 + Rectangle { + required property int index + width: 10; height: 3 + color: index % 4 === 0 ? Theme.accent : Theme.hair + transform: Rotation { angle: -32 } + } + } + } + + // Left system coupler. + Item { + id: coupler + x: 18; y: 18 + width: 156; height: 184 + + Rectangle { anchors.centerIn: parent; width: 112; height: 112; radius: 56; color: Theme.textAlpha(0.012); border.width: 1; border.color: Theme.accentAlpha(0.55) } + Rectangle { anchors.centerIn: parent; width: 78; height: 78; radius: 39; color: Theme.textAlpha(0); border.width: 1; border.color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 118; height: 1; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 1; height: 118; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 38; height: 38; color: Theme.accentAlpha(0.16); border.width: 1; border.color: Theme.accent; transform: Rotation { angle: 45 } } + Text { anchors.centerIn: parent; text: "11"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 13; font.bold: true } + MicroText { anchors.horizontalCenter: parent.horizontalCenter; anchors.bottom: parent.bottom; anchors.bottomMargin: 15; text: "DOCK BUS"; color: Theme.accent } + } + + Text { + x: 24; y: 16 + text: "CYBER//DOCK" + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 9 + font.bold: true + font.letterSpacing: 1 + } + + // Horizontal cartridge conveyor. + ListView { + id: cartridgeList + x: 178; y: 20 + width: root.width - 356 + height: 180 + orientation: ListView.Horizontal + model: root.apps + currentIndex: root.selectedIndex + spacing: 8 + clip: true + boundsBehavior: Flickable.StopAtBounds + onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + + delegate: MouseArea { + id: cartridge + required property int index + required property var modelData + readonly property bool selected: index === root.selectedIndex + + width: selected ? 128 : 108 + height: selected ? 176 : 148 + y: selected ? 0 : 24 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.selectionRequested(index) + onClicked: root.launchRequested(index) + + Behavior on y { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + Behavior on height { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + + Shape { + id: cartridgeShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: cartridge.selected ? Theme.selection : Theme.textAlpha(0.022) + strokeColor: cartridge.selected ? Theme.accent : Theme.hair + strokeWidth: cartridge.selected ? 2 : 1 + startX: 12; startY: 0 + PathLine { x: cartridgeShape.width - 8; y: 0 } + PathLine { x: cartridgeShape.width; y: 8 } + PathLine { x: cartridgeShape.width; y: cartridgeShape.height - 14 } + PathLine { x: cartridgeShape.width - 14; y: cartridgeShape.height } + PathLine { x: 0; y: cartridgeShape.height } + PathLine { x: 0; y: 12 } + PathLine { x: 12; y: 0 } + } + ShapePath { + fillColor: cartridge.selected ? Theme.accent : Theme.textAlpha(0.12) + strokeWidth: 0 + startX: 0; startY: cartridgeShape.height - 6 + PathLine { x: cartridgeShape.width - 14; y: cartridgeShape.height - 6 } + PathLine { x: cartridgeShape.width - 20; y: cartridgeShape.height } + PathLine { x: 0; y: cartridgeShape.height } + PathLine { x: 0; y: cartridgeShape.height - 6 } + } + } + + MicroText { x: 9; y: 8; text: "C" + String(cartridge.index + 1).padStart(2, "0"); color: cartridge.selected ? Theme.accent : Theme.muted } + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: cartridge.selected ? 30 : 27 + width: cartridge.selected ? 68 : 54 + height: width + color: cartridge.selected ? Theme.accentAlpha(0.12) : Theme.textAlpha(0.018) + border.width: 1 + border.color: cartridge.selected ? Theme.accent : Theme.hair + IconImage { visible: root.showIcons; anchors.centerIn: parent; implicitSize: cartridge.selected ? 48 : 38; source: root.showIcons ? Quickshell.iconPath(cartridge.modelData.icon || "application-x-executable", "application-x-executable") : "" } + Text { visible: !root.showIcons; anchors.centerIn: parent; text: String(cartridge.modelData.name || "?").charAt(0).toUpperCase(); color: cartridge.selected ? Theme.accent : Theme.text; font.family: Theme.displayFont; font.pixelSize: cartridge.selected ? 23 : 18; font.bold: true } + } + Text { + x: 8; anchors.bottom: generic.top; anchors.bottomMargin: 4 + width: parent.width - 16 + text: cartridge.modelData.name || "UNKNOWN" + color: cartridge.selected ? Theme.accent : Theme.text + font.family: Theme.displayFont + font.pixelSize: cartridge.selected ? 10 : 9 + font.bold: cartridge.selected + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + MicroText { + id: generic + x: 8; anchors.bottom: parent.bottom; anchors.bottomMargin: 13 + width: parent.width - 16 + text: cartridge.modelData.genericName || "APPLICATION" + horizontalAlignment: Text.AlignHCenter + } + } + + Text { anchors.centerIn: parent; visible: root.apps.length === 0; text: "NO CARTRIDGES MATCH QUERY"; color: Theme.muted; font.family: Theme.microFont; font.pixelSize: 9; font.letterSpacing: 1.2 } + } + + // Right telemetry clamp. + Item { + id: clamp + anchors.right: parent.right + anchors.rightMargin: 18 + y: 20; width: 150; height: 180 + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0.018) + strokeColor: Theme.hair + strokeWidth: 1 + startX: 0; startY: 0 + PathLine { x: 128; y: 0 } + PathLine { x: 150; y: 22 } + PathLine { x: 150; y: 180 } + PathLine { x: 0; y: 180 } + PathLine { x: 0; y: 0 } + } + } + MicroText { x: 12; y: 12; text: "ACTIVE SLOT"; color: Theme.accent } + Text { x: 12; y: 35; width: parent.width - 24; text: String(Math.max(0, root.selectedIndex + 1)).padStart(3, "0"); color: Theme.text; font.family: Theme.displayFont; font.pixelSize: 28; font.bold: true } + MicroText { x: 12; y: 75; width: parent.width - 24; text: root.selectedApp ? root.selectedApp.name : "NO TARGET"; color: Theme.text } + MicroText { x: 12; y: 96; width: parent.width - 24; text: root.apps.length + " AVAILABLE" } + Row { x: 12; y: 120; spacing: 3; Repeater { model: 12; Rectangle { required property int index; width: 7; height: 4; color: index < Math.min(12, root.apps.length) ? Theme.accent : Theme.hair } } } + MicroText { x: 12; anchors.bottom: parent.bottom; anchors.bottomMargin: 12; text: "BUS // ARMED"; color: Theme.accent } + } + + // Bottom command spine. + Shape { + id: commandSpine + x: 176; y: 211 + width: root.width - 352; height: 48 + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.accentAlpha(0.10) + strokeColor: Theme.accent + strokeWidth: 1 + startX: 14; startY: 0 + PathLine { x: commandSpine.width; y: 0 } + PathLine { x: commandSpine.width - 14; y: commandSpine.height } + PathLine { x: 0; y: commandSpine.height } + PathLine { x: 14; y: 0 } + } + } + Text { x: 196; y: 221; text: ">_"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 17; font.bold: true } + TextInput { + id: dockInput + x: 230; y: 219; width: root.width - 650; height: 34 + verticalAlignment: TextInput.AlignVCenter + color: Theme.text + selectionColor: Theme.accent + selectedTextColor: Theme.surface + font.family: Theme.displayFont + font.pixelSize: 14 + font.letterSpacing: 1 + clip: true + onTextChanged: root.selectionRequested(0) + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Right: case Qt.Key_Tab: root.moveRequested(1); event.accepted = true; break; + case Qt.Key_Left: case Qt.Key_Backtab: root.moveRequested(-1); event.accepted = true; break; + case Qt.Key_PageDown: root.moveRequested(5); event.accepted = true; break; + case Qt.Key_PageUp: root.moveRequested(-5); event.accepted = true; break; + case Qt.Key_Return: case Qt.Key_Enter: root.launchRequested(root.selectedIndex); event.accepted = true; break; + case Qt.Key_Escape: root.dismissRequested(); event.accepted = true; break; + } + } + Text { anchors.fill: parent; verticalAlignment: Text.AlignVCenter; visible: dockInput.text.length === 0; text: "FILTER DOCK CARTRIDGES"; color: Theme.muted; font: dockInput.font } + } + MicroText { x: root.width - 492; y: 228; text: "← → SELECT // ENTER LAUNCH // ESC RETRACT"; color: Theme.accent } + + // Lower rail hardware. + Row { + x: 18; anchors.bottom: parent.bottom; anchors.bottomMargin: 1; spacing: 4 + Repeater { model: 70; Rectangle { required property int index; width: 10; height: 4; color: index % 5 === 0 ? Theme.accent : Theme.hair; transform: Rotation { angle: -32 } } } + } +} diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index e5b6eb1..462c1bf 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -202,6 +202,8 @@ in ++ [ # variant 10 (CTRL+0 is already the quickshell restart binding below) (bind "SUPER + CTRL + SHIFT + 0" (dsp.global "quickshell:launcher10")) + # variant 11 cyber dock + (bind "SUPER + CTRL + SHIFT + D" (dsp.global "quickshell:launcher11")) # restart quickshell (also starts it if not running) (bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs")) # toggle the Slant sidebar From 3aecaf9de56ed6f1019d236a1ec955fff22dc922 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sun, 30 Aug 2026 22:41:30 +0200 Subject: [PATCH 18/24] feat(quickshell): add HyprChromeShell, workspaces panel, persistent backdrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HyprChromeShell owns what the rail's surfaces have to agree on: the target screen, the density and the shortcut that toggles it, and the bar/backdrop layer PAIR. That pair is why the wrapper exists — the backdrop has to sit exactly one layer below the bar in both densities, and split across two files the two assignments drifted apart and put the scrim over the bar. HyprChromeBar is now just its own surface; ChromeBackdrop takes its layer. The backdrop renders in both densities instead of fading out: full-screen while expanded, scoped to the band the rail occupies while collapsed, with a proportional tail fading off the bottom of that band. Both gradients end on their solid color when there is no fade — with a zero-length ramp the start and end stops coincide, Qt sorts stops unstably, and the transparent one winning turned "no fade" into a ramp across the whole scrim. WorkspacesPanel, between host and vitals, shows the active workspace per monitor: one accent box per output collapsed, the full strip with the shown one filled expanded, lined up in a column behind a fixed-width name cell. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U19X5LGTxtq4pb4jdNVivv --- .../hyprchrome/widgets/ChromeBackdrop.qml | 162 +++++++++--- .../hyprchrome/widgets/HyprChromeBar.qml | 104 +++----- .../hyprchrome/widgets/HyprChromeShell.qml | 95 +++++++ .../hyprchrome/widgets/panels/BarPanel.qml | 24 +- .../widgets/workspaces/WorkspacesPanel.qml | 237 ++++++++++++++++++ dotfiles/quickshell/shell.qml | 2 +- 6 files changed, 507 insertions(+), 117 deletions(-) create mode 100644 dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml create mode 100644 dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml diff --git a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml index da5857a..b44d15c 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml @@ -5,12 +5,21 @@ import Quickshell.Wayland import QtQuick import qs.hyprchrome.theme -// Full-screen scrim behind the bar: dims the desktop and lays the dense bar's -// drafting grid over it while the rail is expanded. +// Scrim behind the bar: dims the desktop and lays the dense bar's drafting grid +// over it. It is always on screen — only how far DOWN it reaches changes. +// Expanded, it covers the whole output; collapsed, it shrinks to the band the +// rail itself occupies, so the bar keeps its backing without the desktop being +// dimmed. That collapsed band ends in a fade rather than a cut, so there is no +// hard line across the wallpaper; expanded there is nothing to fade against — +// the scrim runs to the bottom of the output. // -// Sits on the TOP layer while the bar itself is on OVERLAY. Two surfaces on the -// same layer stack by creation order, which is not something to rely on; one -// layer apart is a guarantee — above ordinary windows, below the bar. +// Its layer arrives from HyprChromeShell, which derives it together with the +// bar's so the two stay exactly one level apart — see that file for why the +// pair cannot be split. Collapsed that puts it on BACKGROUND, shared with the +// wallpaper (hyprpaper), where order IS creation order: if the wallpaper is +// restarted under a running shell it comes up on top and the collapsed band +// goes with it. A `layerrule = order` in the Hyprland config is the fix if that +// ever bites. // // It reserves nothing and takes no input: the mask is an empty Region, so // clicks land on whatever is underneath rather than on the scrim. @@ -18,20 +27,71 @@ PanelWindow { id: backdrop property bool active: true - property real dim: 0.55 - property int gridSpacing: 120 + + // Which layer to sit on. An int rather than a private decision: it is half + // of a pair with the bar's, so the shell derives both. + property int wlrLayer: WlrLayer.Top + + property real dim: 0.75 + property int gridSpacing: 60 + + // Height of the collapsed rail, including its margins — the band the scrim + // stays behind while the bar is collapsed. Driven by the host, which is the + // only thing that knows what the panels currently measure. + property real barHeight: 0 + + // Share of the COLLAPSED band spent fading to nothing at the bottom, as a + // fraction rather than a pixel length so the tail scales with whatever the + // rail currently measures. Expanded there is no fade at all: the scrim runs + // to the bottom of the output, where the screen edge ends it. + property real fade: 0.5 + readonly property real fadeAmount: Math.max(0, Math.min(0.95, backdrop.fade)) + + // The share actually in force. Animated rather than switched, so expanding + // shrinks the tail away as the scrim grows instead of dropping a hard edge + // onto the desktop the moment the state flips. + property real fadeSpan: backdrop.active ? 0 : backdrop.fadeAmount + // Position, in fractions of revealHeight, where the falloff starts. 1 while + // expanded, i.e. no falloff. + readonly property real fadeStart: 1 - backdrop.fadeSpan + + // Collapsed, the SOLID part is the bar band and the tail hangs below it, + // hence the division: barHeight is what must survive the fade, not what the + // whole scrim measures. Sized off the static fadeAmount, not the animated + // fadeSpan — the height and the ramp have to animate independently or each + // would be chasing the other. Bound rather than readonly so the Behavior + // below can animate the state change. + property real revealHeight: backdrop.active + ? backdrop.height + : Math.min(backdrop.height, backdrop.barHeight / (1 - backdrop.fadeAmount)) + + // Both matched to the bar's own collapse so the scrim and the panels + // resolve together rather than one trailing the other. + Behavior on revealHeight { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + Behavior on fadeSpan { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } // The dense bar drew this grid at 0.018 against its own near-black panel. // Over a 55% scrim on top of lit windows that is invisible, so it is a // knob rather than a constant. - property real gridOpacity: 0.1 + property real gridOpacity: 0.15 // The accent with its saturation pulled back: warm enough to read as part // of the palette, not so loud that a full-screen grid competes with the // bar. Derived rather than a literal so it tracks a palette change. // Registration crosses sit on every other intersection of the grid. property color crossColor: Theme.muted - property real crossOpacity: 0.35 + property real crossOpacity: 0.45 property int crossSize: 20 // Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd @@ -45,10 +105,42 @@ PanelWindow { Theme.accent.hslLightness, 1) - visible: scrim.opacity > 0 + // Fade targets keep the source RGB and drop only the alpha: interpolating + // toward a plain "transparent" would run the gradient through black. + readonly property color gridSolid: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g, + backdrop.gridColor.b, backdrop.gridOpacity) + readonly property color gridClear: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g, + backdrop.gridColor.b, 0) + readonly property color dimSolid: Qt.rgba(Theme.surface.r, Theme.surface.g, + Theme.surface.b, backdrop.dim) + readonly property color dimClear: Qt.rgba(Theme.surface.r, Theme.surface.g, + Theme.surface.b, 0) + + // What the gradients END on. With no fade the ramp has zero length, so its + // start stop and its end stop sit on the SAME position — and Qt sorts stops + // with an unstable sort, leaving which of the two wins undefined. It picked + // the transparent one, which turned "no fade" into a ramp across the entire + // scrim. Ending on the solid color instead makes the degenerate case + // unambiguous: all three stops match and the fill is flat. + readonly property color gridEnd: backdrop.fadeSpan > 0 ? backdrop.gridClear : backdrop.gridSolid + readonly property color dimEnd: backdrop.fadeSpan > 0 ? backdrop.dimClear : backdrop.dimSolid + + // Same ramp as the gradients above, for the marks that are placed at a + // single y and so cannot carry a gradient of their own. Reads revealHeight + // and fadeStart, so bindings that call it re-evaluate when either changes. + function fadeAt(y: real): real { + const start = backdrop.revealHeight * backdrop.fadeStart; + if (y <= start) + return 1; + if (y >= backdrop.revealHeight) + return 0; + return 1 - (y - start) / (backdrop.revealHeight - start); + } + + visible: backdrop.revealHeight > 0 WlrLayershell.namespace: "hyprchrome-scrim" - WlrLayershell.layer: WlrLayer.Top + WlrLayershell.layer: backdrop.wlrLayer WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore color: "transparent" @@ -68,26 +160,25 @@ PanelWindow { Item { id: scrim - anchors.fill: parent - opacity: backdrop.active ? 1 : 0 - - // Matched to the bar's own collapse so the scrim and the panels resolve - // together rather than one trailing the other. - Behavior on opacity { - NumberAnimation { - duration: 200 - easing.type: Easing.OutCubic - } - } + // Only as tall as the scrim currently reaches; everything inside is + // laid out against this, so shrinking it scopes the whole drawing + // rather than just clipping it. + width: backdrop.width + height: backdrop.revealHeight + clip: true Rectangle { anchors.fill: parent - color: Theme.surface - opacity: backdrop.dim + gradient: Gradient { + GradientStop { position: 0; color: backdrop.dimSolid } + GradientStop { position: backdrop.fadeStart; color: backdrop.dimSolid } + GradientStop { position: 1; color: backdrop.dimEnd } + } } // Faint drafting grid; no gradient and deliberately subordinate to - // whatever is showing through it. + // whatever is showing through it — except at the bottom, where it has to + // fade with the scrim it sits on. Repeater { model: Math.ceil(scrim.width / backdrop.gridSpacing) @@ -97,22 +188,29 @@ PanelWindow { x: index * backdrop.gridSpacing width: 1 height: scrim.height - color: backdrop.gridColor - opacity: backdrop.gridOpacity + gradient: Gradient { + GradientStop { position: 0; color: backdrop.gridSolid } + GradientStop { position: backdrop.fadeStart; color: backdrop.gridSolid } + GradientStop { position: 1; color: backdrop.gridEnd } + } } } Repeater { - model: Math.ceil(scrim.height / backdrop.gridSpacing) + // Modelled against the whole output, not the current reveal, so a + // collapse fades the rules out where they stand instead of + // restocking the Repeater on every animation frame. + model: Math.ceil(backdrop.height / backdrop.gridSpacing) Rectangle { required property int index + readonly property real line: index * backdrop.gridSpacing - y: index * backdrop.gridSpacing + y: line width: scrim.width height: 1 color: backdrop.gridColor - opacity: backdrop.gridOpacity + opacity: backdrop.gridOpacity * backdrop.fadeAt(line) } } @@ -139,7 +237,9 @@ PanelWindow { y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2) width: backdrop.crossSize height: backdrop.crossSize - opacity: backdrop.crossOpacity + // Sampled at the intersection the mark registers against, not at + // its own top edge, so a cross fades as one piece. + opacity: backdrop.crossOpacity * backdrop.fadeAt(row * backdrop.gridSpacing * 2) Rectangle { // Placed with the same Math.floor the item's own offset uses. diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml index b3c810b..16047bb 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml @@ -1,72 +1,35 @@ import QtQuick -import QtQuick.Shapes import QtQuick.Layouts import Quickshell -import Quickshell.Hyprland import Quickshell.Wayland -import qs.hyprchrome.widgets import qs.hyprchrome.widgets.host import qs.hyprchrome.widgets.vitals +import qs.hyprchrome.widgets.workspaces import qs.hyprchrome.widgets.tray -Scope { - id: root - - // Monitor the rail lives on. Falls back to the FIRST connected screen when - // the name matches nothing, so the bar still appears on a single-monitor - // session or after a cable swap (DebugWindow falls back to the last one - // instead — it wants the secondary). - property string screenName: "DP-2" - - // Quickshell.screens is a QML list, not a JS array — no .find() on it. - readonly property var targetScreen: { - const screens = Quickshell.screens; - if (screens.length === 0) - return null; - for (let i = 0; i < screens.length; i++) { - if (screens[i].name === root.screenName) - return screens[i]; - } - return screens[0]; - } - - // Density is a property of the BAR: every panel follows it, so the whole rail - // expands and collapses as one. Panels keep their own animation; only the - // decision is centralised here. - property bool expanded: true - - function toggle() { - root.expanded = !root.expanded; - } - - // SUPER A — see hosts/terra/home/hyprland.nix. - GlobalShortcut { - name: "chrome" - description: "Expand or collapse the hyprchrome bar" - onPressed: root.toggle() - } - - // Scrim first: it is a layer below the bar, so stacking does not depend on - // creation order, but keeping the declaration order the same as the visual - // order costs nothing. - ChromeBackdrop { - screen: root.targetScreen - active: root.expanded - } - - PanelWindow { +// The dense status rail itself: one layer surface holding the panel row. +// +// It owns nothing shared — the screen, the density and its layer all arrive +// from HyprChromeShell, which is also what keeps this surface and the backdrop +// one layer apart. What it does own is its own measurement: `contentHeight` is +// the settled height of the current density, which the shell hands to the +// backdrop and which sizes the exclusive zone. +PanelWindow { id: window - screen: root.targetScreen - visible: root.targetScreen !== null + // Density for every panel on the rail; driven by the shell. + property bool expanded: false + + // Which layer to sit on. An int rather than a private decision: it is half + // of a pair with the backdrop's, so the shell derives both. See + // HyprChromeShell. + property int wlrLayer: WlrLayer.Overlay - // One layer above the scrim, so the stacking is guaranteed rather than - // dependent on surface creation order. See ChromeBackdrop. // Own namespace so a layerrule can exempt the rail from Hyprland's layer // animation without also catching the launchers, which share the default // "quickshell" namespace and do want their fade. WlrLayershell.namespace: "hyprchrome-bar" - WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.layer: window.wlrLayer property int margin: 12 @@ -77,8 +40,8 @@ Scope { // // The zone still follows the target height, so tiled windows reflow once // per toggle, at the start, and slide while the panels animate. - readonly property real expandedContent: Math.max(hostPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2 - readonly property real contentHeight: Math.max(hostPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2 + readonly property real expandedContent: Math.max(hostPanel.expandedHeight, workspacesPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2 + readonly property real contentHeight: Math.max(hostPanel.targetHeight, workspacesPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2 implicitHeight: Math.round(window.expandedContent) @@ -105,21 +68,35 @@ Scope { HostPanel { id: hostPanel - expanded: root.expanded - // Vitals butts against its right edge; the left end of the row is free. + expanded: window.expanded + // Workspaces butt against its right edge; the left end of the row is free. rightChamfer: false toggleOnClick: false - Layout.preferredWidth: 350 + Layout.preferredWidth: 400 + Layout.fillHeight: true + } + + WorkspacesPanel { + id: workspacesPanel + + expanded: window.expanded + // Mid-rail: a panel on either side, so neither corner is cut. + leftChamfer: false + rightChamfer: false + + toggleOnClick: false + // No preferred width: the panel sizes itself to however many outputs + // the session has, the same way the tray sizes itself to its items. Layout.fillHeight: true } VitalsPanel { id: vitalsPanel - expanded: root.expanded - // Host on the left, the spacer on the right — and a spacer is not a - // panel, so that edge keeps its cut. + expanded: window.expanded + // Workspaces on the left, the spacer on the right — and a spacer is not + // a panel, so that edge keeps its cut. leftChamfer: false toggleOnClick: false @@ -134,10 +111,9 @@ Scope { TrayPanel { id: trayPanel - expanded: root.expanded + expanded: window.expanded toggleOnClick: false Layout.fillHeight: true } } - } } diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml new file mode 100644 index 0000000..b2cfbd5 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import qs.hyprchrome.widgets + +// The hyprchrome shell: owns everything the rail's surfaces have to agree on, +// and instantiates them. +// +// State lives here rather than in any one surface because more than one of them +// reads it, and a second reader is what turns a local property into shared +// state. Three things qualify so far: +// +// * which monitor the shell lives on — every surface has to pick the same one +// * the density — the whole rail expands and collapses as one, so the toggle +// and the shortcut that drives it belong to the shell, not to the bar +// * the layer PAIR — the backdrop must sit exactly one layer below the bar in +// both densities. Two surfaces on the same layer stack by creation order, +// which is not something to rely on; one layer apart is a guarantee. Split +// across two files those two assignments drifted apart and the scrim ended +// up over the bar, so they are derived together here and passed down. +// +// A future widget joins by taking `targetScreen` and `expanded` the same way. +Scope { + id: shell + + // Monitor the rail lives on. Falls back to the FIRST connected screen when + // the name matches nothing, so the bar still appears on a single-monitor + // session or after a cable swap (DebugWindow falls back to the last one + // instead — it wants the secondary). + property string screenName: "DP-2" + + // Quickshell.screens is a QML list, not a JS array — no .find() on it. + readonly property var targetScreen: { + const screens = Quickshell.screens; + if (screens.length === 0) + return null; + for (let i = 0; i < screens.length; i++) { + if (screens[i].name === shell.screenName) + return screens[i]; + } + return screens[0]; + } + + // Density is a property of the SHELL: every panel follows it, so the whole + // rail expands and collapses as one. Panels keep their own animation; only + // the decision is centralised here. + property bool expanded: false + + function toggle() { + shell.expanded = !shell.expanded; + } + + // Expanded the rail is over everything; collapsed it drops below ordinary + // windows. BOTTOM rather than BACKGROUND for the collapsed bar: it is the + // lowest level that still leaves a layer underneath for the backdrop, and + // it keeps the rail off the wallpaper's own level. + readonly property int barLayer: shell.expanded ? WlrLayer.Overlay : WlrLayer.Bottom + readonly property int backdropLayer: shell.expanded ? WlrLayer.Top : WlrLayer.Background + + // SUPER A — see hosts/terra/home/hyprland.nix. + GlobalShortcut { + name: "chrome" + description: "Expand or collapse the hyprchrome bar" + onPressed: shell.toggle() + } + + // Backdrop first: it is a layer below the bar, so stacking does not depend + // on creation order, but keeping the declaration order the same as the + // visual order costs nothing. + ChromeBackdrop { + screen: shell.targetScreen + active: shell.expanded + wlrLayer: shell.backdropLayer + + // Collapsed, the scrim only backs the rail, so it needs the band the + // rail occupies. contentHeight is the SETTLED height for the current + // state — it jumps once per toggle rather than tracking the panels + // frame by frame, so the backdrop animates the change itself instead of + // chasing a value that is already being animated. + barHeight: bar.contentHeight + } + + HyprChromeBar { + id: bar + + screen: shell.targetScreen + // Set here, not from the window's own `screen`: reading that inside + // `visible` is circular — a hidden window has no screen to report. + visible: shell.targetScreen !== null + expanded: shell.expanded + wlrLayer: shell.barLayer + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml index 1c711b9..aee1063 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml @@ -82,7 +82,7 @@ Item { // Where the summary sits when collapsed: just past the slug chip, and // centred in the strip the panel collapses to, which is sized to the // summary itself (or the height floor, whichever is taller). - readonly property real briefLeft: panel.headerContentWidth + readonly property real briefLeft: panel.headerContentWidth + 6 readonly property real collapsedHeight: Math.max(panel.minimumHeight, brief.height + panel.headerPadding * 2) readonly property real briefTop: Math.round((panel.collapsedHeight - brief.height) / 2) @@ -154,24 +154,6 @@ Item { PathLine { x: 0; y: panel.offsetY } } - // Connecting edges — drawn only on a side whose chamfer is off, which is - // exactly where a neighbour butts against this panel. - ShapePath { - fillColor: "transparent" - strokeColor: Theme.accent - strokeWidth: panel.rightChamfer ? 0 : panel.connectorWidth - startX: panelShape.width; startY: panel.offsetY - PathLine { x: panelShape.width; y: panelShape.height } - } - - ShapePath { - fillColor: "transparent" - strokeColor: Theme.accent - strokeWidth: panel.leftChamfer ? 0 : panel.connectorWidth - startX: 0; startY: panel.offsetY - PathLine { x: 0; y: panelShape.height } - } - // Upper left accent line ShapePath { @@ -206,7 +188,7 @@ Item { y: Math.round((panel.headerHeight - height) / 2) // Puts the title where the accent line ends: chip + headerPadding on // both sides of it. - spacing: panel.headerPadding + spacing: panel.headerPadding + 6 // Header slug — the one piece that survives a collapse, so the strip // still says which panel it is. @@ -223,7 +205,7 @@ Item { text: panel.panelId color: Theme.surface font.family: Theme.microFont - font.pixelSize: 9 + font.pixelSize: 11 font.bold: true } } diff --git a/dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml new file mode 100644 index 0000000..71f9302 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml @@ -0,0 +1,237 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import QtQuick +import qs.hyprchrome.theme +import qs.hyprchrome.widgets.panels + +// Which workspace each monitor is currently showing, in the hyprchrome panel +// chrome, in both of BarPanel's densities. +// +// One indicator in both: a box carrying the workspace's name, filled accent +// while its monitor is showing it and outlined otherwise. Collapsed, each +// output gets exactly one — the workspace it is on. Expanded, it gets the whole +// strip it cycles through, at a larger cell, with the shown one filled. +// +// Expanded, the strips line up in a column: the output name sits in a +// fixed-width cell, so the boxes start at the same x on every line regardless +// of how long a connector name is. +// +// Both densities read the same two models, so they cannot disagree. Hyprland's +// own distinction is kept: `active` is the workspace its monitor is showing +// (one per output), `focused` is the single one taking input — so the fill +// marks the shown workspace and the accent marker marks where the keyboard is. +// +// Display only: workspaces expose activate(), but BarPanel's density toggle +// covers the whole panel, so a chip could not receive the click anyway. +BarPanel { + id: panel + + panelId: "WKS" + title: "WORKSPACES" + meta: panel.monitorCount + (panel.monitorCount === 1 ? " OUTPUT" : " OUTPUTS") + + readonly property int monitorCount: Hyprland.monitors.values.length + + // Sizes itself horizontally, like the tray: how many outputs a session has + // is not something the bar can hardcode. + implicitWidth: Math.max(panel.headerMinWidth, + panel.briefLeft + brief.implicitWidth + panel.padding, + panel.padding * 2 + outputs.implicitWidth) + + // Height of one expanded output line; every cell on it centres against this. + readonly property int lineHeight: 26 + + // Width of the output-name cell, which is what makes the strips align. + // Fixed rather than measured: a connector name is "DP-2" or "HDMI-A-1", and + // the alternative is probing every name's rendered width to take a maximum, + // which costs a hidden Text per output to save nothing. Anything longer + // elides. + readonly property int nameWidth: 64 + + // What to call a workspace. Hyprland numbers them, but a named workspace + // carries its name instead and a scratchpad arrives as "special:" — + // the prefix is noise once it is sitting next to a monitor's name. + function label(ws): string { + if (!ws) + return "--"; + const name = ws.name ?? ""; + if (name.startsWith("special:")) + return name.slice(8).toUpperCase(); + return (name.length > 0 ? name : String(ws.id)).toUpperCase(); + } + + // The ordinary workspaces on one output, lowest id first. Specials share + // the same list under negative ids: they show up as the active workspace + // when one is open, but never as a slot in the strip, which is meant to be + // the fixed set the output cycles through. + function slots(monitor): var { + return Hyprland.workspaces.values + .filter(ws => ws.monitor === monitor && ws.id > 0) + .sort((a, b) => a.id - b.id); + } + + // Collapsed: output name and the one box it is showing. + summary: Row { + id: brief + + spacing: 14 + + Repeater { + model: Hyprland.monitors + + Row { + id: briefOutput + + required property HyprlandMonitor modelData + + spacing: 8 + height: 18 + + // A Row aligns its children by their tops only, so the label + // takes the box's height and centres its text in it. + Text { + text: briefOutput.modelData.name + color: briefOutput.modelData.focused ? Theme.accent : Theme.muted + font.family: Theme.microFont + font.pixelSize: 11 + font.letterSpacing: 1.2 + height: parent.height + verticalAlignment: Text.AlignVCenter + } + + Chip { + anchors.verticalCenter: parent.verticalCenter + modelData: briefOutput.modelData.activeWorkspace + // Filled by construction: this box IS the output's active + // workspace, so it does not wait on the flag that says so. + shown: true + cell: 16 + } + } + } + + Text { + visible: panel.monitorCount === 0 + text: "NO OUTPUTS" + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 1.2 + height: 18 + verticalAlignment: Text.AlignVCenter + } + } + + // Expanded: one line per output, strips aligned. + Column { + id: outputs + + spacing: 4 + + Repeater { + model: Hyprland.monitors + + Output {} + } + + Text { + visible: panel.monitorCount === 0 + text: "NO OUTPUTS" + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 1.2 + height: panel.lineHeight + verticalAlignment: Text.AlignVCenter + } + } + + // One output: focus marker, name, then its workspace strip. Every cell is + // lineHeight tall and centres its own content, so the pieces sit on one + // line and the same cell widths repeat down the column. + component Output: Row { + id: output + + required property HyprlandMonitor modelData + + spacing: 10 + + // Marker rather than a colored name: the focused output has to be + // findable without reading anything. + Rectangle { + width: 3 + height: panel.lineHeight + color: output.modelData.focused ? Theme.accent : Theme.hair + } + + Item { + width: panel.nameWidth + height: panel.lineHeight + + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + text: output.modelData.name + color: output.modelData.focused ? Theme.accent : Theme.muted + font.family: Theme.microFont + font.pixelSize: 10 + font.letterSpacing: 1.4 + elide: Text.ElideRight + } + } + + Item { + width: strip.implicitWidth + height: panel.lineHeight + + Row { + id: strip + + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Repeater { + model: panel.slots(output.modelData) + + Chip { + cell: 22 + } + } + } + } + } + + // The indicator, at whatever size the density asks for: filled accent while + // its output is showing that workspace, outlined otherwise. + component Chip: Rectangle { + id: chip + + required property HyprlandWorkspace modelData + + // Box height; the width grows with the label and the type scales with + // the box, so one component covers both densities. + property int cell: 16 + property bool shown: chip.modelData?.active ?? false + + readonly property bool urgent: chip.modelData?.urgent ?? false + + width: Math.max(chip.cell, chipText.implicitWidth + chip.cell / 2) + height: chip.cell + color: chip.shown ? Theme.accent : "transparent" + border.width: 1 + border.color: chip.urgent ? Theme.hot : chip.shown ? Theme.accent : Theme.hair + + Text { + id: chipText + + anchors.centerIn: parent + text: panel.label(chip.modelData) + color: chip.shown ? Theme.surface : chip.urgent ? Theme.hot : Theme.muted + font.family: Theme.microFont + font.pixelSize: 11 + font.bold: chip.shown + } + } +} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index e3415d7..da3257d 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -18,7 +18,7 @@ import qs.hyprchrome.widgets.vitals Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. - HyprChromeBar {} + HyprChromeShell {} // App launcher variants — 1–11; variant 8 remains the primary HUD. LauncherStack {} // 1 — left vertical list From 2bf71494f4cbbda8f038b8ddf733d92aff550f50 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Mon, 31 Aug 2026 00:30:58 +0200 Subject: [PATCH 19/24] feat(quickshell): cap the rail's open chamfers and trace between them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chamfer with no neighbour behind it now carries the corner it removed, put back OUTSIDE the panel as a detached accent triangle. capGap is the perpendicular distance from the cut, hence the per-axis shift of capGap over root 2: the cap moves along the cut's normal, not along an axis. The trace joining two caps belongs to the RAIL, not the panel — the run it draws is the gap BETWEEN two panels, which no panel can see. HyprChromeBar filters the row down to panels (a slack Item has no `rightChamfer`, so spacers drop out, and dropping them is exactly what makes a trace span them), then joins each right cap to the next left cap: straight, one 45° step at the midpoint of the gap, straight. It meets the middle of each cap's outward face rather than its tip. TestPanel is a staging slot between two spacers. It counts seconds since load, which is the cheapest proof the panel is live, and standing alone it exercises a cap and a trace at both ends — something a rail of butted panels never does. Restructures the module in the same commit, since the moves and the edits above land in the same files: folders are PascalCase, the bar and its widgets moved under Widgets/Bar, and DebugWindow takes the HyprChrome Theme instead of the legacy one. The two singletons are identical today, so that is not a visual fix — it is a palette edit reaching the debug stage in future. Rename detection needs -M40% to follow HyprChromeBar, which grew past the default similarity threshold. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U19X5LGTxtq4pb4jdNVivv --- .../DebugWindow.qml | 2 +- .../theme => HyprChrome/Theme}/Theme.qml | 0 .../Widgets/Bar/Debug}/LoremPanel.qml | 4 +- .../Widgets/Bar/Debug/TestPanel.qml | 69 ++++++ .../Widgets/Bar/Host}/HostPanel.qml | 4 +- .../HyprChrome/Widgets/Bar/HyprChromeBar.qml | 217 ++++++++++++++++++ .../Widgets/Bar/Panels}/BarPanel.qml | 59 ++++- .../Widgets/Bar/Tray}/TrayIcon.qml | 2 +- .../Widgets/Bar/Tray}/TrayPanel.qml | 4 +- .../Widgets/Bar/Vitals}/GpuBusy.qml | 0 .../Widgets/Bar/Vitals}/SegmentMeter.qml | 2 +- .../Widgets/Bar/Vitals}/VitalRow.qml | 2 +- .../Widgets/Bar/Vitals}/VitalsPanel.qml | 4 +- .../Bar/Workspaces}/WorkspacesPanel.qml | 4 +- .../Widgets}/ChromeBackdrop.qml | 2 +- .../Widgets}/HyprChromeShell.qml | 3 +- .../hyprchrome/widgets/HyprChromeBar.qml | 119 ---------- dotfiles/quickshell/shell.qml | 10 +- 18 files changed, 366 insertions(+), 141 deletions(-) rename dotfiles/quickshell/{hyprchrome => HyprChrome}/DebugWindow.qml (99%) rename dotfiles/quickshell/{hyprchrome/theme => HyprChrome/Theme}/Theme.qml (100%) rename dotfiles/quickshell/{hyprchrome/widgets/debug => HyprChrome/Widgets/Bar/Debug}/LoremPanel.qml (95%) create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Bar/Debug/TestPanel.qml rename dotfiles/quickshell/{hyprchrome/widgets/host => HyprChrome/Widgets/Bar/Host}/HostPanel.qml (99%) create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml rename dotfiles/quickshell/{hyprchrome/widgets/panels => HyprChrome/Widgets/Bar/Panels}/BarPanel.qml (82%) rename dotfiles/quickshell/{hyprchrome/widgets/tray => HyprChrome/Widgets/Bar/Tray}/TrayIcon.qml (98%) rename dotfiles/quickshell/{hyprchrome/widgets/tray => HyprChrome/Widgets/Bar/Tray}/TrayPanel.qml (96%) rename dotfiles/quickshell/{hyprchrome/widgets/vitals => HyprChrome/Widgets/Bar/Vitals}/GpuBusy.qml (100%) rename dotfiles/quickshell/{hyprchrome/widgets/vitals => HyprChrome/Widgets/Bar/Vitals}/SegmentMeter.qml (97%) rename dotfiles/quickshell/{hyprchrome/widgets/vitals => HyprChrome/Widgets/Bar/Vitals}/VitalRow.qml (98%) rename dotfiles/quickshell/{hyprchrome/widgets/vitals => HyprChrome/Widgets/Bar/Vitals}/VitalsPanel.qml (98%) rename dotfiles/quickshell/{hyprchrome/widgets/workspaces => HyprChrome/Widgets/Bar/Workspaces}/WorkspacesPanel.qml (99%) rename dotfiles/quickshell/{hyprchrome/widgets => HyprChrome/Widgets}/ChromeBackdrop.qml (99%) rename dotfiles/quickshell/{hyprchrome/widgets => HyprChrome/Widgets}/HyprChromeShell.qml (98%) delete mode 100644 dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml diff --git a/dotfiles/quickshell/hyprchrome/DebugWindow.qml b/dotfiles/quickshell/HyprChrome/DebugWindow.qml similarity index 99% rename from dotfiles/quickshell/hyprchrome/DebugWindow.qml rename to dotfiles/quickshell/HyprChrome/DebugWindow.qml index 3bd543c..91aeed2 100644 --- a/dotfiles/quickshell/hyprchrome/DebugWindow.qml +++ b/dotfiles/quickshell/HyprChrome/DebugWindow.qml @@ -4,7 +4,7 @@ import Quickshell import Quickshell.Hyprland import Quickshell.Wayland import QtQuick -import qs.widgets.theme +import qs.HyprChrome.Theme // Debug stage: a bare, chrome-less staging area in the middle of ONE monitor // (the secondary by default), used to look at a widget in isolation before it diff --git a/dotfiles/quickshell/hyprchrome/theme/Theme.qml b/dotfiles/quickshell/HyprChrome/Theme/Theme.qml similarity index 100% rename from dotfiles/quickshell/hyprchrome/theme/Theme.qml rename to dotfiles/quickshell/HyprChrome/Theme/Theme.qml diff --git a/dotfiles/quickshell/hyprchrome/widgets/debug/LoremPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Debug/LoremPanel.qml similarity index 95% rename from dotfiles/quickshell/hyprchrome/widgets/debug/LoremPanel.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Debug/LoremPanel.qml index 96998fb..0e1546f 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/debug/LoremPanel.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Debug/LoremPanel.qml @@ -1,8 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick -import qs.hyprchrome.theme -import qs.hyprchrome.widgets.panels +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Bar.Panels // Staging widget for the debug window: a BarPanel carrying filler copy in both // of the panel's densities — the full block when expanded, one elided line when diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Bar/Debug/TestPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Debug/TestPanel.qml new file mode 100644 index 0000000..a3eae22 --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Debug/TestPanel.qml @@ -0,0 +1,69 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Bar.Panels + +// Staging slot for whatever is being worked on, sized to itself so it can be +// dropped anywhere in the rail without a width. It counts seconds since the +// shell loaded, which is the cheapest thing that proves the panel is live and +// not a still frame — a reload visibly restarts it. +// +// It also stands ALONE between two spacers, so it is the pair of caps and +// traces that a rail of butted panels never exercises: a right cap joining the +// next panel's left cap across a gap, twice over. +BarPanel { + id: test + + panelId: "TST" + title: "TEST" + meta: "STAGE" + + property int seconds: 0 + + // Sized to its content, like the tray: a staging panel has no business + // reserving a share of the rail. + implicitWidth: Math.max(test.headerMinWidth, + test.briefLeft + brief.implicitWidth + test.padding, + test.padding * 2 + body.implicitWidth) + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: test.seconds++ + } + + summary: Text { + id: brief + + text: "T+" + test.seconds + color: Theme.accent + font.family: Theme.displayFont + font.pixelSize: 13 + font.bold: true + } + + Column { + id: body + + spacing: 2 + + Text { + text: "T+" + test.seconds + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 17 + font.bold: true + font.letterSpacing: 1 + } + + Text { + text: "SECONDS SINCE LOAD" + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 1.4 + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Host/HostPanel.qml similarity index 99% rename from dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Host/HostPanel.qml index ec2ad7a..4079473 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/host/HostPanel.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Host/HostPanel.qml @@ -4,8 +4,8 @@ import Quickshell import Quickshell.Io import QtQuick import QtQuick.Layouts -import qs.hyprchrome.theme -import qs.hyprchrome.widgets.panels +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Bar.Panels // Host identity in the hyprchrome panel chrome: the machine's name set large, // with its timezone and the current date and time. diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml new file mode 100644 index 0000000..4f5f2f2 --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml @@ -0,0 +1,217 @@ +import QtQuick +import QtQuick.Shapes +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Bar.Debug +import qs.HyprChrome.Widgets.Bar.Host +import qs.HyprChrome.Widgets.Bar.Vitals +import qs.HyprChrome.Widgets.Bar.Workspaces +import qs.HyprChrome.Widgets.Bar.Tray + +// The dense status rail itself: one layer surface holding the panel row. +// +// It owns nothing shared — the screen, the density and its layer all arrive +// from HyprChromeShell, which is also what keeps this surface and the backdrop +// one layer apart. What it does own is its own measurement: `contentHeight` is +// the settled height of the current density, which the shell hands to the +// backdrop and which sizes the exclusive zone. +PanelWindow { + id: window + + // Density for every panel on the rail; driven by the shell. + property bool expanded: false + + // Which layer to sit on. An int rather than a private decision: it is half + // of a pair with the backdrop's, so the shell derives both. See + // HyprChromeShell. + property int wlrLayer: WlrLayer.Overlay + + // Own namespace so a layerrule can exempt the rail from Hyprland's layer + // animation without also catching the launchers, which share the default + // "quickshell" namespace and do want their fade. + WlrLayershell.namespace: "hyprchrome-bar" + WlrLayershell.layer: window.wlrLayer + + property int margin: 12 + + // The surface never resizes: it is always tall enough for the expanded + // rail, and only the exclusive zone tracks the current state. Resizing a + // layer surface makes Hyprland animate the change, which showed up as the + // panels twitching a pixel or two the moment the collapse finished. + // + // The zone still follows the target height, so tiled windows reflow once + // per toggle, at the start, and slide while the panels animate. + readonly property real expandedContent: Math.max(hostPanel.expandedHeight, workspacesPanel.expandedHeight, vitalsPanel.expandedHeight, testPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2 + readonly property real contentHeight: Math.max(hostPanel.targetHeight, workspacesPanel.targetHeight, vitalsPanel.targetHeight, testPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2 + + implicitHeight: Math.round(window.expandedContent) + + exclusionMode: ExclusionMode.Normal + exclusiveZone: Math.round(window.contentHeight) + + // Only the panels take input. Without this the surface would keep eating + // clicks across its full height while the rail is collapsed. + mask: Region { + item: panelRow + } + + anchors { top: true; left: true; right: true; } + + color: "transparent" + + RowLayout { + id: panelRow + x: window.margin + y: window.margin + width: window.width - window.margin * 2 + spacing: 0 + + HostPanel { + id: hostPanel + + expanded: window.expanded + // Workspaces butt against its right edge; the left end of the row is free. + rightChamfer: false + + toggleOnClick: false + Layout.preferredWidth: 400 + Layout.fillHeight: true + } + + WorkspacesPanel { + id: workspacesPanel + + expanded: window.expanded + // Mid-rail: a panel on either side, so neither corner is cut. + leftChamfer: false + rightChamfer: false + + toggleOnClick: false + // No preferred width: the panel sizes itself to however many outputs + // the session has, the same way the tray sizes itself to its items. + Layout.fillHeight: true + } + + VitalsPanel { + id: vitalsPanel + + expanded: window.expanded + // Workspaces on the left, the spacer on the right — and a spacer is not + // a panel, so that edge keeps its cut. + leftChamfer: false + + toggleOnClick: false + Layout.preferredWidth: 500 + Layout.fillHeight: true + } + + // Slack on both sides of the staging panel, so it floats between the left + // group and the tray rather than butting against either. Two spacers is + // also what puts a gap on both of its sides, which is what gives it a cap + // and a trace at each end. + Item { Layout.fillWidth: true } + + TestPanel { + id: testPanel + + expanded: window.expanded + toggleOnClick: false + Layout.fillHeight: true + } + + Item { Layout.fillWidth: true } + + TrayPanel { + id: trayPanel + + expanded: window.expanded + toggleOnClick: false + Layout.fillHeight: true + } + } + + // Traces between the panels' caps. They live here rather than in BarPanel + // because the run they draw is the gap BETWEEN two panels, which is the one + // piece of this geometry no panel can see. Laid over the row, in the row's + // own coordinates, so a panel's x is directly usable. + Item { + id: traces + + x: panelRow.x + y: panelRow.y + width: panelRow.width + height: panelRow.height + + // Consecutive PANELS, with the spacers dropped — a spacer has no caps, so + // it is not something a trace can start or end at, and skipping it is + // exactly what makes the trace span it. `rightChamfer` is the tell: an + // Item put in the row for slack has no such property. + readonly property var pairs: { + const panels = []; + for (let i = 0; i < panelRow.children.length; i++) { + const child = panelRow.children[i]; + if (child.rightChamfer !== undefined) + panels.push(child); + } + + // A cap only exists on an open chamfer, so a pair that has both is + // exactly a pair with something to join. + const found = []; + for (let i = 0; i + 1 < panels.length; i++) { + if (panels[i].rightChamfer && panels[i + 1].leftChamfer) + found.push({ from: panels[i], to: panels[i + 1] }); + } + return found; + } + + Repeater { + model: traces.pairs + + CapTrace { + anchors.fill: parent + } + } + } + + // One run: out of a panel's top-right cap, along the top, one 45° step down + // at the midpoint of the gap, then along the bottom into the next panel's + // bottom-left cap. 45° means the step is as wide as it is tall, so the run + // IS the drop — clamped if the gap is too narrow to fit it, which is the + // only case where the angle gives. + component CapTrace: Item { + id: trace + + required property var modelData + + property int lineWidth: 3 + + readonly property real fromX: trace.modelData.from.x + trace.modelData.from.rightCapX + readonly property real fromY: trace.modelData.from.y + trace.modelData.from.rightCapY + readonly property real toX: trace.modelData.to.x + trace.modelData.to.leftCapX + readonly property real toY: trace.modelData.to.y + trace.modelData.to.leftCapY + + readonly property real drop: trace.toY - trace.fromY + readonly property real gap: trace.toX - trace.fromX + readonly property real step: Math.max(0, Math.min(Math.abs(trace.drop), trace.gap)) + readonly property real mid: (trace.fromX + trace.toX) / 2 + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: "transparent" + strokeColor: Theme.accent + // Nothing to draw while the panels overlap, which they do for a frame + // or two while the row is still laying itself out. + strokeWidth: trace.gap > 0 ? trace.lineWidth : 0 + startX: trace.fromX; startY: trace.fromY + PathLine { x: trace.mid - trace.step / 2; y: trace.fromY } + PathLine { x: trace.mid + trace.step / 2; y: trace.toY } + PathLine { x: trace.toX; y: trace.toY } + } + } + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Panels/BarPanel.qml similarity index 82% rename from dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Panels/BarPanel.qml index aee1063..fe8b88e 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Panels/BarPanel.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Shapes -import qs.hyprchrome.theme +import qs.HyprChrome.Theme // Chamfered panel chrome for the dense status rail: outline, corner accent // lines, header strip (id chip / title / meta / tick marks) and a collapsing @@ -135,6 +135,36 @@ Item { property int outlineWidth: 1 readonly property int connectorWidth: panel.outlineWidth + 2 + // A cut corner has nothing butting against it, so it gets capped: the very + // corner the chamfer removed, put back OUTSIDE the panel as a detached + // accent triangle. Its hypotenuse faces the cut and its right angle points + // away, so the cap and the notch read as two halves of one corner. + // + // Nothing guards these — a chamfer that is off measures zero, which + // collapses its triangle to no area at all, so one piece of geometry covers + // both cases. + // + // capGap is the perpendicular distance from the chamfer, which is why the + // per-axis shift is it over root 2 rather than the gap itself: the cap + // moves along the cut's normal, not along an axis. + property real capGap: 4 + readonly property real capOffset: panel.capGap / Math.SQRT2 + + // Where a trace attaches: the MIDDLE of the cap's outward-facing edge — the + // vertical one, since a trace arrives horizontally — rather than the tip, + // so the line meets the triangle's face instead of clipping its corner. + // That edge runs from the cut's end to the corner, so its midpoint is the + // half-way point between them, carried out by the same offset as the cap. + // + // The RAIL draws those traces, between one panel's right cap and the next + // panel's left cap, because the run between two panels is the one piece of + // this that no panel can see. All a panel owes it is where its own caps + // ended up. + readonly property real rightCapX: panel.width + panel.capOffset + readonly property real rightCapY: (panel.offsetY + panel.activeChamfer) / 2 - panel.capOffset + readonly property real leftCapX: -panel.capOffset + readonly property real leftCapY: panel.height - panel.activeChamfer / 2 + panel.capOffset + Shape { id: panelShape anchors.fill: parent @@ -154,6 +184,33 @@ Item { PathLine { x: 0; y: panel.offsetY } } + // Cap on the top-right chamfer: the corner the cut removed, sitting + // just outside it. The two ends of its hypotenuse are the same points + // the outline turns on, shifted clear along the cut's normal; the third + // is the corner itself, which the outline never reaches. + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: panelShape.width - panel.rightCut + panel.capOffset; startY: panel.offsetY - panel.capOffset + PathLine { + x: panelShape.width + panel.capOffset + y: (panel.rightChamfer ? panel.activeChamfer : panel.offsetY) - panel.capOffset + } + PathLine { x: panelShape.width + panel.capOffset; y: panel.offsetY - panel.capOffset } + PathLine { x: panelShape.width - panel.rightCut + panel.capOffset; y: panel.offsetY - panel.capOffset } + } + + // Cap on the bottom-left chamfer, the same triangle mirrored, clearing + // the panel in the other direction. + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: panel.leftCut - panel.capOffset; startY: panelShape.height + panel.capOffset + PathLine { x: -panel.capOffset; y: panelShape.height - panel.leftCut + panel.capOffset } + PathLine { x: -panel.capOffset; y: panelShape.height + panel.capOffset } + PathLine { x: panel.leftCut - panel.capOffset; y: panelShape.height + panel.capOffset } + } + // Upper left accent line ShapePath { diff --git a/dotfiles/quickshell/hyprchrome/widgets/tray/TrayIcon.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Tray/TrayIcon.qml similarity index 98% rename from dotfiles/quickshell/hyprchrome/widgets/tray/TrayIcon.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Tray/TrayIcon.qml index 9ad4542..59fe08a 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/tray/TrayIcon.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Tray/TrayIcon.qml @@ -4,7 +4,7 @@ import Quickshell import Quickshell.Services.SystemTray import QtQuick import QtQuick.Shapes -import qs.hyprchrome.theme +import qs.HyprChrome.Theme // One tray item as a chamfered cell. Declares `modelData` required so it can be // a Repeater delegate directly, without an Item wrapper in between. diff --git a/dotfiles/quickshell/hyprchrome/widgets/tray/TrayPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Tray/TrayPanel.qml similarity index 96% rename from dotfiles/quickshell/hyprchrome/widgets/tray/TrayPanel.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Tray/TrayPanel.qml index 01c80b2..649cddb 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/tray/TrayPanel.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Tray/TrayPanel.qml @@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound import Quickshell import Quickshell.Services.SystemTray import QtQuick -import qs.hyprchrome.theme -import qs.hyprchrome.widgets.panels +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Bar.Panels // System tray in the hyprchrome panel chrome, in both densities: the same // items, drawn large enough to hit when expanded and shrunk onto the header diff --git a/dotfiles/quickshell/hyprchrome/widgets/vitals/GpuBusy.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/GpuBusy.qml similarity index 100% rename from dotfiles/quickshell/hyprchrome/widgets/vitals/GpuBusy.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/GpuBusy.qml diff --git a/dotfiles/quickshell/hyprchrome/widgets/vitals/SegmentMeter.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/SegmentMeter.qml similarity index 97% rename from dotfiles/quickshell/hyprchrome/widgets/vitals/SegmentMeter.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/SegmentMeter.qml index aee364e..95f5b53 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/vitals/SegmentMeter.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/SegmentMeter.qml @@ -1,7 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick -import qs.hyprchrome.theme +import qs.HyprChrome.Theme // Segmented horizontal meter: a row of cells lit up to `value`. // diff --git a/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalRow.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/VitalRow.qml similarity index 98% rename from dotfiles/quickshell/hyprchrome/widgets/vitals/VitalRow.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/VitalRow.qml index 47069e5..caf011e 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalRow.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/VitalRow.qml @@ -1,7 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick -import qs.hyprchrome.theme +import qs.HyprChrome.Theme // One metric of the expanded vitals panel: label, meter, readout on a line. // diff --git a/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalsPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/VitalsPanel.qml similarity index 98% rename from dotfiles/quickshell/hyprchrome/widgets/vitals/VitalsPanel.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/VitalsPanel.qml index 00c1cca..ab4e894 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/vitals/VitalsPanel.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Vitals/VitalsPanel.qml @@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import qs.hyprchrome.theme -import qs.hyprchrome.widgets.panels +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Bar.Panels import qs.widgets.vitals // Host vitals in the hyprchrome panel chrome, in both of BarPanel's densities. diff --git a/dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Workspaces/WorkspacesPanel.qml similarity index 99% rename from dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Bar/Workspaces/WorkspacesPanel.qml index 71f9302..279ae50 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/Workspaces/WorkspacesPanel.qml @@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound import Quickshell import Quickshell.Hyprland import QtQuick -import qs.hyprchrome.theme -import qs.hyprchrome.widgets.panels +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Bar.Panels // Which workspace each monitor is currently showing, in the hyprchrome panel // chrome, in both of BarPanel's densities. diff --git a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml b/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml similarity index 99% rename from dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml rename to dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml index b44d15c..da699c6 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml @@ -3,7 +3,7 @@ pragma ComponentBehavior: Bound import Quickshell import Quickshell.Wayland import QtQuick -import qs.hyprchrome.theme +import qs.HyprChrome.Theme // Scrim behind the bar: dims the desktop and lays the dense bar's drafting grid // over it. It is always on screen — only how far DOWN it reaches changes. diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml similarity index 98% rename from dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml rename to dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml index b2cfbd5..2539bc2 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml @@ -3,7 +3,8 @@ pragma ComponentBehavior: Bound import Quickshell import Quickshell.Hyprland import Quickshell.Wayland -import qs.hyprchrome.widgets +import qs.HyprChrome.Widgets.Bar +import qs.HyprChrome.Widgets // The hyprchrome shell: owns everything the rail's surfaces have to agree on, // and instantiates them. diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml deleted file mode 100644 index 16047bb..0000000 --- a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml +++ /dev/null @@ -1,119 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import Quickshell.Wayland -import qs.hyprchrome.widgets.host -import qs.hyprchrome.widgets.vitals -import qs.hyprchrome.widgets.workspaces -import qs.hyprchrome.widgets.tray - -// The dense status rail itself: one layer surface holding the panel row. -// -// It owns nothing shared — the screen, the density and its layer all arrive -// from HyprChromeShell, which is also what keeps this surface and the backdrop -// one layer apart. What it does own is its own measurement: `contentHeight` is -// the settled height of the current density, which the shell hands to the -// backdrop and which sizes the exclusive zone. -PanelWindow { - id: window - - // Density for every panel on the rail; driven by the shell. - property bool expanded: false - - // Which layer to sit on. An int rather than a private decision: it is half - // of a pair with the backdrop's, so the shell derives both. See - // HyprChromeShell. - property int wlrLayer: WlrLayer.Overlay - - // Own namespace so a layerrule can exempt the rail from Hyprland's layer - // animation without also catching the launchers, which share the default - // "quickshell" namespace and do want their fade. - WlrLayershell.namespace: "hyprchrome-bar" - WlrLayershell.layer: window.wlrLayer - - property int margin: 12 - - // The surface never resizes: it is always tall enough for the expanded - // rail, and only the exclusive zone tracks the current state. Resizing a - // layer surface makes Hyprland animate the change, which showed up as the - // panels twitching a pixel or two the moment the collapse finished. - // - // The zone still follows the target height, so tiled windows reflow once - // per toggle, at the start, and slide while the panels animate. - readonly property real expandedContent: Math.max(hostPanel.expandedHeight, workspacesPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2 - readonly property real contentHeight: Math.max(hostPanel.targetHeight, workspacesPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2 - - implicitHeight: Math.round(window.expandedContent) - - exclusionMode: ExclusionMode.Normal - exclusiveZone: Math.round(window.contentHeight) - - // Only the panels take input. Without this the surface would keep eating - // clicks across its full height while the rail is collapsed. - mask: Region { - item: panelRow - } - - anchors { top: true; left: true; right: true; } - - color: "transparent" - - RowLayout { - id: panelRow - x: window.margin - y: window.margin - width: window.width - window.margin * 2 - spacing: 0 - - HostPanel { - id: hostPanel - - expanded: window.expanded - // Workspaces butt against its right edge; the left end of the row is free. - rightChamfer: false - - toggleOnClick: false - Layout.preferredWidth: 400 - Layout.fillHeight: true - } - - WorkspacesPanel { - id: workspacesPanel - - expanded: window.expanded - // Mid-rail: a panel on either side, so neither corner is cut. - leftChamfer: false - rightChamfer: false - - toggleOnClick: false - // No preferred width: the panel sizes itself to however many outputs - // the session has, the same way the tray sizes itself to its items. - Layout.fillHeight: true - } - - VitalsPanel { - id: vitalsPanel - - expanded: window.expanded - // Workspaces on the left, the spacer on the right — and a spacer is not - // a panel, so that edge keeps its cut. - leftChamfer: false - - toggleOnClick: false - Layout.preferredWidth: 500 - Layout.fillHeight: true - } - - // Slack lives between the left group and the tray, so the tray sits - // flush right whatever the other panels measure. - Item { Layout.fillWidth: true } - - TrayPanel { - id: trayPanel - - expanded: window.expanded - toggleOnClick: false - Layout.fillHeight: true - } - } -} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index da3257d..9974731 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -10,11 +10,11 @@ import qs.widgets.osd import qs.widgets.systray import qs.widgets.theme import qs.widgets.vitals -import qs.hyprchrome -import qs.hyprchrome.widgets -import qs.hyprchrome.widgets.debug -import qs.hyprchrome.widgets.host -import qs.hyprchrome.widgets.vitals +import qs.HyprChrome +import qs.HyprChrome.Widgets +import qs.HyprChrome.Widgets.Bar.Debug +import qs.HyprChrome.Widgets.Bar.Host +import qs.HyprChrome.Widgets.Bar.Vitals Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. From 05d26e386f33f3e7d3dbf964780b300def711062 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 1 Sep 2026 22:35:23 +0200 Subject: [PATCH 20/24] feat(quickshell): add polkit authentication agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers a polkit agent for the logind session and presents its requests in the hyprchrome panel chrome. PolkitPrompt owns the agent, the layer-shell surface and focus; PolkitPromptContent is the headlessly renderable visual core, staged by tests/PolkitPromptHeadless.qml. Replaces terra's hyprpolkitagent autostart, which had been dead for a while: the unit was never installed, so the start failed silently and the session ran with no polkit agent at all. Verified against a live agent — registration, the PAM conversation, retry after a rejected attempt, and cancellation. Behaviours found by tracing that the component now documents: * registration is ASYNCHRONOUS, so a Component.onCompleted check reports a false failure while a change handler cannot see a total failure at all (a failed registration never changes the property) — hence the deadline * a flow arrives with isResponseRequired false and an empty prompt, so the field is still disabled when the window first becomes visible and the re-focus on that transition is load bearing * concurrent requests SUPERSEDE rather than queue, orphaning the older one. Cancelling it from QML trips "QObject::connect(AuthFlow, PolkitAgentImpl): invalid nullptr parameter" upstream and costs the live prompt as well, so it is deliberately left alone * Identity.id is the raw uid, not unix-user: Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud --- .../Widgets/Polkit/PolkitPrompt.qml | 231 +++++++++++++++ .../Widgets/Polkit/PolkitPromptContent.qml | 271 ++++++++++++++++++ dotfiles/quickshell/shell.qml | 6 + .../quickshell/tests/PolkitPromptHeadless.qml | 33 +++ hosts/terra/home/hyprland.nix | 6 +- 5 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml create mode 100644 dotfiles/quickshell/tests/PolkitPromptHeadless.qml diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml new file mode 100644 index 0000000..fb46029 --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml @@ -0,0 +1,231 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Polkit +import qs.HyprChrome.Theme + +// Polkit authentication agent for the hyprchrome shell. +// +// Instantiating PolkitAgent IS the registration — it registers a listener for +// this logind session in componentComplete(), so there is nothing to start and +// nothing to call. Two consequences: +// +// * Only ONE agent may hold a session. hyprpolkitagent must not be running +// (hosts/terra/home/hyprland.nix autostart), or registration fails and this +// dialog silently never appears. `isRegistered` is the check. +// * `path` is write-once — the binary refuses a later assignment with +// "cannot change path after it has been set." Set it here or not at all. +// +// Concurrent requests SUPERSEDE each other — they do not queue. Verified +// against a live trace of two simultaneous `pkexec` calls: both logged +// "activating authentication request" back to back, each with its own cookie +// and its own "setting up session", with no wait for the first to finish. +// `agent.flow` simply becomes the newest request. +// +// The consequence is that the earlier request is ORPHANED: its PAM session is +// live and polkit is still waiting on it, but nothing in QML can reach it any +// more, so its caller hangs until it gives up and polkit cancels — which +// surfaces as quickshell's "the cancelled request was not found in the queue". +// This dialog therefore shows the newest request and loses the older one. See +// the flow-change handler below; fixing it properly means holding superseded +// flows in QML and re-presenting them, which is only worth doing if concurrent +// authorization prompts turn out to happen in practice. +// +// Everything below re-latches per flow instead of caching it. +// +// The visual core lives in PolkitPromptContent so it can be rendered headlessly +// and staged in DebugWindow; this file owns the agent, the surface and focus. +Scope { + id: root + + // Where the flow's identity list is currently pointed. Held here rather + // than read back off the flow because the content addresses identities by + // index and AuthFlow addresses them by object. + readonly property var flow: agent.flow + + // Whether this shell actually holds the session's agent. Exposed because + // failure is invisible from the outside: an unregistered agent simply never + // shows a dialog, which looks exactly like "no one asked for authorization". + readonly property alias registered: agent.isRegistered + + // Reset per REQUEST, not per window show. + // + // A second request supersedes the first by swapping `flow` while the dialog + // is already up, so the window never hides in between. Keying the reset off + // the surface's visibility therefore skips that swap entirely and the new + // request inherits whatever was typed for the old one — a password entered + // for one action left sitting in the box for a different action. The flow + // object changing is the event that actually means "new request". + // Do NOT cancel the superseded flow here. It is tempting — a superseded + // request is unreachable but still live, so its caller hangs until killed, + // and cancelling would at least fail it fast. Tried, and it makes things + // strictly worse: cancelling a flow that is no longer the agent's active + // one tears down state the CURRENT request still needs, and quickshell then + // logs + // + // QObject::connect(AuthFlow, PolkitAgentImpl): invalid nullptr parameter + // + // leaving the live request with a broken agent and no dialog at all. So the + // superseded request is dismissed and the one the user can actually see + // never appears. Leaving it orphaned costs one hung caller; cancelling it + // costs the prompt as well. + onFlowChanged: { + if (root.flow) { + content.clearResponse(); + content.focusInput(); + } + } + + function identityIndex(flow) { + if (!flow || !flow.selectedIdentity) + return 0; + for (let i = 0; i < flow.identities.length; i++) { + if (flow.identities[i] === flow.selectedIdentity) + return i; + } + return 0; + } + + PolkitAgent { + id: agent + + // Default is /org/quickshell/PolkitAgent; named explicitly because it + // cannot be changed after startup and a second shell would collide. + path: "/org/quickshell/PolkitAgent" + + onIsRegisteredChanged: { + if (agent.isRegistered) + console.info("polkit: agent registered at", agent.path); + else + console.warn("polkit: agent lost its registration — this session now has no polkit agent"); + } + } + + // Registration is ASYNCHRONOUS. It is started in the agent's + // componentComplete but only lands a DBus round trip later — measured at + // under 250ms here, still false at Component.onCompleted. So neither an + // immediate check nor the change handler above can report a total failure: + // an agent that never registers stays false from construction onward and + // changes nothing, which is silence rather than an error. Hence a deadline. + // + // Hot reload is fine: quickshell hands the listener to the new generation + // ("taking over listener from previous generation") and isRegistered goes + // true again, verified on a live reload. + // + // Do NOT turn this into a rebuild-and-retry loop. Tried, with the agent in + // a Loader so a fresh one could be constructed. It cannot work: the subject + // polkit means is the SESSION, this process already holds a listener for + // it, and so every rebuilt agent fails identically with + // + // ...PolicyKit1.Error.Failed: + // An authentication agent already exists for the given subject + // + // Nothing QML can do releases that listener. The one time registration did + // fail across a reload, the cause was upstream state already corrupted by + // cancelling a superseded flow (see the flow handler above) — not the + // reload itself, and not something a retry would have recovered. + Timer { + interval: 2000 + running: true + + onTriggered: { + if (!agent.isRegistered) + console.warn("polkit: agent still unregistered after 2s — another agent (hyprpolkitagent, polkit-gnome, cosmic-osd) is probably holding this session"); + } + } + + PanelWindow { + id: win + + // isCompleted is checked as well as null: the flow reports its terminal + // state before the agent drops it, and the dialog should not linger for + // those frames showing a request that has already been decided. + visible: root.flow !== null && !root.flow.isCompleted + + WlrLayershell.layer: WlrLayer.Overlay + // A real modal — unlike the rest of the rail, this one must take the + // keyboard, or the password goes to whatever window was focused. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + color: Theme.textAlpha(0) + + anchors { + top: true + left: true + right: true + bottom: true + } + + // Scrim. No click-to-dismiss: a polkit request is answered or + // explicitly cancelled, and losing one to a stray click on the + // wallpaper would leave the caller waiting with no visible reason. + Rectangle { + anchors.fill: parent + color: Theme.surface + opacity: 0.72 + } + + PolkitPromptContent { + id: content + + anchors.centerIn: parent + width: 520 + + message: root.flow ? root.flow.message : "" + actionId: root.flow ? root.flow.actionId : "" + iconName: root.flow ? root.flow.iconName : "" + identities: root.flow ? root.flow.identities : [] + selectedIdentity: root.identityIndex(root.flow) + responseRequired: root.flow ? root.flow.isResponseRequired : false + inputPrompt: root.flow ? root.flow.inputPrompt : "" + responseVisible: root.flow ? root.flow.responseVisible : false + supplementaryMessage: root.flow ? root.flow.supplementaryMessage : "" + supplementaryIsError: root.flow ? root.flow.supplementaryIsError : false + failed: root.flow ? root.flow.failed : false + + onSubmitted: value => { + if (root.flow) + root.flow.submit(value); + } + + onCancelled: { + if (root.flow) + root.flow.cancelAuthenticationRequest(); + } + + // AuthFlow refuses a null identity, so the index is bounds-checked + // here rather than trusting the view. + onIdentityRequested: index => { + if (root.flow && index >= 0 && index < root.flow.identities.length) + root.flow.selectedIdentity = root.flow.identities[index]; + } + } + + // Wipe the box on a rejected attempt. `failed` flags the attempt, not + // the request — polkit lets PAM retry, and the flow stays live with a + // fresh prompt, so the field has to be cleared without closing. + Connections { + target: root.flow + enabled: root.flow !== null + + function onFailedChanged() { + if (root.flow.failed) + content.clearResponse(); + } + + // Re-focus when the conversation asks for something. This is load + // bearing, not defensive: a flow arrives with isResponseRequired + // FALSE and an empty inputPrompt — PAM has not asked yet — so the + // window becomes visible while the field is still disabled, and the + // focusInput() below it cannot land. The prompt shows up a moment + // later, and that is the edge that must take the keyboard. The same + // handler covers a second factor and a post-failure retry. + function onIsResponseRequiredChanged() { + if (root.flow.isResponseRequired) + content.focusInput(); + } + } + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml new file mode 100644 index 0000000..952cfc9 --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml @@ -0,0 +1,271 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Bar.Panels + +// Headlessly renderable visual core of the polkit authentication prompt. +// +// Nothing here imports Quickshell.Services.Polkit: every field an AuthFlow +// exposes arrives as a plain property and every action leaves as a signal, so +// the whole dialog can be rendered offscreen (tools/quickshell-preview) and +// staged in DebugWindow without a real authorization request. PolkitPrompt.qml +// owns the agent and does the mapping. +// +// `identities` is read structurally — each entry only needs `displayName` — so +// the adapter can hand over the flow's QList unchanged while the +// headless test passes plain JS objects. +Item { + id: root + + // ---- flow state, mirrored ---- + property string message: "" + property string actionId: "" + property string iconName: "" + property bool showIcon: true + + // Who may authenticate. One entry is the common case and renders as a + // plain line; the picker only appears when polkit actually offers a + // choice (a user in several admin groups, or root plus wheel). + property var identities: [] + property int selectedIdentity: 0 + + // PAM conversation. `responseVisible` is polkit's echo flag — it is NOT + // always false: a smartcard PIN prompt or a security-question stack asks + // for echoed input, and masking those makes the prompt unusable. + property bool responseRequired: false + property string inputPrompt: "" + property bool responseVisible: false + + // pam_info / pam_error text, and whether the last attempt was rejected. + property string supplementaryMessage: "" + property bool supplementaryIsError: false + property bool failed: false + + property alias response: responseInput.text + + signal submitted(string value) + signal cancelled + signal identityRequested(int index) + + function focusInput() { responseInput.forceActiveFocus(); } + function clearResponse() { responseInput.text = ""; } + + implicitWidth: 520 + implicitHeight: panel.implicitHeight + + // Escape reaches here by propagating up the focus chain from the TextInput, + // which does not consume it — so cancelling works whether or not the input + // currently has focus. + Keys.onEscapePressed: event => { + root.cancelled(); + event.accepted = true; + } + + component MicroText: Text { + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 8 + font.letterSpacing: 0.9 + elide: Text.ElideRight + } + + BarPanel { + id: panel + + width: root.width + panelId: "PKT" + title: "AUTHORIZATION REQUIRED" + // The action id is the one piece that says WHAT is being authorized + // independently of the (localizable, often vague) message. + meta: root.actionId + chamfer: 16 + + // A modal, not a rail panel: clicking the body must reach the input + // rather than collapse the dialog out from under it. + expanded: true + toggleOnClick: false + + // Collapsed rendering is never shown here, but BarPanel keeps both + // slots instantiated, so the summary stays bound to the same truth. + summary: MicroText { + text: root.inputPrompt + color: Theme.text + } + + // Sized like every other BarPanel body: the slot decides the width and + // the layout's implicitHeight becomes its height, so a wrapped message + // or an extra pam_info line grows the panel instead of being clipped. + ColumnLayout { + width: parent.width + spacing: 10 + + // ---- what is being asked ---- + RowLayout { + Layout.fillWidth: true + spacing: 10 + + IconImage { + visible: root.showIcon && root.iconName !== "" + implicitSize: 32 + source: root.showIcon && root.iconName !== "" + ? Quickshell.iconPath(root.iconName, "dialog-password") + : "" + } + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.message + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 13 + font.letterSpacing: 0.6 + } + } + + // ---- identity ---- + // Single identity: stated, not offered. Several: chips, because a + // combo box would be the only QtQuick.Controls widget in the rail. + MicroText { + Layout.fillWidth: true + visible: root.identities.length === 1 + text: "AS " + (root.identities.length === 1 + ? root.identities[0].displayName : "") + } + + Flow { + Layout.fillWidth: true + visible: root.identities.length > 1 + spacing: 6 + + Repeater { + model: root.identities + + Rectangle { + id: chip + + required property int index + required property var modelData + + readonly property bool current: chip.index === root.selectedIdentity + + width: chipLabel.implicitWidth + 14 + height: chipLabel.implicitHeight + 8 + color: chip.current ? Theme.accent : "transparent" + border.width: 1 + border.color: chip.current ? Theme.accent : Theme.disabled + + Text { + id: chipLabel + anchors.centerIn: parent + text: chip.modelData.displayName + color: chip.current ? Theme.surface : Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 0.8 + } + + MouseArea { + anchors.fill: parent + onClicked: root.identityRequested(chip.index) + } + } + } + } + + // ---- the conversation ---- + Rectangle { + Layout.fillWidth: true + implicitHeight: 34 + color: Theme.selection + border.width: 1 + border.color: root.failed ? Theme.hot : Theme.hair + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 10 + + Text { + text: ">_" + color: root.responseRequired ? Theme.accent : Theme.disabled + font.family: Theme.displayFont + font.pixelSize: 15 + font.bold: true + } + + TextInput { + id: responseInput + + Layout.fillWidth: true + Layout.fillHeight: true + verticalAlignment: TextInput.AlignVCenter + enabled: root.responseRequired + color: Theme.text + selectionColor: Theme.accent + selectedTextColor: Theme.surface + font.family: Theme.displayFont + font.pixelSize: 14 + font.letterSpacing: 1 + clip: true + + echoMode: root.responseVisible + ? TextInput.Normal : TextInput.Password + passwordCharacter: "▪" + // Qt reveals the last typed character for a moment by + // default. On a screen-visible layer-shell overlay + // that is a shoulder-surfing hole, so: never. + passwordMaskDelay: 0 + + onAccepted: { + if (root.responseRequired) + root.submitted(responseInput.text); + } + + // Placeholder: TextInput has none of its own, and the + // PAM prompt ("Password:", "PIN:") is the only label + // this field gets. + Text { + anchors.verticalCenter: parent.verticalCenter + visible: responseInput.text.length === 0 + text: root.inputPrompt + color: Theme.disabled + font: responseInput.font + } + } + } + } + + // ---- pam_info / pam_error ---- + Text { + Layout.fillWidth: true + visible: root.supplementaryMessage !== "" + wrapMode: Text.Wrap + text: root.supplementaryMessage + color: root.supplementaryIsError ? Theme.hot : Theme.muted + font.family: Theme.microFont + font.pixelSize: 9 + font.letterSpacing: 0.7 + } + + // ---- key hints ---- + RowLayout { + Layout.fillWidth: true + spacing: 14 + + MicroText { text: "ENTER AUTHENTICATE" } + MicroText { text: "ESC CANCEL" } + Item { Layout.fillWidth: true } + MicroText { + text: root.responseVisible ? "ECHO ON" : "" + color: Theme.hot + } + } + } + } +} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 9974731..4ed87b9 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -15,6 +15,7 @@ import qs.HyprChrome.Widgets import qs.HyprChrome.Widgets.Bar.Debug import qs.HyprChrome.Widgets.Bar.Host import qs.HyprChrome.Widgets.Bar.Vitals +import qs.HyprChrome.Widgets.Polkit Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. @@ -34,6 +35,11 @@ Scope { CyberDock {} // 11 — cyberpunk bottom cartridge dock Notifications {} + + // Polkit authentication agent. Registers for this logind session on + // creation, so it replaces hyprpolkitagent rather than coexisting with it + // — only one agent may hold a session (see hosts/terra/home/hyprland.nix). + PolkitPrompt {} VolumeOsd {} // Host vitals HUD — toggle with SUPER CTRL V. diff --git a/dotfiles/quickshell/tests/PolkitPromptHeadless.qml b/dotfiles/quickshell/tests/PolkitPromptHeadless.qml new file mode 100644 index 0000000..2b2d7e1 --- /dev/null +++ b/dotfiles/quickshell/tests/PolkitPromptHeadless.qml @@ -0,0 +1,33 @@ +import QtQuick +import qs.HyprChrome.Widgets.Polkit + +// Offscreen render of the polkit prompt with a failed first attempt and two +// eligible identities — the state that exercises every optional element at +// once (picker, pam_error text, rejected-attempt border). +// +// ./tools/quickshell-preview/render.sh \ +// tests/PolkitPromptHeadless.qml \ +// .artifacts/quickshell-preview/polkit-prompt.png 560 320 +PolkitPromptContent { + width: 520 + + message: "Authentication is required to install or remove software" + actionId: "org.freedesktop.packagekit.package-install" + iconName: "system-software-install" + showIcon: false + + identities: [ + { id: "1000", displayName: "darman", isGroup: false }, + { id: "0", displayName: "root", isGroup: false } + ] + selectedIdentity: 0 + + responseRequired: true + inputPrompt: "Password:" + responseVisible: false + response: "hunter2" + + supplementaryMessage: "Authentication failure. 2 attempts remaining." + supplementaryIsError: true + failed: true +} diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index 11674bd..e8eabff 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -310,7 +310,11 @@ in "hyprland.start" (lua '' function() - hl.exec_cmd("systemctl --user start hyprpolkitagent") + -- No polkit agent is started here: quickshell registers one + -- itself (HyprChrome/Widgets/Polkit), and a session admits only + -- one. The hyprpolkitagent line this replaces had been dead for + -- a while anyway — the unit was never installed, so the start + -- failed silently and the session ran with no agent at all. hl.exec_cmd("cosmic-settings-daemon") hl.exec_cmd("quickshell") hl.exec_cmd("alacritty", { workspace = "special:terminal silent" }) From 38608c40088d0c299e1452714e6543574f1bca74 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 1 Sep 2026 23:24:05 +0200 Subject: [PATCH 21/24] feat(quickshell): give the shell the polkit agent and one shared scrim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves PolkitPrompt from shell.qml into HyprChromeShell. Whether a prompt is open is shell state by the same rule as the screen and the layer pair: two surfaces read it. The prompt no longer carries a backdrop of its own. There is one ChromeBackdrop per output and a prompt raises them all, so a prompt over an already-expanded rail reuses the scrim that is there rather than stacking a second one on it, and a prompt over a collapsed rail expands that same scrim from its bar-height band to the whole output. The layer pair now keys off `scrimUp` (expanded OR prompting) rather than off the density, which keeps bar and backdrop exactly one level apart in every state. A prompt over a collapsed rail raises both: BACKGROUND sits under ordinary windows so a scrim there dims nothing, and the bar has to stay one above the scrim or the shell dims its own chrome. The rail is raised but stays collapsed — its layer answers to the scrim, its height to `expanded`. Outputs the rail does not live on get a scrim only while a prompt is up; a modal that dims one monitor and leaves the others lit does not read as modal. Expanding the rail still dims only the rail's screen, which is the existing behaviour and the right one. The dialog follows Hyprland.focusedMonitor rather than the rail's screen — a password prompt belongs where the user is looking — matched by name against Quickshell.screens, falling back to the rail's screen rather than to nothing. SUPER A is frozen while a prompt is open, and dropped rather than queued, so the rail does not spring open the moment the dialog goes. Verified on two monitors via hyprctl layers, both densities, plus the toggle block with an odd number of presses (two cancel out and prove nothing). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud --- .../HyprChrome/Widgets/HyprChromeShell.qml | 129 +++++++++++++++++- .../Widgets/Polkit/PolkitPrompt.qml | 39 ++++-- dotfiles/quickshell/shell.qml | 5 - 3 files changed, 150 insertions(+), 23 deletions(-) diff --git a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml index 2539bc2..8b9c3c1 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml @@ -5,17 +5,22 @@ import Quickshell.Hyprland import Quickshell.Wayland import qs.HyprChrome.Widgets.Bar import qs.HyprChrome.Widgets +import qs.HyprChrome.Widgets.Polkit // The hyprchrome shell: owns everything the rail's surfaces have to agree on, // and instantiates them. // // State lives here rather than in any one surface because more than one of them // reads it, and a second reader is what turns a local property into shared -// state. Three things qualify so far: +// state. Four things qualify so far: // // * which monitor the shell lives on — every surface has to pick the same one // * the density — the whole rail expands and collapses as one, so the toggle // and the shortcut that drives it belong to the shell, not to the bar +// * whether an authorization prompt is up — it raises the same scrim the rail +// uses and freezes the density while it is open, so two surfaces read it. +// That is why the agent lives here rather than as a sibling of the +// launchers in shell.qml // * the layer PAIR — the backdrop must sit exactly one layer below the bar in // both densities. Two surfaces on the same layer stack by creation order, // which is not something to rely on; one layer apart is a guarantee. Split @@ -44,21 +49,86 @@ Scope { return screens[0]; } + // Every output the rail does NOT live on. They get a scrim of their own + // while a prompt is up, because a modal that dims one monitor and leaves + // the others lit does not read as modal at all — and the rail's backdrop + // covers exactly one output. + readonly property var otherScreens: { + const out = []; + const screens = Quickshell.screens; + for (let i = 0; i < screens.length; i++) { + if (screens[i] !== shell.targetScreen) + out.push(screens[i]); + } + return out; + } + + // Where a prompt appears: wherever the user is actually looking, which is + // not necessarily where the rail lives. Hyprland reports the focused + // monitor by name, and Quickshell.screens is keyed the same way, so the + // two are matched by name exactly as targetScreen is above. + // + // Falls back to the rail's own screen rather than to nothing: a prompt that + // fails to place itself would leave its caller blocked on a dialog nobody + // can see. + readonly property var focusedScreen: { + const focused = Hyprland.focusedMonitor; + if (!focused) + return shell.targetScreen; + + const screens = Quickshell.screens; + for (let i = 0; i < screens.length; i++) { + if (screens[i].name === focused.name) + return screens[i]; + } + return shell.targetScreen; + } + // Density is a property of the SHELL: every panel follows it, so the whole // rail expands and collapses as one. Panels keep their own animation; only // the decision is centralised here. + // + // A prompt does NOT change it: an authorization request leaves the rail at + // whatever density it was, and only freezes it there. property bool expanded: false function toggle() { + // Frozen while a prompt is up, and dropped rather than queued: SUPER A + // during a prompt does nothing at all, instead of arming a change that + // springs the rail open or shut the moment the dialog goes. + if (polkit.prompting) + return; + shell.expanded = !shell.expanded; } - // Expanded the rail is over everything; collapsed it drops below ordinary - // windows. BOTTOM rather than BACKGROUND for the collapsed bar: it is the + // Whether the scrim is up, from EITHER cause. This is the fact the surfaces + // actually share — the rail's density is only one of the two things that + // can raise it — so the backdrop and the layer pair below key off this + // rather than off `expanded`. + // + // One backdrop instance serves both. A prompt arriving over an already + // expanded rail therefore changes nothing about the scrim: it is already up, + // already full height, and the dialog simply appears above it. A prompt over + // a COLLAPSED rail expands that same scrim from its bar-height band to the + // whole output, using the animation it already has, and the rail stays + // collapsed throughout. + readonly property bool scrimUp: shell.expanded || polkit.prompting + + // Scrim up, the rail is over everything; scrim down, it drops below ordinary + // windows. BOTTOM rather than BACKGROUND for the lowered bar: it is the // lowest level that still leaves a layer underneath for the backdrop, and // it keeps the rail off the wallpaper's own level. - readonly property int barLayer: shell.expanded ? WlrLayer.Overlay : WlrLayer.Bottom - readonly property int backdropLayer: shell.expanded ? WlrLayer.Top : WlrLayer.Background + // + // Both key off `scrimUp`, not `expanded`, so the pair stays exactly one + // level apart in every state — which is the whole point of deriving them + // together. A prompt over a collapsed rail raises BOTH: the scrim has to + // clear ordinary windows to dim them at all (BACKGROUND sits under them), + // and the bar has to stay one above the scrim or the shell would be dimming + // its own chrome. The rail is raised but still collapsed: its layer answers + // to the scrim, its height to `expanded`. + readonly property int barLayer: shell.scrimUp ? WlrLayer.Overlay : WlrLayer.Bottom + readonly property int backdropLayer: shell.scrimUp ? WlrLayer.Top : WlrLayer.Background // SUPER A — see hosts/terra/home/hyprland.nix. GlobalShortcut { @@ -72,7 +142,7 @@ Scope { // visual order costs nothing. ChromeBackdrop { screen: shell.targetScreen - active: shell.expanded + active: shell.scrimUp wlrLayer: shell.backdropLayer // Collapsed, the scrim only backs the rail, so it needs the band the @@ -83,6 +153,33 @@ Scope { barHeight: bar.contentHeight } + // The same scrim on every other output, up only while a prompt is. These + // have no rail to back, so barHeight stays 0 and revealHeight falls to + // nothing between prompts — the surfaces take themselves off screen rather + // than lingering as a strip the way the rail's does. + // + // Deliberately NOT tied to `scrimUp`: expanding the rail dims the rail's + // monitor only, which is the existing behaviour and the right one — the + // rail is a thing on one screen. A prompt is the only event that concerns + // every screen at once. + // + // TOP unconditionally: there is no bar on these outputs to keep one level + // above the scrim, and BACKGROUND would put the dim under ordinary windows + // where it would dim nothing. Inactive they are invisible, so the level + // costs nothing between prompts. + Variants { + model: shell.otherScreens + + ChromeBackdrop { + required property var modelData + + screen: modelData + active: polkit.prompting + wlrLayer: WlrLayer.Top + barHeight: 0 + } + } + HyprChromeBar { id: bar @@ -93,4 +190,24 @@ Scope { expanded: shell.expanded wlrLayer: shell.barLayer } + + // Polkit authentication agent. It registers for this logind session on + // creation, so it replaces hyprpolkitagent rather than coexisting with it — + // only one agent may hold a session (see hosts/terra/home/hyprland.nix). + // + // It lives here rather than beside the launchers in shell.qml because its + // state is shared: `prompting` raises the scrim and freezes the density, + // which makes it shell state by the same rule as the screen and the layer + // pair. It owns only its dialog; the scrim above is the rail's. + // + // Declared LAST on purpose. While a prompt is up the bar is on Overlay too, + // and there is no layer above Overlay to escape to, so the dialog stays on + // top by being the later surface. In practice it is later regardless — its + // window only exists while a request is open, so it is always created after + // the bar's — but the declaration order says so without relying on that. + PolkitPrompt { + id: polkit + + screen: shell.focusedScreen + } } diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml index fb46029..a2e7b7a 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml @@ -40,6 +40,12 @@ import qs.HyprChrome.Theme Scope { id: root + // Which output the dialog appears on. Driven by the shell, which puts it on + // the focused monitor rather than on the rail's — a password prompt belongs + // where the user is looking. Left unset it falls back to whatever screen + // quickshell picks, which is right for a single-monitor session. + property var screen: null + // Where the flow's identity list is currently pointed. Held here rather // than read back off the flow because the content addresses identities by // index and AuthFlow addresses them by object. @@ -50,6 +56,15 @@ Scope { // shows a dialog, which looks exactly like "no one asked for authorization". readonly property alias registered: agent.isRegistered + // Whether a request is being presented. Both surfaces read it, so it is + // decided once here rather than each deriving it — the scrim and the dialog + // must come and go on the same frame. + // + // isCompleted is checked as well as null: the flow reports its terminal + // state before the agent drops it, and neither surface should linger for + // those frames over a request that has already been decided. + readonly property bool prompting: root.flow !== null && !root.flow.isCompleted + // Reset per REQUEST, not per window show. // // A second request supersedes the first by swapping `flow` while the dialog @@ -136,13 +151,16 @@ Scope { } } + // No scrim of its own. The shell owns the single ChromeBackdrop and raises + // it for either cause — an expanded rail or an open prompt — so a prompt + // arriving over an already-expanded rail reuses the scrim that is already + // there instead of stacking a second one on top of it. `prompting` above is + // what the shell reads to decide. See HyprChromeShell. PanelWindow { id: win - // isCompleted is checked as well as null: the flow reports its terminal - // state before the agent drops it, and the dialog should not linger for - // those frames showing a request that has already been decided. - visible: root.flow !== null && !root.flow.isCompleted + screen: root.screen + visible: root.prompting WlrLayershell.layer: WlrLayer.Overlay // A real modal — unlike the rest of the rail, this one must take the @@ -158,14 +176,11 @@ Scope { bottom: true } - // Scrim. No click-to-dismiss: a polkit request is answered or - // explicitly cancelled, and losing one to a stray click on the - // wallpaper would leave the caller waiting with no visible reason. - Rectangle { - anchors.fill: parent - color: Theme.surface - opacity: 0.72 - } + // No scrim here — ChromeBackdrop above draws it. This surface stays + // transparent but unmasked, so it still swallows clicks across the + // whole output: a polkit request is answered or explicitly cancelled, + // and losing one to a stray click on the wallpaper would leave the + // caller waiting with no visible reason. PolkitPromptContent { id: content diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 4ed87b9..7272fad 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -15,7 +15,6 @@ import qs.HyprChrome.Widgets import qs.HyprChrome.Widgets.Bar.Debug import qs.HyprChrome.Widgets.Bar.Host import qs.HyprChrome.Widgets.Bar.Vitals -import qs.HyprChrome.Widgets.Polkit Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. @@ -36,10 +35,6 @@ Scope { Notifications {} - // Polkit authentication agent. Registers for this logind session on - // creation, so it replaces hyprpolkitagent rather than coexisting with it - // — only one agent may hold a session (see hosts/terra/home/hyprland.nix). - PolkitPrompt {} VolumeOsd {} // Host vitals HUD — toggle with SUPER CTRL V. From 27b924f5e9b269d2afa1c1ea67e9851e0d711e4f Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 1 Sep 2026 23:38:09 +0200 Subject: [PATCH 22/24] docs(quickshell): correct CLAUDE.md's config-path and architecture claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file said ~/.config/quickshell is a symlink to this repo and that saving a file hot-reloads the running shell. Both are wrong and cost real debugging time: hosts/terra/home.nix ships the tree with xdg.configFile."quickshell", which COPIES it into the store, so the config dir is a read-only symlink into /nix/store and the running shell watches the frozen copy. Hot reload only applies to a working-tree instance, which is what `nix develop` + qs-dev is for. Also records the trap that follows from it: a new file must be git-added before it can be deployed at all, because flakes read the git tree and drop untracked files silently — producing a deployed config that fails to load, which does not surface until the next restart because the running shell keeps serving the store path it resolved at launch. Architecture section was stale independently of that: shell.qml has not instantiated Bar/BarTop/BarBottom for a while, there are eleven launcher variants rather than eight, the HyprChrome tree was undocumented, and there are TWO Theme singletons — a token added to one does not exist in the other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud --- dotfiles/quickshell/CLAUDE.md | 110 ++++++++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 13 deletions(-) diff --git a/dotfiles/quickshell/CLAUDE.md b/dotfiles/quickshell/CLAUDE.md index c840656..1668082 100644 --- a/dotfiles/quickshell/CLAUDE.md +++ b/dotfiles/quickshell/CLAUDE.md @@ -4,38 +4,122 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A [Quickshell](https://quickshell.org/) configuration — a QML-based Wayland desktop shell (bar, launcher, tray, decorations) for a Hyprland/wlroots setup. `~/.config/quickshell` is a symlink to this repo, so Quickshell loads `shell.qml` here as the "default" config. +A [Quickshell](https://quickshell.org/) configuration — a QML-based Wayland desktop shell (bar, launcher, tray, decorations) for a Hyprland/wlroots setup. `shell.qml` is the entry point. ## Running / testing changes +**`~/.config/quickshell` is NOT a symlink to this repo.** `hosts/terra/home.nix` +ships the tree with `xdg.configFile."quickshell"`, which COPIES it into the nix +store, so the config directory is a read-only symlink into `/nix/store/...`. +Editing a file here therefore changes nothing about the running shell: it is +watching the frozen store copy, and every edit would otherwise cost a +`nixos-rebuild`. Two consequences worth knowing before debugging anything: + +- **A new file must be `git add`ed before it can be deployed at all.** Flakes + read the git tree, and untracked files are silently dropped — with no warning + and no eval error. An untracked module that `shell.qml` imports produces a + deployed config that fails to load, which does not surface until the next + restart because the running shell keeps serving the store path it resolved at + launch. +- To check what a deploy actually shipped, compare the evaluated source with + what is live: + `nix eval --raw '.#nixosConfigurations.terra.config.home-manager.users.darman.xdg.configFile."quickshell".source'` + then `ls` that path against `ls -l ~/.config/quickshell`. + ```sh -qs # runs ~/.config/quickshell/shell.qml (this repo, since it's the symlinked default config) -qs -p . # run this directory explicitly regardless of symlink -qs -n # exit immediately if another instance is already running (use to avoid duplicate shells while iterating) +nix develop # then: qs-dev — swap the running shell for the WORKING TREE, no rebuild +qs # runs the packaged (store) config +qs -p . # run this directory explicitly +qs -n # exit immediately if another instance is already running +qs kill # kill the default-config instance ('qs kill -p ' for a working-tree one) ``` -Quickshell hot-reloads QML on file save when already running, so for most edits just save and check the running instance rather than restarting `qs`. It watches file CONTENT: `touch` alone never reloads (mtime is not a change), while any real edit does, including inode-replacing ones (`sed -i`, `perl -i`). A save that is not picked up leaves the shell rendering the previous config with no error — `qs log` shows a `Reloading configuration...` line for every save it saw, so that is the check. There is no separate build/lint/test tooling in this repo — verification is visual/behavioral via the running shell. `qmlls` (QML language server) is configured via `.qmlls.ini` for editor diagnostics. +`qs-dev` is the edit-save-see loop: it starts a working-tree instance, waits +until it is confirmed up, and only then kills the packaged one, so a QML error +leaves you on your normal bar instead of no bar. It is a SWAP rather than a +second instance because quickshell keys instance identity on the config path — +two instances would both map layer-shell bars onto every output. See the +`nix develop` block in `flake.nix`. + +Pointed at the working tree, quickshell hot-reloads on file save. It watches +file CONTENT: `touch` alone never reloads (mtime is not a change), while any +real edit does, including inode-replacing ones (`sed -i`, `perl -i`). A save +that is not picked up leaves the shell rendering the previous config with no +error — `qs log` shows a `Reloading configuration...` line for every save it +saw, so that is the check. + +A single component can also be run in isolation, which is the way to exercise +something that owns a service or a surface without bringing up the whole rail: +point `qs -p` at a scratch directory whose `shell.qml` instantiates only that +component, with the repo's directories symlinked in for the `qs.` imports. + +There is no build/lint/test tooling wired up in this repo, but two things are +worth reaching for. `qmllint` (from qtdeclarative) catches syntax and binding +errors without a compositor — expect noise from the synthesized `qs.*` modules +and the `Theme` singleton, which it cannot resolve: + +```sh +qmllint -I /lib/qt-6/qml -I /lib/qt-6/qml -I . .qml +``` + +And `tools/quickshell-preview/render.sh` renders an `Item`-rooted component to +a PNG offscreen (see `tests/`). Keep production `PanelWindow` wrappers thin and +put the visuals in an `Item` so they can go through that path. `qmlls` is +configured via `.qmlls.ini` for editor diagnostics. ## Architecture -`shell.qml` is the entry point: a `Scope` that instantiates the top-level pieces — `Bar`, `BarBottom`, `BarTop`, and a hidden `Launcher` — as siblings. Each top-level widget manages its own `PanelWindow`(s); there's no central layout manager. +`shell.qml` is a `Scope` instantiating the top-level pieces as siblings: +`HyprChromeShell` (the status rail), the eleven launcher variants, +`Notifications`, `VolumeOsd` and `Vitals`. Each manages its own +`PanelWindow`(s); there is no central layout manager. + +The one exception, and the pattern to follow for anything new that needs it, is +`HyprChromeShell`: it owns the state its surfaces have to AGREE on rather than +letting each decide — which monitor they live on, the rail's density, whether a +polkit prompt is open, and the layer pair. A second reader is what makes a +property shell state; the file's own header comment enumerates them and says +why each qualifies. Layer levels in particular are derived TOGETHER, because +two surfaces on one layer stack by creation order while one layer apart is a +guarantee. **Import convention**: QML modules are imported by their path under the repo root using the `qs.` namespace, e.g. `import qs.widgets.launcher`, `import qs.widgets.decoration`. Sibling files in the same directory are imported with a relative string import instead (e.g. `Bar.qml` does `import "modules"`). -**Multi-monitor**: Bar/BarTop/BarBottom each wrap their `PanelWindow` in `Variants { model: Quickshell.screens }`, so one window instance is created per connected screen. `pragma ComponentBehavior: Bound` + `required property var modelData` is the standard pattern for these per-screen delegates. +**Multi-monitor**: a surface that must exist on every screen wraps its +`PanelWindow` in `Variants { model: Quickshell.screens }`, one instance per +connected screen; `pragma ComponentBehavior: Bound` + `required property var +modelData` is the standard pattern for those delegates. A surface that belongs +to ONE screen instead takes it as a property from the shell. Note +`Quickshell.screens` is a QML list, not a JS array — no `.find()` or `.filter()` +on it, hence the index loops in `HyprChromeShell`. **Directory layout**: -- `widgets/bar/` — `Bar.qml` is the main sidebar (right-anchored, full height) hosting the module stack (date, clock, tray, decorative dividers); `BarTop.qml`/`BarBottom.qml` are thin accent-colored strips anchored to the top/bottom edges. -- `widgets/bar/modules/` — individual bar widgets (`Clock`, `Date`, `Tray`/`TrayItem`, `Volume`) built on the shared `BarWidget` base component. -- `widgets/launcher/` — shared `AppModel` search/execution plus eight launcher variants. `ApplicationLauncher` (variant 8 and the primary `SUPER` launcher) keeps its visual core in the headlessly renderable `ApplicationLauncherContent`; variants 1–7 remain available on `SUPER CTRL 1–7` for comparison. +- `HyprChrome/` — the current shell. `Widgets/HyprChromeShell.qml` is the owner + described above; `Widgets/ChromeBackdrop.qml` is the scrim (dim + drafting + grid) shared by the rail and the polkit prompt; `Widgets/Bar/` holds the rail + and its panels, with `Bar/Panels/BarPanel.qml` the chamfered chrome they all + extend; `Widgets/Polkit/` is the authentication agent and its dialog; + `Theme/Theme.qml` is this tree's palette singleton. `DebugWindow.qml` stages a + single widget on the secondary monitor for eyeballing it in isolation. +- `widgets/bar/` — `DenseBar` and `StatusBarPanel`, the rail's predecessor. Not + instantiated by `shell.qml` any more; `StatusBarPanel` is still used by the + launchers. +- `widgets/launcher/` — shared `AppModel` search/execution plus eleven launcher + variants. `ApplicationLauncher` (variant 8, and the primary `SUPER` launcher) + keeps its visual core in the headlessly renderable + `ApplicationLauncherContent`; the rest are available on `SUPER CTRL 1–11` for + comparison. - `widgets/decoration/` — reusable QtQuick `Shape`-based visual accents (angled panel edges, slashes) used to give bar panels their non-rectangular look. `Dummy.qml` is a placeholder/test rectangle. - `widgets/input/` — thin wrappers around `QtQuick.Controls` inputs (currently just `TextField`). - `widgets/layout/` — `HorizontalStack`/`VerticalStack`: `RowLayout`/`ColumnLayout` wrappers that expose `default property alias content` for terser call sites, with a trailing filler `Item` that soaks up remaining space. - `assets/` — SVG icons referenced via `file://${Quickshell.shellDir}/assets/...`. -**Styling**: All colors and font families come from the `Theme` singleton -(`widgets/theme/Theme.qml`, `import qs.widgets.theme`) — there are no color or -font literals left anywhere under `widgets/`. Add a token there rather than +**Styling**: All colors and font families come from a `Theme` singleton — there +are no color or font literals left anywhere under `widgets/`. There are TWO, +carrying the same palette for the two trees: `widgets/theme/Theme.qml` +(`import qs.widgets.theme`) and `HyprChrome/Theme/Theme.qml` +(`import qs.HyprChrome.Theme`). Match the one your file's tree already uses; a +token added to one does not exist in the other. Add a token rather than hardcoding a value; alpha variants of the two main colors go through `Theme.textAlpha(a)` / `Theme.accentAlpha(a)` instead of a hand-written `Qt.rgba(...)`. Metrics (sizes, spacing) are still per-component. From b3c3cc38f0a7030f32d26b6957bb88107b200e73 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 1 Sep 2026 23:38:23 +0200 Subject: [PATCH 23/24] feat(quickshell): give the prompt its own panel, ESC handling and placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolkitPanel replaces BarPanel as the dialog's chrome. Deliberately not a subclass or a fork: most of BarPanel is density machinery — summary slot, animated height, state pair, transitions — that a modal never uses, and inheriting it would tie the dialog's look to a component whose real job is the rail, so every restyle here would have to be justified against the panels up there. It keeps the shell's silhouette (cut corners with detached accent caps, the accent rules, the header strip) and drops the rail's tick decoration. ESC closes the rail. The bar had no keyboard focus at all, so this adds it, gated by the shell rather than left to the compositor to arbitrate between two exclusive surfaces — that resolves by stacking and would invert silently the day the layers change: grabsKeyboard: shell.expanded && !polkit.prompting so ESC dismisses the prompt while one is open and closes the rail afterwards. Verified by instrumenting the handoff: expanded -> true, prompt open -> false, prompt dismissed -> true, collapsed -> false. Note the rail now takes EXCLUSIVE keyboard focus while expanded, which is the cost of answering a keypress the user has not aimed at anything. The dialog sits a third of the way down rather than centred, panel centre on the third so it grows symmetrically as the message wraps, floored at a margin so a tall prompt on a short output cannot be pushed off the top. Also carries the backdrop tuning: dim 0.75 -> 0.65, gridOpacity 0.15 -> 0.10, crossOpacity 0.45 -> 0.15, now that the scrim is used by the prompt as well as the rail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud --- .../HyprChrome/Widgets/Bar/HyprChromeBar.qml | 44 ++++ .../HyprChrome/Widgets/ChromeBackdrop.qml | 6 +- .../HyprChrome/Widgets/HyprChromeShell.qml | 10 + .../HyprChrome/Widgets/Polkit/PolkitPanel.qml | 202 ++++++++++++++++++ .../Widgets/Polkit/PolkitPrompt.qml | 11 +- .../Widgets/Polkit/PolkitPromptContent.qml | 22 +- 6 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPanel.qml diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml index 4f5f2f2..f85e001 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml @@ -28,6 +28,50 @@ PanelWindow { // HyprChromeShell. property int wlrLayer: WlrLayer.Overlay + // Whether the rail should hold the keyboard, so ESC can close it. Driven by + // the shell rather than derived from `expanded`, because the rail is not the + // only thing that wants the keyboard: while a polkit prompt is up the shell + // withholds this, so ESC reaches the DIALOG and dismisses that instead. + // Once the prompt is gone the rail gets the keyboard back and a second ESC + // closes the rail — one key, one thing at a time, innermost first. + property bool grabsKeyboard: false + + // EXCLUSIVE rather than OnDemand: OnDemand only offers focus to a surface + // the user clicks, and the whole point here is to answer a keypress the + // user has not aimed at anything. Taking the keyboard is defensible because + // an expanded rail is already a modal-ish state — it dims the desktop + // behind itself with the same scrim the prompt uses. + WlrLayershell.keyboardFocus: window.grabsKeyboard + ? WlrKeyboardFocus.Exclusive + : WlrKeyboardFocus.None + + signal dismissed + + // A layer surface only delivers keys to an item that has active focus, and + // nothing in the rail wants focus for its own sake — the panels are + // readouts. So one focus sink covers the whole surface. It re-takes focus + // whenever the grab is handed back, since losing the surface's focus drops + // the item's too. + Item { + id: keySink + + anchors.fill: parent + focus: true + + Keys.onEscapePressed: event => { + window.dismissed(); + event.accepted = true; + } + + Connections { + target: window + function onGrabsKeyboardChanged() { + if (window.grabsKeyboard) + keySink.forceActiveFocus(); + } + } + } + // Own namespace so a layerrule can exempt the rail from Hyprland's layer // animation without also catching the launchers, which share the default // "quickshell" namespace and do want their fade. diff --git a/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml b/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml index da699c6..a9aff52 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml @@ -32,7 +32,7 @@ PanelWindow { // of a pair with the bar's, so the shell derives both. property int wlrLayer: WlrLayer.Top - property real dim: 0.75 + property real dim: 0.65 property int gridSpacing: 60 // Height of the collapsed rail, including its margins — the band the scrim @@ -84,14 +84,14 @@ PanelWindow { // The dense bar drew this grid at 0.018 against its own near-black panel. // Over a 55% scrim on top of lit windows that is invisible, so it is a // knob rather than a constant. - property real gridOpacity: 0.15 + property real gridOpacity: 0.10 // The accent with its saturation pulled back: warm enough to read as part // of the palette, not so loud that a full-screen grid competes with the // bar. Derived rather than a literal so it tracks a palette change. // Registration crosses sit on every other intersection of the grid. property color crossColor: Theme.muted - property real crossOpacity: 0.45 + property real crossOpacity: 0.15 property int crossSize: 20 // Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd diff --git a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml index 8b9c3c1..6f90fd5 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml @@ -189,6 +189,16 @@ Scope { visible: shell.targetScreen !== null expanded: shell.expanded wlrLayer: shell.barLayer + + // ESC closes the rail — but only when it is the innermost thing open. + // While a prompt is up the rail gives up the keyboard so ESC dismisses + // the DIALOG; the prompt closing hands it back, and the next ESC closes + // the rail. Withheld rather than left to the compositor to arbitrate + // between two exclusive surfaces, which would decide by stacking and + // silently swap the order the day the layers change. + grabsKeyboard: shell.expanded && !polkit.prompting + + onDismissed: shell.expanded = false } // Polkit authentication agent. It registers for this logind session on diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPanel.qml new file mode 100644 index 0000000..57451ec --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPanel.qml @@ -0,0 +1,202 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes +import qs.HyprChrome.Theme + +// Panel chrome for the authentication dialog. +// +// Deliberately NOT BarPanel. The rail's panel exists to carry two renderings of +// the same data and cross-fade between them as the rail changes density, and +// almost all of its size is that machinery: the summary slot, the animated +// height, the state pair, the transitions. A modal has exactly one density and +// never collapses, so inheriting all of that would mean carrying dead weight +// and, worse, tying the dialog's look to a component whose real job is the rail +// — every restyle here would have to be justified against the panels up there. +// +// What it does keep is the silhouette, because that is the shell's visual +// signature rather than the rail's: two cut corners (top-right, bottom-left) +// with detached accent caps outside them, an accent rule under the header slug, +// its mirror at the lower right, and a header strip of slug / title / meta. +// +// This is the file to edit to restyle the prompt. Nothing else reads it. +Item { + id: panel + + property string panelId: "" + property string title: "" + property string meta: "" + + property int chamfer: 16 + property int padding: 14 + property int outlineWidth: 1 + property int accentLineThickness: 3 + + readonly property int headerHeight: 30 + + // Gap between the header rule and the body. + property int headerGap: 10 + readonly property int headerPadding: 10 + + // Detached corner caps: the corner each chamfer removed, put back outside + // the panel as an accent triangle whose hypotenuse faces the cut. capGap is + // the perpendicular distance from the cut, so the per-axis shift is it over + // root 2 — the cap moves along the cut's normal, not along an axis. + property real capGap: 4 + readonly property real capOffset: panel.capGap / Math.SQRT2 + + // Never let the two cuts cross, which would turn the outline inside out on + // a panel shorter than twice the chamfer. + readonly property real activeChamfer: Math.max(2, Math.min(panel.chamfer, panel.height / 2 - 1)) + + // The accent rule under the slug is sized to the slug, not to the panel. + readonly property real accentLineWidth: Math.min(panel.width, slugChip.width + panel.headerPadding * 2) + + default property alias content: body.data + + implicitHeight: Math.round(body.y + body.height + panel.padding) + + Shape { + id: panelShape + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + // Outline: square except for the two cut corners. + ShapePath { + fillColor: Theme.surface + strokeColor: Theme.hair + strokeWidth: panel.outlineWidth + + startX: 0; startY: 0 + PathLine { x: panelShape.width - panel.activeChamfer; y: 0 } + PathLine { x: panelShape.width; y: panel.activeChamfer } + PathLine { x: panelShape.width; y: panelShape.height } + PathLine { x: panel.activeChamfer; y: panelShape.height } + PathLine { x: 0; y: panelShape.height - panel.activeChamfer } + PathLine { x: 0; y: 0 } + } + + // Cap on the top-right cut. + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + + startX: panelShape.width - panel.activeChamfer + panel.capOffset + startY: -panel.capOffset + PathLine { x: panelShape.width + panel.capOffset; y: panel.activeChamfer - panel.capOffset } + PathLine { x: panelShape.width + panel.capOffset; y: -panel.capOffset } + PathLine { x: panelShape.width - panel.activeChamfer + panel.capOffset; y: -panel.capOffset } + } + + // Cap on the bottom-left cut, the same triangle mirrored. + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + + startX: panel.activeChamfer - panel.capOffset + startY: panelShape.height + panel.capOffset + PathLine { x: -panel.capOffset; y: panelShape.height - panel.activeChamfer + panel.capOffset } + PathLine { x: -panel.capOffset; y: panelShape.height + panel.capOffset } + PathLine { x: panel.activeChamfer - panel.capOffset; y: panelShape.height + panel.capOffset } + } + + // Accent rule under the slug. + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + + startX: 0; startY: 0 + PathLine { x: panel.accentLineWidth; y: 0 } + PathLine { x: panel.accentLineWidth; y: panel.accentLineThickness } + PathLine { x: 0; y: panel.accentLineThickness } + PathLine { x: 0; y: 0 } + } + + // Its mirror at the lower right. + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + + startX: panelShape.width; startY: panelShape.height + PathLine { x: panelShape.width - panel.accentLineWidth; y: panelShape.height } + PathLine { x: panelShape.width - panel.accentLineWidth; y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width; y: panelShape.height } + } + } + + // Header: slug chip, title, and the meta text pinned right. + Row { + id: headerRow + + x: panel.headerPadding + y: Math.round((panel.headerHeight - height) / 2) + spacing: panel.headerPadding + 6 + + Rectangle { + id: slugChip + + width: slugText.implicitWidth + 8 + height: slugText.implicitHeight + 4 + color: Theme.accent + + Text { + id: slugText + + anchors.centerIn: parent + text: panel.panelId + color: Theme.surface + font.family: Theme.microFont + font.pixelSize: 11 + font.bold: true + } + } + + Text { + anchors.verticalCenter: slugChip.verticalCenter + text: panel.title + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 12 + font.bold: true + font.letterSpacing: 1.1 + elide: Text.ElideRight + } + } + + Text { + anchors.right: parent.right + anchors.rightMargin: panel.headerPadding + 2 + y: Math.round((panel.headerHeight - implicitHeight) / 2) + visible: panel.meta.length > 0 + text: panel.meta + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 8 + font.letterSpacing: 0.7 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + + // Header rule. + Rectangle { + x: 1 + y: panel.headerHeight + width: parent.width - 2 + height: 1 + color: Theme.text + opacity: 0.12 + } + + // Body. Measured by childrenRect, so a child must carry its own size and + // must NOT anchor to this slot. + Item { + id: body + + x: panel.padding + y: panel.headerHeight + panel.headerGap + width: Math.max(0, panel.width - panel.padding * 2) + height: childrenRect.height + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml index a2e7b7a..2674bff 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml @@ -185,7 +185,16 @@ Scope { PolkitPromptContent { id: content - anchors.centerIn: parent + // A third of the way down rather than centred: a password prompt + // reads better above the middle, and on a tall output dead-centre + // puts it below the natural resting line of the eye. + // + // The panel's own CENTRE lands on the third, so the dialog grows + // symmetrically about that line as the message wraps or a pam_info + // line appears. Floored at the same margin the width leaves, so a + // tall prompt on a short output cannot be pushed off the top. + anchors.horizontalCenter: parent.horizontalCenter + y: Math.max(32, Math.round(parent.height / 3 - height / 2)) width: 520 message: root.flow ? root.flow.message : "" diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml index 952cfc9..135594f 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml @@ -5,7 +5,6 @@ import QtQuick.Layouts import Quickshell import Quickshell.Widgets import qs.HyprChrome.Theme -import qs.HyprChrome.Widgets.Bar.Panels // Headlessly renderable visual core of the polkit authentication prompt. // @@ -73,7 +72,7 @@ Item { elide: Text.ElideRight } - BarPanel { + PolkitPanel { id: panel width: root.width @@ -82,23 +81,10 @@ Item { // The action id is the one piece that says WHAT is being authorized // independently of the (localizable, often vague) message. meta: root.actionId - chamfer: 16 - // A modal, not a rail panel: clicking the body must reach the input - // rather than collapse the dialog out from under it. - expanded: true - toggleOnClick: false - - // Collapsed rendering is never shown here, but BarPanel keeps both - // slots instantiated, so the summary stays bound to the same truth. - summary: MicroText { - text: root.inputPrompt - color: Theme.text - } - - // Sized like every other BarPanel body: the slot decides the width and - // the layout's implicitHeight becomes its height, so a wrapped message - // or an extra pam_info line grows the panel instead of being clipped. + // The body slot decides the width and the layout's implicitHeight + // becomes its height, so a wrapped message or an extra pam_info line + // grows the panel instead of being clipped. ColumnLayout { width: parent.width spacing: 10 From f7b12bc7cd4366b2cb8a1df0e505b2b11338dc58 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Fri, 18 Sep 2026 20:39:06 +0200 Subject: [PATCH 24/24] WIP --- .envrc | 1 + CLAUDE.md | 3 + README.md | 9 +- dotfiles/quickshell/CLAUDE.md | 17 +- .../Widgets/Askpass/AskpassPrompt.qml | 182 ++++++++++ .../HyprChrome/Widgets/HyprChromeShell.qml | 64 +++- .../Widgets/Launcher/AppLauncher.qml} | 54 ++- .../Widgets/Launcher/AppLauncherContent.qml} | 17 +- .../HyprChrome/Widgets/Launcher/AppModel.qml | 44 +++ .../Widgets/Launcher/LauncherPanel.qml | 136 +++++++ dotfiles/quickshell/shell.qml | 7 +- ...erHeadless.qml => AppLauncherHeadless.qml} | 4 +- flake.lock | 80 ++--- flake.nix | 262 +++++++------- hosts/mars/configuration.nix | 6 +- hosts/mars/hermes-agent.nix | 64 +++- hosts/mars/luna-sites-README.md | 94 +++++ hosts/mars/luna-sites-test.nix | 186 ++++++++++ hosts/mars/luna-sites.nix | 334 ++++++++++++++++++ hosts/terra/configuration.nix | 55 ++- hosts/terra/home.nix | 56 ++- hosts/terra/home/hyprland.nix | 21 +- hosts/terra/home/theme.nix | 16 + services/desktop/librechat.nix | 2 +- 24 files changed, 1474 insertions(+), 240 deletions(-) create mode 100644 .envrc create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Askpass/AskpassPrompt.qml rename dotfiles/quickshell/{widgets/launcher/ApplicationLauncher.qml => HyprChrome/Widgets/Launcher/AppLauncher.qml} (51%) rename dotfiles/quickshell/{widgets/launcher/ApplicationLauncherContent.qml => HyprChrome/Widgets/Launcher/AppLauncherContent.qml} (98%) create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppModel.qml create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Launcher/LauncherPanel.qml rename dotfiles/quickshell/tests/{ApplicationLauncherHeadless.qml => AppLauncherHeadless.qml} (96%) create mode 100644 hosts/mars/luna-sites-README.md create mode 100644 hosts/mars/luna-sites-test.nix create mode 100644 hosts/mars/luna-sites.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..f9d77ee --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +PATH_add scripts diff --git a/CLAUDE.md b/CLAUDE.md index 00d956d..e384ccc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,9 @@ nix build .#nixosConfigurations.mercury-vm.config.system.build.vm -o result nix build .#nixosConfigurations.jupiter-vbox.config.system.build.virtualBoxOVA # end-to-end VM test of `deploy kexec-local` (~45s once the tarball is built) nix build .#checks.x86_64-linux.kexec-local -L +# VM test of luna's app hosting on mars (hosts/mars/luna-sites.nix): podman socket +# proxy, registry validation, caddy routes, reboot persistence +nix build .#checks.x86_64-linux.luna-sites -L ``` `checks.kexec-local` is the only way to exercise `kexec-local` at all: it jumps the diff --git a/README.md b/README.md index 2429ef4..6f1b8f5 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Flake-based NixOS config. Hosts: `jupiter` (ZimaBlade, NAS + services), `neptun` (netcup VPS: public reverse proxy, Authentik, headscale), -`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop), `mars` (on-site, -single-purpose: Hermes Agent only). +`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop), `mars` (on-site: +Hermes Agent, plus the LAN web apps luna hosts at `http://mars.sol//`). ## Structure @@ -27,9 +27,12 @@ hosts/ vm.nix # VirtualBox test image (jupiter-vbox) neptun/ # netcup public reverse proxy + tailnet node configuration.nix disk-config.nix hardware-configuration.nix secrets.nix - mars/ # on-site, single-purpose: Hermes Agent only + mars/ # on-site: Hermes Agent + luna's LAN web apps configuration.nix disk-config.nix hardware-configuration.nix secrets.nix hermes-agent.nix # Hermes Agent (moved here from jupiter) + luna-sites.nix # luna's apps: rootless podman + caddy, no nix edit per app + luna-sites-README.md # what luna is told (mounted into her container) + luna-sites-test.nix # VM test: nix build .#checks.x86_64-linux.luna-sites -L secrets/ # age-encrypted sops files, one per host scripts/ # deploy, edit_secrets ``` diff --git a/dotfiles/quickshell/CLAUDE.md b/dotfiles/quickshell/CLAUDE.md index 1668082..f25a650 100644 --- a/dotfiles/quickshell/CLAUDE.md +++ b/dotfiles/quickshell/CLAUDE.md @@ -99,16 +99,21 @@ on it, hence the index loops in `HyprChromeShell`. grid) shared by the rail and the polkit prompt; `Widgets/Bar/` holds the rail and its panels, with `Bar/Panels/BarPanel.qml` the chamfered chrome they all extend; `Widgets/Polkit/` is the authentication agent and its dialog; + `Widgets/Launcher/` is the primary application launcher (`SUPER_L`); `Theme/Theme.qml` is this tree's palette singleton. `DebugWindow.qml` stages a single widget on the secondary monitor for eyeballing it in isolation. + + The prompt and the launcher are MODALS: each raises the shared scrim, lands + on the focused monitor, and takes the keyboard off the rail. That is why the + shell instantiates them rather than `shell.qml` — see `modalOpen` there, which + is the one place a new modal has to be named. - `widgets/bar/` — `DenseBar` and `StatusBarPanel`, the rail's predecessor. Not instantiated by `shell.qml` any more; `StatusBarPanel` is still used by the - launchers. -- `widgets/launcher/` — shared `AppModel` search/execution plus eleven launcher - variants. `ApplicationLauncher` (variant 8, and the primary `SUPER` launcher) - keeps its visual core in the headlessly renderable - `ApplicationLauncherContent`; the rest are available on `SUPER CTRL 1–11` for - comparison. + remaining launcher variants. +- `widgets/launcher/` — shared `AppModel` search/execution plus the ten launcher + variants still under evaluation, on `SUPER CTRL 1–11`. Variant 8 has moved to + `HyprChrome/Widgets/Launcher/`; `AppModel.qml` is duplicated there so the + HyprChrome tree stands alone, and this copy goes when the variants do. - `widgets/decoration/` — reusable QtQuick `Shape`-based visual accents (angled panel edges, slashes) used to give bar panels their non-rectangular look. `Dummy.qml` is a placeholder/test rectangle. - `widgets/input/` — thin wrappers around `QtQuick.Controls` inputs (currently just `TextField`). - `widgets/layout/` — `HorizontalStack`/`VerticalStack`: `RowLayout`/`ColumnLayout` wrappers that expose `default property alias content` for terser call sites, with a trailing filler `Item` that soaks up remaining space. diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Askpass/AskpassPrompt.qml b/dotfiles/quickshell/HyprChrome/Widgets/Askpass/AskpassPrompt.qml new file mode 100644 index 0000000..27bd58f --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Askpass/AskpassPrompt.qml @@ -0,0 +1,182 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import qs.HyprChrome.Theme +import qs.HyprChrome.Widgets.Polkit + +// GUI password prompt for `sudo -A`, reusing the polkit dialog. +// +// sudo does NOT speak polkit — it is setuid + PAM reading your tty, and no +// sudoers option bridges the two. What it does support is an ASKPASS helper: a +// program it runs to obtain the password, which prints it on stdout. So this is +// not the polkit agent serving sudo; it is a second, separate path that happens +// to render the same dialog. +// +// Flow, driven by the helper in home.nix (`qs-askpass`): +// +// sudo -A +// -> qs-askpass makes a 0600 fifo under XDG_RUNTIME_DIR +// -> qs ipc call askpass prompt "" "" (returns at once) +// -> this dialog opens, user types +// -> a one-line writer is started here, secret written to its STDIN +// -> qs-askpass reads the fifo and prints the secret on stdout +// -> sudo reads it +// +// The secret travels on a pipe the whole way. It is never an argument and never +// an environment variable, so it does not appear in /proc for any process — the +// fifo PATH is in argv, which is not secret. It does cross more process +// boundaries than the polkit path, where the password stays inside the PAM +// conversation; that is the inherent cost of askpass, not of this design. +// +// Cancelling answers with an empty line, so the helper reads nothing, exits +// non-zero, and sudo aborts rather than burning a retry on a blank password. +Scope { + id: root + + // Which output to appear on; the shell puts it on the focused monitor. + property var screen: null + + // The fifo the helper is blocked reading. Non-empty means a request is in + // flight, which is exactly what "a prompt is open" means here. + property string fifoPath: "" + property string promptText: "" + property bool failed: false + + readonly property bool active: root.fifoPath !== "" + + // Held only between submit and the writer process actually starting: a + // Process cannot be written to before it is running. + property string pendingSecret: "" + + IpcHandler { + target: "askpass" + + // Called by qs-askpass. Returns immediately — the helper blocks on the + // fifo, not on this call, because an IpcHandler function runs on the + // QML thread and blocking here would freeze the whole shell. + function prompt(message: string, fifo: string): string { + if (root.active) + return "busy"; + + root.promptText = message === "" ? "Password:" : message; + root.fifoPath = fifo; + root.failed = false; + return "ok"; + } + + // So a helper that times out can take the dialog down with it rather + // than leaving it on screen with nothing listening. + function cancel(): string { + root.dismiss(); + return "ok"; + } + } + + // Cancelling answers with an EMPTY line rather than by closing silently: + // the helper then reads zero bytes and exits non-zero, so sudo aborts + // instead of spending a retry on a blank password. + function dismiss() { + root.respond(""); + } + + function submit(secret) { + root.respond(secret); + } + + // The writer reads ONE LINE and exits; it does not wait for EOF. + // + // The obvious version — `cat > fifo`, write the secret, then close stdin by + // setting stdinEnabled false — does not terminate. Measured: the secret + // arrives intact but `cat` never sees EOF, so the fifo is never closed and + // the helper blocks until its timeout. sudo would hang after you typed. + // + // A single `read` needs no EOF at all: the trailing newline ends it, the + // shell writes what it got and exits, and THAT close is what gives the + // helper its EOF. `IFS=` keeps leading and trailing whitespace, `-r` keeps + // backslashes, and the secret still travels on stdin rather than in argv. + function respond(secret) { + if (!root.active) + return; + + root.pendingSecret = secret + "\n"; + writer.command = ["sh", "-c", "IFS= read -r line; printf %s \"$line\" > \"$1\"", "sh", root.fifoPath]; + writer.running = true; + root.fifoPath = ""; + } + + // Opening a fifo for writing BLOCKS until a reader attaches, which is why + // this is a subprocess rather than a FileView: the helper's `cat` is that + // reader, and blocking the QML thread on it would freeze the shell. + Process { + id: writer + + stdinEnabled: true + + // Written on `started`, not at respond() time: a Process has no stdin + // to write to until it is actually running. + onStarted: { + writer.write(root.pendingSecret); + root.pendingSecret = ""; + } + } + + PanelWindow { + id: win + + screen: root.screen + visible: root.active + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + color: Theme.textAlpha(0) + + anchors { + top: true + left: true + right: true + bottom: true + } + + // No click-off dismissal, for the same reason the polkit dialog has + // none: something is blocked waiting on the answer, and losing it to a + // stray click would leave sudo hanging with no visible cause. + + PolkitPromptContent { + id: content + + anchors.horizontalCenter: parent.horizontalCenter + y: Math.max(32, Math.round(parent.height / 3 - height / 2)) + width: 520 + + // Deliberately the polkit dialog's own content component: this is a + // password prompt with the same shape, and keeping one means a + // restyle of PolkitPanel covers both. `identities` stays empty — + // sudo offers no choice of who authenticates — which hides the + // picker and the "AS" line on its own. + message: "Authentication is required to run a command as another user" + actionId: "sudo" + iconName: "" + showIcon: false + identities: [] + + responseRequired: true + inputPrompt: root.promptText + responseVisible: false + failed: root.failed + + onSubmitted: value => root.submit(value) + onCancelled: root.dismiss() + } + + onVisibleChanged: { + if (win.visible) { + content.clearResponse(); + content.focusInput(); + } + } + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml index 6f90fd5..3d6f7cf 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml @@ -6,6 +6,8 @@ import Quickshell.Wayland import qs.HyprChrome.Widgets.Bar import qs.HyprChrome.Widgets import qs.HyprChrome.Widgets.Polkit +import qs.HyprChrome.Widgets.Launcher +import qs.HyprChrome.Widgets.Askpass // The hyprchrome shell: owns everything the rail's surfaces have to agree on, // and instantiates them. @@ -17,10 +19,11 @@ import qs.HyprChrome.Widgets.Polkit // * which monitor the shell lives on — every surface has to pick the same one // * the density — the whole rail expands and collapses as one, so the toggle // and the shortcut that drives it belong to the shell, not to the bar -// * whether an authorization prompt is up — it raises the same scrim the rail -// uses and freezes the density while it is open, so two surfaces read it. -// That is why the agent lives here rather than as a sibling of the -// launchers in shell.qml +// * whether a MODAL is open — the polkit prompt, the launcher, or the sudo +// askpass dialog. Each raises the same scrim the rail uses, freezes the +// density, lands on the focused monitor and takes the keyboard off the +// rail, so several surfaces read it. That is why they live here rather than +// as siblings of the remaining launcher variants in shell.qml // * the layer PAIR — the backdrop must sit exactly one layer below the bar in // both densities. Two surfaces on the same layer stack by creation order, // which is not something to rely on; one layer apart is a guarantee. Split @@ -96,24 +99,30 @@ Scope { // Frozen while a prompt is up, and dropped rather than queued: SUPER A // during a prompt does nothing at all, instead of arming a change that // springs the rail open or shut the moment the dialog goes. - if (polkit.prompting) + if (shell.modalOpen) return; shell.expanded = !shell.expanded; } - // Whether the scrim is up, from EITHER cause. This is the fact the surfaces - // actually share — the rail's density is only one of the two things that - // can raise it — so the backdrop and the layer pair below key off this - // rather than off `expanded`. + // The surfaces that take over the screen: they dim EVERY output, land on the + // focused one, and take the keyboard off the rail. Grouped because + // everything below treats them alike, so a fourth one joins by being named + // here and nowhere else. + readonly property bool modalOpen: polkit.prompting || launcher.active || askpass.active + + // Whether the scrim is up, from ANY cause. This is the fact the surfaces + // actually share — the rail's density is only one of the things that can + // raise it — so the backdrop and the layer pair below key off this rather + // than off `expanded`. // - // One backdrop instance serves both. A prompt arriving over an already - // expanded rail therefore changes nothing about the scrim: it is already up, - // already full height, and the dialog simply appears above it. A prompt over - // a COLLAPSED rail expands that same scrim from its bar-height band to the - // whole output, using the animation it already has, and the rail stays - // collapsed throughout. - readonly property bool scrimUp: shell.expanded || polkit.prompting + // One backdrop instance serves all of them. A modal opening over an already + // expanded rail therefore changes nothing about the scrim on that monitor: + // it is already up, already full height, and the modal simply appears above + // it. Over a COLLAPSED rail the same scrim expands from its bar-height band + // to the whole output, using the animation it already has, and the rail + // stays collapsed throughout. + readonly property bool scrimUp: shell.expanded || shell.modalOpen // Scrim up, the rail is over everything; scrim down, it drops below ordinary // windows. BOTTOM rather than BACKGROUND for the lowered bar: it is the @@ -174,7 +183,7 @@ Scope { required property var modelData screen: modelData - active: polkit.prompting + active: shell.modalOpen wlrLayer: WlrLayer.Top barHeight: 0 } @@ -196,7 +205,7 @@ Scope { // the rail. Withheld rather than left to the compositor to arbitrate // between two exclusive surfaces, which would decide by stacking and // silently swap the order the day the layers change. - grabsKeyboard: shell.expanded && !polkit.prompting + grabsKeyboard: shell.expanded && !shell.modalOpen onDismissed: shell.expanded = false } @@ -220,4 +229,23 @@ Scope { screen: shell.focusedScreen } + + // Primary application launcher — SUPER_L. Migrated out of + // widgets/launcher/; the ten remaining variants are still evaluation copies + // and stay in shell.qml. Declared after the bar for the same reason the + // prompt is: while it is open the bar is on Overlay too, and there is no + // layer above Overlay to escape to. + AppLauncher { + id: launcher + + screen: shell.focusedScreen + } + + // GUI password prompt for `sudo -A`. Not the polkit agent — sudo cannot use + // one — but it renders the same dialog. See the file for the flow. + AskpassPrompt { + id: askpass + + screen: shell.focusedScreen + } } diff --git a/dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncher.qml similarity index 51% rename from dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncher.qml index cc1a063..c27ad19 100644 --- a/dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncher.qml @@ -4,18 +4,41 @@ import Quickshell import Quickshell.Hyprland import Quickshell.Wayland import QtQuick -import qs.widgets.theme +import qs.HyprChrome.Theme -// Primary application launcher (variant 8). The full-screen layer-shell adapter -// owns focus, DesktopEntries and execution; ApplicationLauncherContent remains -// an Item so the complete visual state can be rendered headlessly. +// Primary application launcher — the one on SUPER_L. +// +// Migrated from widgets/launcher/ApplicationLauncher.qml. Two things changed in +// the move, both because HyprChromeShell now owns the state its surfaces share: +// +// * no scrim of its own. The shell raises the single ChromeBackdrop for any +// of its causes — expanded rail, polkit prompt, this — so opening the +// launcher over an already-expanded rail reuses the scrim that is there +// rather than laying a second dim on top of it. +// * `active` is read by the shell, which uses it to raise that scrim, to +// place this on the focused monitor, and to decide who gets the keyboard. +// +// The full-screen layer-shell adapter owns focus, DesktopEntries and execution; +// AppLauncherContent stays an Item so the whole visual state can be rendered +// headlessly (tests/AppLauncherHeadless.qml). Scope { id: root property bool active: false - function toggle() { root.active = !root.active; } + // Which output to appear on. Driven by the shell, which puts it on the + // focused monitor — a launcher belongs where the user is looking, which is + // not necessarily where the rail lives. + property var screen: null + function toggle() { root.active = !root.active; } + function close() { root.active = false; } + + // The name is legacy: this was "variant 8" of eleven, and both SUPER_L (via + // open_launcher.sh) and SUPER CTRL 8 still dispatch quickshell:launcher8. + // Renaming it means editing hosts/terra/home/hyprland.nix AND the script + // together, and neither takes effect until a deploy — so the shortcut would + // be dead in the running session in between. Kept as-is deliberately. GlobalShortcut { name: "launcher8" description: "Toggle dense application command index" @@ -25,7 +48,9 @@ Scope { PanelWindow { id: win + screen: root.screen visible: root.active + WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive exclusionMode: ExclusionMode.Ignore @@ -71,19 +96,18 @@ Scope { search: content.query } - Rectangle { + // Click-off dismissal. The scrim itself belongs to the shell and takes + // no input (its mask is empty), so the catcher lives here: a + // transparent full-surface MouseArea UNDER the content, which is what + // keeps clicks on the launcher itself from closing it. + MouseArea { anchors.fill: parent - color: Theme.surface - opacity: 0.72 - - MouseArea { - anchors.fill: parent - onClicked: root.active = false - } + onClicked: root.close() } - ApplicationLauncherContent { + AppLauncherContent { id: content + anchors.centerIn: parent width: 1080 height: 620 @@ -95,7 +119,7 @@ Scope { onSelectionRequested: index => win.selectedIndex = win.clampSelection(index) onMoveRequested: delta => win.move(delta) onLaunchRequested: index => win.launch(index) - onDismissRequested: root.active = false + onDismissRequested: root.close() } } } diff --git a/dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncherContent.qml similarity index 98% rename from dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncherContent.qml index cb11326..f9e9ecb 100644 --- a/dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncherContent.qml @@ -5,12 +5,11 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts import QtQuick.Shapes -import qs.widgets.bar -import qs.widgets.theme +import qs.HyprChrome.Theme // Headlessly renderable visual core for the primary application launcher. -// Runtime concerns (DesktopEntries, layer shell, launching) stay in -// ApplicationLauncher.qml; this component only renders state and emits intent. +// Runtime concerns (DesktopEntries, layer shell, focus, launching) stay in +// AppLauncher.qml; this component only renders state and emits intent. Item { id: root @@ -70,7 +69,7 @@ Item { } } - StatusBarPanel { + LauncherPanel { anchors.fill: parent panelId: "008" title: "APPLICATION COMMAND INDEX" @@ -78,7 +77,7 @@ Item { chamfer: 18 // Query module. - StatusBarPanel { + LauncherPanel { id: queryPanel x: 18 y: 34 @@ -184,7 +183,7 @@ Item { } // Search result table. - StatusBarPanel { + LauncherPanel { id: resultPanel x: 18 y: 118 @@ -347,7 +346,7 @@ Item { } // Selected application inspector. - StatusBarPanel { + LauncherPanel { id: inspector x: 700 y: 34 @@ -569,7 +568,7 @@ Item { } // Dense command footer. - StatusBarPanel { + LauncherPanel { x: 18 y: 550 width: 1044 diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppModel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppModel.qml new file mode 100644 index 0000000..2acbdfc --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppModel.qml @@ -0,0 +1,44 @@ +import Quickshell +import QtQuick + +// Non-visual, reusable app-search model shared by every launcher variant. +// Set `search`; read `apps` (a ranked, filtered list of DesktopEntry). +QtObject { + id: root + + property string search: "" + + // `keywords`/`categories` come through as string lists, so coerce every + // field to a string before matching (String([]) joins with commas). + function haystack(a) { + return (String(a.name || "") + " " + String(a.genericName || "") + " " + String(a.comment || "") + " " + String(a.keywords || "")).toLowerCase(); + } + + readonly property var apps: { + const all = DesktopEntries.applications.values.filter(a => !a.noDisplay); + const q = root.search.trim().toLowerCase(); + + if (q.length === 0) + return all.slice().sort((x, y) => String(x.name).localeCompare(String(y.name))); + + const matches = all.filter(a => root.haystack(a).includes(q)); + + // Prefix matches on the visible name rank first, then alphabetical. + return matches.slice().sort((x, y) => { + const xs = String(x.name).toLowerCase().startsWith(q) ? 0 : 1; + const ys = String(y.name).toLowerCase().startsWith(q) ? 0 : 1; + if (xs !== ys) + return xs - ys; + return String(x.name).localeCompare(String(y.name)); + }); + } + + function launch(index) { + const list = root.apps; + if (index >= 0 && index < list.length) { + list[index].execute(); + return true; + } + return false; + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Launcher/LauncherPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/LauncherPanel.qml new file mode 100644 index 0000000..8179daa --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/LauncherPanel.qml @@ -0,0 +1,136 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes +import qs.HyprChrome.Theme + +// Chamfered panel chrome for the launcher: outline, corner accent lines, and +// the optional header strip (id chip / title / meta / tick marks). Content is +// supplied as children by the call site. +// +// A sibling of BarPanel rather than a use of it: BarPanel is almost entirely +// density machinery (summary slot, animated height, state pair, transitions) +// for a rail that expands and collapses, and the launcher has exactly one +// density. Same reasoning as PolkitPanel — see that file. +// +// Carried over from widgets/bar/StatusBarPanel.qml, which the legacy launcher +// variants still use. Restyle this one freely; it is read only by the launcher. +Item { + id: panel + + property string panelId: "" + property string title: "" + property string meta: "" + property bool showHeader: true + property int chamfer: 13 + property int offsetY: 2 + property int accentLineThickness: 3 + + Shape { + id: panelShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: Theme.surface + strokeColor: Theme.hair + strokeWidth: 1 + startX: 0; startY: panel.offsetY + PathLine { x: panelShape.width - panel.chamfer; y: panel.offsetY } + PathLine { x: panelShape.width; y: panel.chamfer } + PathLine { x: panelShape.width; y: panelShape.height } + PathLine { x: panel.chamfer; y: panelShape.height } + PathLine { x: 0; y: panelShape.height - panel.chamfer } + PathLine { x: 0; y: panel.offsetY } + } + + // Upper left accent line + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: 0; startY: 0 + PathLine { x: Math.min(49, panelShape.width / 3); y: 0 } + PathLine { x: Math.min(49, panelShape.width / 3); y: panel.accentLineThickness } + PathLine { x: 0; y: panel.accentLineThickness } + PathLine { x: 0; y: 0 } + } + + // Lower right accent line + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: panelShape.width; startY: panelShape.height + PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height } + PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness } + PathLine { x: panelShape.width; y: panelShape.height } + } + } + + Rectangle { + visible: panel.showHeader + x: 1; y: 22 + width: parent.width - 2 + height: 1 + color: Theme.text + opacity: 0.12 + } + + Rectangle { + visible: panel.showHeader + x: 5; y: 7 + width: panel.panelId.length > 2 ? 29 : 24 + height: 11 + color: Theme.accent + Text { + anchors.centerIn: parent + text: panel.panelId + color: Theme.surface + font.family: Theme.microFont + font.pixelSize: 8 + font.bold: true + } + } + + Text { + visible: panel.showHeader + x: 40; y: 7 + width: parent.width - 105 + text: panel.title + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 9 + font.bold: true + font.letterSpacing: 1.1 + elide: Text.ElideRight + } + + // Inlined rather than reusing DenseBarContent's MicroText, which is an + // inline component and therefore not visible from another file. + Text { + 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) + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 6 + font.letterSpacing: 0.7 + 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: 4; height: 2; color: Theme.accent } + } + } +} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 7272fad..99dfee1 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -20,7 +20,11 @@ Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. HyprChromeShell {} - // App launcher variants — 1–11; variant 8 remains the primary HUD. + // App launcher variants still under evaluation, on SUPER CTRL 1–11. + // Variant 8 — the primary launcher on SUPER_L — has moved into + // HyprChrome/Widgets/Launcher and is instantiated by HyprChromeShell, + // because the shell owns the scrim, the focused monitor and the keyboard + // arbitration it now shares with the polkit prompt. LauncherStack {} // 1 — left vertical list LauncherGrid {} // 2 — centered icon grid LauncherSpotlight {} // 3 — top-center command bar @@ -28,7 +32,6 @@ Scope { LauncherDock {} // 5 — deck rising from the bottom bar LauncherSlant {} // 6 — angular / sheared panel LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot) - ApplicationLauncher {} // 8 — dense HUD command index (primary) BladeLauncher {} // 9 — asymmetric blade matrix OrbitLauncher {} // 10 — radial targeting arena CyberDock {} // 11 — cyberpunk bottom cartridge dock diff --git a/dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml b/dotfiles/quickshell/tests/AppLauncherHeadless.qml similarity index 96% rename from dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml rename to dotfiles/quickshell/tests/AppLauncherHeadless.qml index 71ca1d9..122ce33 100644 --- a/dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml +++ b/dotfiles/quickshell/tests/AppLauncherHeadless.qml @@ -1,7 +1,7 @@ import QtQuick -import qs.widgets.launcher +import qs.HyprChrome.Widgets.Launcher -ApplicationLauncherContent { +AppLauncherContent { width: 1080 height: 620 diff --git a/flake.lock b/flake.lock index ab1ac67..5341c59 100644 --- a/flake.lock +++ b/flake.lock @@ -14,11 +14,11 @@ "uv2nix": "uv2nix" }, "locked": { - "lastModified": 1787577519, - "narHash": "sha256-YNAXQTgR26RJiX2vtYjk6OtBu2jMWeq4qUN6sUrt6Lc=", + "lastModified": 1788820723, + "narHash": "sha256-NIqHasKysUniYrJozavASUF4MqpsyBg7lZZNtM+WrwI=", "owner": "nix-community", "repo": "authentik-nix", - "rev": "30c37930450d7a5fefa8ffec613f037fc75c3071", + "rev": "fd34a5238314351ed92dd79d00f518b8a03e19cb", "type": "github" }, "original": { @@ -30,16 +30,16 @@ "authentik-src": { "flake": false, "locked": { - "lastModified": 1784731584, - "narHash": "sha256-/HdXzjjvuSW7zjbCNJKm3Fj8gvIwfrDf8mOYev0yuIg=", + "lastModified": 1788268887, + "narHash": "sha256-069RXUkk4aSYVelezdmYM70TGIJM8KyTBrLW3Vv0XdM=", "owner": "goauthentik", "repo": "authentik", - "rev": "0c67ea476be6319f1b2a41cb0f5ed128af37b99b", + "rev": "b4de7336e903ef51febf42c0ff3b57c484866cdc", "type": "github" }, "original": { "owner": "goauthentik", - "ref": "version/2026.5.6", + "ref": "version/2026.8.1", "repo": "authentik", "type": "github" } @@ -47,11 +47,11 @@ "client-ts-generator-src": { "flake": false, "locked": { - "lastModified": 1784638510, - "narHash": "sha256-NfwEWQ/SRjgeUz+F/7uoWAMwk7OqdF2+686krhvJn2M=", + "lastModified": 1787926240, + "narHash": "sha256-CNazk55jeMBdP/5cf9scshRGCiKALdERny8Oues1zcY=", "owner": "goauthentik", "repo": "client-ts", - "rev": "5850af5867bef6fd4291731797d21b704c7f189d", + "rev": "26b3e23c928e22e4aa66223b5b996e9e68047f0f", "type": "github" }, "original": { @@ -101,11 +101,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1785627969, - "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", + "lastModified": 1788450739, + "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", + "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", "type": "github" }, "original": { @@ -142,11 +142,11 @@ ] }, "locked": { - "lastModified": 1787377438, - "narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=", + "lastModified": 1788642154, + "narHash": "sha256-sPpQFVaFTDqO/4vvCAhuAhqTgqN/ygu+9eJcs5eB0js=", "owner": "nix-community", "repo": "home-manager", - "rev": "65258d5c65a250189fde2e35f490d15e064c4c62", + "rev": "fd0956c99c41ae3c13a73a638f1f7e963aebc4ab", "type": "github" }, "original": { @@ -270,11 +270,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1787728766, - "narHash": "sha256-g2oZlrBU3AI2ubCiY/UyE9ALTIDueTcF//QP3vaY9IQ=", + "lastModified": 1788938537, + "narHash": "sha256-ooFA+3//Y9bpyFjVwTvPegsWngEoQ0pGj+2DJ5cVyJ0=", "owner": "nix-community", "repo": "nixos-anywhere", - "rev": "6b77f26ec4538ced04bf1d02f374b0ec02e9c27e", + "rev": "9df41112343713520ba071674cf8e45e91c25845", "type": "github" }, "original": { @@ -291,11 +291,11 @@ "nixos-unstable": "nixos-unstable" }, "locked": { - "lastModified": 1787826771, - "narHash": "sha256-gWkyr3I/cg4SHWGkAoUo24+BQaqoi+S0T2JxDjsA+pw=", + "lastModified": 1789036642, + "narHash": "sha256-ng2Ou1UrVCP98OAJiq8Mv6aDF/kjQWyaKm4PaxWtAu4=", "owner": "nix-community", "repo": "nixos-images", - "rev": "f6714acc84ce92df7286c89a571ad1e946057a5b", + "rev": "4fcaaefd02b5bc777f99afd620cb1a9aa1fb5b83", "type": "github" }, "original": { @@ -323,11 +323,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1786862985, - "narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=", + "lastModified": 1788316716, + "narHash": "sha256-bc7rSpXIdn9QWGNqfWcPZWOhEVF8NoeAZkWq0XWnf/k=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44", + "rev": "3ed67ec0a4d3c7ab4ae1f04f8ee8df07bfa506a2", "type": "github" }, "original": { @@ -339,11 +339,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1785031560, - "narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=", + "lastModified": 1788057806, + "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c", + "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", "type": "github" }, "original": { @@ -354,11 +354,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1787814960, - "narHash": "sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ=", + "lastModified": 1789073787, + "narHash": "sha256-xfX/toC2QV707s06GbP4II/TxYF0fNQj7s5/LClNDKc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c27cdad491a991b11ed731760aa2ef8db0cb0410", + "rev": "aff8a0b28396750446e5537a96461bc4facdb287", "type": "github" }, "original": { @@ -370,11 +370,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1787753485, - "narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=", + "lastModified": 1789009968, + "narHash": "sha256-GB16oaxpsrnGNnGIE+0uYBqMRzB9X6FYDUvjvnJAYew=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1", + "rev": "d58a46e3bc02d91ebe04667f8397752a749c0024", "type": "github" }, "original": { @@ -507,11 +507,11 @@ ] }, "locked": { - "lastModified": 1786629091, - "narHash": "sha256-gkig4nPi1CWc4Z50GBsjE4ygSE7hMpl/TwID2an2Cck=", + "lastModified": 1788914643, + "narHash": "sha256-4GuMPW90JSxXWDPUB9M+1m7fYbe3H0apOd86/zBQ2Kw=", "owner": "Mic92", "repo": "sops-nix", - "rev": "a8627b21b9107c5711c96b84f32a9a4b3d45295f", + "rev": "13616fff713a9f94055c66f15687ebdc17a335df", "type": "github" }, "original": { @@ -584,11 +584,11 @@ ] }, "locked": { - "lastModified": 1786615403, - "narHash": "sha256-U++y7nM/6xiEcWI7q4fQoPZjPvRaTwkSqzBOVoEBjUE=", + "lastModified": 1788001239, + "narHash": "sha256-AELmsXPI546MhbC/ZXC7WRUkCz7d4rqKTHUmliIgPpI=", "owner": "pyproject-nix", "repo": "uv2nix", - "rev": "4b59abb2ae1896d2a0e1abfc47fbc9bf985ea730", + "rev": "7f9c6b613d2e749e54854b1d60ab6a2192db889e", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 7501c10..e40a313 100644 --- a/flake.nix +++ b/flake.nix @@ -111,8 +111,8 @@ ]; }; - # mars — on-site x86_64 box, single-purpose: Hermes Agent only. - # See hosts/mars/*. + # mars — on-site x86_64 box: Hermes Agent, plus the LAN web apps luna + # hosts herself (hosts/mars/luna-sites.nix). See hosts/mars/*. mars = nixpkgs.lib.nixosSystem { inherit system; specialArgs = { inherit inputs; }; @@ -420,138 +420,146 @@ # After the jump the test driver's backdoor is gone with the old kernel, # so the installer is driven over a forwarded ssh port instead (the same # approach nixos-images uses in its own kexec test). - checks.${system}.kexec-local = - let - pkgs = nixpkgs.legacyPackages.${system}; - tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball; - sshKey = nixos-images + "/nix/kexec-installer/ssh-keys/id_ed25519"; - in - pkgs.testers.runNixOSTest { - name = "deploy-kexec-local"; + checks.${system} = { + kexec-local = + let + pkgs = nixpkgs.legacyPackages.${system}; + tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball; + sshKey = nixos-images + "/nix/kexec-installer/ssh-keys/id_ed25519"; + in + pkgs.testers.runNixOSTest { + name = "deploy-kexec-local"; - nodes.machine = { modulesPath, ... }: { - imports = [ (modulesPath + "/profiles/minimal.nix") ]; - virtualisation.vlans = [ ]; - # kexec-local refuses to run if RAM < 3x the installer image, and - # the staging dir needs ~3x the tarball on /var/tmp. - virtualisation.memorySize = 4 * 1024; - virtualisation.diskSize = 12 * 1024; - virtualisation.forwardPorts = [{ host.port = 2222; guest.port = 22; }]; + nodes.machine = { modulesPath, ... }: { + imports = [ (modulesPath + "/profiles/minimal.nix") ]; + virtualisation.vlans = [ ]; + # kexec-local refuses to run if RAM < 3x the installer image, and + # the staging dir needs ~3x the tarball on /var/tmp. + virtualisation.memorySize = 4 * 1024; + virtualisation.diskSize = 12 * 1024; + virtualisation.forwardPorts = [{ host.port = 2222; guest.port = 22; }]; - services.openssh.enable = true; - users.users.root.openssh.authorizedKeys.keyFiles = [ "${sshKey}.pub" ]; + services.openssh.enable = true; + users.users.root.openssh.authorizedKeys.keyFiles = [ "${sshKey}.pub" ]; - # Everything the script shells out to, minus nix — the test uses the - # HOMELAB_KEXEC_* hook so no build happens inside the VM. - environment.systemPackages = with pkgs; [ - bash gnutar coreutils findutils util-linux cpio gzip - ]; - system.extraDependencies = [ tarball pkgs.cpio pkgs.gzip ]; + # Everything the script shells out to, minus nix — the test uses the + # HOMELAB_KEXEC_* hook so no build happens inside the VM. + environment.systemPackages = with pkgs; [ + bash gnutar coreutils findutils util-linux cpio gzip + ]; + system.extraDependencies = [ tarball pkgs.cpio pkgs.gzip ]; - environment.etc."deploy".source = ./scripts/deploy; + environment.etc."deploy".source = ./scripts/deploy; + }; + + testScript = /* python */ '' + import os, shutil, subprocess, tempfile, time + + start_all() + machine.wait_for_unit("sshd.service") + + # ssh refuses a private key that is group/world readable, and nix + # store paths are 0444 — copy it out and tighten the mode. + keydir = tempfile.mkdtemp() + key = os.path.join(keydir, "id_ed25519") + shutil.copyfile("${sshKey}", key) + os.chmod(key, 0o600) + + def ssh(cmd, check=True, stdout=None): + return subprocess.run( + [ "${pkgs.openssh}/bin/ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=1", + "-i", key, + "-p", "2222", "root@127.0.0.1", "--" ] + cmd, + text=True, check=check, stdout=stdout) + + machine.succeed("install -Dm755 /etc/deploy /root/deploy") + + # systemd-run starts units with a bare PATH that lacks + # /run/current-system/sw/bin, so `#!/usr/bin/env bash` cannot even + # resolve bash, let alone tar/findmnt/nohup. Set it explicitly. + env = ( + " --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin" + " --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz" + " --setenv=HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio" + " --setenv=HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip" + ) + # Same values for the foreground (non-systemd-run) invocation below. + envsh = ( + "HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz" + " HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio" + " HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip" + ) + + # Marker on a tmpfs: it must NOT survive the jump, proving we really + # booted a new kernel rather than just restarting a service. + machine.succeed("touch /run/pre-kexec-marker") + host_key_before = machine.succeed("cat /etc/ssh/ssh_host_ed25519_key.pub").strip() + + while ssh(["true"], check=False).returncode != 0: + time.sleep(1) + + # Refuses without --yes when stdin is not a tty (read gets EOF). + # Must reach the confirmation prompt, so it needs the same env — + # otherwise it just dies early on the nix build and proves nothing. + out = machine.fail(f"{envsh} /root/deploy kexec-local &1") + assert "using prebuilt kexec installer" in out, \ + f"never reached the prompt, so the refusal proves nothing:\n{out}" + + # systemd-run so the call returns immediately: the script stays + # alive ~60s on purpose, outliving kexec-run.sh's `sleep 6`. + machine.succeed(f"systemd-run --collect --unit=kexec-local{env} /root/deploy kexec-local --yes") + + print("waiting for the jump...") + deadline = time.time() + 300 + while ssh(["true"], check=False).returncode == 0: + # Surface a dead unit immediately instead of stalling until the + # deadline and blaming "never left the old kernel". + st = ssh(["systemctl", "is-active", "kexec-local"], + check=False, stdout=subprocess.PIPE).stdout or "" + if st.strip() in ("failed", "inactive"): + # NB: not `log` — the driver already binds that name to its + # AbstractLogger and the type check rejects the shadowing. + unit_log = ssh(["journalctl", "-u", "kexec-local", "--no-pager"], + check=False, stdout=subprocess.PIPE).stdout or "" + raise AssertionError( + f"kexec-local.service ended ({st.strip()}) without jumping:\n{unit_log}") + assert time.time() < deadline, "machine never left the old kernel" + time.sleep(1) + + print("waiting for the installer...") + deadline = time.time() + 300 + while ssh(["true"], check=False).returncode != 0: + assert time.time() < deadline, "installer never came up" + time.sleep(1) + + # It really is the RAM installer, not the old system. + host = ssh(["hostname"], stdout=subprocess.PIPE).stdout.strip() + assert host == "nixos-installer", f"hostname is {host}, not nixos-installer" + + assert ssh(["ls", "/run/pre-kexec-marker"], check=False).returncode != 0, \ + "old /run survived — this was not a fresh kernel" + + # The host key is carried across (kexec-run.sh copies /etc/ssh into + # the appended initrd), which is why `kexec` does no ssh-keygen -R. + host_key_after = ssh( + ["cat", "/etc/ssh/ssh_host_ed25519_key.pub"], stdout=subprocess.PIPE + ).stdout.strip() + assert host_key_before == host_key_after, \ + f"host key changed: {host_key_before} != {host_key_after}" + + machine.crash() + ''; }; - testScript = /* python */ '' - import os, shutil, subprocess, tempfile, time - - start_all() - machine.wait_for_unit("sshd.service") - - # ssh refuses a private key that is group/world readable, and nix - # store paths are 0444 — copy it out and tighten the mode. - keydir = tempfile.mkdtemp() - key = os.path.join(keydir, "id_ed25519") - shutil.copyfile("${sshKey}", key) - os.chmod(key, 0o600) - - def ssh(cmd, check=True, stdout=None): - return subprocess.run( - [ "${pkgs.openssh}/bin/ssh", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "ConnectTimeout=1", - "-i", key, - "-p", "2222", "root@127.0.0.1", "--" ] + cmd, - text=True, check=check, stdout=stdout) - - machine.succeed("install -Dm755 /etc/deploy /root/deploy") - - # systemd-run starts units with a bare PATH that lacks - # /run/current-system/sw/bin, so `#!/usr/bin/env bash` cannot even - # resolve bash, let alone tar/findmnt/nohup. Set it explicitly. - env = ( - " --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin" - " --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz" - " --setenv=HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio" - " --setenv=HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip" - ) - # Same values for the foreground (non-systemd-run) invocation below. - envsh = ( - "HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz" - " HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio" - " HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip" - ) - - # Marker on a tmpfs: it must NOT survive the jump, proving we really - # booted a new kernel rather than just restarting a service. - machine.succeed("touch /run/pre-kexec-marker") - host_key_before = machine.succeed("cat /etc/ssh/ssh_host_ed25519_key.pub").strip() - - while ssh(["true"], check=False).returncode != 0: - time.sleep(1) - - # Refuses without --yes when stdin is not a tty (read gets EOF). - # Must reach the confirmation prompt, so it needs the same env — - # otherwise it just dies early on the nix build and proves nothing. - out = machine.fail(f"{envsh} /root/deploy kexec-local &1") - assert "using prebuilt kexec installer" in out, \ - f"never reached the prompt, so the refusal proves nothing:\n{out}" - - # systemd-run so the call returns immediately: the script stays - # alive ~60s on purpose, outliving kexec-run.sh's `sleep 6`. - machine.succeed(f"systemd-run --collect --unit=kexec-local{env} /root/deploy kexec-local --yes") - - print("waiting for the jump...") - deadline = time.time() + 300 - while ssh(["true"], check=False).returncode == 0: - # Surface a dead unit immediately instead of stalling until the - # deadline and blaming "never left the old kernel". - st = ssh(["systemctl", "is-active", "kexec-local"], - check=False, stdout=subprocess.PIPE).stdout or "" - if st.strip() in ("failed", "inactive"): - # NB: not `log` — the driver already binds that name to its - # AbstractLogger and the type check rejects the shadowing. - unit_log = ssh(["journalctl", "-u", "kexec-local", "--no-pager"], - check=False, stdout=subprocess.PIPE).stdout or "" - raise AssertionError( - f"kexec-local.service ended ({st.strip()}) without jumping:\n{unit_log}") - assert time.time() < deadline, "machine never left the old kernel" - time.sleep(1) - - print("waiting for the installer...") - deadline = time.time() + 300 - while ssh(["true"], check=False).returncode != 0: - assert time.time() < deadline, "installer never came up" - time.sleep(1) - - # It really is the RAM installer, not the old system. - host = ssh(["hostname"], stdout=subprocess.PIPE).stdout.strip() - assert host == "nixos-installer", f"hostname is {host}, not nixos-installer" - - assert ssh(["ls", "/run/pre-kexec-marker"], check=False).returncode != 0, \ - "old /run survived — this was not a fresh kernel" - - # The host key is carried across (kexec-run.sh copies /etc/ssh into - # the appended initrd), which is why `kexec` does no ssh-keygen -R. - host_key_after = ssh( - ["cat", "/etc/ssh/ssh_host_ed25519_key.pub"], stdout=subprocess.PIPE - ).stdout.strip() - assert host_key_before == host_key_after, \ - f"host key changed: {host_key_before} != {host_key_after}" - - machine.crash() - ''; + # VM test for hosts/mars/luna-sites.nix (header of luna-sites-test.nix): + # nix build .#checks.x86_64-linux.luna-sites -L + luna-sites = import ./hosts/mars/luna-sites-test.nix { + pkgs = nixpkgs.legacyPackages.${system}; }; + }; # `nix develop` — hot-reload loop for dotfiles/quickshell. # diff --git a/hosts/mars/configuration.nix b/hosts/mars/configuration.nix index 13a0d3f..1b30215 100644 --- a/hosts/mars/configuration.nix +++ b/hosts/mars/configuration.nix @@ -1,13 +1,15 @@ { config, pkgs, ... }: -# mars — on-site x86_64 box, single-purpose: runs Hermes Agent only. -# See hermes-agent.nix for what that is and why it moved here from jupiter. +# mars — on-site x86_64 box for Hermes Agent (luna), plus the web apps she +# hosts herself. See hermes-agent.nix for what Hermes is and why it moved here +# from jupiter, and luna-sites.nix for the app hosting. { imports = [ ./hardware-configuration.nix ./disk-config.nix # disko: OS-disk partitions + filesystems ./secrets.nix # sops-nix: samba/tailscale/hermes secrets ./hermes-agent.nix + ./luna-sites.nix # luna's LAN web apps: http://mars.sol// ../../common.nix # shared base: user / ssh / nix / firewall ../../services/containers.nix ../../services/vpn/tailscale.nix diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index 4e7cd56..683bf31 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -49,8 +49,8 @@ # (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a # trustedInterface, services/vpn/tailscale.nix). Public route: neptun's # hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this -# over the tailnet. mars runs no Caddy of its own (single-purpose box), so -# there is no LAN vhost — reach the dashboard directly via mars's tailnet +# over the tailnet. mars's own Caddy (luna-sites.nix) only serves luna's apps +# and has no vhost for this — reach the dashboard directly via mars's tailnet # name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging. # # Uses upstream's generic self-hosted OIDC plugin, same Authentik @@ -196,13 +196,31 @@ in # directly against the real instance during the first version of this # setup). Delete-then-add is idempotent either way and picks up a rotated # token for free. + # + # `tea logins add` is the ONLY step in here that touches the network, and + # ordering is what makes it survivable. switch-to-configuration restarts + # NetworkManager and starts this unit in the SAME pass: on 2026-09-11 the + # two landed in the same second, tea's connect went out over an interface + # that was still coming back, and the kernel spent 2m48s on SYN retries + # before reporting "connection timed out". That failed this unit, which + # podman-hermes-agent Requires=, so a five-second network blip took the + # whole container down and returned 4 from the deploy. Hence + # network-online.target below, the bounded reachability probe in the script, + # and TimeoutStartSec as the backstop — no single blocking call in here may + # outlive the deploy that started it. systemd.services.hermes-agent-prepare-dirs = { description = "Create Hermes state dirs + luna's git/tea access before the container starts"; before = [ "podman-hermes-agent.service" ]; wantedBy = [ "podman-hermes-agent.service" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; - path = [ pkgs.git pkgs.tea ]; + path = [ pkgs.git pkgs.tea pkgs.curl pkgs.coreutils ]; serviceConfig.Type = "oneshot"; + # Everything here is either local or bounded to ~30s by the probe loop, so + # anything past two minutes is a hang, not slowness. Failing at that point + # is strictly better than holding the deploy open. + serviceConfig.TimeoutStartSec = "120"; script = '' mkdir -p ${hermesHome} mkdir -p ${dropboxDir} @@ -231,9 +249,43 @@ in git config --global user.name "luna" git config --global user.email "luna@${giteaHost}" - tea logins delete luna 2>/dev/null || true - GITEA_SERVER_TOKEN="$(cat "$token_file")" tea logins add \ - --name luna --url "https://${giteaHost}" --no-version-check + # Probe before touching the login, with a hard per-attempt timeout: a + # bare TCP connect to an interface that is still coming up hangs for + # ~3 minutes on kernel SYN retries, and tea has no timeout flag of its + # own. /api/v1/version is unauthenticated, so this says "is gitea + # reachable", never "is the token good" — the token is the add's job. + # + # Probing FIRST (rather than retrying the add) is what protects the + # login that is already there. delete-then-add is not atomic: an add + # that fails because the network is down leaves luna with no login at + # all, strictly worse than the stale-but-working one we started with. + # Unreachable therefore means skip the refresh entirely and warn. + gitea_up=0 + for attempt in 1 2 3; do + if curl -fsS --max-time 5 -o /dev/null "https://${giteaHost}/api/v1/version"; then + gitea_up=1 + break + fi + echo "${giteaHost} unreachable (attempt $attempt/3); retrying in 5s" >&2 + sleep 5 + done + + if [ "$gitea_up" = 1 ]; then + # Reachable but the add still fails == a real problem (revoked or + # under-scoped token, gitea rejecting the login), and that stays + # fatal: it is a config error, it will not fix itself on the next + # boot, and it should be loud. + tea logins delete luna 2>/dev/null || true + GITEA_SERVER_TOKEN="$(cat "$token_file")" timeout 60 tea logins add \ + --name luna --url "https://${giteaHost}" --no-version-check + else + # Deliberately not fatal. Every other thing this unit does is local, + # and podman-hermes-agent Requires= it — failing here would take + # Telegram and the dashboard down over a transient blip. luna keeps + # git (the credential helper above needs no network to be written) + # and loses only the tea CLI until the next start re-runs this. + echo "WARNING: ${giteaHost} unreachable; left luna's tea login untouched." >&2 + fi # Hand everything written above to the container's uid/gid. This does # NOT happen by itself: the image's cont-init only chowns hermesHome's diff --git a/hosts/mars/luna-sites-README.md b/hosts/mars/luna-sites-README.md new file mode 100644 index 0000000..646c51f --- /dev/null +++ b/hosts/mars/luna-sites-README.md @@ -0,0 +1,94 @@ +# Hosting your web apps on mars + +You can run web apps as containers and publish them on the home network at +`http://mars.sol//`, without anyone changing mars's configuration. +Everything below takes effect immediately — no restart, no redeploy. + +This file is mounted read-only and is rewritten on every restart. Save what +you need from it to your memory. + +## How it fits together + +- `podman` in your shell does not run containers next to you. It talks, + through `$CONTAINER_HOST`, to a separate unprivileged account on mars + (`luna-apps`). Containers there keep running when you restart, and come + back after mars reboots if they were started with `--restart=always`. +- Caddy on mars routes `http://mars.sol//` to the port you name in + `/opt/data/sites/.json`. A service on mars checks that file and + writes the outcome to `/opt/data/sites-status.txt`. + +## Publish an app + +1. Put the source under `/opt/data/apps//` with a `Containerfile` (or + `Dockerfile`), and build it. The directory is uploaded, so this works from + where you are: + + podman build -t localhost/ /opt/data/apps/ + +2. Run it. Publish its port on `127.0.0.1` only, using a host port between + @portMin@ and @portMax@ that no other app uses (`podman ps` shows the + taken ones): + + podman run -d --name --restart=always \ + -p 127.0.0.1:20001:8080 localhost/ + +3. Register it: + + echo '{"port": 20001}' > /opt/data/sites/.json + +4. Check that it took, then fetch it: + + cat /opt/data/sites-status.txt + curl -si http://127.0.0.1// + + It is now at `http://mars.sol//` for anyone on the home network. + +## Rules the registry enforces + +- `` is lowercase letters, digits and `-`, starts with a letter or + digit, at most 32 characters. The file is `/opt/data/sites/.json`. +- The file holds exactly one JSON object, and only `port` is read. +- `port` is an integer from @portMin@ to @portMax@. Anything else is rejected + (that includes everything else already running on mars). +- A rejected entry never affects the others. `sites-status.txt` says why. +- If `sites-status.txt` starts with `ERROR`, that is a fault on mars's side, + not in your entry — tell darman. + +## Writing apps that work under // + +Caddy strips `/` before the request reaches your app, so the app itself +sees `/`, `/style.css`, `/api/items`. The browser, however, is at +`http://mars.sol//`, so every link, asset URL and fetch() in the page must +keep that prefix: + +- Prefer relative URLs: `style.css`, `./api/items` — not `/style.css`. +- Or set the framework's public base URL to `//` (e.g. Vite's `base`). + Avoid settings that ALSO expect the prefix on incoming requests (Next.js + `basePath`); the prefix has already been removed by then. +- The original prefix arrives in the `X-Forwarded-Prefix` header. +- `http://mars.sol/` redirects to `http://mars.sol//`. + +## Files and data + +- `-v /opt/data/...:/somewhere` does not work: those paths exist only inside + your container, and `luna-apps` cannot see your files. Copy code into the + image in the `Containerfile`. +- Keep an app's state in a named volume: `-v -data:/data`. +- Pulling public images works (`podman pull docker.io/library/nginx`). +- Do not copy tokens or anything else from `/opt/data` into an app. The apps + cannot read your files; keep it that way. + +## Update, inspect, remove + +- Update: rebuild, `podman rm -f `, run it again on the same port. The + JSON file stays as it is. +- Inspect: `podman ps -a`, `podman logs `, `cat /opt/data/sites-status.txt`. +- Remove: `rm /opt/data/sites/.json`, then `podman rm -f `, and + optionally `podman rmi localhost/` and `podman volume rm -data`. + +## Limits + +- Home network only: plain `http://`, not reachable from the internet, not on + mgaction.town. +- There is no login in front of these apps. Anyone on the home network can + use them, so do not publish anything that would be a problem to expose there. diff --git a/hosts/mars/luna-sites-test.nix b/hosts/mars/luna-sites-test.nix new file mode 100644 index 0000000..bf9bb07 --- /dev/null +++ b/hosts/mars/luna-sites-test.nix @@ -0,0 +1,186 @@ +# VM test for luna-sites.nix. Run: +# nix build .#checks.x86_64-linux.luna-sites -L +# +# mars has no VM target, and nearly everything luna-sites does only exists at +# runtime: a rootless podman socket reached through a proxy from another +# container's uid, a path unit, a caddy reload, linger + podman-restart after +# a reboot. So this drives it the way luna does — every podman and registry +# command runs inside a stand-in for the Hermes container, as uid 986 — and +# checks that bad entries are refused without taking good ones down. +{ pkgs }: +let + # `contents` is symlinked into the image root and its closure ships as + # layers, so the app image is self-contained under luna-apps. The stand-in + # is NOT: hermes-agent mounts the host's /nix/store over the image's own, + # which is why the node adds busybox to the VM's store below. + busyboxImage = { name, extraCommands ? "", cmd }: pkgs.dockerTools.buildLayeredImage { + inherit name; + tag = "latest"; + contents = [ pkgs.busybox ]; + extraCommands = "mkdir -p tmp && chmod 1777 tmp\n" + extraCommands; + config.Cmd = cmd; + }; + + # Stand-in for docker.io/nousresearch/hermes-agent: a shell and nothing else. + # The podman client comes from the store, mounted by luna-sites.nix exactly + # as on mars. + standin = busyboxImage { + name = "hermes-standin"; + cmd = [ "/bin/sleep" "infinity" ]; + }; + + # The "app" luna builds on top of. No network in the VM, so it is loaded + # from the store instead of pulled. Runs under luna-apps, which has no + # /nix/store mount — hence the closure inside the image. + app = busyboxImage { + name = "testapp"; + extraCommands = "mkdir -p www && echo hello > www/index.html"; + cmd = [ "/bin/httpd" "-f" "-p" "8080" "-h" "/www" ]; + }; +in +pkgs.testers.runNixOSTest { + name = "luna-sites"; + + nodes.mars = { + imports = [ ./luna-sites.nix ]; + + virtualisation.memorySize = 2048; + virtualisation.diskSize = 4096; + environment.systemPackages = [ pkgs.curl ]; + # The stand-in's /bin symlinks point into /nix/store, and the /nix/store + # mount below replaces the image's copy with the VM's, which only holds + # the system closure. Without this: "executable file `/bin/sleep` not + # found". (The real Hermes image is not nix-built, so mars never hits it.) + system.extraDependencies = [ pkgs.busybox ]; + + # What hermes-agent.nix provides, minus Hermes itself: same uid/gid, host + # networking, hermesHome at /opt/data, /nix/store read-only. + users.groups.hermes.gid = 983; + systemd.tmpfiles.rules = [ + "d /var/lib/hermes 0750 root hermes -" + "d /var/lib/hermes/.hermes 0750 986 983 -" + ]; + virtualisation.oci-containers.containers.hermes-agent = { + image = "hermes-standin:latest"; + imageFile = standin; + extraOptions = [ "--network=host" "--user=986:983" ]; + volumes = [ + "/var/lib/hermes/.hermes:/opt/data" + "/nix/store:/nix/store:ro" + ]; + environment = { + HERMES_UID = "986"; + HERMES_GID = "983"; + HOME = "/opt/data"; + }; + }; + }; + + testScript = /* python */ '' + import shlex + + status_file = "/var/lib/hermes/.hermes/sites-status.txt" + + def luna(cmd): + """Run cmd the way luna would: inside her container, as uid 986.""" + return mars.succeed("podman exec hermes-agent sh -c " + shlex.quote(cmd)) + + def code(path): + return mars.succeed( + f"curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1{path}" + ).strip() + + def status_line(entry): + lines = mars.succeed(f"cat {status_file}").splitlines() + found = [l for l in lines if l.split(" ", 1)[0] == entry] + assert len(found) == 1, f"no single status line for {entry}:\n" + "\n".join(lines) + return found[0] + + start_all() + mars.wait_for_unit("caddy.service") + mars.wait_for_unit("podman-hermes-agent.service") + + with subtest("caddy starts with nothing registered"): + # The import glob matches no file on a fresh box; caddy must still run. + assert code("/") == "404" + + with subtest("luna's podman is luna-apps's rootless podman"): + assert luna("id -u").strip() == "986" + assert luna("podman info --format '{{.Host.Security.Rootless}}'").strip() == "true" + readme = luna("cat /opt/data/sites-README.md") + assert "20000" in readme and "@port" not in readme, "README placeholders not substituted" + + with subtest("build and run an app, as luna would"): + luna("podman load -i ${app}") + luna( + "mkdir -p /opt/data/apps/notes && " + "printf 'FROM localhost/testapp:latest\\nRUN echo built > /www/built.txt\\n' " + "> /opt/data/apps/notes/Containerfile" + ) + luna("podman build -t localhost/notes /opt/data/apps/notes") + luna("podman run -d --name notes --restart=always -p 127.0.0.1:20001:8080 localhost/notes") + mars.wait_until_succeeds("curl -sf http://127.0.0.1:20001/built.txt") + # Container root maps to luna-apps on the host: not root, not uid 986. + mars.succeed("pgrep -u luna-apps -f 'httpd -f -p 8080'") + + with subtest("registering routes /notes/ to it"): + luna("""echo '{"port": 20001}' > /opt/data/sites/notes.json""") + mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt | grep -qx built") + # httpd has no /www/notes/, so the 200 above also proves the prefix is stripped. + assert " ok " in status_line("notes.json") + out = mars.succeed( + "curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://127.0.0.1/notes" + ) + assert out.startswith("308 ") and out.endswith("/notes/"), out + mars.succeed("stat -c %U:%a /var/lib/luna-sites/live/notes.caddy | grep -qx root:644") + + with subtest("bad entries are rejected one by one"): + luna("""echo '{"port": 9119}' > /opt/data/sites/dash.json""") + luna("echo nope > /opt/data/sites/broken.json") + luna(": > /opt/data/sites/empty.json") + luna("""echo '{"port": 20002}{"port": 20003}' > /opt/data/sites/two.json""") + luna("""echo '{"port": 20003.5}' > /opt/data/sites/frac.json""") + luna("""echo '{"port": "20004"}' > /opt/data/sites/str.json""") + luna("""echo '{"port": 20005}' > /opt/data/sites/Bad_Name.json""") + luna("ln -s /etc/shadow /opt/data/sites/link.json") + mars.wait_until_succeeds(f"grep -q '^link.json ' {status_file}") + for entry, why in [ + ("dash.json", "port 9119 is outside 20000-20999"), + ("broken.json", "not valid JSON"), + ("empty.json", "expected exactly one JSON object"), + ("two.json", "expected exactly one JSON object"), + ("frac.json", "port must be an integer"), + ("str.json", "port must be an integer"), + ("Bad_Name.json", "name must match"), + ("link.json", "not a regular file"), + ]: + line = status_line(entry) + assert " rejected " in line and why in line, line + assert " ok " in status_line("notes.json") + # A burst like the one above used to trip systemd's start limit, which + # fails the path unit for good and silently ignores every later entry. + mars.succeed("systemctl is-active luna-sites.path") + assert code("/notes/built.txt") == "200" + assert code("/dash/") == "404" + mars.succeed("test \"$(ls /var/lib/luna-sites/live)\" = notes.caddy") + # The status file is hers, and nothing root-written is left in her tree + # (bar the README's mountpoint, which podman itself creates). + mars.succeed(f"stat -c %u {status_file} | grep -qx 986") + mars.fail("find /var/lib/hermes/.hermes -user root ! -name sites-README.md | grep .") + + with subtest("removing the entry removes the route"): + luna("rm /opt/data/sites/notes.json") + mars.wait_until_succeeds("test \"$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/notes/built.txt)\" = 404") + + with subtest("apps and routes come back after a reboot"): + luna("""echo '{"port": 20001}' > /opt/data/sites/notes.json""") + mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt") + mars.shutdown() + mars.start() + mars.wait_for_unit("caddy.service") + # Nobody logs in: linger starts luna-apps's manager, podman-restart the container. + mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt | grep -qx built", timeout=180) + mars.wait_for_unit("podman-hermes-agent.service") + assert luna("podman ps --format '{{.Names}}'").split() == ["notes"] + ''; +} diff --git a/hosts/mars/luna-sites.nix b/hosts/mars/luna-sites.nix new file mode 100644 index 0000000..2e3e527 --- /dev/null +++ b/hosts/mars/luna-sites.nix @@ -0,0 +1,334 @@ +{ config, pkgs, ... }: + +# luna-sites — luna (the Hermes agent, hermes-agent.nix) hosts her own web apps +# on mars, LAN-only, at http://mars.sol//, with no nix edit per app. +# +# luna, inside hermes-agent (uid 986) +# │ podman … → $CONTAINER_HOST = /run/luna-podman/podman.sock (luna-apps:hermes 0660) +# ▼ systemd-socket-proxyd, running AS luna-apps +# luna-apps's rootless podman (its linger'd user manager) — her app containers +# +# /opt/data/sites/.json {"port": N} hermesHome/sites, hers to write +# ▼ luna-sites.path → luna-sites.service (root): validate, caddy validate, reload +# /var/lib/luna-sites/live/.caddy root-owned, imported by caddy +# /opt/data/sites-status.txt what was accepted, and why not +# +# Why a registry of {name, port} instead of letting her drop Caddyfile +# snippets: a snippet can proxy to anything on this box (the dashboard on +# 9119, the webhook listener on 8644, node-exporter) or file_server anything +# caddy can read, and one syntax error keeps caddy from coming up on the next +# boot. The generator only ever emits one fixed shape from a validated name +# and a port inside portMin..portMax, so none of that is expressible. +# +# Why paths, not .mars.sol: mars has no fixed DHCP lease, and a wildcard +# needs one. `address=/…/` takes an IP, and pihole-FTL's dnsmasq skips +# wildcard --cname entries outside authoritative zones (cache_reload(): +# `if (a->alias[1] != '*' …)`). Moving to subdomains later only changes the +# fragment the generator writes; the registry format stays. +# +# Why a podman socket instead of ssh: what she needs is long-running processes +# OUTSIDE her own container (anything started inside it dies with the +# container, and sits next to her Telegram/gitea tokens). The socket gives +# exactly that and no host shell. It is not a strong boundary on its own — +# rootless podman socket access is code execution as luna-apps, which can read +# whatever that user can — but luna-apps owns nothing and cannot enter +# /var/lib/hermes (0750 root:hermes), so the apps cannot reach her tokens. +# +# She learns all this from a read-only README mounted at +# /opt/data/sites-README.md (luna-sites-README.md). She self-manages her +# memories, so nothing in this file reaches her otherwise — see the dropped +# repo clone in hermes-agent.nix's header for what happens when it doesn't. +# +# VM test: nix build .#checks.x86_64-linux.luna-sites -L (luna-sites-test.nix) +let + user = "luna-apps"; + # Pinned so the user manager's socket path below is known at build time. + uid = 1001; + userSocket = "/run/user/${toString uid}/podman/podman.sock"; + + hermes = config.virtualisation.oci-containers.containers.hermes-agent; + hermesUid = hermes.environment.HERMES_UID; + hermesGid = hermes.environment.HERMES_GID; + # hermes-agent.nix's hermesHome — the container sees it as /opt/data. + hermesHome = "/var/lib/hermes/.hermes"; + sitesDir = "${hermesHome}/sites"; + statusFile = "${hermesHome}/sites-status.txt"; + + stateDir = "/var/lib/luna-sites"; + liveDir = "${stateDir}/live"; + socketDir = "/run/luna-podman"; + + portMin = 20000; + portMax = 20999; + + readme = pkgs.replaceVars ./luna-sites-README.md { + portMin = toString portMin; + portMax = toString portMax; + }; +in +{ + imports = [ + ../../services/containers.nix + ../../services/network/caddy.nix + ]; + + # ---- luna-apps: the account her apps run as ---- + users.users.${user} = { + isNormalUser = true; + inherit uid; + description = "luna's hosted web apps (rootless podman)"; + # Nothing ever logs in as this user. Only its systemd user manager runs, + # kept up without a session by linger, which is what brings the podman + # socket and podman-restart back after a reboot. + linger = true; + autoSubUidGidRange = true; # rootless podman's user namespace + hashedPassword = "!"; + shell = "${pkgs.shadow}/bin/nologin"; + }; + + # `--restart=always` containers only come back after a reboot through this + # unit — rootless podman has no daemon to remember them. The podman module + # already enables podman.socket for every user's manager; this one is + # scoped to luna-apps. + systemd.user.services.podman-restart = { + wantedBy = [ "default.target" ]; + unitConfig.ConditionUser = user; + }; + + # ---- the socket luna's container talks to ---- + # luna-apps's own socket lives under /run/user/1001 (0700), which the + # container's uid cannot enter. This re-exposes it to group hermes, and the + # proxy behind it runs as luna-apps, so it holds no access beyond the socket + # it forwards to. + systemd.sockets.luna-apps-podman = { + wantedBy = [ "sockets.target" ]; + listenStreams = [ "${socketDir}/podman.sock" ]; + socketConfig = { + SocketUser = user; + SocketGroup = "hermes"; + SocketMode = "0660"; + DirectoryMode = "0755"; + }; + }; + systemd.services.luna-apps-podman = { + description = "Forward luna's podman socket to luna-apps's rootless podman"; + requires = [ "user@${toString uid}.service" ]; + after = [ "user@${toString uid}.service" ]; + serviceConfig = { + User = user; + ExecStart = "${config.systemd.package}/lib/systemd/systemd-socket-proxyd ${userSocket}"; + }; + }; + + # ---- luna's side ---- + # Merges into hermes-agent.nix's container definition. + virtualisation.oci-containers.containers.hermes-agent = { + volumes = [ + # The directory, not the socket file: the socket is created by systemd + # at boot, and a file bind mount would pin whatever inode was there when + # the container started. Read-only still permits connect(). + "${socketDir}:${socketDir}:ro" + "${config.virtualisation.podman.package}/bin/podman:/usr/local/bin/podman:ro" + "${readme}:/opt/data/sites-README.md:ro" + ]; + # Every podman command in there goes to luna-apps, never to the rootful + # podman the container itself runs under. + environment.CONTAINER_HOST = "unix://${socketDir}/podman.sock"; + }; + systemd.services.podman-hermes-agent = { + wants = [ "luna-apps-podman.socket" ]; + after = [ "luna-apps-podman.socket" ]; + }; + + # ---- caddy ---- + # `:80` rather than http://mars.sol, so it answers whatever name the LAN + # used to get here (mars, mars.sol, the IP). Until the generator's first run + # the import glob matches nothing, which caddy only warns about. + services.caddy.virtualHosts.":80".extraConfig = '' + import ${liveDir}/*.caddy + handle { + respond "No app registered here. luna's apps live at //." 404 + } + ''; + + # ---- registry → caddy ---- + # Fires on create/delete/rename/close-after-write of entries in sitesDir. + # While sitesDir does not exist yet, systemd watches its parents instead. + systemd.paths.luna-sites = { + wantedBy = [ "multi-user.target" ]; + pathConfig.PathChanged = sitesDir; + }; + + systemd.services.luna-sites = { + description = "Turn luna's site registry into caddy routes"; + # Also runs once at boot, for edits made while nothing was watching. + wantedBy = [ "multi-user.target" ]; + # After caddy, so the reload below never races caddy's own start. Nothing + # orders caddy after THIS unit, which is what keeps the blocking + # `systemctl reload caddy` from waiting on its own start job. + after = [ "caddy.service" ]; + # No start rate limit. The default (5 starts in 10s) is hit by nothing + # more than a handful of quick writes — the VM test does exactly that — + # and when it is, systemd also fails luna-sites.path for good + # (unit-start-limit-hit): every later registration is silently ignored + # until someone runs reset-failed. Bursts are absorbed by the debounce at + # the top of the script instead. + startLimitIntervalSec = 0; + path = [ pkgs.jq pkgs.util-linux pkgs.diffutils config.services.caddy.package ]; + # caddy validate wants somewhere to write its data/config dirs. + environment = { + HOME = "/tmp"; + XDG_DATA_HOME = "/tmp"; + XDG_CONFIG_HOME = "/tmp"; + }; + serviceConfig = { + Type = "oneshot"; + StateDirectory = "luna-sites"; + StateDirectoryMode = "0755"; # caddy (User=caddy) reads live/ + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + # "-": hermesHome does not exist on a box Hermes has never started on; + # the script checks for that itself. + ReadWritePaths = [ "-${hermesHome}" ]; + }; + script = '' + set -euo pipefail + + # Everything that touches luna's tree runs as the container's uid, never + # as root: she controls every path under it, including swapping one for + # a symlink into /etc between a check here and its use. + as_luna() { setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups -- "$@"; } + + if [ ! -d ${hermesHome} ]; then + echo "${hermesHome} does not exist yet; nothing to do" + exit 0 + fi + # mkdir -p leaves an existing dir untouched, so this does not re-fire + # the path unit on every run. + as_luna mkdir -p ${sitesDir} + rm -rf ${stateDir}/stage.* + + report=$(mktemp) + + reject() { printf '%-24s rejected %s\n' "$f" "$1" >> "$report"; } + + # Written as her uid next to the target, then renamed into place, so + # she never reads a half-written file. + publish_report() { + local tmp + tmp=$(as_luna mktemp ${hermesHome}/.sites-status.XXXXXX) + { + printf '# luna-sites, %s. How this works: /opt/data/sites-README.md\n' "$(date -Is)" + if [ -n "''${1:-}" ]; then printf '%s\n' "$1"; fi + if [ -s "$report" ]; then cat "$report"; else echo "(no sites registered)"; fi + } | as_luna tee "$tmp" >/dev/null + as_luna mv -f "$tmp" ${statusFile} + } + + entries() { + as_luna find ${sitesDir} -mindepth 1 -maxdepth 1 -name '*.json' -printf '%y %f %s %T@\n' | sort + } + + generate() { + local stage entry type f name verdict port + : > "$report" + stage=$(mktemp -d ${stateDir}/stage.XXXXXX) + chmod 0755 "$stage" + + while IFS= read -r -d "" entry; do + type=''${entry%% *} + f=''${entry#* } + name=''${f%.json} + + if ! [[ $name =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then + reject "name must match [a-z0-9][a-z0-9-]{0,31}" + continue + fi + # Refused rather than followed. The read below happens as her uid + # either way, so this is about clear feedback, not safety. + if [ "$type" != f ]; then + reject "not a regular file" + continue + fi + + verdict=$(as_luna head -c 4096 -- ${sitesDir}/"$f" | jq -rs \ + --argjson min ${toString portMin} --argjson max ${toString portMax} ' + if length != 1 or (.[0] | type) != "object" then "expected exactly one JSON object" + else .[0].port as $p + | if ($p | type) != "number" or $p != ($p | floor) then "port must be an integer" + elif $p < $min or $p > $max then "port \($p) is outside \($min)-\($max)" + else "ok \($p | floor)" end + end + ' 2>/dev/null) || verdict="not valid JSON" + + case $verdict in + "ok "*) port=''${verdict#ok } ;; + *) reject "$verdict"; continue ;; + esac + if ! [[ $port =~ ^[0-9]+$ ]]; then + reject "port must be an integer" + continue + fi + + # The only shape that is ever generated. Stripping the prefix means + # the app sees `/`; X-Forwarded-Prefix tells it where it really is. + { + printf '# %s\n' "${sitesDir}/$f" + printf 'redir /%s /%s/ 308\n' "$name" "$name" + printf 'handle_path /%s/* {\n' "$name" + printf '\treverse_proxy 127.0.0.1:%s {\n' "$port" + printf '\t\theader_up X-Forwarded-Prefix /%s\n' "$name" + printf '\t}\n}\n' + } > "$stage/$name.caddy" + printf '%-24s ok http://mars.sol/%s/ -> 127.0.0.1:%s\n' "$f" "$name" "$port" >> "$report" + done < <(as_luna find ${sitesDir} -mindepth 1 -maxdepth 1 -name '*.json' -printf '%y %f\0' | sort -z) + + # Nothing she controls reaches these files except a validated name and + # an integer, so a failure here is a bug in this unit, not her entry. + printf ':80 {\n\timport %s/*.caddy\n}\n' "$stage" > "$stage.Caddyfile" + if ! caddy validate --adapter caddyfile --config "$stage.Caddyfile"; then + rm -rf "$stage" "$stage.Caddyfile" + publish_report "ERROR: the generated routes failed caddy validate, so nothing changed. This is a bug in luna-sites, not in your entries - tell darman (journalctl -u luna-sites)." + exit 1 + fi + rm -f "$stage.Caddyfile" + + if [ -d ${liveDir} ] && diff -r ${liveDir} "$stage" >/dev/null; then + rm -rf "$stage" + else + rm -rf ${stateDir}/previous + if [ -d ${liveDir} ]; then mv ${liveDir} ${stateDir}/previous; fi + mv "$stage" ${liveDir} + # caddy's reload is all-or-nothing: on failure it keeps serving the + # old routes, so put the old files back to match what is live. + if systemctl is-active --quiet caddy.service && ! systemctl reload caddy.service; then + rm -rf ${liveDir} + if [ -d ${stateDir}/previous ]; then mv ${stateDir}/previous ${liveDir}; fi + publish_report "ERROR: caddy refused the new routes, so the previous ones are still live. This is a bug in luna-sites, not in your entries - tell darman (journalctl -u luna-sites)." + exit 1 + fi + rm -rf ${stateDir}/previous + fi + publish_report + } + + # Debounce: writes usually come in bursts (several files, or an editor's + # write-then-rename), and every trigger that lands while this oneshot + # is still activating merges into this same start job instead of + # queuing another. One second collapses a burst into one run. + sleep 1 + + # That merging also means an entry written mid-run would otherwise wait + # for the next unrelated change. Compare the registry before and after, + # and go again. Bounded, so a writer in a loop cannot pin the unit. + for attempt in 1 2 3 4 5; do + before=$(entries) + generate + if [ "$before" = "$(entries)" ]; then exit 0; fi + echo "registry changed during run $attempt; regenerating" + done + echo "registry still changing after 5 runs; leaving the rest to the next trigger" >&2 + ''; + }; +} diff --git a/hosts/terra/configuration.nix b/hosts/terra/configuration.nix index 03dd041..80d4a3d 100644 --- a/hosts/terra/configuration.nix +++ b/hosts/terra/configuration.nix @@ -43,6 +43,51 @@ in # https://nix.dev/permalink/stub-ld ---- programs.nix-ld.enable = true; + # The default set above is deliberately minimal and carries no X11, + # freetype, wayland or xkbcommon, so a prebuilt *graphical* binary dies + # before it draws anything. JetBrains IDEs installed through Toolbox are the + # case that surfaced this: their bundled JBR aborts with `libX11.so.6: + # cannot open shared object file` unless the Toolbox GUI — itself an FHS + # wrapper — is what launches them, which makes them unusable from a terminal + # or from a per-repo devShell. These are the libraries `ldd` reports missing + # across a JBR's own .so files, plus the three it resolves by dlopen rather + # than DT_NEEDED: fontconfig for font discovery, libGL, and libsecret for + # the credential store. Definitions merge, so this adds to the module's base + # list rather than replacing it (zlib is already there). + programs.nix-ld.libraries = with pkgs; [ + freetype + fontconfig + libGL + libxkbcommon + wayland + libsecret + libx11 + libxext + libxi + libxrender + libxtst + libxcursor + libxrandr + libxinerama + libxcb + + # CLion Nova's C++ backend (the clion-radler plugin) is a .NET 10 + # application bundling its own runtime, and .NET refuses to start + # without ICU: libSystem.Globalization.Native.so dlopens libicuuc.so + # and libicui18n.so, and failing that the IDE reports "Couldn't find a + # valid ICU package installed on the system" and comes up degraded. + icu + ]; + + # ---- envfs: serves /bin and /usr/bin from the calling process's PATH ---- + # NixOS ships only /bin/sh, but plenty of third-party tooling writes scripts + # with a hardcoded interpreter. JetBrains Toolbox is the standing example: + # it generates ~/.local/share/JetBrains/Toolbox/scripts/{clion,rider,...} + # with `#!/bin/bash`, so every one of those shims fails with `bad + # interpreter` in any shell. envfs resolves such shebangs against PATH, + # which fixes them all at once instead of per-IDE wrappers. + services.envfs.enable = true; + # ---- home-manager (user-level config for darman) ---- # Base settings (useGlobalPkgs/useUserPackages/backupFileExtension) and the # shared zsh baseline now live in common.nix + home/common.nix, applied to @@ -96,7 +141,15 @@ in # in VRAM alone, so ollama offloads the inactive experts to CPU RAM. # Sparse activation makes that far less painful than it'd be for a dense # model this size, but still expect it to run slower than the two above. - loadModels = [ "gemma4:12b" "qwen3.6:35b-a3b" ]; + # VladimirGav/qwen3.8-27B-14GB-IQ4: dense 27B at IQ4, ~14GB of weights — + # nominally fits the 6800 XT's 16G, but that leaves only ~2G for the KV + # cache and the compositor, so expect partial CPU offload as context grows + # (OLLAMA_CONTEXT_LENGTH below applies to every model on this server). + loadModels = [ + "gemma4:12b" + "qwen3.6:35b-a3b" + "VladimirGav/qwen3.8-27B-14GB-IQ4" + ]; # Ollama truncates context far below the model's real window unless # told otherwise (the OpenAI-compat /v1 route it's reached through has # no way to set this per-request). 131072 chosen as the practical diff --git a/hosts/terra/home.nix b/hosts/terra/home.nix index 8eb295d..b61e6a5 100644 --- a/hosts/terra/home.nix +++ b/hosts/terra/home.nix @@ -1,6 +1,53 @@ { pkgs, unstable, inputs, ... }: let tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; }; + + # SUDO_ASKPASS helper: renders sudo's password prompt in the quickshell + # shell (HyprChrome/Widgets/Askpass) instead of on the terminal. + # + # sudo does NOT speak polkit — it is setuid + PAM reading the tty, and no + # sudoers option bridges the two — so this is the askpass mechanism, a + # separate path that happens to reuse the polkit dialog's look. `run0` is the + # polkit-native alternative if you want the agent itself. + # + # A package rather than a file in dotfiles/quickshell because SUDO_ASKPASS + # must point at something EXECUTABLE, and xdg.configFile copies keep their + # store mode — which is why open_launcher.sh has to be invoked as + # `bash ` rather than run directly. + # + # The secret comes back over a 0600 fifo, never in argv or the environment, + # so it is not visible in /proc to anything. Cancelling closes the fifo + # without writing: `cat` reads nothing, this exits non-zero, and sudo aborts + # instead of burning a retry on an empty password. + qs-askpass = pkgs.writeShellApplication { + name = "qs-askpass"; + runtimeInputs = [ pkgs.quickshell pkgs.coreutils ]; + text = '' + runtime="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + fifo="$(mktemp -u "$runtime/qs-askpass.XXXXXXXX")" + mkfifo -m 600 "$fifo" + trap 'rm -f "$fifo"' EXIT + + # Returns immediately; the dialog is asynchronous and we block on the + # fifo, not on the IPC call. + if ! qs ipc call askpass prompt "''${1:-Password:}" "$fifo" >/dev/null 2>&1; then + echo "qs-askpass: quickshell is not running or has no askpass handler" >&2 + exit 1 + fi + + # Bounded, so a prompt nobody answers fails instead of wedging sudo for + # good. On timeout take the dialog down too, or it would sit there with + # nothing listening. + if ! secret="$(timeout 120 cat "$fifo")"; then + qs ipc call askpass cancel >/dev/null 2>&1 || true + echo "qs-askpass: timed out waiting for the prompt" >&2 + exit 1 + fi + + [ -n "$secret" ] || exit 1 + printf '%s\n' "$secret" + ''; + }; in { # home.stateVersion, programs.home-manager.enable, programs.zsh.enable all @@ -34,6 +81,12 @@ in # it instead of the root /var/run/docker.sock. home.sessionVariables.DOCKER_HOST = "unix:///run/user/1000/podman/podman.sock"; + # Only sets WHICH helper sudo uses; it still only calls it when asked with + # `sudo -A` (or when there is no tty at all). Plain `sudo` keeps prompting on + # the terminal, deliberately: aliasing it wholesale would break every sudo in + # a TTY or over ssh, where there is no shell to draw the dialog. + home.sessionVariables.SUDO_ASKPASS = "${qs-askpass}/bin/qs-askpass"; + xdg.userDirs = { enable = true; }; @@ -46,13 +99,14 @@ in (pkgs.writeTextDir "share/mime/packages/application-x-ms-sln.xml" (builtins.readFile ../../dotfiles/mime/application-x-ms-sln.xml)) unstable.claude-code + unstable.codex pkgs.opencode pkgs.quickshell + qs-askpass pkgs.github-cli pkgs.tea pkgs.docker-compose pkgs.hyprcursor - pkgs.bibata-cursors pkgs.papirus-icon-theme ]; diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index e8eabff..4dc20e3 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -31,12 +31,19 @@ let lua = lib.generators.mkLuaInline; + # Cursor theme+size live in home.pointerCursor (theme.nix) so the name is + # in one place; hyprland.lua is what actually gets them into the graphical + # session's environment (hm-session-vars.sh is only sourced by login shells). + cursorName = config.home.pointerCursor.name; + cursorSize = toString config.home.pointerCursor.size; + # Wallpaper images aren't checked into this repo (binary blobs) — pulled # from the existing Wallhaven library on /mnt/hdd_01 instead. Picked once # here rather than at runtime, since hyprpaper has no built-in "random" # mode; re-pick and rebuild (or swap in real per-monitor selection) when # this stops being a placeholder. - wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png"; + # wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png"; + wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-mlwz78.png"; # Dispatchers → the new hl.dsp.* API (signatures verified against hyprland # 0.55's src/config/lua/bindings/LuaBindingsDispatchers.cpp). @@ -94,7 +101,7 @@ in settings = { # ---- colours (from colors.conf) ---- fg_color = { _var = "rgba(eeeeeeff)"; }; - fg_accent = { _var = "rgba(ffd063ff)"; }; + fg_accent = { _var = "rgba(e8722aff)"; }; fg_accent_alt = { _var = "rgba(ff9d42ff)"; }; bg_color = { _var = "rgba(0f1012ff)"; }; bg_accent = { _var = "rgba(963c38ff)"; }; @@ -117,7 +124,7 @@ in debug.disable_logs = false; general = { - border_size = 0; + border_size = 2; col = { inactive_border = lua "bg_accent"; active_border = { @@ -182,10 +189,10 @@ in # ---- environment (environment.conf) ---- env = [ - { _args = [ "HYPRCURSOR_THEME" "Bibata-Modern-Classic" ]; } - { _args = [ "HYPRCURSOR_SIZE" "24" ]; } - { _args = [ "XCURSOR_THEME" "Bibata-Modern-Classic" ]; } - { _args = [ "XCURSOR_SIZE" "24" ]; } + { _args = [ "HYPRCURSOR_THEME" cursorName ]; } + { _args = [ "HYPRCURSOR_SIZE" cursorSize ]; } + { _args = [ "XCURSOR_THEME" cursorName ]; } + { _args = [ "XCURSOR_SIZE" cursorSize ]; } { _args = [ "GDK_BACKEND" "wayland,x11" ]; } { _args = [ "SDL_VIDEODRIVER" "wayland" ]; } { _args = [ "CLUTTER_BACKEND" "wayland" ]; } diff --git a/hosts/terra/home/theme.nix b/hosts/terra/home/theme.nix index 396200a..2dc2b5b 100644 --- a/hosts/terra/home/theme.nix +++ b/hosts/terra/home/theme.nix @@ -23,6 +23,22 @@ in }; }; + # The cursor theme. XCURSOR_THEME alone is not enough for Steam: the client + # UI (steamwebhelper) runs inside a pressure-vessel container that rebuilds + # /etc, so the /etc/profiles/per-user/darman/share/icons entry of + # XCURSOR_PATH does not exist in there and libXcursor finds no theme by + # that name — it falls back to the built-in core X11 cursor. $HOME and + # /nix are bind-mounted into the container, so the ~/.icons symlink that + # `dotIcons` (on by default) drops does resolve. Same class of problem as + # the ~/.themes/~/.icons flatpak workaround above. + home.pointerCursor = { + name = "Bibata-Modern-Classic"; + package = pkgs.bibata-cursors; + size = 24; + gtk.enable = true; + hyprcursor.enable = true; + }; + # Flatpak apps are sandboxed and can't see XDG_DATA_DIRS/nix-store theme # paths, so the portal-reported GTK theme / icon theme names resolve to # nothing inside the sandbox and they fall back to Adwaita. Flatpak diff --git a/services/desktop/librechat.nix b/services/desktop/librechat.nix index 379fc42..ca683de 100644 --- a/services/desktop/librechat.nix +++ b/services/desktop/librechat.nix @@ -36,7 +36,7 @@ # at runtime with whatever's pulled (see loadModels in # hosts/terra/configuration.nix) — kept roughly in sync anyway # so the UI has sane names before the first fetch completes. - default = [ "gemma4:12b" "qwen3.6:35b-a3b" ]; + default = [ "gemma4:12b" "qwen3.6:35b-a3b" "VladimirGav/qwen3.8-27B-14GB-IQ4:latest" ]; fetch = true; # pull the model list from ollama at startup }; titleConvo = true;