feat(quickshell): give the shell the polkit agent and one shared scrim

Moves PolkitPrompt from shell.qml into HyprChromeShell. Whether a prompt is
open is shell state by the same rule as the screen and the layer pair: two
surfaces read it.

The prompt no longer carries a backdrop of its own. There is one
ChromeBackdrop per output and a prompt raises them all, so a prompt over an
already-expanded rail reuses the scrim that is there rather than stacking a
second one on it, and a prompt over a collapsed rail expands that same scrim
from its bar-height band to the whole output.

The layer pair now keys off `scrimUp` (expanded OR prompting) rather than off
the density, which keeps bar and backdrop exactly one level apart in every
state. A prompt over a collapsed rail raises both: BACKGROUND sits under
ordinary windows so a scrim there dims nothing, and the bar has to stay one
above the scrim or the shell dims its own chrome. The rail is raised but
stays collapsed — its layer answers to the scrim, its height to `expanded`.

Outputs the rail does not live on get a scrim only while a prompt is up; a
modal that dims one monitor and leaves the others lit does not read as modal.
Expanding the rail still dims only the rail's screen, which is the existing
behaviour and the right one.

The dialog follows Hyprland.focusedMonitor rather than the rail's screen — a
password prompt belongs where the user is looking — matched by name against
Quickshell.screens, falling back to the rail's screen rather than to nothing.

SUPER A is frozen while a prompt is open, and dropped rather than queued, so
the rail does not spring open the moment the dialog goes.

Verified on two monitors via hyprctl layers, both densities, plus the toggle
block with an odd number of presses (two cancel out and prove nothing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud
This commit is contained in:
2026-09-01 23:24:05 +02:00
co-authored by Claude Opus 5
parent 05d26e386f
commit 38608c4008
3 changed files with 150 additions and 23 deletions
@@ -5,17 +5,22 @@ 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. Three things qualify so far:
// 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
@@ -44,21 +49,86 @@ Scope {
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;
}
// Expanded the rail is over everything; collapsed it drops below ordinary
// windows. BOTTOM rather than BACKGROUND for the collapsed bar: it is the
// 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.
readonly property int barLayer: shell.expanded ? WlrLayer.Overlay : WlrLayer.Bottom
readonly property int backdropLayer: shell.expanded ? WlrLayer.Top : WlrLayer.Background
//
// 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 {
@@ -72,7 +142,7 @@ Scope {
// visual order costs nothing.
ChromeBackdrop {
screen: shell.targetScreen
active: shell.expanded
active: shell.scrimUp
wlrLayer: shell.backdropLayer
// Collapsed, the scrim only backs the rail, so it needs the band the
@@ -83,6 +153,33 @@ Scope {
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
@@ -93,4 +190,24 @@ Scope {
expanded: shell.expanded
wlrLayer: shell.barLayer
}
// 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
}
}
@@ -40,6 +40,12 @@ import qs.HyprChrome.Theme
Scope {
id: root
// Which output the dialog appears on. Driven by the shell, which puts it on
// the focused monitor rather than on the rail's — a password prompt belongs
// where the user is looking. Left unset it falls back to whatever screen
// quickshell picks, which is right for a single-monitor session.
property var screen: null
// Where the flow's identity list is currently pointed. Held here rather
// than read back off the flow because the content addresses identities by
// index and AuthFlow addresses them by object.
@@ -50,6 +56,15 @@ Scope {
// shows a dialog, which looks exactly like "no one asked for authorization".
readonly property alias registered: agent.isRegistered
// Whether a request is being presented. Both surfaces read it, so it is
// decided once here rather than each deriving it — the scrim and the dialog
// must come and go on the same frame.
//
// isCompleted is checked as well as null: the flow reports its terminal
// state before the agent drops it, and neither surface should linger for
// those frames over a request that has already been decided.
readonly property bool prompting: root.flow !== null && !root.flow.isCompleted
// Reset per REQUEST, not per window show.
//
// A second request supersedes the first by swapping `flow` while the dialog
@@ -136,13 +151,16 @@ Scope {
}
}
// No scrim of its own. The shell owns the single ChromeBackdrop and raises
// it for either cause — an expanded rail or an open prompt — so a prompt
// arriving over an already-expanded rail reuses the scrim that is already
// there instead of stacking a second one on top of it. `prompting` above is
// what the shell reads to decide. See HyprChromeShell.
PanelWindow {
id: win
// isCompleted is checked as well as null: the flow reports its terminal
// state before the agent drops it, and the dialog should not linger for
// those frames showing a request that has already been decided.
visible: root.flow !== null && !root.flow.isCompleted
screen: root.screen
visible: root.prompting
WlrLayershell.layer: WlrLayer.Overlay
// A real modal — unlike the rest of the rail, this one must take the
@@ -158,14 +176,11 @@ Scope {
bottom: true
}
// Scrim. No click-to-dismiss: a polkit request is answered or
// explicitly cancelled, and losing one to a stray click on the
// wallpaper would leave the caller waiting with no visible reason.
Rectangle {
anchors.fill: parent
color: Theme.surface
opacity: 0.72
}
// No scrim here — ChromeBackdrop above draws it. This surface stays
// transparent but unmasked, so it still swallows clicks across the
// whole output: a polkit request is answered or explicitly cancelled,
// and losing one to a stray click on the wallpaper would leave the
// caller waiting with no visible reason.
PolkitPromptContent {
id: content