diff --git a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml index 2539bc2..8b9c3c1 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml @@ -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 + } } diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml index fb46029..a2e7b7a 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml @@ -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 diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 4ed87b9..7272fad 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -15,7 +15,6 @@ import qs.HyprChrome.Widgets import qs.HyprChrome.Widgets.Bar.Debug import qs.HyprChrome.Widgets.Bar.Host import qs.HyprChrome.Widgets.Bar.Vitals -import qs.HyprChrome.Widgets.Polkit Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. @@ -36,10 +35,6 @@ Scope { Notifications {} - // Polkit authentication agent. 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). - PolkitPrompt {} VolumeOsd {} // Host vitals HUD — toggle with SUPER CTRL V.