diff --git a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml index da5857a..b44d15c 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml @@ -5,12 +5,21 @@ 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. +// 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. // -// 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. +// 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. @@ -18,20 +27,71 @@ PanelWindow { id: backdrop property bool active: true - property real dim: 0.55 - property int gridSpacing: 120 + + // 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.1 + 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.35 + property real crossOpacity: 0.45 property int crossSize: 20 // Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd @@ -45,10 +105,42 @@ PanelWindow { Theme.accent.hslLightness, 1) - visible: scrim.opacity > 0 + // 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: WlrLayer.Top + WlrLayershell.layer: backdrop.wlrLayer WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore color: "transparent" @@ -68,26 +160,25 @@ PanelWindow { 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 - } - } + // 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 - color: Theme.surface - opacity: backdrop.dim + 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. + // 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) @@ -97,22 +188,29 @@ PanelWindow { x: index * backdrop.gridSpacing width: 1 height: scrim.height - color: backdrop.gridColor - opacity: backdrop.gridOpacity + gradient: Gradient { + GradientStop { position: 0; color: backdrop.gridSolid } + GradientStop { position: backdrop.fadeStart; color: backdrop.gridSolid } + GradientStop { position: 1; color: backdrop.gridEnd } + } } } Repeater { - model: Math.ceil(scrim.height / backdrop.gridSpacing) + // 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: index * backdrop.gridSpacing + y: line width: scrim.width height: 1 color: backdrop.gridColor - opacity: backdrop.gridOpacity + opacity: backdrop.gridOpacity * backdrop.fadeAt(line) } } @@ -139,7 +237,9 @@ PanelWindow { y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2) width: backdrop.crossSize height: backdrop.crossSize - opacity: backdrop.crossOpacity + // 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. diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml index b3c810b..16047bb 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml @@ -1,72 +1,35 @@ 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.workspaces 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 { +// 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 - screen: root.targetScreen - visible: root.targetScreen !== null + // 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 - // 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 + WlrLayershell.layer: window.wlrLayer property int margin: 12 @@ -77,8 +40,8 @@ Scope { // // 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 + readonly property real expandedContent: Math.max(hostPanel.expandedHeight, workspacesPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2 + readonly property real contentHeight: Math.max(hostPanel.targetHeight, workspacesPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2 implicitHeight: Math.round(window.expandedContent) @@ -105,21 +68,35 @@ Scope { HostPanel { id: hostPanel - expanded: root.expanded - // Vitals butts against its right edge; the left end of the row is free. + expanded: window.expanded + // Workspaces butt against its right edge; the left end of the row is free. rightChamfer: false toggleOnClick: false - Layout.preferredWidth: 350 + 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: root.expanded - // Host on the left, the spacer on the right — and a spacer is not a - // panel, so that edge keeps its cut. + 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 @@ -134,10 +111,9 @@ Scope { TrayPanel { id: trayPanel - expanded: root.expanded + expanded: window.expanded toggleOnClick: false Layout.fillHeight: true } } - } } diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml new file mode 100644 index 0000000..b2cfbd5 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeShell.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +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 + } +} diff --git a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml index 1c711b9..aee1063 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml @@ -82,7 +82,7 @@ Item { // 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 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) @@ -154,24 +154,6 @@ Item { 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 { @@ -206,7 +188,7 @@ Item { 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 + spacing: panel.headerPadding + 6 // Header slug — the one piece that survives a collapse, so the strip // still says which panel it is. @@ -223,7 +205,7 @@ Item { text: panel.panelId color: Theme.surface font.family: Theme.microFont - font.pixelSize: 9 + font.pixelSize: 11 font.bold: true } } diff --git a/dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml new file mode 100644 index 0000000..71f9302 --- /dev/null +++ b/dotfiles/quickshell/hyprchrome/widgets/workspaces/WorkspacesPanel.qml @@ -0,0 +1,237 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import QtQuick +import qs.hyprchrome.theme +import qs.hyprchrome.widgets.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:" — + // 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 + } + } +} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index e3415d7..da3257d 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -18,7 +18,7 @@ import qs.hyprchrome.widgets.vitals Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. - HyprChromeBar {} + HyprChromeShell {} // App launcher variants — 1–11; variant 8 remains the primary HUD. LauncherStack {} // 1 — left vertical list