Compare commits

...
9 Commits
Author SHA1 Message Date
darman 15e081e52d Merge remote-tracking branch 'origin/feat/quickshell-dense-bar' into feat/quickshell-dense-bar 2026-08-30 21:18:05 +02:00
darmanandClaude Opus 5 61da7748af chore(terra): unload hypr-chrome, round corners at 25
Comments out both the plugin and its `plugin.hyprchrome` settings — Hyprland
rejects plugin config for a plugin that is not loaded, so the two have to go
together. The flake input stays, so re-enabling is two uncommented lines.

With the window frames gone, decoration rounding goes 10 -> 25 and
rounding_power 2.0 -> 1.0 (previously unset). Both were applied live first via
`hyprctl eval 'hl.config{...}'`; plain `hyprctl keyword` is refused by the lua
config's non-legacy parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 04:13:54 +02:00
darmanandClaude Opus 5 bbe35dd72e feat(quickshell): join adjacent panels into a continuous rail
At spacing 0 a row of panels read as separate tiles, each closing its own
silhouette. BarPanel can now drop either cut corner — rightChamfer (top-right)
and leftChamfer (bottom-left), the only two the shape cuts — so an edge that a
neighbour butts against runs square into it. The bar turns off the host's right
and the vitals' left; the tray keeps both, since the spacer between them is not
a panel and that edge is free.

A side with its chamfer off also draws a connector: the shared edge restroked
in accent at outlineWidth + 2, so the join reads as a deliberate seam rather
than two outlines that happen to touch. The outline's own width becomes a
property so the connector can be defined against it instead of as a second
literal.

The lower-right accent strip takes accentLineWidth, the same slug-derived width
the upper-left one already used, instead of its own Math.min(49, width / 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 04:13:43 +02:00
darmanandClaude Opus 5 c69aa4fea2 fix(quickshell): stop the bar twitching when a collapse finishes
Two causes, both outside the panel animation itself.

bodyBottom and bodyEndPadding are animated reals, so their sum spends the tail
of every transition on a fraction. A layout rounds that up, then drops a pixel
the moment the animation lands on its exact value — a 1px hop after the motion
has visibly finished. implicitHeight and targetHeight now round.

The rest was the surface. Hyprland animates layer-surface resizes (animations
enabled, `layers` left at its default), and the deferred shrink put that resize
exactly where the panel motion ended. The bar is now sized once to the expanded
rail via the new BarPanel.expandedHeight and never resizes; only exclusiveZone
tracks the state, so the desktop still reflows once per toggle, at the start.
That retires barHeight, the content-height handler and the 340ms shrink timer.

A surface that stays tall would swallow clicks across the screen while the rail
is collapsed, so input is masked to the panel row.

Instrumenting BarPanel per frame ruled the panels themselves out first: slug
and summary hold the same absolute y through an entire collapse.

The bar and scrim also get their own layer namespaces. Nothing depends on them
yet; they are the handle for a layerrule that would exempt the rail from
compositor animations without catching the launchers, which share the default
"quickshell" namespace and do want their fade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 03:44:40 +02:00
darmanandClaude Opus 5 e7c30fd39d feat(quickshell): show local and tailnet address in HostPanel
A third line under user // timezone, muted so the identity block stays a
descending three tiers.

Unlike the name, user and zone — none of which can change under a running
shell — addresses can, so this polls on a 30s timer instead of joining the
startup one-shot. The local one is read off the interface holding the default
route with tailscale0 excluded: as an exit node tailscale0 holds that route
itself, and the panel would show the tailnet address on both sides.

Drops the expanded body's `height: 48`. A third line has to grow the panel,
and BarPanel measures the body to decide how tall it is, so a fixed height
would have clipped the new row instead. The bar follows through targetHeight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 03:30:35 +02:00
darmanandClaude Opus 5 15698d3a78 feat(devshell): stream quickshell warnings and errors in nix develop
qs-log follows the working-tree instance's log filtered to WARN|ERROR (-a for
everything), and the shellHook starts it in the background once qs-dev is up,
taking it down again in the exit trap alongside qs-prod. A binding loop or a
failed binding is a WARN, and easy to miss when it scrolls past unwatched.

It starts at the end of the log rather than replaying the backlog, and
re-attaches in a loop: `qs log -f` ends when the instance it attached to exits,
and the dev shell outlives individual instances.

Also corrects the hot-reload note added in 7268221, which was wrong on both
counts. Tested against the running shell: `touch` never reloads (mtime is not a
content change) and inode-replacing edits like `sed -i` are picked up fine. The
reliable check is whether `qs log` shows a "Reloading configuration..." line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 03:25:26 +02:00
darmanandClaude Opus 5 7eb7b948e8 feat(quickshell): dim the desktop behind the expanded hyprchrome bar
A full-screen scrim carrying the dense bar's drafting grid, shown while the
rail is expanded and faded out with it.

It sits on the TOP layer while the bar moves to OVERLAY. Both on one layer
would stack by surface creation order, which is not something to rely on; a
layer apart makes "above windows, below the bar" a guarantee. The mask is an
empty Region, so the scrim takes no clicks and reserves nothing.

