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 // Whether the rail should hold the keyboard, so ESC can close it. Driven by // the shell rather than derived from `expanded`, because the rail is not the // only thing that wants the keyboard: while a polkit prompt is up the shell // withholds this, so ESC reaches the DIALOG and dismisses that instead. // Once the prompt is gone the rail gets the keyboard back and a second ESC // closes the rail — one key, one thing at a time, innermost first. property bool grabsKeyboard: false // EXCLUSIVE rather than OnDemand: OnDemand only offers focus to a surface // the user clicks, and the whole point here is to answer a keypress the // user has not aimed at anything. Taking the keyboard is defensible because // an expanded rail is already a modal-ish state — it dims the desktop // behind itself with the same scrim the prompt uses. WlrLayershell.keyboardFocus: window.grabsKeyboard ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None signal dismissed // A layer surface only delivers keys to an item that has active focus, and // nothing in the rail wants focus for its own sake — the panels are // readouts. So one focus sink covers the whole surface. It re-takes focus // whenever the grab is handed back, since losing the surface's focus drops // the item's too. Item { id: keySink anchors.fill: parent focus: true Keys.onEscapePressed: event => { window.dismissed(); event.accepted = true; } Connections { target: window function onGrabsKeyboardChanged() { if (window.grabsKeyboard) keySink.forceActiveFocus(); } } } // 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 } } } } }