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
@@ -0,0 +1,39 @@
pragma ComponentBehavior: Bound
import QtQuick
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
// 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
}
}
@@ -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
}
}
}
@@ -0,0 +1,266 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
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.
//
// 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 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
// 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();
}
}
}
// 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
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
// 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 {
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 {
text: (panel.userName || "NODE") + " // " + (panel.zoneName || "LOCAL")
color: Theme.accent
font.family: Theme.microFont
font.pixelSize: 9
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
// puts between its host name and node readout.
Rectangle {
// 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
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
}
}
}
}
@@ -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 }
}
}
}
}
@@ -0,0 +1,469 @@
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 + 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)
// 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
// 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 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
// 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))
// 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
// 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
preferredRendererType: Shape.CurveRenderer
// Main panel shape
ShapePath {
fillColor: Theme.surface
strokeColor: Theme.hair
strokeWidth: panel.outlineWidth
startX: 0; startY: panel.offsetY
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.leftCut; y: panelShape.height }
PathLine { x: 0; y: panelShape.height - panel.leftCut }
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 {
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 - 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
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 + 6
// 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: 11
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
}
}
}
}
]
}
@@ -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
}
}
@@ -0,0 +1,80 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
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
// 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
}
}
}
@@ -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;
}
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -0,0 +1,142 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
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.
//
// 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 0100 °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
}
}
}
@@ -0,0 +1,237 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Hyprland
import QtQuick
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.
//
// 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
}
}
}