Files
homelab/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.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

224 lines
9.9 KiB
QML

pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import qs.HyprChrome.Widgets.Bar
import qs.HyprChrome.Widgets
import qs.HyprChrome.Widgets.Polkit
// 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. Four 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
// * whether an authorization prompt is up — it raises the same scrim the rail
// uses and freezes the density while it is open, so two surfaces read it.
// That is why the agent lives here rather than as a sibling of the
// launchers in shell.qml
// * 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];
}
// Every output the rail does NOT live on. They get a scrim of their own
// while a prompt is up, because a modal that dims one monitor and leaves
// the others lit does not read as modal at all — and the rail's backdrop
// covers exactly one output.
readonly property var otherScreens: {
const out = [];
const screens = Quickshell.screens;
for (let i = 0; i < screens.length; i++) {
if (screens[i] !== shell.targetScreen)
out.push(screens[i]);
}
return out;
}
// Where a prompt appears: wherever the user is actually looking, which is
// not necessarily where the rail lives. Hyprland reports the focused
// monitor by name, and Quickshell.screens is keyed the same way, so the
// two are matched by name exactly as targetScreen is above.
//
// Falls back to the rail's own screen rather than to nothing: a prompt that
// fails to place itself would leave its caller blocked on a dialog nobody
// can see.
readonly property var focusedScreen: {
const focused = Hyprland.focusedMonitor;
if (!focused)
return shell.targetScreen;
const screens = Quickshell.screens;
for (let i = 0; i < screens.length; i++) {
if (screens[i].name === focused.name)
return screens[i];
}
return shell.targetScreen;
}
// 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.
//
// A prompt does NOT change it: an authorization request leaves the rail at
// whatever density it was, and only freezes it there.
property bool expanded: false
function toggle() {
// Frozen while a prompt is up, and dropped rather than queued: SUPER A
// during a prompt does nothing at all, instead of arming a change that
// springs the rail open or shut the moment the dialog goes.
if (polkit.prompting)
return;
shell.expanded = !shell.expanded;
}
// Whether the scrim is up, from EITHER cause. This is the fact the surfaces
// actually share — the rail's density is only one of the two things that
// can raise it — so the backdrop and the layer pair below key off this
// rather than off `expanded`.
//
// One backdrop instance serves both. A prompt arriving over an already
// expanded rail therefore changes nothing about the scrim: it is already up,
// already full height, and the dialog simply appears above it. A prompt over
// a COLLAPSED rail expands that same scrim from its bar-height band to the
// whole output, using the animation it already has, and the rail stays
// collapsed throughout.
readonly property bool scrimUp: shell.expanded || polkit.prompting
// Scrim up, the rail is over everything; scrim down, it drops below ordinary
// windows. BOTTOM rather than BACKGROUND for the lowered 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.
//
// Both key off `scrimUp`, not `expanded`, so the pair stays exactly one
// level apart in every state — which is the whole point of deriving them
// together. A prompt over a collapsed rail raises BOTH: the scrim has to
// clear ordinary windows to dim them at all (BACKGROUND sits under them),
// and the bar has to stay one above the scrim or the shell would be dimming
// its own chrome. The rail is raised but still collapsed: its layer answers
// to the scrim, its height to `expanded`.
readonly property int barLayer: shell.scrimUp ? WlrLayer.Overlay : WlrLayer.Bottom
readonly property int backdropLayer: shell.scrimUp ? 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.scrimUp
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
}
// The same scrim on every other output, up only while a prompt is. These
// have no rail to back, so barHeight stays 0 and revealHeight falls to
// nothing between prompts — the surfaces take themselves off screen rather
// than lingering as a strip the way the rail's does.
//
// Deliberately NOT tied to `scrimUp`: expanding the rail dims the rail's
// monitor only, which is the existing behaviour and the right one — the
// rail is a thing on one screen. A prompt is the only event that concerns
// every screen at once.
//
// TOP unconditionally: there is no bar on these outputs to keep one level
// above the scrim, and BACKGROUND would put the dim under ordinary windows
// where it would dim nothing. Inactive they are invisible, so the level
// costs nothing between prompts.
Variants {
model: shell.otherScreens
ChromeBackdrop {
required property var modelData
screen: modelData
active: polkit.prompting
wlrLayer: WlrLayer.Top
barHeight: 0
}
}
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
// ESC closes the rail — but only when it is the innermost thing open.
// While a prompt is up the rail gives up the keyboard so ESC dismisses
// the DIALOG; the prompt closing hands it back, and the next ESC closes
// the rail. Withheld rather than left to the compositor to arbitrate
// between two exclusive surfaces, which would decide by stacking and
// silently swap the order the day the layers change.
grabsKeyboard: shell.expanded && !polkit.prompting
onDismissed: shell.expanded = false
}
// Polkit authentication agent. It registers for this logind session on
// creation, so it replaces hyprpolkitagent rather than coexisting with it —
// only one agent may hold a session (see hosts/terra/home/hyprland.nix).
//
// It lives here rather than beside the launchers in shell.qml because its
// state is shared: `prompting` raises the scrim and freezes the density,
// which makes it shell state by the same rule as the screen and the layer
// pair. It owns only its dialog; the scrim above is the rail's.
//
// Declared LAST on purpose. While a prompt is up the bar is on Overlay too,
// and there is no layer above Overlay to escape to, so the dialog stays on
// top by being the later surface. In practice it is later regardless — its
// window only exists while a request is open, so it is always created after
// the bar's — but the declaration order says so without relying on that.
PolkitPrompt {
id: polkit
screen: shell.focusedScreen
}
}