Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .wiki/MultiMonitorPlacement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<RefCell<Vec<Window>>>` 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.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
41 changes: 26 additions & 15 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ fn main() -> Result<()> {
/// the config dir (used by the hot-reload watcher).
struct App {
main_loop: MainLoop,
surfaces: Vec<Window>,
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<RefCell<Vec<Window>>>,
webview: WebView,
dispatcher: Rc<RefCell<Dispatcher>>,
config_dir: Option<PathBuf>,
Expand All @@ -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) {
Expand Down Expand Up @@ -165,15 +170,21 @@ fn build_app() -> Result<App> {
// 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<RefCell<Vec<Window>>> = 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,
Expand Down
38 changes: 38 additions & 0 deletions src/window.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use std::cell::RefCell;
use std::rc::Rc;

use gtk4::glib;
use gtk4::prelude::*;
use gtk4::{CssProvider, EventControllerKey, Window, gdk};
Expand Down Expand Up @@ -196,6 +199,41 @@ pub fn enumerate_monitors() -> Vec<gdk::Monitor> {
.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<RefCell<Vec<Window>>>, 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 —
Expand Down
Loading