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.Hyprland
import Quickshell.Wayland import Quickshell.Wayland
import QtQuick import QtQuick
import qs.widgets.theme import qs.HyprChrome.Theme
// Debug stage: a bare, chrome-less staging area in the middle of ONE monitor // 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 // (the secondary by default), used to look at a widget in isolation before it
@@ -1,8 +1,8 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import qs.hyprchrome.theme import qs.HyprChrome.Theme
import qs.hyprchrome.widgets.panels import qs.HyprChrome.Widgets.Bar.Panels
// Staging widget for the debug window: a BarPanel carrying filler copy in both // 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 // 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 Quickshell.Io
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.hyprchrome.theme import qs.HyprChrome.Theme
import qs.hyprchrome.widgets.panels import qs.HyprChrome.Widgets.Bar.Panels
// Host identity in the hyprchrome panel chrome: the machine's name set large, // Host identity in the hyprchrome panel chrome: the machine's name set large,
// with its timezone and the current date and time. // 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
import QtQuick.Shapes import QtQuick.Shapes
import qs.hyprchrome.theme import qs.HyprChrome.Theme
// Chamfered panel chrome for the dense status rail: outline, corner accent // Chamfered panel chrome for the dense status rail: outline, corner accent
// lines, header strip (id chip / title / meta / tick marks) and a collapsing // lines, header strip (id chip / title / meta / tick marks) and a collapsing
@@ -135,6 +135,36 @@ Item {
property int outlineWidth: 1 property int outlineWidth: 1
readonly property int connectorWidth: panel.outlineWidth + 2 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 { Shape {
id: panelShape id: panelShape
anchors.fill: parent anchors.fill: parent
@@ -154,6 +184,33 @@ Item {
PathLine { x: 0; y: panel.offsetY } 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 // Upper left accent line
ShapePath { ShapePath {
@@ -4,7 +4,7 @@ import Quickshell
import Quickshell.Services.SystemTray import Quickshell.Services.SystemTray
import QtQuick import QtQuick
import QtQuick.Shapes 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 // One tray item as a chamfered cell. Declares `modelData` required so it can be
// a Repeater delegate directly, without an Item wrapper in between. // a Repeater delegate directly, without an Item wrapper in between.
@@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Services.SystemTray import Quickshell.Services.SystemTray
import QtQuick import QtQuick
import qs.hyprchrome.theme import qs.HyprChrome.Theme
import qs.hyprchrome.widgets.panels import qs.HyprChrome.Widgets.Bar.Panels
// System tray in the hyprchrome panel chrome, in both densities: the same // 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 // items, drawn large enough to hit when expanded and shrunk onto the header
@@ -1,7 +1,7 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import qs.hyprchrome.theme import qs.HyprChrome.Theme
// Segmented horizontal meter: a row of cells lit up to `value`. // Segmented horizontal meter: a row of cells lit up to `value`.
// //
@@ -1,7 +1,7 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import qs.hyprchrome.theme import qs.HyprChrome.Theme
// One metric of the expanded vitals panel: label, meter, readout on a line. // One metric of the expanded vitals panel: label, meter, readout on a line.
// //
@@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.hyprchrome.theme import qs.HyprChrome.Theme
import qs.hyprchrome.widgets.panels import qs.HyprChrome.Widgets.Bar.Panels
import qs.widgets.vitals import qs.widgets.vitals
// Host vitals in the hyprchrome panel chrome, in both of BarPanel's densities. // Host vitals in the hyprchrome panel chrome, in both of BarPanel's densities.
@@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Hyprland import Quickshell.Hyprland
import QtQuick import QtQuick
import qs.hyprchrome.theme import qs.HyprChrome.Theme
import qs.hyprchrome.widgets.panels import qs.HyprChrome.Widgets.Bar.Panels
// Which workspace each monitor is currently showing, in the hyprchrome panel // Which workspace each monitor is currently showing, in the hyprchrome panel
// chrome, in both of BarPanel's densities. // chrome, in both of BarPanel's densities.
@@ -3,7 +3,7 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import QtQuick 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 // 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. // 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
import Quickshell.Hyprland import Quickshell.Hyprland
import Quickshell.Wayland 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, // The hyprchrome shell: owns everything the rail's surfaces have to agree on,
// and instantiates them. // 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.systray
import qs.widgets.theme import qs.widgets.theme
import qs.widgets.vitals import qs.widgets.vitals
import qs.hyprchrome import qs.HyprChrome
import qs.hyprchrome.widgets import qs.HyprChrome.Widgets
import qs.hyprchrome.widgets.debug import qs.HyprChrome.Widgets.Bar.Debug
import qs.hyprchrome.widgets.host import qs.HyprChrome.Widgets.Bar.Host
import qs.hyprchrome.widgets.vitals import qs.HyprChrome.Widgets.Bar.Vitals
Scope { Scope {
// Dense multi-monitor status rail; visual core is headlessly renderable. // Dense multi-monitor status rail; visual core is headlessly renderable.