diff options
| author | srdusr <[email protected]> | 2025-09-26 22:07:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-26 22:07:00 +0200 |
| commit | 9a9aa8fe9d4f4f9b15860b45244e695942c90afd (patch) | |
| tree | a7a8289731d49713caeaf06f023ab04f78cc663c /crates/core/src/manager | |
| parent | b46dfa56504f62ecf969914dfed106bf587c4c4a (diff) | |
| download | srdwm-9a9aa8fe9d4f4f9b15860b45244e695942c90afd.tar.gz srdwm-9a9aa8fe9d4f4f9b15860b45244e695942c90afd.zip | |
Fake (headless) monitors, and fix new windows all opening in one spot
Two independent pieces landed together this pass - both real, both
scoped, see docs/TODO.md for the full narrative on each:
Fake monitors: a genuinely independent, additional wl_output with no DRM
connector/CRTC behind it at all - distinct from srd.monitor.split
(divides one real output's own placement rectangle). Researched niri's
own Headless backend first (cloned at ~/reference-wms/niri): its render()
never actually composites anything, a no-render stub for that project's
test suite only. This one is real: it renders whatever is placed on it,
on demand, whenever a zwlr_screencopy_manager_v1 client asks for a frame.
New crates/wayland/src/udev/virtual_heads.rs (create/remove a real Output
+ global, render-on-demand for screencopy, integrated into platform.rs's
monitors() as a genuine srdwm_core::Monitor so core placement/workspace
code needs zero special-casing). New IPC/CLI: srd dispatch create
fake-monitor <name> <w>x<h> / remove fake-monitor <name>. Core-side
request queue in crates/core/src/manager/fake_monitor.rs.
Placement bug, root-caused and fixed: every new window opened alone
landed in the exact same spot, "not at all like Windows" (reported live).
SmartPlacement::place tried a grid cell first, and grid's own cell count
is existing.len() + 1 - with nothing else open (opening one app at a
time, the ordinary case), that's always 1, so a 1x1 grid returns the same
single cell forever regardless of session history. Cascade had the same
bug in a second form (its own step was existing.len() % max_steps, also
always 0 with nothing open). Fixed: WindowManager::next_cascade_step (a
Cell - add_window's own target_monitor stays borrowed across the call)
advances on every real placement and is never reset by a window closing;
place() now skips grid entirely when nothing else is open, going
straight to cascade, since grid's real job (dividing space among
concurrent windows) has nothing to divide when there's no concurrency.
Full workspace build/test/clippy clean (223 core / 141 wayland / 29
platform / 24 ctl / 28 config / 10 x11 tests, 0 failed, 0 clippy
warnings), built and installed.
Diffstat (limited to 'crates/core/src/manager')
| -rw-r--r-- | crates/core/src/manager/fake_monitor.rs | 69 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 23 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 36 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 11 |
4 files changed, 131 insertions, 8 deletions
diff --git a/crates/core/src/manager/fake_monitor.rs b/crates/core/src/manager/fake_monitor.rs new file mode 100644 index 0000000..2533e83 --- /dev/null +++ b/crates/core/src/manager/fake_monitor.rs @@ -0,0 +1,69 @@ +//! Requesting a fake (fully virtual, no real hardware) monitor be created +//! or removed - see `crates/wayland/src/udev/virtual_heads.rs`'s own +//! module doc comment for the full design. Split out the same way +//! `lock.rs`/`input_pin.rs` are: everything here is plain `impl +//! WindowManager` methods; see `super` (`mod.rs`) for `WindowManager`'s +//! field definitions. + +use super::*; + +impl WindowManager { + /// Queues a request to create a fake monitor named `name` at + /// `width`x`height` - the only caller today is the IPC + /// `"create_fake_monitor"` dispatch, the compositor-agnostic side of + /// `srd dispatch create fake-monitor`. Core has no real `wl_output` + /// to create itself (backend-owned, same as every other cross- + /// boundary request here); the Wayland backend drains and applies + /// this on its own next poll. + pub fn request_create_fake_monitor(&mut self, name: String, width: u32, height: u32) { + self.create_fake_monitor_requests.push((name, width, height)); + } + + pub fn drain_create_fake_monitor_requests(&mut self) -> Vec<(String, u32, u32)> { + std::mem::take(&mut self.create_fake_monitor_requests) + } + + /// Same cross-boundary-request pattern, for removing a fake monitor + /// by name - the IPC `"remove_fake_monitor"` dispatch. + pub fn request_remove_fake_monitor(&mut self, name: String) { + self.remove_fake_monitor_requests.push(name); + } + + pub fn drain_remove_fake_monitor_requests(&mut self) -> Vec<String> { + std::mem::take(&mut self.remove_fake_monitor_requests) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_create_request_is_reported_once_then_the_queue_is_empty() { + let mut wm = WindowManager::new(); + assert!(wm.drain_create_fake_monitor_requests().is_empty()); + wm.request_create_fake_monitor("FAKE-1".into(), 1920, 1080); + assert_eq!(wm.drain_create_fake_monitor_requests(), vec![("FAKE-1".to_string(), 1920, 1080)]); + assert!(wm.drain_create_fake_monitor_requests().is_empty()); + } + + #[test] + fn a_remove_request_is_reported_once_then_the_queue_is_empty() { + let mut wm = WindowManager::new(); + wm.request_remove_fake_monitor("FAKE-1".into()); + assert_eq!(wm.drain_remove_fake_monitor_requests(), vec!["FAKE-1".to_string()]); + assert!(wm.drain_remove_fake_monitor_requests().is_empty()); + } + + #[test] + fn multiple_create_requests_before_a_drain_are_all_preserved() { + // Unlike `request_pin_input`'s own "replace, don't accumulate" + // semantics (one pid can only ever have one current pin target), + // two different fake-monitor names are two genuinely independent + // creations - both must survive to the next drain. + let mut wm = WindowManager::new(); + wm.request_create_fake_monitor("FAKE-1".into(), 1920, 1080); + wm.request_create_fake_monitor("FAKE-2".into(), 1280, 720); + assert_eq!(wm.drain_create_fake_monitor_requests(), vec![("FAKE-1".to_string(), 1920, 1080), ("FAKE-2".to_string(), 1280, 720)]); + } +} diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs index a5cde36..c82bced 100644 --- a/crates/core/src/manager/mod.rs +++ b/crates/core/src/manager/mod.rs @@ -80,6 +80,12 @@ pub struct WindowManager { /// ever learn) to a specific window. See `input_pin.rs`'s own doc /// comment. pin_input_requests: Vec<(i32, Option<WindowId>)>, + /// Same cross-boundary-request pattern, for fake (fully virtual, no + /// real hardware) monitors - see `fake_monitor.rs`'s own doc comment + /// and `crates/wayland/src/udev/virtual_heads.rs`'s module doc + /// comment for the full design. + create_fake_monitor_requests: Vec<(String, u32, u32)>, + remove_fake_monitor_requests: Vec<String>, /// Same cross-boundary-request pattern as `output_position_requests` /// just above, for enable/disable - see `request_output_enabled`'s /// own doc comment for why this is keyed by name, not `MonitorId`. @@ -170,6 +176,19 @@ pub struct WindowManager { layouts: HashMap<String, Box<dyn Layout>>, pub tiling: TilingConfig, pub placement: PlacementConfig, + /// Feeds `SmartPlacement::place`'s own `cascade_step` - advances on + /// every real cascade/grid placement, session-long, never reset by a + /// window closing. See `SmartPlacement::cascade`'s own doc comment + /// for the reported "every window opens in the same spot" bug this + /// exists to fix. A `Cell`, not a plain field: `add_window`'s own + /// `target_monitor` is a `&Monitor` borrowed from `self.monitors` and + /// stays alive across the same call that needs to bump this counter, + /// so a plain `&mut self` write there would conflict with that live + /// immutable borrow - interior mutability sidesteps it without + /// restructuring the borrow, the same reasoning any of this + /// compositor's other `Rc<RefCell<...>>`-style shared-mutation points + /// already accept. + next_cascade_step: std::cell::Cell<u32>, /// Whether geometry changes made via `toggle_maximize`/`toggle_fullscreen` /// should be animated. Read from `general.animations`; a backend's open /// animation is gated on this too, since core has no notion of "open". @@ -410,6 +429,8 @@ impl WindowManager { monitors: Vec::new(), output_position_requests: Vec::new(), pin_input_requests: Vec::new(), + create_fake_monitor_requests: Vec::new(), + remove_fake_monitor_requests: Vec::new(), output_enable_requests: Vec::new(), disabled_monitors: HashMap::new(), monitor_splits: HashMap::new(), @@ -460,6 +481,7 @@ impl WindowManager { layouts, tiling: TilingConfig::default(), placement: PlacementConfig::default(), + next_cascade_step: std::cell::Cell::new(0), animations_enabled: true, animation_duration_ms: 200, shadows_enabled: true, @@ -510,6 +532,7 @@ impl WindowManager { mod capture; mod dragresize; +mod fake_monitor; mod focus; mod hittest; mod input_pin; diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs index 725af1f..c82d3dd 100644 --- a/crates/core/src/manager/tests.rs +++ b/crates/core/src/manager/tests.rs @@ -18,8 +18,11 @@ w.geometry = Rect::new(0, 0, 400, 300); wm.add_window(w); let placed = wm.window(id).unwrap().geometry; - // Grid placement starts at grid_margin, not (0,0). - assert_eq!(placed.x, wm.placement.grid_margin as i32); + // The first window on an empty workspace cascades (see + // `SmartPlacement::place`'s own doc comment on why grid is + // skipped entirely when nothing else is open), starting at + // `cascade_offset`, not (0,0). + assert_eq!(placed.x, wm.placement.cascade_offset); } #[test] @@ -569,16 +572,35 @@ let mut wa = Window::new(a, "a"); wa.geometry = Rect::new(0, 0, 400, 300); wm.add_window(wa); + // `a`'s own real, auto-placed geometry - read back rather than + // assumed, since `SmartPlacement` (not the `Rect` set above, + // which `add_window` overwrites) decides where it actually lands. + let a_geom = wm.window(a).unwrap().geometry; let b = wm.alloc_window_id(); - let mut wb = Window::new(b, "b"); - wb.geometry = Rect::new(0, 0, 400, 300); // identical geometry to `a` - wm.add_window(wb); + wm.add_window(Window::new(b, "b")); + // Forced to genuinely identical geometry to `a` *after* placement + // (`add_window`'s own `SmartPlacement` would otherwise place `b` + // to avoid overlapping `a`, defeating this test's actual point -- + // the overlap here needs to be real, not incidental). + wm.window_mut(b).unwrap().geometry = a_geom; let other_workspace = wm.add_workspace("2", "dynamic"); wm.move_window_to_workspace(b, other_workspace); // b is now off-screen, not minimized - let (hit_id, _) = wm.hit_test(200, 10).unwrap(); + // `hit_test` specifically means titlebar/border/resize-margin hits + // (see its own doc comment) - a point in the window's plain + // content area always resolves `None` there by design (`w`'s own + // opaque content is in the way, `hit_test_with`'s own comment). + // A few pixels below the top edge, horizontally centered, is + // safely inside the titlebar band without landing in a corner + // resize zone. + let (px, py) = (a_geom.x + a_geom.width as i32 / 2, a_geom.y + 5); + let (hit_id, _) = wm.hit_test(px, py).unwrap(); assert_eq!(hit_id, a, "a click must land on the visible window, not one hidden on another workspace"); - assert_eq!(wm.window_at(200, 10), Some(a)); + // `window_at` (content-inclusive) is checked at the window's + // actual center instead - unlike `hit_test`, it has no titlebar- + // only restriction to work around. + let center = (a_geom.x + a_geom.width as i32 / 2, a_geom.y + a_geom.height as i32 / 2); + assert_eq!(wm.window_at(center.0, center.1), Some(a)); } #[test] diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs index ff6c4c8..b2edb6b 100644 --- a/crates/core/src/manager/windows.rs +++ b/crates/core/src/manager/windows.rs @@ -149,7 +149,16 @@ impl WindowManager { if layout_name != "tiling" { let existing: Vec<Rect> = self.windows_on_workspace(workspace).map(|w| w.geometry).collect(); let size = (window.geometry.width, window.geometry.height); - window.geometry = SmartPlacement::place(monitor, &existing, size, &self.placement); + // `next_cascade_step` advances on every real placement, + // never reset by a window closing - see `SmartPlacement:: + // cascade`'s own doc comment for the reported bug this + // fixes ("every window opens in the same spot" when + // opening one app at a time, closing each before the + // next, which kept `existing` empty at the moment of + // every single placement). + let step = self.next_cascade_step.get(); + self.next_cascade_step.set(step.wrapping_add(1)); + window.geometry = SmartPlacement::place(monitor, &existing, size, &self.placement, step); } } if let Some(geometry) = actions.as_ref().and_then(|a| a.geometry) { |