Files
homelab/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml
T
2026-09-18 20:39:06 +02:00

252 lines
11 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
import qs.HyprChrome.Widgets.Launcher
import qs.HyprChrome.Widgets.Askpass
// 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 a MODAL is open — the polkit prompt, the launcher, or the sudo
// askpass dialog. Each raises the same scrim the rail uses, freezes the
// density, lands on the focused monitor and takes the keyboard off the
// rail, so several surfaces read it. That is why they live here rather than
// as siblings of the remaining launcher variants 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 (shell.modalOpen)
return;
shell.expanded = !shell.expanded;
}
// The surfaces that take over the screen: they dim EVERY output, land on the
// focused one, and take the keyboard off the rail. Grouped because
// everything below treats them alike, so a fourth one joins by being named
// here and nowhere else.
readonly property bool modalOpen: polkit.prompting || launcher.active || askpass.active
// Whether the scrim is up, from ANY cause. This is the fact the surfaces
// actually share — the rail's density is only one of the things that can
// raise it — so the backdrop and the layer pair below key off this rather
// than off `expanded`.
//
// One backdrop instance serves all of them. A modal opening over an already
// expanded rail therefore changes nothing about the scrim on that monitor:
// it is already up, already full height, and the modal simply appears above
// it. Over a COLLAPSED rail the same scrim expands 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 || shell.modalOpen
// 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: shell.modalOpen
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 && !shell.modalOpen
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
}
// Primary application launcher — SUPER_L. Migrated out of
// widgets/launcher/; the ten remaining variants are still evaluation copies
// and stay in shell.qml. Declared after the bar for the same reason the
// prompt is: while it is open the bar is on Overlay too, and there is no
// layer above Overlay to escape to.
AppLauncher {
id: launcher
screen: shell.focusedScreen
}
// GUI password prompt for `sudo -A`. Not the polkit agent — sudo cannot use
// one — but it renders the same dialog. See the file for the flow.
AskpassPrompt {
id: askpass
screen: shell.focusedScreen
}
}