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:
@@ -0,0 +1,106 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Debug stage: a bare, chrome-less staging area in the middle of ONE monitor
|
||||
// (the secondary by default), used to look at a widget in isolation before it
|
||||
// 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,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 0–100 °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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Scrim behind the bar: dims the desktop and lays the dense bar's drafting grid
|
||||
// over it. It is always on screen — only how far DOWN it reaches changes.
|
||||
// Expanded, it covers the whole output; collapsed, it shrinks to the band the
|
||||
// rail itself occupies, so the bar keeps its backing without the desktop being
|
||||
// dimmed. That collapsed band ends in a fade rather than a cut, so there is no
|
||||
// hard line across the wallpaper; expanded there is nothing to fade against —
|
||||
// the scrim runs to the bottom of the output.
|
||||
//
|
||||
// Its layer arrives from HyprChromeShell, which derives it together with the
|
||||
// bar's so the two stay exactly one level apart — see that file for why the
|
||||
// pair cannot be split. Collapsed that puts it on BACKGROUND, shared with the
|
||||
// wallpaper (hyprpaper), where order IS creation order: if the wallpaper is
|
||||
// restarted under a running shell it comes up on top and the collapsed band
|
||||
// goes with it. A `layerrule = order` in the Hyprland config is the fix if that
|
||||
// ever bites.
|
||||
//
|
||||
// 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
|
||||
|
||||
// Which layer to sit on. An int rather than a private decision: it is half
|
||||
// of a pair with the bar's, so the shell derives both.
|
||||
property int wlrLayer: WlrLayer.Top
|
||||
|
||||
property real dim: 0.75
|
||||
property int gridSpacing: 60
|
||||
|
||||
// Height of the collapsed rail, including its margins — the band the scrim
|
||||
// stays behind while the bar is collapsed. Driven by the host, which is the
|
||||
// only thing that knows what the panels currently measure.
|
||||
property real barHeight: 0
|
||||
|
||||
// Share of the COLLAPSED band spent fading to nothing at the bottom, as a
|
||||
// fraction rather than a pixel length so the tail scales with whatever the
|
||||
// rail currently measures. Expanded there is no fade at all: the scrim runs
|
||||
// to the bottom of the output, where the screen edge ends it.
|
||||
property real fade: 0.5
|
||||
readonly property real fadeAmount: Math.max(0, Math.min(0.95, backdrop.fade))
|
||||
|
||||
// The share actually in force. Animated rather than switched, so expanding
|
||||
// shrinks the tail away as the scrim grows instead of dropping a hard edge
|
||||
// onto the desktop the moment the state flips.
|
||||
property real fadeSpan: backdrop.active ? 0 : backdrop.fadeAmount
|
||||
// Position, in fractions of revealHeight, where the falloff starts. 1 while
|
||||
// expanded, i.e. no falloff.
|
||||
readonly property real fadeStart: 1 - backdrop.fadeSpan
|
||||
|
||||
// Collapsed, the SOLID part is the bar band and the tail hangs below it,
|
||||
// hence the division: barHeight is what must survive the fade, not what the
|
||||
// whole scrim measures. Sized off the static fadeAmount, not the animated
|
||||
// fadeSpan — the height and the ramp have to animate independently or each
|
||||
// would be chasing the other. Bound rather than readonly so the Behavior
|
||||
// below can animate the state change.
|
||||
property real revealHeight: backdrop.active
|
||||
? backdrop.height
|
||||
: Math.min(backdrop.height, backdrop.barHeight / (1 - backdrop.fadeAmount))
|
||||
|
||||
// Both matched to the bar's own collapse so the scrim and the panels
|
||||
// resolve together rather than one trailing the other.
|
||||
Behavior on revealHeight {
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on fadeSpan {
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// 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.15
|
||||
|
||||
// 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.45
|
||||
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)
|
||||
|
||||
// Fade targets keep the source RGB and drop only the alpha: interpolating
|
||||
// toward a plain "transparent" would run the gradient through black.
|
||||
readonly property color gridSolid: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g,
|
||||
backdrop.gridColor.b, backdrop.gridOpacity)
|
||||
readonly property color gridClear: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g,
|
||||
backdrop.gridColor.b, 0)
|
||||
readonly property color dimSolid: Qt.rgba(Theme.surface.r, Theme.surface.g,
|
||||
Theme.surface.b, backdrop.dim)
|
||||
readonly property color dimClear: Qt.rgba(Theme.surface.r, Theme.surface.g,
|
||||
Theme.surface.b, 0)
|
||||
|
||||
// What the gradients END on. With no fade the ramp has zero length, so its
|
||||
// start stop and its end stop sit on the SAME position — and Qt sorts stops
|
||||
// with an unstable sort, leaving which of the two wins undefined. It picked
|
||||
// the transparent one, which turned "no fade" into a ramp across the entire
|
||||
// scrim. Ending on the solid color instead makes the degenerate case
|
||||
// unambiguous: all three stops match and the fill is flat.
|
||||
readonly property color gridEnd: backdrop.fadeSpan > 0 ? backdrop.gridClear : backdrop.gridSolid
|
||||
readonly property color dimEnd: backdrop.fadeSpan > 0 ? backdrop.dimClear : backdrop.dimSolid
|
||||
|
||||
// Same ramp as the gradients above, for the marks that are placed at a
|
||||
// single y and so cannot carry a gradient of their own. Reads revealHeight
|
||||
// and fadeStart, so bindings that call it re-evaluate when either changes.
|
||||
function fadeAt(y: real): real {
|
||||
const start = backdrop.revealHeight * backdrop.fadeStart;
|
||||
if (y <= start)
|
||||
return 1;
|
||||
if (y >= backdrop.revealHeight)
|
||||
return 0;
|
||||
return 1 - (y - start) / (backdrop.revealHeight - start);
|
||||
}
|
||||
|
||||
visible: backdrop.revealHeight > 0
|
||||
|
||||
WlrLayershell.namespace: "hyprchrome-scrim"
|
||||
WlrLayershell.layer: backdrop.wlrLayer
|
||||
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
|
||||
|
||||
// Only as tall as the scrim currently reaches; everything inside is
|
||||
// laid out against this, so shrinking it scopes the whole drawing
|
||||
// rather than just clipping it.
|
||||
width: backdrop.width
|
||||
height: backdrop.revealHeight
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: backdrop.dimSolid }
|
||||
GradientStop { position: backdrop.fadeStart; color: backdrop.dimSolid }
|
||||
GradientStop { position: 1; color: backdrop.dimEnd }
|
||||
}
|
||||
}
|
||||
|
||||
// Faint drafting grid; no gradient and deliberately subordinate to
|
||||
// whatever is showing through it — except at the bottom, where it has to
|
||||
// fade with the scrim it sits on.
|
||||
Repeater {
|
||||
model: Math.ceil(scrim.width / backdrop.gridSpacing)
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
x: index * backdrop.gridSpacing
|
||||
width: 1
|
||||
height: scrim.height
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: backdrop.gridSolid }
|
||||
GradientStop { position: backdrop.fadeStart; color: backdrop.gridSolid }
|
||||
GradientStop { position: 1; color: backdrop.gridEnd }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
// Modelled against the whole output, not the current reveal, so a
|
||||
// collapse fades the rules out where they stand instead of
|
||||
// restocking the Repeater on every animation frame.
|
||||
model: Math.ceil(backdrop.height / backdrop.gridSpacing)
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
readonly property real line: index * backdrop.gridSpacing
|
||||
|
||||
y: line
|
||||
width: scrim.width
|
||||
height: 1
|
||||
color: backdrop.gridColor
|
||||
opacity: backdrop.gridOpacity * backdrop.fadeAt(line)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Sampled at the intersection the mark registers against, not at
|
||||
// its own top edge, so a cross fades as one piece.
|
||||
opacity: backdrop.crossOpacity * backdrop.fadeAt(row * backdrop.gridSpacing * 2)
|
||||
|
||||
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,96 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import qs.HyprChrome.Widgets.Bar
|
||||
import qs.HyprChrome.Widgets
|
||||
|
||||
// The hyprchrome shell: owns everything the rail's surfaces have to agree on,
|
||||
// and instantiates them.
|
||||
//
|
||||
// State lives here rather than in any one surface because more than one of them
|
||||
// reads it, and a second reader is what turns a local property into shared
|
||||
// state. Three things qualify so far:
|
||||
//
|
||||
// * which monitor the shell lives on — every surface has to pick the same one
|
||||
// * the density — the whole rail expands and collapses as one, so the toggle
|
||||
// and the shortcut that drives it belong to the shell, not to the bar
|
||||
// * the layer PAIR — the backdrop must sit exactly one layer below the bar in
|
||||
// both densities. Two surfaces on the same layer stack by creation order,
|
||||
// which is not something to rely on; one layer apart is a guarantee. Split
|
||||
// across two files those two assignments drifted apart and the scrim ended
|
||||
// up over the bar, so they are derived together here and passed down.
|
||||
//
|
||||
// A future widget joins by taking `targetScreen` and `expanded` the same way.
|
||||
Scope {
|
||||
id: shell
|
||||
|
||||
// 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 === shell.screenName)
|
||||
return screens[i];
|
||||
}
|
||||
return screens[0];
|
||||
}
|
||||
|
||||
// Density is a property of the SHELL: 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: false
|
||||
|
||||
function toggle() {
|
||||
shell.expanded = !shell.expanded;
|
||||
}
|
||||
|
||||
// Expanded the rail is over everything; collapsed it drops below ordinary
|
||||
// windows. BOTTOM rather than BACKGROUND for the collapsed bar: it is the
|
||||
// lowest level that still leaves a layer underneath for the backdrop, and
|
||||
// it keeps the rail off the wallpaper's own level.
|
||||
readonly property int barLayer: shell.expanded ? WlrLayer.Overlay : WlrLayer.Bottom
|
||||
readonly property int backdropLayer: shell.expanded ? WlrLayer.Top : WlrLayer.Background
|
||||
|
||||
// SUPER A — see hosts/terra/home/hyprland.nix.
|
||||
GlobalShortcut {
|
||||
name: "chrome"
|
||||
description: "Expand or collapse the hyprchrome bar"
|
||||
onPressed: shell.toggle()
|
||||
}
|
||||
|
||||
// Backdrop 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: shell.targetScreen
|
||||
active: shell.expanded
|
||||
wlrLayer: shell.backdropLayer
|
||||
|
||||
// Collapsed, the scrim only backs the rail, so it needs the band the
|
||||
// rail occupies. contentHeight is the SETTLED height for the current
|
||||
// state — it jumps once per toggle rather than tracking the panels
|
||||
// frame by frame, so the backdrop animates the change itself instead of
|
||||
// chasing a value that is already being animated.
|
||||
barHeight: bar.contentHeight
|
||||
}
|
||||
|
||||
HyprChromeBar {
|
||||
id: bar
|
||||
|
||||
screen: shell.targetScreen
|
||||
// Set here, not from the window's own `screen`: reading that inside
|
||||
// `visible` is circular — a hidden window has no screen to report.
|
||||
visible: shell.targetScreen !== null
|
||||
expanded: shell.expanded
|
||||
wlrLayer: shell.barLayer
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user