The grid is the dense bar's, at twice the spacing and with registration
crosses on every other intersection. Its 0.018 opacity was tuned against a
near-black panel and is invisible over a scrim on lit windows, so grid and
crosses are both properties rather than constants, and the lines take a
desaturated accent derived from the palette instead of plain text colour.

Cross geometry rounds with Math.floor on both the mark's offset and the bars
inside it. anchors.*Center halves the box unfloored, which put an even-sized
mark half a pixel off the 1px rule it registers against. crossThickness is in
steps for the same reason: 1 -> 1px, 2 -> 3px, 3 -> 5px, since only an odd
width straddles a rule symmetrically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 03:25:14 +02:00
darmanandClaude Opus 5 7268221a51 fix(quickshell): drop layout-overridden geometry in HostPanel
The expanded body is a RowLayout, so the divider's `y`/`width`/`height`, the
clock column's `width: 210`, and the user/zone text's `y: 32` were all being
discarded silently — a layout assigns its children's geometry, and a Column
positions its own. Only the anchors among these ever warned.

The divider's 8px inset becomes Layout margins, and `Layout.alignment` goes
from the clock column, where it is ignored while the item fills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 02:41:39 +02:00
darmanandClaude Opus 5 e38a8403ac feat(quickshell): add hyprchrome bar with collapsible panels
A second shell chrome under dotfiles/quickshell/hyprchrome, built around a
BarPanel that carries TWO renderings of its data: the default children are the
expanded detail view, `summary` the terse one shown while collapsed. Both stay
bound to the same sources, so the densities cannot disagree, and the panel
cross-fades between them while its height animates.

Panels: HostPanel (hostname, user, timezone, clock), VitalsPanel (CPU load and
temperature, memory, GPU load and temperature, all metered), TrayPanel (system
tray, self-sizing). HyprChromeBar pins them to DP-2 and owns `expanded` for the
whole rail — SUPER A, via GlobalShortcut "chrome".

GPU busy comes off sysfs rather than the node_exporter scrape VitalsData
already does: the hwmon collector carries the card's temps, power and clocks
but not its utilisation.

The bar's height binds to each panel's `targetHeight` — where it will settle,
not where the animation currently is — because the exclusive zone is
window-sized by default, and binding to the animated height relayouts every
tiled window on the output twelve times per toggle. The zone follows the target
immediately so the desktop reflows once, at the start; the surface itself
shrinks only after the panels finish, or it would clip them mid-animation.

