From 4a55182ce17fd4474f8be1e8819a41b7ccd11045 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Sun, 31 Aug 2025 14:04:00 +0200 Subject: Wire aspect_ratio, phone_mode and pin_input through config/IPC/CLI The Lua and IPC/CLI surface for the three new core primitives (crates/core: aspect_ratio rule action, general.phone_mode, virtual- pointer window pinning): - srd.rule(..., { aspect_ratio = "9:16" }): parses a "W:H" string into a validated (u32, u32), rejecting a malformed value as a real Lua error at config-load time rather than silently ignoring it. - general.phone_mode config default, plus srd set phone_mode for the live equivalent (same shape as animations/shadows/rounded_corners). - pin_input IPC dispatch ({"cmd":"pin_input","pid":,"id":}, id omitted to unpin) and its CLI surface, srd dispatch pin input / unpin input . Keyed by the owning client's process id, not an opaque per-object id nothing outside the Wayland backend could ever learn - a controlling tool already knows its own pid for free. See docs/TODO.md for the full design reasoning behind each of these. --- crates/config/src/engine/general.rs | 27 +++++++++++++++-- crates/config/src/engine/support.rs | 14 +++++++++ crates/config/src/engine/tests.rs | 26 ++++++++++++++++ crates/ctl/src/main.rs | 59 +++++++++++++++++++++++++++++++++++-- crates/platform/src/ipc.rs | 43 +++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 5 deletions(-) (limited to 'crates') diff --git a/crates/config/src/engine/general.rs b/crates/config/src/engine/general.rs index e5147cd..6276854 100644 --- a/crates/config/src/engine/general.rs +++ b/crates/config/src/engine/general.rs @@ -128,8 +128,8 @@ impl Engine { /// class_regex = "...", instance = "..." }, { floating = true, /// workspace = 2, x = .., y = .., width = .., height = .., /// decorated = false, border_color = {r,g,b}, border_width = 2, - /// corner_radius = 10, maximized = true, opacity = 0.9 })`. At least - /// one matcher field is + /// corner_radius = 10, maximized = true, opacity = 0.9, + /// aspect_ratio = "9:16" })`. At least one matcher field is /// required; unmatched rules apply nothing. /// /// `title`/`class` are plain substring/exact match, cheap and cover @@ -172,6 +172,28 @@ impl Engine { _ => None, } }; + // `aspect_ratio = "9:16"` - the "phone monitor / special + // workspace" ask's own real, scoped answer (see `Window:: + // aspect_ratio`'s own doc comment in `crates/core`): a rule + // matching any VM/emulator/`scrcpy` window by `app_id` keeps + // it phone-shaped through a resize, with no Android-specific + // (or even VM-specific) code anywhere in this compositor. + // `"W:H"` (a plain string, not a table) matches this + // project's own `border_color = {r,g,b}` precedent for "a + // structured value needs its own small parse", just with a + // string instead of a table since a ratio is conventionally + // written that way everywhere (`16:9`, `9:16`, `4:3`). + let aspect_ratio: Option<(u32, u32)> = match actions.get::<_, Option>("aspect_ratio")? { + Some(spec) => { + let (w, h) = spec + .split_once(':') + .and_then(|(w, h)| Some((w.trim().parse::().ok()?, h.trim().parse::().ok()?))) + .filter(|(w, h)| *w > 0 && *h > 0) + .ok_or_else(|| mlua::Error::RuntimeError(format!("srd.rule: aspect_ratio must be \"W:H\" with positive integers, got {spec:?}")))?; + Some((w, h)) + } + None => None, + }; let rule = WindowRule { matcher: WindowMatch { title_contains, class, title_regex, class_regex, instance }, @@ -187,6 +209,7 @@ impl Engine { pinned: actions.get("pinned")?, opacity: actions.get("opacity")?, resize_margin: actions.get("resize_margin")?, + aspect_ratio, }, }; state.borrow().wm.borrow_mut().add_rule(rule); diff --git a/crates/config/src/engine/support.rs b/crates/config/src/engine/support.rs index 7537fce..97c499f 100644 --- a/crates/config/src/engine/support.rs +++ b/crates/config/src/engine/support.rs @@ -154,11 +154,25 @@ pub(super) fn default_config() -> HashMap { // every other ordinary flag rather than left absent for a backend to // decide. set("general.gpu", Bool(false)); + // `false`: opt-in single-app-at-a-time placement, off by default so + // an ordinary desktop session's floating/tiling behavior is + // completely unaffected - see `WindowManager::phone_mode`'s own doc + // comment. + set("general.phone_mode", Bool(false)); // Real desktop icons (Home/Computer/Trash plus `~/Desktop`'s own // contents) on by default - see `WindowManager::desktop_icons_ // enabled`'s own doc comment for why this, unlike `general.gpu` just // above, doesn't need an opt-in safety net. set("general.desktop_icons", Bool(true)); + // On by default - see `WindowManager::desktop_icons_all_monitors`'s + // own doc comment. + set("general.desktop_icons_all_monitors", Bool(true)); + // `0` (no static reservation) by default - see `WindowManager:: + // reserve_top`'s own doc comment for what this is and why. + set("general.reserve_top", Number(0.0)); + set("general.reserve_bottom", Number(0.0)); + set("general.reserve_left", Number(0.0)); + set("general.reserve_right", Number(0.0)); // Empty by default - see `WindowManager::file_manager`'s own doc // comment: empty means "dispatch via `xdg-open`", not "no file manager // configured, do nothing". diff --git a/crates/config/src/engine/tests.rs b/crates/config/src/engine/tests.rs index f170320..f4f011b 100644 --- a/crates/config/src/engine/tests.rs +++ b/crates/config/src/engine/tests.rs @@ -196,6 +196,32 @@ assert!(wm.borrow().is_floating(id)); } + #[test] + fn srd_rule_aspect_ratio_applies_a_parsed_w_h_pair_on_creation() { + let dir = tempfile::tempdir().unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + let engine = Engine::new(wm.clone(), dir.path()).unwrap(); + engine.lua.load(r#"srd.rule({ class = "scrcpy" }, { aspect_ratio = "9:16" })"#).exec().unwrap(); + let id = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + let mut w = srdwm_core::Window::new(id, "phone"); + w.app_id = "scrcpy".into(); + wm.add_window(w); + id + }; + assert_eq!(wm.borrow().window(id).unwrap().aspect_ratio, Some((9, 16))); + } + + #[test] + fn srd_rule_rejects_a_malformed_aspect_ratio() { + let dir = tempfile::tempdir().unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + let engine = Engine::new(wm.clone(), dir.path()).unwrap(); + let err = engine.lua.load(r#"srd.rule({ class = "x" }, { aspect_ratio = "sixteen by nine" })"#).exec(); + assert!(err.is_err()); + } + #[test] fn srd_monitor_split_stores_a_split_request_by_connector_name() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs index 8ed6d2f..929d44c 100644 --- a/crates/ctl/src/main.rs +++ b/crates/ctl/src/main.rs @@ -178,14 +178,14 @@ fn build_request(args: &[String]) -> Result { // as booleans at all, not a string it then has to reject. Some("set") => { let key = args.get(1).ok_or( - "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/shadows/rounded_corners/animations/night_light/reading_mode/decoration_mode)", + "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/shadows/rounded_corners/animations/night_light/reading_mode/phone_mode/decoration_mode)", )?; let raw = args.get(2).ok_or("set needs a value")?; let value = match key.as_str() { "border_width" | "corner_radius" | "gap_inner" | "gap_outer" => { raw.parse::().map_err(|_| format!("{key} needs a numeric value"))?.to_string() } - "shadows" | "rounded_corners" | "animations" | "night_light" | "reading_mode" => match raw.as_str() { + "shadows" | "rounded_corners" | "animations" | "night_light" | "reading_mode" | "phone_mode" => match raw.as_str() { "true" | "false" => raw.clone(), _ => return Err(format!("{key} needs 'true' or 'false'")), }, @@ -225,7 +225,7 @@ fn build_request(args: &[String]) -> Result { fn build_dispatch(args: &[String]) -> Result { let verb = args.first().ok_or("dispatch needs an action, e.g. 'focus', 'close', 'toggle maximize', 'move window'")?; let usage_hint = - "expected one of: focus, close, lock, toggle visibility/maximize/fullscreen/floating/pinned, move window/workspace, activate workspace, cycle keyboard layout"; + "expected one of: focus, close, lock, toggle visibility/maximize/fullscreen/floating/pinned, move window/workspace, activate workspace, cycle keyboard layout, pin input, unpin input"; match verb.as_str() { "focus" | "close" => { let id: u64 = args.get(1).ok_or("dispatch needs an id")?.parse().map_err(|_| "id must be a number".to_string())?; @@ -321,6 +321,23 @@ fn build_dispatch(args: &[String]) -> Result { _ => Err(format!("unknown 'set output' target '{noun}' - {usage_hint}")), } } + // `srd dispatch pin input ` / `srd dispatch + // unpin input ` - the CLI surface for `pin_input`, Phase 2 + // of the multi-cursor plan (`docs/TODO.md`). `` is the + // controlling tool's own process id (`std::process::id()` from + // whichever program created the `zwlr_virtual_pointer_unstable_v1` + // object to be pinned), not a window or workspace id. + "pin" | "unpin" => { + if args.get(1).map(String::as_str) != Some("input") { + return Err(format!("'{verb}' only supports 'input' - {usage_hint}")); + } + let pid: i64 = args.get(2).ok_or("dispatch needs a pid")?.parse().map_err(|_| "pid must be a number".to_string())?; + if verb == "unpin" { + return Ok(format!(r#"{{"cmd":"pin_input","pid":{pid}}}"#)); + } + let id: u64 = args.get(3).ok_or("'pin input' needs a window id")?.parse().map_err(|_| "window id must be a number".to_string())?; + Ok(format!(r#"{{"cmd":"pin_input","pid":{pid},"id":{id}}}"#)) + } _ => Err(format!("unknown dispatch action '{verb}' - {usage_hint}")), } } @@ -347,6 +364,8 @@ fn print_usage() { eprintln!(" srd dispatch cycle keyboard layout"); eprintln!(" srd dispatch set output position "); eprintln!(" srd dispatch set output enabled "); + eprintln!(" srd dispatch pin input "); + eprintln!(" srd dispatch unpin input "); eprintln!(" srd capture workspace [x]"); eprintln!(" srd set border_width "); eprintln!(" srd set border_color <#hex>"); @@ -358,6 +377,7 @@ fn print_usage() { eprintln!(" srd set animations "); eprintln!(" srd set night_light "); eprintln!(" srd set reading_mode "); + eprintln!(" srd set phone_mode "); eprintln!(" srd set decoration_mode "); } @@ -435,6 +455,13 @@ mod tests { assert!(build_request(&args(&["dispatch", "set", "output", "position", "1", "not-a-number", "0"])).is_err()); } + #[test] + fn set_phone_mode_accepts_only_true_or_false() { + assert_eq!(build_request(&args(&["set", "phone_mode", "true"])).unwrap(), r#"{"cmd":"set","key":"phone_mode","value":true}"#); + assert_eq!(build_request(&args(&["set", "phone_mode", "false"])).unwrap(), r#"{"cmd":"set","key":"phone_mode","value":false}"#); + assert!(build_request(&args(&["set", "phone_mode", "maybe"])).is_err()); + } + #[test] fn set_output_enabled_accepts_a_numeric_id_and_a_name() { assert_eq!(build_request(&args(&["dispatch", "set", "output", "enabled", "1", "false"])).unwrap(), r#"{"cmd":"set_output_enabled","id":1,"enabled":false}"#); @@ -444,6 +471,32 @@ mod tests { ); } + #[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}"#); + } + + #[test] + fn unpin_input_builds_a_request_with_no_window_id() { + assert_eq!(build_request(&args(&["dispatch", "unpin", "input", "12345"])).unwrap(), r#"{"cmd":"pin_input","pid":12345}"#); + } + + #[test] + fn pin_input_requires_the_literal_noun_input() { + assert!(build_request(&args(&["dispatch", "pin", "output", "12345", "7"])).is_err()); + } + + #[test] + fn pin_input_requires_a_window_id_but_unpin_does_not() { + assert!(build_request(&args(&["dispatch", "pin", "input", "12345"])).is_err(), "'pin input' needs a window id"); + assert!(build_request(&args(&["dispatch", "unpin", "input", "12345", "7"])).is_ok(), "'unpin input' ignores a trailing window id rather than erroring"); + } + + #[test] + fn pin_input_rejects_a_non_numeric_pid() { + assert!(build_request(&args(&["dispatch", "pin", "input", "not-a-number", "7"])).is_err()); + } + #[test] fn set_output_enabled_rejects_anything_but_true_or_false() { assert!(build_request(&args(&["dispatch", "set", "output", "enabled", "1", "yes"])).is_err()); diff --git a/crates/platform/src/ipc.rs b/crates/platform/src/ipc.rs index 1a4b12f..2a55294 100644 --- a/crates/platform/src/ipc.rs +++ b/crates/platform/src/ipc.rs @@ -359,6 +359,12 @@ struct SettingsResponse { animations: bool, night_light: bool, reading_mode: bool, + /// `WindowManager::phone_mode`'s own doc comment: read-only here so a + /// shell panel (AGS) can adapt its own chrome to the same + /// single-app-at-a-time signal srdwm's own placement already uses, + /// without a second, separate way to ask "is this a phone-shaped + /// session". + phone_mode: bool, } /// `"keyboard_layout"`'s one-shot reply shape - the active XKB layout's @@ -740,6 +746,7 @@ fn handle_request(line: &[u8], wm: &std::rc::Rc,"id":}` pins + // every `zwlr_virtual_pointer_unstable_v1` object that client owns + // to window `id` - Phase 2 of the multi-cursor plan (`docs/ + // TODO.md`), the primitive an agent-controlling tool needs to + // operate one specific window without moving the human's own + // cursor or stealing focus. Omitting `id` unpins instead (`{"cmd": + // "pin_input","pid":}`) - one command for both directions, + // since "pin" and "unpin" are really just "set the pin to Some or + // None", the same shape `set_output_enabled`'s own boolean already + // uses for two related actions on one dispatch. Keyed by pid, not + // an opaque per-object id nothing outside the Wayland backend + // could ever learn - a controlling tool already knows its own + // pid (`std::process::id()`) for free. Queued via `request_pin_ + // input` and applied by the Wayland backend on its own next poll, + // same one-poll-tick latency `set_output_position` already has. + "pin_input" => { + let Some(pid) = req.get("pid").and_then(|v| v.as_i64()) else { + return (err("missing pid"), false); + }; + wm.borrow_mut().request_pin_input(pid as i32, id); + (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) @@ -1174,6 +1203,20 @@ fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc` - live equivalent of `general. + // phone_mode`, same "config-settable at startup, also live via + // `srd set`" shape as `animations`/`shadows`/`rounded_corners` + // just above. Only ever changes how the *next* new window opens + // (`WindowManager::add_window`'s own use of this) - `changed` + // is still `true` since a subscriber (a shell panel adapting its + // own chrome to this same signal) genuinely has something new to + // read from `srd settings`, even though no *window* moves as a + // direct result of this call alone. + "phone_mode" => { + let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("phone_mode needs a boolean value"), false) }; + wm.borrow_mut().phone_mode = v; + (ok(), true) + } "blur" => (err("blur is not supported - no GPU shader path on this compositor's software renderer yet"), false), // The two ported Hyprland `decoration:screen_shader` scripts -- // mutually exclusive by construction (`srdwm_core::ColorFilter` is -- cgit v1.2.3