srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-26 22:07:00 +0200
committersrdusr <[email protected]>2025-09-26 22:07:00 +0200
commit9a9aa8fe9d4f4f9b15860b45244e695942c90afd (patch)
treea7a8289731d49713caeaf06f023ab04f78cc663c
parentb46dfa56504f62ecf969914dfed106bf587c4c4a (diff)
downloadsrdwm-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.
-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
-rw-r--r--docs/TODO.md22
11 files changed, 578 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(())
+ }
+}
diff --git a/docs/TODO.md b/docs/TODO.md
index d7c62a8..98deb48 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -13,6 +13,28 @@ that has the full story. Keep this list current as items close or open;
update the source doc's own entry too, don't let this drift into a
second stale copy the way `PANEL_SUPPORT_TODO.md` did.
+## Real bug, root-caused and fixed: every new window opened alone landed in the exact same spot, not at all like Windows (2026-08-27)
+
+Reported live. Root-caused by reading `SmartPlacement::place`, not guessed: it tried a grid cell first, falling back to cascade only once the grid was full. Grid's own cell count is `existing.len() + 1` - with nothing else open (the overwhelmingly common real workflow: open one app, use it, close it, open the next), that count is always `1`, so the grid is always exactly one cell, and a 1x1 grid returns the same single cell every time regardless of session history. Cascade had a second, compounding version of the same bug: its own step was `existing.len() % max_steps`, also always `0` with nothing else open, so even a from-scratch cascade calculation reset to the origin on every call.
+
+Fixed both. `WindowManager` gained `next_cascade_step` (a `Cell<u32>`, not a plain field - `add_window`'s own `target_monitor` stays borrowed from `self.monitors` for the whole call, so a plain `&mut self` write would conflict with that live borrow), incremented on every real placement and *never* reset by a window closing - real Windows keeps advancing its own cascade position the same way even as earlier windows close. `SmartPlacement::cascade` now takes this counter instead of `existing.len()`. `SmartPlacement::place` now skips `grid` entirely when `existing` is empty, going straight to cascade instead - grid's real job is dividing screen space fairly among *concurrent* windows, and with nothing to divide against there is no way for a 1x1 grid to vary by history no matter how it's computed; cascading is the correct strategy for "nothing else is open right now."
+
+New/updated tests cover both the isolated `cascade` behavior and the full `place`/`add_window` integration (`a_window_opened_alone_cascades_rather_than_using_a_pointless_1x1_grid`, `opening_the_same_app_alone_twice_in_a_row_lands_in_different_spots`, `cascade_step_keeps_advancing_even_if_the_previous_window_closed`); two pre-existing tests that hardcoded the old grid-based first-window position were updated to match the new, deliberate behavior, not silently left contradicting it.
+
+Full workspace build/test/clippy clean (223 core / 141 wayland / 29 platform / 24 ctl / 28 config / 10 x11 tests). Not yet live-verified against a real interactive session (needs a restart) - the algorithm change itself is proven by the new tests, but "does it feel right opening real apps" is a live check still owed.
+
+## Fake (fully virtual, headless) monitors: a real, visible one, not a test stub (2026-08-27)
+
+Distinct from `srd.monitor.split` (divides one *real* output's own placement rectangle) and from the phone-monitor item's own aspect_ratio rule (a window-level primitive) - this is a genuinely independent, additional `wl_output` with no DRM connector/CRTC behind it at all. Researched prior art before building: niri ships a real `Headless` backend (`backend/headless.rs`, cloned at `~/reference-wms/niri`) but its own `render()` never actually composites anything - a no-render stub purely for that project's own test suite, not a usable feature. This is a real, visible one instead: it actually renders whatever is placed on it, on demand, whenever a `zwlr_screencopy_manager_v1` client (`grim`, `wf-recorder`, a custom viewer) asks for a frame.
+
+New `crates/wayland/src/udev/virtual_heads.rs` (its own module doc comment has the full design and stated scope limits: no layer-shell chrome, no native-lock participation, no `wlr-output-management-v1` listing - all additive later, none block basic use). `CompState::create_virtual_head`/`remove_virtual_head` create/destroy a real `Output` + `wl_output` global with no hardware behind it, placed left-to-right after every existing head. `service_virtual_head_captures` intercepts any pending screencopy capture targeting a fake monitor *before* `render_udev_frame` ever sees it (otherwise it would wait forever for a real page-flip that will never come - the exact hang class `docs/PANEL_SUPPORT_TODO.md`'s own P1 already named), and renders it on demand by reusing `udev/capture.rs::capture_workspace`'s exact off-screen-render technique, selecting windows by `Window::monitor` (a fake monitor's own real windows) rather than by workspace.
+
+Fully integrated with core placement: `platform.rs`'s `monitors()` reports each fake monitor as a genuine `srdwm_core::Monitor` (scale 1.0, no exclusive zone), so `add_window`/tiling/workspace-switching all treat it exactly like a real monitor with zero special-casing - and removing one rehomes its windows via `WindowManager::set_monitors`'s own existing safety net, the same one a real monitor unplug already relies on.
+
+New IPC (`create_fake_monitor`/`remove_fake_monitor`, `crates/platform/src/ipc.rs`) and CLI: `srd dispatch create fake-monitor <name> <width>x<height>` / `srd dispatch remove fake-monitor <name>`. Core-side request queue in `crates/core/src/manager/fake_monitor.rs`, same cross-boundary shape every other backend-owned request already uses.
+
+Full workspace build/test/clippy clean (221 core / 141 wayland / 29 platform / 24 ctl / 28 config / 10 x11 tests, 0 failed, 0 clippy warnings). Not yet live-verified against a real running session (needs a restart to pick up the installed binary, same standing rule as every other change this shift) - `srd dispatch create fake-monitor TEST-1 1920x1080` then `grim -o TEST-1 /tmp/test.png` is the concrete verification path once restarted.
+
## Optional phone mode for AGS and srdwm: srdwm's own real half built (2026-08-27)
Split the ask honestly rather than guessing at AGS's own side blind: the srdwm-side primitive a "phone mode" needs is a placement policy (new windows default to maximized, since a phone-shaped screen has no room for more than one at a time) plus a real signal a panel can read to adapt its own chrome - both now real; the AGS-side chrome adaptation itself is work in that project, not this one, the same boundary this session's other AGS-adjacent entries (combined monitor modes, the layout-restore race) already draw.