Files
homelab/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml
T
darmanandClaude Opus 5 b3c3cc38f0 feat(quickshell): give the prompt its own panel, ESC handling and placement
PolkitPanel replaces BarPanel as the dialog's chrome. Deliberately not a
subclass or a fork: most of BarPanel is density machinery — summary slot,
animated height, state pair, transitions — that a modal never uses, and
inheriting it would tie the dialog's look to a component whose real job is the
rail, so every restyle here would have to be justified against the panels up
there. It keeps the shell's silhouette (cut corners with detached accent caps,
the accent rules, the header strip) and drops the rail's tick decoration.

ESC closes the rail. The bar had no keyboard focus at all, so this adds it,
gated by the shell rather than left to the compositor to arbitrate between two
exclusive surfaces — that resolves by stacking and would invert silently the
day the layers change:

    grabsKeyboard: shell.expanded && !polkit.prompting

so ESC dismisses the prompt while one is open and closes the rail afterwards.
Verified by instrumenting the handoff: expanded -> true, prompt open -> false,
prompt dismissed -> true, collapsed -> false. Note the rail now takes EXCLUSIVE
keyboard focus while expanded, which is the cost of answering a keypress the
user has not aimed at anything.

The dialog sits a third of the way down rather than centred, panel centre on
the third so it grows symmetrically as the message wraps, floored at a margin
so a tall prompt on a short output cannot be pushed off the top.

Also carries the backdrop tuning: dim 0.75 -> 0.65, gridOpacity 0.15 -> 0.10,
crossOpacity 0.45 -> 0.15, now that the scrim is used by the prompt as well as
the rail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud
2026-09-01 23:38:23 +02:00

262 lines
9.4 KiB
QML

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 }
}
}
}
}