diff options
Diffstat (limited to 'crates')
| -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 | ||||
| -rw-r--r-- | crates/core/src/placement.rs | 105 | ||||
| -rw-r--r-- | crates/ctl/src/main.rs | 42 | ||||
| -rw-r--r-- | crates/platform/src/ipc.rs | 21 | ||||
| -rw-r--r-- | crates/wayland/src/udev/mod.rs | 6 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 40 | ||||
| -rw-r--r-- | crates/wayland/src/udev/virtual_heads.rs | 226 |
10 files changed, 556 insertions, 23 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) { diff --git a/crates/core/src/placement.rs b/crates/core/src/placement.rs index 0247666..e2b3d04 100644 --- a/crates/core/src/placement.rs +++ b/crates/core/src/placement.rs @@ -122,8 +122,26 @@ pub struct SmartPlacement; impl SmartPlacement { /// Place a new window of `size` given the geometries of windows already /// occupying `monitor`. Tries a grid cell first, falling back to cascade. - pub fn place(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig) -> Rect { - Self::grid(monitor, existing, size, cfg).unwrap_or_else(|| Self::cascade(monitor, existing, size, cfg)) + /// `cascade_step` is a monotonically increasing counter the caller owns + /// (`WindowManager::next_cascade_step`) - see `cascade`'s own doc + /// comment for why this can't just be `existing.len()` the way `grid`'s + /// own cell count legitimately still is. + /// + /// Skips grid entirely when `existing` is empty, going straight to + /// cascade instead: grid's real job is dividing space fairly among + /// *concurrent* windows, and with nothing else open there is nothing + /// to divide against - a 1x1 grid is mathematically one single cell + /// no matter how it's rotated, so it can never vary by session + /// history the way this reported bug needs. This is the actual + /// overwhelmingly common case in practice (open one app, use it, + /// close it, open the next), which is exactly why the bug this fixes + /// ("every window opens in the same spot") was reported as the normal + /// experience, not an edge case. + pub fn place(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig, cascade_step: u32) -> Rect { + if existing.is_empty() { + return Self::cascade(monitor, size, cfg, cascade_step); + } + Self::grid(monitor, existing, size, cfg).unwrap_or_else(|| Self::cascade(monitor, size, cfg, cascade_step)) } fn grid(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig) -> Option<Rect> { @@ -155,10 +173,27 @@ impl SmartPlacement { None } - /// Diagonal cascade, stepping by `cascade_offset` per already-placed - /// window and wrapping back to the origin once it would run off the + /// Diagonal cascade, stepping by `cascade_offset` per window opened so + /// far and wrapping back to the origin once it would run off the /// monitor. - fn cascade(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig) -> Rect { + /// + /// Driven by `cascade_step` - a counter the caller keeps incrementing + /// across the whole session - rather than `existing.len()` (how many + /// windows happen to be open on this workspace *right now*), which is + /// what this used to take. That reads as reasonable ("cascade further + /// when more windows are open") but has a real, reported bug baked in: + /// the overwhelmingly common way people actually use a desktop is one + /// app at a time - open, use, close, open the next - and `existing` + /// is empty at the start of every single one of those opens, so `step` + /// was `0` every time regardless of how many windows had already been + /// opened-and-closed that session. Reported live as "every window + /// spawns in the exact same place and size, not at all like Windows" -- + /// confirmed by reading this function, not guessed: real Windows + /// cascades the *next* window further even after you close the + /// previous one, which needs a counter that survives a window closing, + /// not one derived from whoever is still open at the moment of the + /// next placement. + fn cascade(monitor: &Monitor, size: (u32, u32), cfg: &PlacementConfig, cascade_step: u32) -> Rect { let area = monitor.geometry; let width = size.0.min(area.width); let height = size.1.min(area.height); @@ -167,7 +202,7 @@ impl SmartPlacement { let max_steps_y = ((area.height as i32 - height as i32) / cfg.cascade_offset.max(1)).max(1); let max_steps = max_steps_x.min(max_steps_y).max(1); - let step = existing.len() as i32 % max_steps; + let step = (cascade_step as i32) % max_steps; let x = (area.x + cfg.cascade_offset + step * cfg.cascade_offset).min(area.right() - width as i32).max(area.x); let y = (area.y + cfg.cascade_offset + step * cfg.cascade_offset).min(area.bottom() - height as i32).max(area.y); Rect::new(x, y, width, height) @@ -210,18 +245,36 @@ mod tests { } #[test] - fn first_window_goes_in_top_left_grid_cell() { + fn a_window_opened_alone_cascades_rather_than_using_a_pointless_1x1_grid() { + // `place` skips `grid` entirely when nothing else is open - see + // its own doc comment for why: a grid with nothing to divide space + // against is always exactly one cell, which can never vary by + // session history, and "one app open at a time" is the ordinary + // case, not an edge one. + let cfg = PlacementConfig::default(); + let r = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 0); + assert_eq!(r.x, cfg.cascade_offset); + assert_eq!(r.y, cfg.cascade_offset); + } + + #[test] + fn opening_the_same_app_alone_twice_in_a_row_lands_in_different_spots() { + // The concrete reported symptom, exercised through the real + // `place` entry point (not `cascade` directly, unlike the more + // targeted unit test below) - opening one window, closing it, and + // opening another must not silently collapse back to the exact + // same spot just because `existing` is empty again both times. let cfg = PlacementConfig::default(); - let r = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg); - assert_eq!(r.x, cfg.grid_margin as i32); - assert_eq!(r.y, cfg.grid_margin as i32); + let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 0); + let second = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 1); + assert_ne!(first, second); } #[test] fn grid_avoids_occupied_cells() { let cfg = PlacementConfig::default(); - let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg); - let second = SmartPlacement::place(&monitor(), &[first], (400, 300), &cfg); + let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 0); + let second = SmartPlacement::place(&monitor(), &[first], (400, 300), &cfg, 1); assert!(!first.overlaps(&second), "second window must not overlap the first: {first:?} vs {second:?}"); } @@ -230,17 +283,39 @@ mod tests { let cfg = PlacementConfig { max_grid: 1, ..Default::default() }; // max_grid=1 means the grid is always a single cell, so a second // window can never find a free grid cell and must cascade. - let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg); - let second = SmartPlacement::place(&monitor(), &[first], (400, 300), &cfg); + let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg, 0); + let second = SmartPlacement::place(&monitor(), &[first], (400, 300), &cfg, 1); assert_ne!(first, second); // First window is grid-placed (offset by grid_margin); the second no // longer fits any grid cell and falls back to cascade, which steps - // from the monitor origin by `cascade_offset` per already-placed window. + // from the monitor origin by `cascade_offset` per window opened so + // far this session (the caller's own counter, passed in as `1` here). assert_eq!(second.x, cfg.cascade_offset * 2); assert_eq!(second.y, cfg.cascade_offset * 2); } #[test] + fn cascade_step_keeps_advancing_even_if_the_previous_window_closed() { + // The actual reported bug this counter exists to fix: opening one + // window at a time (closing each before the next) used to reset + // `existing` to empty every time, so `step` - driven by `existing. + // len()` - was always 0 regardless of how many windows had already + // been opened-and-closed. A cascade_step the caller keeps + // incrementing across the session, independent of what is + // currently open, is what actually fixes it. Calls `cascade` + // directly (not `place`): `place`'s own grid-first fallback would + // succeed for an empty `existing` regardless of this test's own + // point (a grid's cell *count* legitimately does depend on live + // occupancy - see `place`'s own doc comment on why only `cascade` + // takes this counter), so a `place`-level test couldn't actually + // isolate cascade's own behavior here. + let cfg = PlacementConfig::default(); + let first = SmartPlacement::cascade(&monitor(), (400, 300), &cfg, 0); + let second = SmartPlacement::cascade(&monitor(), (400, 300), &cfg, 1); + assert_ne!(first, second, "an unchanged cascade_step of 0 vs 1 must not collapse to the same spot"); + } + + #[test] fn snap_left_edge_yields_left_half() { let cfg = PlacementConfig::default(); let dragged = Rect::new(2, 100, 400, 300); // x=2 is within threshold of left edge diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs index 929d44c..d714405 100644 --- a/crates/ctl/src/main.rs +++ b/crates/ctl/src/main.rs @@ -321,6 +321,27 @@ fn build_dispatch(args: &[String]) -> Result<String, String> { _ => Err(format!("unknown 'set output' target '{noun}' - {usage_hint}")), } } + // `srd dispatch create fake-monitor <name> <width>x<height>` / + // `srd dispatch remove fake-monitor <name>` - a fully virtual + // `wl_output` with no real hardware behind it. See + // `crates/wayland/src/udev/virtual_heads.rs`'s own module doc + // comment for the full design and scope (content is readable via + // any `zwlr_screencopy_manager_v1` client - `grim -o <name>`, + // concretely - there is no real display to look at directly). + "create" | "remove" => { + if args.get(1).map(String::as_str) != Some("fake-monitor") { + return Err(format!("'{verb}' only supports 'fake-monitor' - {usage_hint}")); + } + let name = args.get(2).ok_or("'fake-monitor' needs a name")?; + if verb == "remove" { + return Ok(format!(r#"{{"cmd":"remove_fake_monitor","name":{name:?}}}"#)); + } + let size = args.get(3).ok_or("'create fake-monitor' needs a <width>x<height>")?; + let (w, h) = size.split_once('x').ok_or("size must be '<width>x<height>', e.g. 1920x1080")?; + let width: u32 = w.parse().map_err(|_| "width must be a number".to_string())?; + let height: u32 = h.parse().map_err(|_| "height must be a number".to_string())?; + Ok(format!(r#"{{"cmd":"create_fake_monitor","name":{name:?},"width":{width},"height":{height}}}"#)) + } // `srd dispatch pin input <pid> <window-id>` / `srd dispatch // unpin input <pid>` - the CLI surface for `pin_input`, Phase 2 // of the multi-cursor plan (`docs/TODO.md`). `<pid>` is the @@ -366,6 +387,8 @@ fn print_usage() { eprintln!(" srd dispatch set output enabled <name|id> <true|false>"); eprintln!(" srd dispatch pin input <pid> <window-id>"); eprintln!(" srd dispatch unpin input <pid>"); + eprintln!(" srd dispatch create fake-monitor <name> <width>x<height>"); + eprintln!(" srd dispatch remove fake-monitor <name>"); eprintln!(" srd capture workspace <id> <path> [<width>x<height>]"); eprintln!(" srd set border_width <n>"); eprintln!(" srd set border_color <#hex>"); @@ -472,6 +495,25 @@ mod tests { } #[test] + fn create_fake_monitor_builds_a_sized_request() { + assert_eq!( + build_request(&args(&["dispatch", "create", "fake-monitor", "FAKE-1", "1920x1080"])).unwrap(), + r#"{"cmd":"create_fake_monitor","name":"FAKE-1","width":1920,"height":1080}"# + ); + } + + #[test] + fn remove_fake_monitor_needs_no_size() { + assert_eq!(build_request(&args(&["dispatch", "remove", "fake-monitor", "FAKE-1"])).unwrap(), r#"{"cmd":"remove_fake_monitor","name":"FAKE-1"}"#); + } + + #[test] + fn create_fake_monitor_rejects_a_malformed_size() { + assert!(build_request(&args(&["dispatch", "create", "fake-monitor", "FAKE-1", "1920"])).is_err()); + assert!(build_request(&args(&["dispatch", "create", "fake-monitor", "FAKE-1", "widexhigh"])).is_err()); + } + + #[test] fn pin_input_builds_a_request_with_pid_and_window_id() { assert_eq!(build_request(&args(&["dispatch", "pin", "input", "12345", "7"])).unwrap(), r#"{"cmd":"pin_input","pid":12345,"id":7}"#); } diff --git a/crates/platform/src/ipc.rs b/crates/platform/src/ipc.rs index 2a55294..1e3b792 100644 --- a/crates/platform/src/ipc.rs +++ b/crates/platform/src/ipc.rs @@ -846,6 +846,27 @@ fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<WindowManager wm.borrow_mut().request_pin_input(pid as i32, id); (ok(), true) } + // `{"cmd":"create_fake_monitor","name":<string>,"width":<u32>, + // "height":<u32>}` - a fully virtual `wl_output` with no real + // hardware behind it, applied by whichever backend owns real + // output hardware (only the udev backend can; the winit/nested + // backend has no headless render path to draw one with). See + // `crates/wayland/src/udev/virtual_heads.rs`'s own module doc + // comment for the full design and scope. + "create_fake_monitor" => { + let Some(name) = req.get("name").and_then(|v| v.as_str()) else { return (err("missing name"), false) }; + let (Some(width), Some(height)) = (req.get("width").and_then(|v| v.as_u64()), req.get("height").and_then(|v| v.as_u64())) else { + return (err("missing width/height"), false); + }; + wm.borrow_mut().request_create_fake_monitor(name.to_string(), width as u32, height as u32); + (ok(), true) + } + // `{"cmd":"remove_fake_monitor","name":<string>}`. + "remove_fake_monitor" => { + let Some(name) = req.get("name").and_then(|v| v.as_str()) else { return (err("missing name"), false) }; + wm.borrow_mut().request_remove_fake_monitor(name.to_string()); + (ok(), true) + } // `srd.window.maximize()`/`.fullscreen()`'s exact IPC-side // equivalents - lets an external script (or a live diagnostic // check, same as `toggle_visibility`/`focus`/`close` already allow) diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs index 27a7c2c..5ed96e4 100644 --- a/crates/wayland/src/udev/mod.rs +++ b/crates/wayland/src/udev/mod.rs @@ -200,6 +200,10 @@ pub(crate) struct UdevState { /// Shared by every head: one GPU, one software renderer. pub(crate) renderer: PixmanRenderer, pub(crate) heads: Vec<UdevHead>, + /// Fully virtual "fake" monitors - no DRM connector/CRTC, never + /// scanned out. See `virtual_heads.rs`'s own module doc comment for + /// the full design and scope. + pub(crate) virtual_heads: Vec<VirtualHead>, pub(crate) active: bool, /// Pointer position in the *global* space, so it can cross between /// monitors; clamped to the union of all head rectangles. @@ -558,5 +562,7 @@ mod outputs; mod platform; mod render; mod session; +mod virtual_heads; pub use platform::UdevPlatform; +pub(crate) use virtual_heads::VirtualHead; diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs index 9023cb9..b09bd4b 100644 --- a/crates/wayland/src/udev/platform.rs +++ b/crates/wayland/src/udev/platform.rs @@ -162,6 +162,7 @@ impl UdevPlatform { card: card.clone(), renderer, heads, + virtual_heads: Vec::new(), active: true, pointer_pos: (width as f64 / 2.0, height as f64 / 2.0).into(), secondary_cursors: std::collections::HashMap::new(), @@ -621,6 +622,22 @@ impl Platform for UdevPlatform { for (pid, window) in pin_requests { self.state.set_virtual_pointer_pin(pid, window); } + // Applies any `srd dispatch create fake-monitor`/`remove fake- + // monitor` IPC requests queued since the last poll - see + // `crates/wayland/src/udev/virtual_heads.rs`'s own module doc + // comment. + let create_fake_monitor_requests = self.state.wm.borrow_mut().drain_create_fake_monitor_requests(); + for (name, width, height) in create_fake_monitor_requests { + if let Err(e) = self.state.create_virtual_head(name.clone(), width as i32, height as i32) { + log::warn!("fake monitor: failed to create {name}: {e}"); + } + } + let remove_fake_monitor_requests = self.state.wm.borrow_mut().drain_remove_fake_monitor_requests(); + for name in remove_fake_monitor_requests { + if let Err(e) = self.state.remove_virtual_head(&name) { + log::warn!("fake monitor: failed to remove {name}: {e}"); + } + } // Throttled the same way and for the same underlying reason as the // `ipc.poll()` call above - this is the *other*, larger half of // this cycle's needless work at the dead-pipe-driven spin rate. @@ -648,6 +665,12 @@ impl Platform for UdevPlatform { const RENDER_INTERVAL: Duration = Duration::from_millis(8); if self.last_render.elapsed() >= RENDER_INTERVAL { self.last_render = Instant::now(); + // Before `render_udev_frame` drains `self.screencopy_pending` + // for real heads - see `service_virtual_head_captures`'s own + // doc comment for why a fake-monitor capture must never reach + // that drain at all (it would wait forever for a real page- + // flip that will never come). + self.state.service_virtual_head_captures(); self.state.render_udev_frame(); } Ok(self.pending.borrow_mut().drain(..).collect()) @@ -826,6 +849,23 @@ impl Platform for UdevPlatform { next_id += 1; } } + // Fake monitors (`virtual_heads.rs`) get the same treatment a real + // head does, minus the layer-shell exclusive-zone/reservation math + // (nothing binds a bar/dock to one in this phase, so there is + // never a zone to shrink `usable` by) and minus `srd.monitor. + // split` (a fake monitor already *is* exactly the size it was + // created at - splitting it further is a real, separate ask this + // phase doesn't attempt). `full == usable == maximize`, `scale` + // always `1.0` - see `VirtualHead`'s own doc comment for why. + for head in &udev.virtual_heads { + let full = srdwm_core::Rect::new(head.location.x, head.location.y, head.size.0 as u32, head.size.1 as u32); + let mut m = srdwm_core::Monitor::new(next_id, head.name.clone(), full); + m.full_geometry = full; + m.maximize_geometry = full; + m.primary = false; + out.push(m); + next_id += 1; + } Ok(out) } diff --git a/crates/wayland/src/udev/virtual_heads.rs b/crates/wayland/src/udev/virtual_heads.rs new file mode 100644 index 0000000..38444e3 --- /dev/null +++ b/crates/wayland/src/udev/virtual_heads.rs @@ -0,0 +1,226 @@ +//! Fully virtual "fake" monitors: a real, independent `wl_output` with no +//! DRM connector/CRTC/hardware behind it at all - the actual "multiple +//! monitors on one physical screen, or none" ask (distinct from `srd. +//! monitor.split`, which divides one *real* output's own placement +//! rectangle rather than creating a second, genuinely independent output; +//! see that feature's own doc comment). A real, if narrower, prior-art +//! comparison point: niri ships a `Headless` backend (`backend/ +//! headless.rs`, cloned at `~/reference-wms/niri`) that also creates a +//! real `Output` with no hardware behind it - but its own `render()` +//! never actually composites anything, purely a no-render stub for that +//! project's own test suite. This is a genuine, visible one: it actually +//! renders whatever is placed on it, on demand. +//! +//! **Scope, stated plainly rather than silently assumed away**: a fake +//! monitor has no real display attached, so there is no "look at your +//! other monitor" the way a second physical panel gives you for free. +//! Its content is exposed the same way any output's content already is +//! to an external tool - `zwlr_screencopy_manager_v1` (already real, +//! already tested: `grim`, `wf-recorder`, a purpose-built viewer, or a +//! remote-desktop/streaming pipeline can all read it) - rendered fresh +//! on every capture request rather than continuously, since nothing +//! needs to *present* a frame nobody is watching every 16ms the way a +//! real, scanned-out head does. Three things a real head has that this +//! deliberately does not, for this first phase: no layer-shell chrome +//! (a bar/dock could bind to it and would be composited if it did, but +//! nothing here spawns one automatically), no participation in the +//! native lock's per-output "every output presented a cleared frame" +//! confirmation (`self.outputs`, `lock.rs`'s own doc comment) - a +//! monitor nothing can physically see doesn't need confirming, and +//! including it would wait forever on a frame this module never drives +//! unprompted - and no `wlr-output-management-v1` listing (a display- +//! settings panel won't offer to reposition it). All three are additive +//! if ever wanted; none block basic use (bind it, place windows on it, +//! read it back). +//! +//! Placement, positioning, per-monitor workspace assignment, and window +//! rehoming on removal all reuse the exact machinery a real hotplugged +//! monitor already goes through unmodified - `WindowManager:: +//! set_monitors` already rehomes a window whose monitor vanished onto +//! whatever monitor remains (see that function's own doc comment), the +//! same safety net a real unplug relies on. + +use super::*; +use smithay::backend::allocator::Fourcc; +use smithay::backend::renderer::element::surface::render_elements_from_surface_tree; +use smithay::backend::renderer::element::Kind; +use smithay::backend::renderer::{Bind, Offscreen}; +use smithay::output::{Mode as OutputMode, PhysicalProperties, Subpixel}; +use smithay::reexports::wayland_server::backend::GlobalId; +use smithay::utils::Transform; + +/// One fake monitor: real enough to have its own `wl_output` global, a +/// place in `WindowManager::monitors()`, and windows genuinely assigned +/// to it - just never scanned out to any real display. See this +/// module's own doc comment for the full scope. +pub(crate) struct VirtualHead { + pub(crate) name: String, + pub(crate) output: Output, + pub(crate) global: GlobalId, + pub(crate) size: (i32, i32), + pub(crate) location: Point<i32, Logical>, +} + +impl CompState { + /// Creates a new fake monitor named `name` at `width`x`height`, + /// placed immediately to the right of every existing head (real or + /// fake) - the same plain left-to-right default a genuinely new + /// real head gets, since there is no previous session's position to + /// restore for something that was never plugged in. + pub(crate) fn create_virtual_head(&mut self, name: String, width: i32, height: i32) -> Result<(), String> { + if width <= 0 || height <= 0 { + return Err("width and height must both be positive".to_string()); + } + let Some(udev) = self.udev.as_ref() else { return Err("fake monitors need the udev (real-hardware) backend".to_string()) }; + if udev.heads.iter().any(|h| h.output.name() == name) || udev.virtual_heads.iter().any(|h| h.name == name) { + return Err(format!("an output named {name} already exists")); + } + let max_x = udev + .heads + .iter() + .map(|h| h.location.x + h.size.0) + .chain(udev.virtual_heads.iter().map(|h| h.location.x + h.size.0)) + .max() + .unwrap_or(0); + let location: Point<i32, Logical> = (max_x, 0).into(); + + let output = Output::new(name.clone(), PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "virtual".into() }); + let mode = OutputMode { size: (width, height).into(), refresh: 60_000 }; + output.change_current_state(Some(mode), Some(Transform::Normal), Some(smithay::output::Scale::Fractional(1.0)), Some((location.x, location.y).into())); + output.set_preferred(mode); + let global = output.create_global::<CompState>(&self.dh); + + self.udev.as_mut().unwrap().virtual_heads.push(VirtualHead { name, output, global, size: (width, height), location }); + // Payload discarded unread - `main.rs`'s own handler for this + // event just re-queries the whole monitor list, same as a real + // hotplug (see `reprobe_outputs`'s matching push). + self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0)))); + Ok(()) + } + + /// Removes the fake monitor named `name`: destroys its `wl_output` + /// global and drops it from the virtual-head list. Any window still + /// assigned to it is rehomed by the very next `monitors()` re-query's + /// call into `WindowManager::set_monitors` - the same safety net a + /// real monitor unplug already relies on, not a separate code path + /// invented for this. + pub(crate) fn remove_virtual_head(&mut self, name: &str) -> Result<(), String> { + let Some(udev) = self.udev.as_mut() else { return Err("no udev backend".to_string()) }; + let Some(index) = udev.virtual_heads.iter().position(|h| h.name == name) else { + return Err(format!("no fake monitor named {name}")); + }; + let head = udev.virtual_heads.remove(index); + self.dh.remove_global::<CompState>(head.global); + self.pending.borrow_mut().push(CoreEvent::MonitorRemoved(0)); + Ok(()) + } + + /// Services every currently-pending `zwlr_screencopy_frame_v1` + /// capture that targets a fake monitor - called once per poll, + /// *before* `render_udev_frame` drains `self.screencopy_pending` for + /// real heads, so a request for a fake monitor is never left sitting + /// in that queue waiting for a real page-flip that will never come + /// (see `render_udev_frame`'s own "left over, wasn't ready this + /// pass" comment for the hang this would otherwise reproduce, this + /// time permanently rather than just until the next real flip). + /// + /// Renders on demand rather than continuously: nothing scans this + /// output out anywhere, so there is no reason to recomposite it + /// every frame when no capture is currently pending. Reuses exactly + /// `udev/capture.rs::capture_workspace`'s own off-screen-render + /// technique (gather element list, `create_buffer`+`bind`, a fresh + /// `OutputDamageTracker`, `render_output`) - the difference is this + /// hands the freshly-rendered framebuffer straight to `screencopy:: + /// service_pending` instead of reading it back to a PPM file, and + /// selects windows by `Window::monitor` (this fake monitor's own id) + /// rather than by workspace, since a fake monitor is a genuinely + /// independent screen with its own windows, not a mirror of + /// whichever workspace a reference monitor happens to show. + pub(crate) fn service_virtual_head_captures(&mut self) { + if self.screencopy_pending.is_empty() { + return; + } + let Some(udev) = self.udev.as_ref() else { return }; + if udev.virtual_heads.is_empty() { + return; + } + // Every fake monitor's own `Output` identity, name, origin and + // size - `PendingCapture::output` is already a real smithay + // `Output` (not a per-client `WlOutput` resource), so matching it + // against a `VirtualHead`'s own `Output` is a plain equality + // check, the same identity `render_udev_frame`'s per-head capture + // split already uses for real heads. + struct HeadSummary { + output: Output, + name: String, + origin: (i32, i32), + size: (i32, i32), + } + let heads: Vec<HeadSummary> = + udev.virtual_heads.iter().map(|h| HeadSummary { output: h.output.clone(), name: h.name.clone(), origin: (h.location.x, h.location.y), size: h.size }).collect(); + + // Grouped by which virtual head each capture targets, moved (not + // cloned - `PendingCapture` holds live protocol objects with no + // `Clone` impl, and none is needed here) out of the shared queue + // in one pass; anything left over (a real head's own capture) + // goes straight back for `render_udev_frame`'s own drain. + let mut by_head: std::collections::HashMap<String, Vec<crate::screencopy::PendingCapture>> = std::collections::HashMap::new(); + let mut rest = Vec::new(); + for capture in std::mem::take(&mut self.screencopy_pending) { + match heads.iter().find(|h| h.output == capture.output) { + Some(h) => by_head.entry(h.name.clone()).or_default().push(capture), + None => rest.push(capture), + } + } + self.screencopy_pending = rest; + + for head in heads { + let Some(pending) = by_head.remove(&head.name) else { continue }; + let monitor_id = self.wm.borrow().monitors().iter().find(|m| (m.geometry.x, m.geometry.y) == head.origin).map(|m| m.id); + let Some(monitor_id) = monitor_id else { + crate::screencopy::fail_pending(pending); + continue; + }; + if let Err(e) = self.render_virtual_head(monitor_id, head.origin, head.size, pending) { + log::warn!("fake monitor: render for capture failed: {e}"); + } + } + } + + fn render_virtual_head(&mut self, monitor_id: srdwm_core::MonitorId, origin: (i32, i32), size: (i32, i32), pending: Vec<crate::screencopy::PendingCapture>) -> Result<(), String> { + let ids: Vec<srdwm_core::WindowId> = self.wm.borrow().visible_windows_front_to_back().filter(|w| w.monitor == monitor_id).map(|w| w.id).collect(); + let Some(udev) = self.udev.as_mut() else { return Err("no udev backend".to_string()) }; + let mut elements: Vec<crate::elements::OverlayElement<PixmanRenderer>> = Vec::new(); + for id in ids { + let Some(w) = self.id_to_window.get(&id) else { continue }; + let Some(surface) = crate::elements::window_wl_surface(w) else { continue }; + let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue }; + let content_offset = w.geometry().loc; + let loc = (geom.x - origin.0 - content_offset.x, geom.y - origin.1 - content_offset.y); + elements.extend(render_elements_from_surface_tree::<_, crate::elements::OverlayElement<PixmanRenderer>>( + &mut udev.renderer, + &surface, + loc, + 1.0, + 1.0, + Kind::Unspecified, + )); + } + + let (w, h) = size; + let mut target = udev.renderer.create_buffer(Fourcc::Xrgb8888, (w, h).into()).map_err(|e| format!("create_buffer: {e}"))?; + let mut framebuffer = udev.renderer.bind(&mut target).map_err(|e| format!("bind: {e}"))?; + let mut tracker = OutputDamageTracker::new((w, h), 1.0, Transform::Normal); + // Flat dark fill, not a real wallpaper - a fake monitor has no + // `zwlr_layer_shell_v1` background client bound to it in this + // phase (see this module's own doc comment on scope), so there + // is nothing to composite one from; a solid color reads as + // "empty desktop", not "broken", the same reasoning `udev/ + // capture.rs`'s own module doc comment already gives for why an + // empty capture must not render as literal black. + tracker.render_output(&mut udev.renderer, &mut framebuffer, 0, &elements, [0.05, 0.05, 0.08, 1.0]).map_err(|e| format!("render_output: {e:?}"))?; + + crate::screencopy::service_pending(pending, &mut udev.renderer, &framebuffer); + Ok(()) + } +} |