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
262 lines
9.4 KiB
QML
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|