DebugWindow stages a widget in the middle of the secondary monitor
(SUPER CTRL D), masked so only the staged widget takes pointer input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 02:38:00 +02:00
17 changed files with 1760 additions and 16 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ qs -p . # run this directory explicitly regardless of symlink
qs -n # exit immediately if another instance is already running (use to avoid duplicate shells while iterating) qs -n # exit immediately if another instance is already running (use to avoid duplicate shells while iterating)
``` ```
Quickshell hot-reloads QML on file save when already running, so for most edits just save and check the running instance rather than restarting `qs`. There is no separate build/lint/test tooling in this repo — verification is visual/behavioral via the running shell. `qmlls` (QML language server) is configured via `.qmlls.ini` for editor diagnostics. Quickshell hot-reloads QML on file save when already running, so for most edits just save and check the running instance rather than restarting `qs`. It watches file CONTENT: `touch` alone never reloads (mtime is not a change), while any real edit does, including inode-replacing ones (`sed -i`, `perl -i`). A save that is not picked up leaves the shell rendering the previous config with no error — `qs log` shows a `Reloading configuration...` line for every save it saw, so that is the check. There is no separate build/lint/test tooling in this repo — verification is visual/behavioral via the running shell. `qmlls` (QML language server) is configured via `.qmlls.ini` for editor diagnostics.
## Architecture ## Architecture
@@ -0,0 +1,106 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import QtQuick
import qs.widgets.theme
// Debug stage: a bare, chrome-less staging area in the middle of ONE monitor
// (the secondary by default), used to look at a widget in isolation before it
// has a home in the bar or a launcher.
//
// DebugWindow {
// VitalBar { width: 220; value: 0.4 }
// }
//
// Children are reparented into the centred slot, which sizes itself to them —
// so they must carry their own size (implicit or explicit). Do NOT anchor a
// child to the slot (`anchors.fill: parent`): the slot measures its children,
// so that is a binding loop.
//
// Nothing is drawn around them — no panel, no background, no dim: whatever is
// staged is exactly what appears. Only the staged widgets take pointer input
// (`mask`), so the rest of the monitor stays clickable, and the window never
// takes keyboard focus. Toggle: SUPER CTRL D.
Scope {
id: root
// Monitor to stage on. Falls back to the LAST connected screen when the
// name matches nothing, so a single-monitor session still gets a stage.
property string screenName: "HDMI-A-1"
property bool active: true
default property alias content: slot.data
// Quickshell.screens is a QML list, not a JS array — no .find() on it.
readonly property var targetScreen: {
const screens = Quickshell.screens;
if (screens.length === 0)
return null;
for (let i = 0; i < screens.length; i++) {
if (screens[i].name === root.screenName)
return screens[i];
}
return screens[screens.length - 1];
}
function toggle() {
root.active = !root.active;
}
GlobalShortcut {
name: "debug"
description: "Toggle the debug widget stage"
onPressed: root.toggle()
}
PanelWindow {
id: win
screen: root.targetScreen
visible: root.active && root.targetScreen !== null
WlrLayershell.layer: WlrLayer.Overlay
// A HUD, not a modal: never steal the keyboard from the focused window.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// Ignore the dense bar's exclusive zone so "centred" means the centre of
// the monitor, not the centre of what is left below the bar.
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
// Everything outside the staged widgets is click-through.
mask: Region {
item: slot
}
Item {
id: slot
anchors.centerIn: parent
width: Math.max(childrenRect.width, placeholder.visible ? placeholder.implicitWidth : 0)
height: Math.max(childrenRect.height, placeholder.visible ? placeholder.implicitHeight : 0)
}
// Sits beside the slot, not inside it, so it never counts itself. Without
// it an unsized child looks identical to a broken window.
Text {
id: placeholder
visible: slot.children.length === 0
anchors.centerIn: parent
text: "NO WIDGETS STAGED"
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 8
font.letterSpacing: 1.2
}
}
}
@@ -0,0 +1,61 @@
pragma Singleton
import Quickshell
import QtQuick
// Single source of truth for the shell's palette and font families.
//
// The shell previously ran two unrelated palettes: an amber one (#FFD063) used
// by the launchers, sidebar, systray, vitals and notifications, and an orange
// one (#e8722a) that only the dense bar had, tokenized as per-file properties.
// This unifies on the ORANGE values under the AMBER naming scheme.
//
// `surface` deliberately takes the dense bar's void (#0a0a0a) rather than the
// old panel background (#0F1012), which also absorbs the near-identical
// #0A0A0C scrim.
Singleton {
// ---- core ----
readonly property color accent: "#e8722a" // was #FFD063 (amber) / #e8722a (bar)
readonly property color text: "#dedede" // was #EEEEEE / #dedede
readonly property color muted: "#858585" // was #7A7B7D / #858585
readonly property color surface: "#0a0a0a" // was #0F1012 + #0A0A0C + #0a0a0a
readonly property color hot: "#ff6b4a" // alert/hot; no bar equivalent, kept
// ---- supporting darks ----
// A three-step ramp above `surface`. `raised` also absorbs #22262C, which
// differed from #292C30 by an imperceptible amount across two call sites.
readonly property color selection: "#1a1c1f" // selected row fill
readonly property color raised: "#292c30" // raised surface / border
readonly property color disabled: "#3a3d42" // unknown / disabled stroke
// ---- accents ----
// The pale "flash" the top/bottom bars show while a launcher is open. Was a
// hand-picked #FFF3C0 against amber; derived here so it tracks `accent`.
// 55% toward white reproduces the original amber relationship closely
// (#FFD063 -> #FFE9B8 vs the hand-picked #FFF3C0).
readonly property color accentSoft: Qt.tint(accent, Qt.rgba(1, 1, 1, 0.55))
readonly property color highlight: "#ffffff"
// ---- fonts ----
// Two faces. `readoutFont` is an alias rather than a second literal so the
// two roles cannot silently drift apart; point it at a different family if
// the readouts should ever diverge from the headings again.
//
// Installed by services/desktop/desktop-apps.nix (nerd-fonts.departure-mono).
// The former readout face, Digital-7 Mono, was never packaged — it relied on
// a manual ~/.dots/fonts/digital_7 install, so dropping it also removes an
// undeclared external dependency.
readonly property string displayFont: "DepartureMono Nerd Font" // headings, large values
readonly property string readoutFont: displayFont // seven-segment readouts: launchers, sidebar, systray, vitals
readonly property string microFont: "DejaVu Sans Mono" // dense bar micro labels
// ---- derived alpha variants ----
// The dense bar hand-encoded these as Qt.rgba(0.87,0.87,0.87,a) = text and
// Qt.rgba(0.91,0.45,0.16,a) = accent. Expressed as functions so the
// relationship survives a palette change.
function textAlpha(a) { return Qt.rgba(text.r, text.g, text.b, a); }
function accentAlpha(a) { return Qt.rgba(accent.r, accent.g, accent.b, a); }
// Hairline rule / panel outline: text at 28%.
readonly property color hair: textAlpha(0.28)
}
@@ -0,0 +1,163 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.hyprchrome.theme
// Full-screen scrim behind the bar: dims the desktop and lays the dense bar's
// drafting grid over it while the rail is expanded.
//
// Sits on the TOP layer while the bar itself is on OVERLAY. Two surfaces on the
// same layer stack by creation order, which is not something to rely on; one
// layer apart is a guarantee — above ordinary windows, below the bar.
//
// It reserves nothing and takes no input: the mask is an empty Region, so
// clicks land on whatever is underneath rather than on the scrim.
PanelWindow {
id: backdrop
property bool active: true
property real dim: 0.55
property int gridSpacing: 120
// The dense bar drew this grid at 0.018 against its own near-black panel.
// Over a 55% scrim on top of lit windows that is invisible, so it is a
// knob rather than a constant.
property real gridOpacity: 0.1
// The accent with its saturation pulled back: warm enough to read as part
// of the palette, not so loud that a full-screen grid competes with the
// bar. Derived rather than a literal so it tracks a palette change.
// Registration crosses sit on every other intersection of the grid.
property color crossColor: Theme.muted
property real crossOpacity: 0.35
property int crossSize: 20
// Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd
// widths can straddle a 1px rule symmetrically, so an even pixel count
// would push every mark half a pixel off the grid it registers against.
property int crossThickness: 2
readonly property int crossWeight: Math.max(1, backdrop.crossThickness) * 2 - 1
property color gridColor: Qt.hsla(Theme.accent.hslHue,
Theme.accent.hslSaturation * 0.45,
Theme.accent.hslLightness,
1)
visible: scrim.opacity > 0
WlrLayershell.namespace: "hyprchrome-scrim"
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
readonly property int crossColumns: Math.ceil(backdrop.width / (backdrop.gridSpacing * 2)) + 1
readonly property int crossRows: Math.ceil(backdrop.height / (backdrop.gridSpacing * 2)) + 1
anchors {
top: true
left: true
right: true
bottom: true
}
mask: Region {}
Item {
id: scrim
anchors.fill: parent
opacity: backdrop.active ? 1 : 0
// Matched to the bar's own collapse so the scrim and the panels resolve
// together rather than one trailing the other.
Behavior on opacity {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
Rectangle {
anchors.fill: parent
color: Theme.surface
opacity: backdrop.dim
}
// Faint drafting grid; no gradient and deliberately subordinate to
// whatever is showing through it.
Repeater {
model: Math.ceil(scrim.width / backdrop.gridSpacing)
Rectangle {
required property int index
x: index * backdrop.gridSpacing
width: 1
height: scrim.height
color: backdrop.gridColor
opacity: backdrop.gridOpacity
}
}
Repeater {
model: Math.ceil(scrim.height / backdrop.gridSpacing)
Rectangle {
required property int index
y: index * backdrop.gridSpacing
width: scrim.width
height: 1
color: backdrop.gridColor
opacity: backdrop.gridOpacity
}
}
// Register marks on every other line, so they land on a 2x grid rather
// than on every crossing — sparse enough to read as drafting registration
// rather than as texture.
Repeater {
model: backdrop.crossColumns * backdrop.crossRows
Item {
required property int index
readonly property int column: index % backdrop.crossColumns
readonly property int row: Math.floor(index / backdrop.crossColumns)
// Marks on every other rule in both directions, so they line up
// in columns as well as rows. Both offsets are whole multiples of
// gridSpacing, so every mark lands on a real intersection.
//
// Math.floor, not /2: the item offset and the bars inside it must
// round the same way, or an even crossSize sits half a pixel off the
// rule it marks.
x: column * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
width: backdrop.crossSize
height: backdrop.crossSize
opacity: backdrop.crossOpacity
Rectangle {
// Placed with the same Math.floor the item's own offset uses.
// anchors.verticalCenter halves the height unfloored, so an even
// crossSize put the 1px bar half a pixel off the rule it marks.
y: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
width: parent.width
height: backdrop.crossWeight
color: backdrop.crossColor
}
Rectangle {
x: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
width: backdrop.crossWeight
height: parent.height
color: backdrop.crossColor
}
}
}
}
}
@@ -0,0 +1,143 @@
import QtQuick
import QtQuick.Shapes
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import qs.hyprchrome.widgets
import qs.hyprchrome.widgets.host
import qs.hyprchrome.widgets.vitals
import qs.hyprchrome.widgets.tray
Scope {
id: root
// Monitor the rail lives on. Falls back to the FIRST connected screen when
// the name matches nothing, so the bar still appears on a single-monitor
// session or after a cable swap (DebugWindow falls back to the last one
// instead — it wants the secondary).
property string screenName: "DP-2"
// Quickshell.screens is a QML list, not a JS array — no .find() on it.
readonly property var targetScreen: {
const screens = Quickshell.screens;
if (screens.length === 0)
return null;
for (let i = 0; i < screens.length; i++) {
if (screens[i].name === root.screenName)
return screens[i];
}
return screens[0];
}
// Density is a property of the BAR: every panel follows it, so the whole rail
// expands and collapses as one. Panels keep their own animation; only the
// decision is centralised here.
property bool expanded: true
function toggle() {
root.expanded = !root.expanded;
}
// SUPER A — see hosts/terra/home/hyprland.nix.
GlobalShortcut {
name: "chrome"
description: "Expand or collapse the hyprchrome bar"
onPressed: root.toggle()
}
// Scrim first: it is a layer below the bar, so stacking does not depend on
// creation order, but keeping the declaration order the same as the visual
// order costs nothing.
ChromeBackdrop {
screen: root.targetScreen
active: root.expanded
}
PanelWindow {
id: window
screen: root.targetScreen
visible: root.targetScreen !== null
// One layer above the scrim, so the stacking is guaranteed rather than
// dependent on surface creation order. See ChromeBackdrop.
// Own namespace so a layerrule can exempt the rail from Hyprland's layer
// animation without also catching the launchers, which share the default
// "quickshell" namespace and do want their fade.
WlrLayershell.namespace: "hyprchrome-bar"
WlrLayershell.layer: WlrLayer.Overlay
property int margin: 12
// The surface never resizes: it is always tall enough for the expanded
// rail, and only the exclusive zone tracks the current state. Resizing a
// layer surface makes Hyprland animate the change, which showed up as the
// panels twitching a pixel or two the moment the collapse finished.
//
// The zone still follows the target height, so tiled windows reflow once
// per toggle, at the start, and slide while the panels animate.
readonly property real expandedContent: Math.max(hostPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2
readonly property real contentHeight: Math.max(hostPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2
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: root.expanded
// Vitals butts against its right edge; the left end of the row is free.
rightChamfer: false
toggleOnClick: false
Layout.preferredWidth: 350
Layout.fillHeight: true
}
VitalsPanel {
id: vitalsPanel
expanded: root.expanded
// Host on the left, the spacer on the right — and a spacer is not a
// panel, so that edge keeps its cut.
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: root.expanded
toggleOnClick: false
Layout.fillHeight: true
}
}
}
}
@@ -0,0 +1,39 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.hyprchrome.theme
import qs.hyprchrome.widgets.panels
// Staging widget for the debug window: a BarPanel carrying filler copy in both
// of the panel's densities — the full block when expanded, one elided line when
// collapsed. Both read the same `text`, so the two modes cannot disagree.
//
// Give it a width; the height follows whichever body is showing.
BarPanel {
id: lorem
property string text: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since 1966, when designers at Letraset and James Mosley, the librarian at St Bride Printing Library in London, took a 1914 Cicero translation and scrambled it to make dummy text for Letraset's Body Type sheets. It has survived not only many decades, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised thanks to these sheets and more recently with desktop publishing software like Aldus PageMaker and Microsoft Word including versions of Lorem Ipsum."
title: "LOREM IPSUM"
// Collapsed: one line, cut off where the panel ends.
summary: Text {
width: parent.width
text: lorem.text
color: Theme.muted
font.family: Theme.displayFont
font.pixelSize: 12
maximumLineCount: 1
elide: Text.ElideRight
}
// Expanded: the whole thing, wrapped.
Text {
width: parent.width
text: lorem.text
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
@@ -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.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,430 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import qs.hyprchrome.theme
// Chamfered panel chrome for the dense status rail: outline, corner accent
// lines, header strip (id chip / title / meta / tick marks) and a collapsing
// body.
//
// The body holds TWO renderings of the same data: the default children are the
// expanded detail view, `summary` the terse one shown while collapsed. Both
// stay instantiated and bound to the same sources — two renderings of one
// truth, not two truths — and the panel cross-fades between them while its
// height animates to whichever is showing.
//
// BarPanel {
// panelId: "02"
// summary: Text { text: "CPU 43%" }
// MetricRow { /* the full view */ }
// }
//
// Expanded, the detail view sits under the header rule. Collapsed, the summary
// moves up ONTO the header line, starting just right of the slug chip and
// centred in the strip, so the whole panel becomes a single line. The slug
// in both modes; title, meta and tick deco fade out with the detail body.
//
// Each slot keeps its own fixed geometry — only the panel's height animates,
// and the body is clipped — so neither rendering reflows while the other one
// is fading. Slot children are measured (`childrenRect`), so they must carry
// their own size and must NOT anchor to the slot.
//
// Colors and fonts both come from the Theme singleton.
Item {
id: panel
property string panelId: ""
property string title: ""
property string meta: ""
property bool expanded: true
property int chamfer: 13
property int offsetY: 2
property int accentLineThickness: 3
readonly property int headerHeight: 28
// Breathing room between the header rule and the detail view. The
// collapsed summary is unaffected — it sits on the header line itself.
property int headerGap: 8
// Left offset of the header row, and the inset the upper-left accent line
// is sized against.
readonly property int headerPadding: 8
// Upper-left accent line, sized to the slug chip it underlines. Lives on
// the panel rather than on the ShapePath: a PathLine does not see its
// ShapePath's own properties by bare name (they resolve through the
// component scope, not the parent object).
// Unclamped: what the header chrome WANTS to span. Everything that has to
// stay independent of the panel's final width reads this one — a width
// that clamps against panel.width cannot also decide it.
readonly property real headerContentWidth: slugChip.width + panel.headerPadding * 2
readonly property real accentLineWidth: Math.min(panel.width, panel.headerContentWidth)
// Narrowest the panel can be before the title runs into the meta text and
// tick deco. A panel that sizes itself to its content (the tray) has to
// take this as a floor; none of it depends on panel.width, so it can.
readonly property real headerMinWidth: panel.headerContentWidth + panelTitle.width
+ headerRow.spacing + headerEnd.width + 12 + panel.headerPadding
property int padding: 12
// Floor for the animated height, so a collapsed strip with a short (or
// empty) summary still reads as a panel rather than a hairline.
property int minimumHeight: 38
// Self-toggling is a convenience for staging a panel on its own. A host
// that drives `expanded` for a whole group turns it off: assigning to a
// bound property from a click would destroy that binding for good.
property bool toggleOnClick: true
default property alias content: detail.data
property alias summary: brief.data
// Where the summary sits when collapsed: just past the slug chip, and
// centred in the strip the panel collapses to, which is sized to the
// summary itself (or the height floor, whichever is taller).
readonly property real briefLeft: panel.headerContentWidth
readonly property real collapsedHeight: Math.max(panel.minimumHeight, brief.height + panel.headerPadding * 2)
readonly property real briefTop: Math.round((panel.collapsedHeight - brief.height) / 2)
// Bottom of the visible body, and the gap kept below it. The states bind
// these to whichever rendering is showing and the transitions animate them,
// so they are plain properties rather than ternaries on implicitHeight —
// an animation cannot drive a binding.
property real bodyBottom: detail.y + detail.height
property real bodyEndPadding: panel.padding
// 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
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 }
}
// Connecting edges — drawn only on a side whose chamfer is off, which is
// exactly where a neighbour butts against this panel.
ShapePath {
fillColor: "transparent"
strokeColor: Theme.accent
strokeWidth: panel.rightChamfer ? 0 : panel.connectorWidth
startX: panelShape.width; startY: panel.offsetY
PathLine { x: panelShape.width; y: panelShape.height }
}
ShapePath {
fillColor: "transparent"
strokeColor: Theme.accent
strokeWidth: panel.leftChamfer ? 0 : panel.connectorWidth
startX: 0; startY: panel.offsetY
PathLine { x: 0; y: panelShape.height }
}
// Upper left accent line
ShapePath {
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
// Header slug — the one piece that survives a collapse, so the strip
// still says which panel it is.
Rectangle {
id: slugChip
width: panelSlugText.implicitWidth + 6
height: panelSlugText.implicitHeight + 3
color: Theme.accent
Text {
id: panelSlugText
anchors.centerIn: parent
text: panel.panelId
color: Theme.surface
font.family: Theme.microFont
font.pixelSize: 9
font.bold: true
}
}
// Header title
Item {
id: panelTitle
width: panelTitleText.implicitWidth + 6
height: panelTitleText.implicitHeight + 3
Text {
id: panelTitleText
anchors.centerIn: parent
text: panel.title
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 11
font.bold: true
font.letterSpacing: 1.1
elide: Text.ElideRight
}
}
}
// Everything else in the header fades as one, so a collapse is a single
// coordinated move rather than four independently timed ones.
Item {
id: headerExtras
anchors.fill: parent
// Header separator
Rectangle {
x: 1; y: panel.headerHeight
width: parent.width - 2
height: 1
color: Theme.text
opacity: 0.12
}
// Header end deco
Row {
id: headerEnd
anchors.right: parent.right
anchors.rightMargin: 12
y: 12
spacing: 4
Text {
id: metaText
visible: panel.meta.length > 0
text: panel.meta
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 8
font.letterSpacing: 0.7
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
// Same height as the meta text, so the Row aligning both by their
// tops also aligns them by their bottoms — no wrapper needed.
Row {
spacing: 2
Repeater {
model: 5
Rectangle { required property int index; width: 4; height: metaText.implicitHeight; color: Theme.accent }
}
}
}
}
// Body. Spans the panel and clips, because mid-collapse the panel is
// already shorter than the detail view that is still fading out.
Item {
id: bodyClip
anchors.fill: parent
clip: true
Item {
id: detail
x: panel.padding
y: panel.headerHeight + panel.headerGap
width: Math.max(0, panel.width - panel.padding * 2)
height: childrenRect.height
visible: opacity > 0
}
Item {
id: brief
x: panel.briefLeft
y: panel.briefTop
width: Math.max(0, panel.width - panel.briefLeft - panel.padding)
height: childrenRect.height
opacity: 0
visible: opacity > 0
}
}
// Click anywhere on the panel to switch densities. Sits above the body, so
// interactive content in a slot would need its own handler on top of this.
MouseArea {
anchors.fill: parent
onClicked: {
if (panel.toggleOnClick)
panel.expanded = !panel.expanded;
}
}
states: [
State {
name: "expanded"
when: panel.expanded
PropertyChanges {
target: panel
bodyBottom: detail.y + detail.height
bodyEndPadding: panel.padding
}
PropertyChanges { target: detail; opacity: 1 }
PropertyChanges { target: brief; opacity: 0 }
PropertyChanges { target: panelTitle; opacity: 1 }
PropertyChanges { target: headerExtras; opacity: 1 }
},
State {
name: "collapsed"
when: !panel.expanded
// Symmetric about the slug's midline: the same gap the summary has
// above it is kept below, so the strip reads as one line.
PropertyChanges {
target: panel
bodyBottom: brief.y + brief.height
bodyEndPadding: panel.collapsedHeight - brief.y - brief.height
}
PropertyChanges { target: detail; opacity: 0 }
PropertyChanges { target: brief; opacity: 1 }
PropertyChanges { target: panelTitle; opacity: 0 }
PropertyChanges { target: headerExtras; opacity: 0 }
}
]
// Out fast, resize, in late. Fading both bodies on the same clock would
// show them at half opacity on top of each other in the middle frames.
transitions: [
Transition {
to: "collapsed"
ParallelAnimation {
NumberAnimation {
targets: [detail, panelTitle, headerExtras]
property: "opacity"
duration: 90
easing.type: Easing.OutCubic
}
NumberAnimation {
target: panel
properties: "bodyBottom,bodyEndPadding"
duration: 200
easing.type: Easing.OutCubic
}
SequentialAnimation {
PauseAnimation { duration: 110 }
NumberAnimation {
target: brief
property: "opacity"
duration: 120
easing.type: Easing.OutCubic
}
}
}
},
Transition {
to: "expanded"
ParallelAnimation {
NumberAnimation {
target: brief
property: "opacity"
duration: 90
easing.type: Easing.OutCubic
}
NumberAnimation {
target: panel
properties: "bodyBottom,bodyEndPadding"
duration: 200
easing.type: Easing.OutCubic
}
SequentialAnimation {
PauseAnimation { duration: 110 }
NumberAnimation {
targets: [detail, panelTitle, headerExtras]
property: "opacity"
duration: 120
easing.type: Easing.OutCubic
}
}
}
}
]
}
@@ -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.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.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
}
}
}
+43 -1
View File
@@ -1,16 +1,24 @@
//@ pragma UseQApplication //@ pragma UseQApplication
import QtQuick
import Quickshell import Quickshell
import Quickshell.Widgets
import qs.widgets.bar import qs.widgets.bar
import qs.widgets.launcher import qs.widgets.launcher
import qs.widgets.notifications import qs.widgets.notifications
import qs.widgets.osd import qs.widgets.osd
import qs.widgets.systray import qs.widgets.systray
import qs.widgets.theme
import qs.widgets.vitals import qs.widgets.vitals
import qs.hyprchrome
import qs.hyprchrome.widgets
import qs.hyprchrome.widgets.debug
import qs.hyprchrome.widgets.host
import qs.hyprchrome.widgets.vitals
Scope { Scope {
// Dense multi-monitor status rail; visual core is headlessly renderable. // Dense multi-monitor status rail; visual core is headlessly renderable.
DenseBar {} HyprChromeBar {}
// App launcher variants — 111; variant 8 remains the primary HUD. // App launcher variants — 111; variant 8 remains the primary HUD.
LauncherStack {} // 1 — left vertical list LauncherStack {} // 1 — left vertical list
@@ -30,4 +38,38 @@ Scope {
// Host vitals HUD — toggle with SUPER CTRL V. // Host vitals HUD — toggle with SUPER CTRL V.
Vitals {} Vitals {}
// Widget staging area, centered on the secondary monitor (HDMI-A-1),
// toggled with SUPER CTRL D. Swap the children below for whatever widget
// is being worked on; they must carry their own size (see DebugWindow).
// DebugWindow {
// id: debugStage
// // VitalsPanel has no implicit width — the slot measures its children, so
// // each staged panel states its own. Height follows the mode it is in.
// // Click a panel to collapse or expand it.
// Column {
// spacing: 12
// padding: 12
// HostPanel {
// width: 560
// }
// HostPanel {
// width: 560
// expanded: false
// }
// VitalsPanel {
// width: 560
// }
// VitalsPanel {
// width: 560
// expanded: false
// }
// }
// }
} }
+36 -2
View File
@@ -583,6 +583,7 @@
# instance is the identical build to the packaged one. # instance is the identical build to the packaged one.
qs = "${nixpkgs.legacyPackages.${system}.quickshell}/bin/qs"; qs = "${nixpkgs.legacyPackages.${system}.quickshell}/bin/qs";
git = "${nixpkgs.legacyPackages.${system}.git}/bin/git"; git = "${nixpkgs.legacyPackages.${system}.git}/bin/git";
grep = "${nixpkgs.legacyPackages.${system}.gnugrep}/bin/grep";
# Resolved at RUN time, not build time: the entire point is to run the # Resolved at RUN time, not build time: the entire point is to run the
# working tree, and `self` here is only a store snapshot of it. # working tree, and `self` here is only a store snapshot of it.
@@ -631,6 +632,33 @@
echo "qs-dev: live on $cfg edits there now hot-reload" echo "qs-dev: live on $cfg edits there now hot-reload"
''; '';
# qs log -f prints everything the instance logs; WARN and ERROR are the
# two that mean something is wrong with the QML in front of you. A
# binding loop or a failed binding is a WARN and easy to miss when it
# scrolls past inside a reload's worth of chatter.
qs-log = pkgs.writeShellScriptBin "qs-log" ''
set -uo pipefail
${preamble}
filter='WARN|ERROR'
case "''${1:-}" in
-a|--all) filter='.' ;;
esac
# -t 1: `qs log -f` replays the whole backlog first, which would dump
# every historical warning into the terminal on shell entry.
#
# `qs log -f` ends when the instance it attached to exits, and the dev
# shell outlives individual instances a QML error kills one, `qs-dev`
# starts another. Re-attach instead of going quiet for the session.
while :; do
if running "$cfg"; then
${qs} log -p "$cfg" -t 1 -f 2>/dev/null | ${grep} --line-buffered -E "$filter" >&2
fi
sleep 1
done
'';
qs-prod = pkgs.writeShellScriptBin "qs-prod" '' qs-prod = pkgs.writeShellScriptBin "qs-prod" ''
set -uo pipefail set -uo pipefail
${preamble} ${preamble}
@@ -641,7 +669,7 @@
''; '';
in in
pkgs.mkShell { pkgs.mkShell {
packages = [ pkgs.quickshell qs-dev qs-prod ]; packages = [ pkgs.quickshell qs-dev qs-prod qs-log ];
# Swap on entry, swap back on exit. Three guards: # Swap on entry, swap back on exit. Three guards:
# - interactive only ($- has i). `nix develop --command X` EXECs X, # - interactive only ($- has i). `nix develop --command X` EXECs X,
@@ -655,9 +683,15 @@
shellHook = '' shellHook = ''
if [[ $- == *i* ]] && [ -n "''${WAYLAND_DISPLAY:-}" ] && [ -z "''${HOMELAB_QS_DEV:-}" ]; then if [[ $- == *i* ]] && [ -n "''${WAYLAND_DISPLAY:-}" ] && [ -z "''${HOMELAB_QS_DEV:-}" ]; then
export HOMELAB_QS_DEV=1 export HOMELAB_QS_DEV=1
qs-dev && trap qs-prod EXIT if qs-dev; then
# Stream the dev instance's warnings and errors into this
# terminal, and take the follower down with the shell.
qs-log & HOMELAB_QS_LOG=$!
trap 'kill "$HOMELAB_QS_LOG" 2>/dev/null; qs-prod' EXIT
fi
fi fi
echo "homelab devshell qs-dev (working tree) / qs-prod (packaged); exit restores" echo "homelab devshell qs-dev (working tree) / qs-prod (packaged); exit restores"
echo "homelab devshell quickshell WARN/ERROR stream here; qs-log -a for everything"
''; '';
}; };
}; };
+20 -12
View File
@@ -89,7 +89,8 @@ in
wayland.windowManager.hyprland = { wayland.windowManager.hyprland = {
enable = true; enable = true;
plugins = [ inputs.hypr-chrome.packages.${pkgs.stdenv.hostPlatform.system}.default ]; # Unloaded for now; re-add together with the plugin.hyprchrome settings below.
# plugins = [ inputs.hypr-chrome.packages.${pkgs.stdenv.hostPlatform.system}.default ];
settings = { settings = {
# ---- colours (from colors.conf) ---- # ---- colours (from colors.conf) ----
fg_color = { _var = "rgba(eeeeeeff)"; }; fg_color = { _var = "rgba(eeeeeeff)"; };
@@ -131,7 +132,8 @@ in
decoration = { decoration = {
dim_special = 0.3; dim_special = 0.3;
rounding = 10; rounding = 25;
rounding_power = 1.0;
blur = { blur = {
enabled = true; enabled = true;
special = true; # blur behind the special workspace special = true; # blur behind the special workspace
@@ -158,16 +160,18 @@ in
allow_workspace_cycles = true; allow_workspace_cycles = true;
}; };
plugin.hyprchrome = { # Unloaded for now — Hyprland rejects plugin config for a plugin that is
enabled = true; # not loaded, so this stays commented until it goes back in `plugins` above.
glow_size = 12; # plugin.hyprchrome = {
glow_strength = 0.85; # enabled = true;
shadow_size = 24; # glow_size = 12;
shadow_color = lua "bg_color"; # glow_strength = 0.85;
shadow_offset = lua "{ 4, 8 }"; # shadow_size = 24;
outline_size = lua "2"; # shadow_color = lua "bg_color";
outline_color = lua "fg_color"; # shadow_offset = lua "{ 4, 8 }";
}; # outline_size = lua "2";
# outline_color = lua "fg_color";
# };
}; };
# ---- animations ---- # ---- animations ----
@@ -210,6 +214,10 @@ in
(bind "SUPER + CTRL + S" (dsp.global "quickshell:sidebar")) (bind "SUPER + CTRL + S" (dsp.global "quickshell:sidebar"))
# toggle the host vitals HUD # toggle the host vitals HUD
(bind "SUPER + CTRL + V" (dsp.global "quickshell:vitals")) (bind "SUPER + CTRL + V" (dsp.global "quickshell:vitals"))
# toggle the debug widget stage (centred on the secondary monitor)
(bind "SUPER + CTRL + D" (dsp.global "quickshell:debug"))
# expand / collapse the hyprchrome bar as a whole
(bind "SUPER + A" (dsp.global "quickshell:chrome"))
(bind "SUPER + B" (dsp.exec "vivaldi")) (bind "SUPER + B" (dsp.exec "vivaldi"))
(bind "SUPER + E" (dsp.exec "cosmic-files")) (bind "SUPER + E" (dsp.exec "cosmic-files"))