feat(quickshell): add HyprChromeShell, workspaces panel, persistent backdrop

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U19X5LGTxtq4pb4jdNVivv
This commit is contained in:
2026-08-30 22:41:30 +02:00
co-authored by Claude Opus 5
parent 15e081e52d
commit 3aecaf9de5
6 changed files with 507 additions and 117 deletions
@@ -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:<name>" —
// 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
}
}
}