From 92432834a1b089cfe577c20924935b1caa821e16 Mon Sep 17 00:00:00 2001 From: "Michael S." Date: Wed, 3 Jun 2026 21:03:12 +0200 Subject: [PATCH] Rebuild dimmers on monitor hotplug; bump to 1.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon's dimmer set was a one-shot snapshot of gdk::Display::default().monitors() taken in build_app. On a multi-GPU rig where the primary briefly attaches to a passthrough HDMI during boot before switching to DP on the main GPU, the snapshot misses the real primary and the daemon never builds a dimmer for it — leaving primary bright after the switch while the secondaries dim correctly. Split App.surfaces into menu_window + Rc>> dimmers so the dimmer set can be replaced without rebuilding the menu (which owns the WebKitGTK process daemon mode keeps alive across hide/show). window::watch_monitor_changes connects items-changed on the GDK monitor list and destroys + rebuilds the dimmer windows on every delta, presenting them immediately if the menu is open. --- .wiki/MultiMonitorPlacement.md | 12 ++++++++-- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 41 +++++++++++++++++++++------------- src/window.rs | 38 +++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/.wiki/MultiMonitorPlacement.md b/.wiki/MultiMonitorPlacement.md index 2119107..2ffad40 100644 --- a/.wiki/MultiMonitorPlacement.md +++ b/.wiki/MultiMonitorPlacement.md @@ -2,12 +2,12 @@ title: "MultiMonitorPlacement" tags: [multi-monitor, layer-shell, hyprland, gotcha, window] related: ["StackDecision", "DaemonMode"] -updated: 2026-05-23 +updated: 2026-06-03 --- # MultiMonitorPlacement -The menu sits on `Layer::Overlay`; the dimmers sit on `Layer::Top`; **every** monitor gets a dimmer including the menu's own. This layout is a deliberate robustness measure — do not "simplify" it back to dimming only the non-menu monitors without re-reading the history below. +The menu sits on `Layer::Overlay`; the dimmers sit on `Layer::Top`; **every** monitor gets a dimmer including the menu's own. The dimmer set is also rebuilt on `gdk::Display::monitors()` items-changed, because the startup snapshot can be incomplete. This layout is a deliberate robustness measure — do not "simplify" it back to dimming only the non-menu monitors without re-reading the history below. ## The bug it fixed @@ -25,6 +25,14 @@ Because cause #2 is timing-sensitive rather than fully understood, dimming **all `pick_menu_monitor` / `settings.output` / the (0,0) primary heuristic decide where the menu is *requested*; in release builds on Hyprland that request is honored. +## Late-arriving monitor (the boot-time GPU/source race) + +Even with every-monitor dimming, one failure mode persisted in daemon mode: build_app's `enumerate_monitors()` is a single snapshot of `gdk::Display::default().monitors()` taken at daemon start, and if the snapshot is missing an output that comes online seconds later, that output never gets a dimmer. Observed in the wild on a multi-GPU rig where the primary briefly attaches to a passthrough HDMI on the secondary GPU during boot before switching back to DP on the main GPU. The user saw: menu lands on cursor monitor (which had a dimmer), but the real primary stayed bright because no dimmer was ever built for it. Cause #2 above hid the underlying cause-#3 here for a while because the visible symptom (menu on cursor monitor) looked like a recurrence of the timing race. + +Fix: `window::watch_monitor_changes` connects an `items-changed` handler on the GDK monitor list (`src/window.rs`). On every delta it destroys the existing dimmer windows, rebuilds one per currently-connected monitor via `build_dimmer`, and presents the new ones immediately if the menu is currently visible. The menu window is left untouched — it owns the WebKitGTK process that [[DaemonMode]] is built around keeping alive across hide/show, so rebuilding it would cost that whole optimization. The `App.dimmers` field is therefore `Rc>>` rather than part of an immutable `surfaces` Vec. + +If a monitor change arrives while glogout is open, the user sees a brief flicker as the dimmer set is swapped — accepted, because the OS-level layout switch that triggered the items-changed is itself a visible event, so an extra flicker is in line with what the user already expects in that moment. + ## Known cosmetic consequence The monitor the menu lands on carries both a dimmer (Top) and the menu body background (`rgba(18,18,22,0.6)` + blur). Because the body is only ~60% opaque, the dimmer behind it bleeds through, stacking to ~0.84 effective darkness vs 0.6 on the others. Whether it's *visible* depends on the wallpaper: near-invisible on a dark one (both approach the dim color), clear on a bright one. Accepted as a minor tradeoff for the safety net. diff --git a/Cargo.lock b/Cargo.lock index 079c1d7..a33c57f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,7 +446,7 @@ dependencies = [ [[package]] name = "glogout" -version = "1.0.0" +version = "1.0.1" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index 5035b89..32bb644 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "glogout" -version = "1.0.0" +version = "1.0.1" edition = "2024" license = "MIT" description = "A Wayland logout menu themed with real HTML, CSS, and JavaScript — no GTK theme inheritance." diff --git a/src/main.rs b/src/main.rs index a61e149..7d9abe4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,7 +62,13 @@ fn main() -> Result<()> { /// the config dir (used by the hot-reload watcher). struct App { main_loop: MainLoop, - surfaces: Vec, + menu_window: Window, + /// Dimmer windows, one per currently-connected monitor. Mutable because + /// the GDK monitor list can change after daemon start — late-arriving + /// outputs (e.g. a primary that comes up on the wrong GPU at boot and + /// switches over a few seconds later) need their own dimmer, which only + /// the items-changed watcher can build. + dimmers: Rc>>, webview: WebView, dispatcher: Rc>, config_dir: Option, @@ -80,24 +86,23 @@ struct App { impl App { fn show(&self) { - for surface in &self.surfaces { - surface.present(); + self.menu_window.present(); + for dimmer in self.dimmers.borrow().iter() { + dimmer.present(); } } fn hide(&self) { - for surface in &self.surfaces { - surface.set_visible(false); + self.menu_window.set_visible(false); + for dimmer in self.dimmers.borrow().iter() { + dimmer.set_visible(false); } } /// True when the menu surface is currently mapped. Used to decide /// what `toggle` should do. fn is_visible(&self) -> bool { - self.surfaces - .first() - .map(|s| s.is_visible()) - .unwrap_or(false) + self.menu_window.is_visible() } fn toggle(&self) { @@ -165,15 +170,21 @@ fn build_app() -> Result { // output and drops it on the focused screen, so we can't reliably know // which monitor to leave undimmed. The layer split keeps the menu on top // of its own dimmer regardless. - let mut surfaces = Vec::with_capacity(monitors.len() + 1); - surfaces.push(menu_window); - for monitor in &monitors { - surfaces.push(window::build_dimmer(monitor)); - } + let dimmers: Rc>> = Rc::new(RefCell::new( + monitors.iter().map(window::build_dimmer).collect(), + )); + + // The monitor list snapshot above can be incomplete — at boot, a primary + // attached to a passthrough GPU may show up on the wrong source for a few + // seconds before switching back, and a daemon that started during that + // window would otherwise never build a dimmer for it. Watch for the + // delta and rebuild the dimmer set when it changes. + window::watch_monitor_changes(dimmers.clone(), menu_window.clone()); Ok(App { main_loop, - surfaces, + menu_window, + dimmers, webview: menu_webview, dispatcher, config_dir, diff --git a/src/window.rs b/src/window.rs index bd98af3..62398ae 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,3 +1,6 @@ +use std::cell::RefCell; +use std::rc::Rc; + use gtk4::glib; use gtk4::prelude::*; use gtk4::{CssProvider, EventControllerKey, Window, gdk}; @@ -196,6 +199,41 @@ pub fn enumerate_monitors() -> Vec { .collect() } +/// Watch the GDK monitor list and rebuild `dimmers` whenever it changes. +/// +/// The dimmer set is computed once from a snapshot at startup, but that +/// snapshot can be incomplete — observed in the wild when a primary display +/// hangs off a passthrough GPU and only switches over to the main GPU a few +/// seconds into boot, after the daemon has already enumerated. Without this +/// watcher the daemon would never carry a dimmer for that late-arriving +/// output and the primary screen would stay bright while the rest dim. +/// +/// On change: destroy the old dimmer windows, build fresh ones for every +/// currently-connected monitor, and present them immediately if the menu is +/// open right now. The menu window itself is left alone — it owns the +/// WebKitGTK process we want to keep across hide/show in daemon mode. +pub fn watch_monitor_changes(dimmers: Rc>>, menu_window: Window) { + let Some(display) = gdk::Display::default() else { + return; + }; + let monitors_model = display.monitors(); + monitors_model.connect_items_changed(move |_, _, _, _| { + let new_monitors = enumerate_monitors(); + let was_visible = menu_window.is_visible(); + let mut dimmers = dimmers.borrow_mut(); + for old in dimmers.drain(..) { + old.destroy(); + } + for monitor in &new_monitors { + let dimmer = build_dimmer(monitor); + if was_visible { + dimmer.present(); + } + dimmers.push(dimmer); + } + }); +} + /// Pick the menu monitor: the one matching `wanted` (by connector name). /// Without a configured output, prefer the monitor at logical (0, 0) — that /// is conventionally the user's primary on both X11 and Wayland setups —