feat(quickshell): cap the rail's open chamfers and trace between them

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U19X5LGTxtq4pb4jdNVivv
This commit is contained in:
2026-08-31 00:30:58 +02:00
co-authored by Claude Opus 5
parent 3aecaf9de5
commit 2bf71494f4
18 changed files with 366 additions and 141 deletions
@@ -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
@@ -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
@@ -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
}
}
}
@@ -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.
@@ -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 }
}
}
}
}
@@ -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 {
@@ -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.
@@ -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
@@ -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`.
//
@@ -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.
//
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
}
}
}
+5 -5
View File
@@ -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.