srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-04-12 22:35:00 +0200
committersrdusr <[email protected]>2024-04-12 22:35:00 +0200
commita9dd8a6d4947cb537f7919c5a66ba4624af61800 (patch)
treed4731cded04ce159ebf3547d4dfe95ca24f4bb02 /crates/core/src
parent709333908d5b7d3157165d1811c8bc1795ab0028 (diff)
downloadsrdwm-a9dd8a6d4947cb537f7919c5a66ba4624af61800.tar.gz
srdwm-a9dd8a6d4947cb537f7919c5a66ba4624af61800.zip
udev: connector hotplug, and rescue windows on an unplugged monitor
Monitors were probed once at startup, so plugging or unplugging one while srdwm was running went unnoticed. A UdevBackend event source now watches for the kernel's `change` uevent and reconciles the head list against a fresh connector probe - forcing a re-probe rather than trusting cached status, since on a hotplug the cache is exactly what has gone stale. Removing a head tears down everything it owned: the wl_output global, its place in the Space, its DRM framebuffers and dumb buffers (dropping the Rust structs alone leaks the kernel-side objects, which matters when a cable is plugged repeatedly), and any lock surface for it - otherwise confirm_lock_if_presented would wait forever on a monitor that no longer exists. New connectors go through the same bring_up_head path as startup, so a monitor plugged in later is set up identically to one present at boot. Heads are then repositioned left-to-right, since removing one shifts the rest, and layer maps re-arranged so bars follow their moved output. set_monitors rehomes windows stranded by the change, and main.rs re-queries the whole monitor list on MonitorAdded/MonitorRemoved rather than applying the single monitor in the event, because the others' positions move too. The rehoming had a bug that unit tests missed and live testing caught. It originally keyed off Window::monitor, but that field records the monitor a window was *assigned* at creation, not where it is: add_window always sets it from the primary monitor, so a window placed on the second monitor by a rule - or dragged there - still reads monitor == 0. The field-only check saw a valid id, skipped the window, and left it at coordinates that no longer existed: invisible and unreachable. Found by unplugging a monitor out from under a real xterm and watching it vanish from both heads. It now keys off geometry, with a regression test that fails against the old logic. Verified in the QEMU VM, booting with one connector and toggling the second at runtime: plug in -> head added and rendering at its own resolution; unplug -> head removed cleanly; and an xterm at global x=1500 survived its monitor being unplugged, reappearing at x=680 (= min(1500, 1280-600)) with its size intact. Writing to /sys/class/drm/<connector>/status changes the connector but emits no uevent on this kernel, so the signal the kernel would send is synthesized with `udevadm trigger`; the whole reaction path is genuinely exercised.
Diffstat (limited to 'crates/core/src')
-rw-r--r--crates/core/src/geometry.rs17
-rw-r--r--crates/core/src/manager.rs164
2 files changed, 181 insertions, 0 deletions
diff --git a/crates/core/src/geometry.rs b/crates/core/src/geometry.rs
index 75feeef..3dd205a 100644
--- a/crates/core/src/geometry.rs
+++ b/crates/core/src/geometry.rs
@@ -42,6 +42,23 @@ impl Rect {
pub fn center(&self) -> (i32, i32) {
(self.x + self.width as i32 / 2, self.y + self.height as i32 / 2)
}
+
+ /// Moves this rect so it lies inside `bounds`, shrinking it only if it
+ /// is genuinely larger than `bounds`.
+ ///
+ /// Used when a monitor is unplugged and its windows have to be rehomed:
+ /// a window at coordinates that no longer exist would otherwise be
+ /// off-screen and unreachable. Position is adjusted in preference to
+ /// size so a window keeps the dimensions the user gave it.
+ pub fn clamped_into(&self, bounds: Rect) -> Rect {
+ let width = self.width.min(bounds.width);
+ let height = self.height.min(bounds.height);
+ // `max(bounds.x)` after `min` so that a bounds smaller than the rect
+ // still yields the bounds' own origin rather than a negative offset.
+ let x = (self.x).min(bounds.right() - width as i32).max(bounds.x);
+ let y = (self.y).min(bounds.bottom() - height as i32).max(bounds.y);
+ Rect { x, y, width, height }
+ }
}
#[cfg(test)]
diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs
index 1f69413..d261c13 100644
--- a/crates/core/src/manager.rs
+++ b/crates/core/src/manager.rs
@@ -98,8 +98,55 @@ impl WindowManager {
// ---- Monitors ----------------------------------------------------
+ /// Replaces the monitor list, rehoming any window left stranded.
+ ///
+ /// Called at startup and again on every hotplug. Unplugging a monitor
+ /// would otherwise leave its windows pointing at a `monitor` id that no
+ /// longer exists: `arrange_workspace` skips those (it looks the monitor
+ /// up to get a rectangle), so they would stop being tiled, and a
+ /// floating window would sit at coordinates that are no longer on any
+ /// screen - unreachable, with no way to drag it back.
+ ///
+ /// Stranded windows are moved to the primary monitor and, if their
+ /// geometry falls outside it, nudged back inside.
+ ///
+ /// This keys off **geometry**, not just the `monitor` field. That field
+ /// records which monitor a window was *assigned* at creation and does
+ /// not track where the window actually is: a floating window dragged --
+ /// or placed by a rule - onto a second monitor keeps `monitor`
+ /// pointing at the first. Trusting the field alone left such a window
+ /// at coordinates that no longer existed once its real monitor was
+ /// unplugged: off-screen and unreachable, with no way to drag it back.
+ /// Found by unplugging a monitor out from under a window in the QEMU VM
+ /// and watching it vanish; the field-only check had passed its unit
+ /// tests because those set `monitor` explicitly.
pub fn set_monitors(&mut self, monitors: Vec<Monitor>) {
self.monitors = monitors;
+
+ let Some(primary) = self.primary_monitor().cloned() else {
+ // No monitors at all (every output unplugged): leave windows
+ // as-is rather than collapsing them onto nothing, so they are
+ // restored intact when an output comes back.
+ return;
+ };
+ let live = self.monitors.clone();
+ for window in self.windows.values_mut() {
+ let visible_on = live.iter().find(|m| m.geometry.overlaps(&window.geometry));
+ match visible_on {
+ // Still on screen: just make sure its monitor id points at a
+ // monitor that exists, so tiling keeps working.
+ Some(monitor) => {
+ if !live.iter().any(|m| m.id == window.monitor) {
+ window.monitor = monitor.id;
+ }
+ }
+ // Nothing on screen shows this window any more.
+ None => {
+ window.geometry = window.geometry.clamped_into(primary.geometry);
+ window.monitor = primary.id;
+ }
+ }
+ }
}
pub fn monitors(&self) -> &[Monitor] {
@@ -796,4 +843,121 @@ mod tests {
assert_ne!(wm.window(a).unwrap().workspace, ws2);
assert!(wm.workspace(ws2).is_none());
}
+
+ // ---- Monitor hotplug -------------------------------------------------
+
+ fn two_monitors() -> Vec<Monitor> {
+ let mut a = Monitor::new(0, "primary", Rect::new(0, 0, 1280, 800));
+ a.primary = true;
+ let b = Monitor::new(1, "secondary", Rect::new(1280, 0, 1920, 1080));
+ vec![a, b]
+ }
+
+ #[test]
+ fn unplugging_a_monitor_rehomes_its_windows_to_the_primary() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "on-second-monitor");
+ w.geometry = Rect::new(1500, 200, 600, 400); // inside monitor 1 only
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().monitor = 1;
+
+ // Monitor 1 goes away.
+ wm.set_monitors(vec![two_monitors().remove(0)]);
+
+ let w = wm.window(id).unwrap();
+ assert_eq!(w.monitor, 0, "window should be rehomed to the primary monitor");
+ assert!(
+ Rect::new(0, 0, 1280, 800).overlaps(&w.geometry),
+ "rehomed window should be on-screen, got {:?}",
+ w.geometry
+ );
+ }
+
+ #[test]
+ fn windows_already_on_a_surviving_monitor_are_left_alone() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "on-primary");
+ w.geometry = Rect::new(10, 20, 300, 200);
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().monitor = 0;
+ wm.window_mut(id).unwrap().geometry = Rect::new(10, 20, 300, 200);
+
+ wm.set_monitors(vec![two_monitors().remove(0)]);
+
+ let w = wm.window(id).unwrap();
+ assert_eq!(w.monitor, 0);
+ assert_eq!(w.geometry, Rect::new(10, 20, 300, 200), "untouched window must not move");
+ }
+
+ #[test]
+ fn a_window_still_overlapping_the_primary_keeps_its_geometry() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "straddling");
+ wm.add_window(w.clone());
+ // Straddles the boundary, so it still overlaps the primary.
+ w.geometry = Rect::new(1200, 100, 400, 300);
+ wm.window_mut(id).unwrap().monitor = 1;
+ wm.window_mut(id).unwrap().geometry = w.geometry;
+
+ wm.set_monitors(vec![two_monitors().remove(0)]);
+
+ let got = wm.window(id).unwrap();
+ assert_eq!(got.monitor, 0, "monitor id must still be remapped");
+ assert_eq!(got.geometry, Rect::new(1200, 100, 400, 300), "already-visible geometry should be kept");
+ }
+
+ #[test]
+ fn losing_every_monitor_leaves_windows_intact_for_when_one_returns() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "orphan");
+ w.geometry = Rect::new(1500, 200, 600, 400);
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().monitor = 1;
+ wm.window_mut(id).unwrap().geometry = Rect::new(1500, 200, 600, 400);
+
+ wm.set_monitors(Vec::new());
+
+ let got = wm.window(id).unwrap();
+ assert_eq!(got.geometry, Rect::new(1500, 200, 600, 400));
+ assert_eq!(got.monitor, 1);
+ }
+
+ #[test]
+ fn a_window_whose_monitor_field_is_stale_is_still_rescued() {
+ // Regression: `add_window` assigns `monitor` from the *primary*
+ // monitor, so a window placed on the second monitor by a rule (or
+ // dragged there) keeps `monitor == 0`. Rehoming that keyed off the
+ // field alone skipped this window entirely and left it off-screen.
+ // Reproduced live by unplugging a monitor out from under an xterm.
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+
+ let id = wm.alloc_window_id();
+ let w = Window::new(id, "placed-by-rule");
+ wm.add_window(w);
+ // Geometry on monitor 1, but `monitor` still says 0 - exactly what
+ // add_window + a geometry rule produce.
+ wm.window_mut(id).unwrap().geometry = Rect::new(1500, 200, 600, 400);
+ assert_eq!(wm.window(id).unwrap().monitor, 0, "precondition: stale field");
+
+ wm.set_monitors(vec![two_monitors().remove(0)]);
+
+ let got = wm.window(id).unwrap();
+ assert!(
+ Rect::new(0, 0, 1280, 800).overlaps(&got.geometry),
+ "window must be pulled back on-screen, got {:?}",
+ got.geometry
+ );
+ }
}