srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/core/src/manager/fake_monitor.rs69
-rw-r--r--crates/core/src/manager/mod.rs23
-rw-r--r--crates/core/src/manager/tests.rs36
-rw-r--r--crates/core/src/manager/windows.rs11
-rw-r--r--crates/core/src/placement.rs105
-rw-r--r--crates/ctl/src/main.rs42
-rw-r--r--crates/platform/src/ipc.rs21
-rw-r--r--crates/wayland/src/udev/mod.rs6
-rw-r--r--crates/wayland/src/udev/platform.rs40
-rw-r--r--crates/wayland/src/udev/virtual_heads.rs226
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(())
+ }
+}