From ea78f94027ecd2c690904a6d7729e9e3cc190a20 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:04:00 +0200 Subject: Make tiling's master/stack ratio live, add settings readback everywhere Investigated the "tiling needs a lot of work" report directly. The MasterStackLayout algorithm itself was already correct; the real gap was that dragging or resizing a tiled window did nothing durable (raw geometry that the next arrange_workspace silently discarded), and master_ratio/master_count had no live path at all (config-file only). A resize-drag on the shared master/stack boundary now live-adjusts TilingConfig::master_ratio and re-arranges the group immediately; srd set master_ratio/master_count do the same for a keybind or script. Found and fixed a real bug while building this: start_resize's own focus_window call re-stacks its target in self.order before the ratio-drag decision used to be made, silently misclassifying real master-column grabs. Fixed by deciding ratio-drag status (and freezing the membership snapshot it depends on) before that raise happens, applying MasterStackLayout directly against the frozen snapshot rather than re-deriving membership from the by-then-reordered live order. Live- verified in a nested compositor, not just unit-tested. Also closes the readback gaps flagged directly by the AGS peer session: border_width/border_color/corner_radius/decoration_mode/gap_inner/ gap_outer/master_ratio/master_count were all live-settable via srd set with no way to read the current value back, and pin_input had no readback at all. SettingsResponse now reports all of them; a new pinned_inputs query (srd pinned inputs) lists every currently pinned pid/window. --- crates/core/src/lib.rs | 2 +- crates/core/src/manager/dragresize.rs | 152 ++++++++++++++++++++++++++++++++-- crates/core/src/manager/input_pin.rs | 32 +++++++ crates/core/src/manager/mod.rs | 24 ++++++ crates/core/src/manager/tests.rs | 102 +++++++++++++++++++++++ crates/core/src/theme.rs | 22 +++++ crates/core/src/window.rs | 14 ++++ crates/ctl/src/main.rs | 26 +++++- crates/platform/src/ipc/dispatch.rs | 45 ++++++++++ crates/platform/src/ipc/tests.rs | 86 +++++++++++++++++++ crates/platform/src/ipc/types.rs | 48 +++++++++++ crates/wayland/src/virtual_pointer.rs | 5 ++ docs/TODO.md | 28 +++++++ 13 files changed, 576 insertions(+), 10 deletions(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 066d6d2..7b728d9 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -22,7 +22,7 @@ pub use monitor::{Monitor, MonitorId}; pub use placement::{PlacementConfig, SmartPlacement, SnapZoneKind}; pub use regex::Regex; pub use rules::{WindowMatch, WindowRule, WindowRuleActions}; -pub use theme::{parse_hex_color, ThemeConfig}; +pub use theme::{format_hex_color, parse_hex_color, ThemeConfig}; pub use window::{ classify_menu_source, parse_button_order, ButtonOrder, GlobalMenu, MenuSource, ResizeEdge, TitlebarButton, TitlebarHit, Window, WindowId, BUTTON_CLUSTER_MARGIN, BUTTON_PITCH, RESIZE_MARGIN, TITLEBAR_HEIGHT, diff --git a/crates/core/src/manager/dragresize.rs b/crates/core/src/manager/dragresize.rs index 1c1b4df..7181bb3 100644 --- a/crates/core/src/manager/dragresize.rs +++ b/crates/core/src/manager/dragresize.rs @@ -114,21 +114,48 @@ impl WindowManager { pub fn start_resize(&mut self, id: WindowId, edge: ResizeEdge, x: i32, y: i32) { if let Some(w) = self.windows.get(&id) { - self.resize = Some(ResizeState { window: id, edge, start_x: x, start_y: y, orig: w.geometry }); + // Decided *before* `focus_window` below re-stacks `id` -- + // see `tiling_ratio_drag`'s own doc comment for why that + // order is load-bearing, not stylistic. + let ratio_drag_ids = self.tiling_ratio_drag(id, edge); + let orig = w.geometry; + self.resize = Some(ResizeState { window: id, edge, start_x: x, start_y: y, orig, orig_master_ratio: self.tiling.master_ratio, ratio_drag_ids }); self.focus_window(id); } } pub fn update_resize(&mut self, x: i32, y: i32) { - let Some(r) = &self.resize else { return }; - let (dx, dy) = (x - r.start_x, y - r.start_y); - let mut new_geom = r.edge.apply_delta(r.orig, dx, dy, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); + // Copied/cloned out rather than kept as a live `&self.resize` + // borrow - the tiling branch below needs `&mut self`, which + // can't coexist with a borrow of the field it's reading. + let Some((window, edge, start_x, start_y, orig, orig_master_ratio, ratio_drag_ids)) = + self.resize.as_ref().map(|r| (r.window, r.edge, r.start_x, r.start_y, r.orig, r.orig_master_ratio, r.ratio_drag_ids.clone())) + else { + return; + }; + let (dx, dy) = (x - start_x, y - start_y); + // Tiling's master/stack boundary is a live *ratio* the whole + // column split is computed from, not one window's own rect - see + // `adjust_master_ratio_for_drag`'s own doc comment for why a plain + // geometry write here would just be silently discarded by the very + // next `arrange_workspace` call anyway (reported live as "tiling + // needs a lot of work": dragging a tiled window's border looked + // like it resized, then snapped back the moment anything else + // triggered a re-arrange). `ratio_drag_ids` was decided once, at + // `start_resize` time, against the pre-focus membership - see + // `tiling_ratio_drag`'s own doc comment for why that snapshot + // (not a live re-derivation) is what has to be used here. + if let Some(ids) = ratio_drag_ids { + self.adjust_master_ratio_for_drag(window, &ids, dx, orig_master_ratio); + return; + } + let mut new_geom = edge.apply_delta(orig, dx, dy, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); // `Window::aspect_ratio`'s own doc comment: a locked-ratio window // (the "phone monitor" case, concretely) re-derives one dimension // from the other here, on top of the ordinary delta above, rather // than needing a second, separate resize code path. - if let Some(ratio) = self.windows.get(&r.window).and_then(|w| w.aspect_ratio) { - new_geom = r.edge.apply_aspect_ratio(new_geom, ratio, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); + if let Some(ratio) = self.windows.get(&window).and_then(|w| w.aspect_ratio) { + new_geom = edge.apply_aspect_ratio(new_geom, ratio, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); } // Same live `w.monitor` correction as `update_drag`'s own doc // comment explains - a resize can cross a monitor boundary at @@ -137,7 +164,7 @@ impl WindowManager { // lookup doesn't care which kind of geometry change put the // window there. let now_on = self.monitors.iter().find(|m| m.geometry.overlaps(&new_geom)).map(|m| m.id); - if let Some(w) = self.windows.get_mut(&r.window) { + if let Some(w) = self.windows.get_mut(&window) { w.geometry = new_geom; if let Some(now_on_id) = now_on { w.monitor = now_on_id; @@ -145,6 +172,117 @@ impl WindowManager { } } + /// Whether resizing `id` along `edge` should live-adjust `self.tiling. + /// master_ratio` instead of writing raw window geometry: `id` must be + /// a non-floating, non-fullscreen member of a `"tiling"`-layout + /// workspace's own master/stack arrangement, there must actually be a + /// stack column to trade width with (a lone master-only window has + /// nothing on the other side of the drag), and `edge` must be the + /// shared boundary line between the two columns - the master + /// column's own right edge, or any stack column window's own left + /// edge, since both name the same physical boundary approached from + /// either side. Anything else (a vertical edge, a floating window, a + /// window under `dynamic`) falls through to the ordinary geometry + /// resize unchanged. + /// + /// **Must be called before `focus_window` runs for this same + /// interaction** - `start_resize`'s own call site is the only correct + /// place, and the returned membership snapshot is what `start_resize` + /// caches into `ResizeState` for `adjust_master_ratio_for_drag` to + /// apply the layout against later, rather than that method re-deriving + /// membership itself from `self.order` at *its* own, later point in + /// time. `focus_window` raises its target to the *end* of `self.order` + /// (`raise_window`), the exact list this membership is read from - so + /// merely grabbing a master window to resize it re-stacks it into what + /// looks like the stack's own last slot an instant later, and anything + /// that re-derives membership after that point (including a first + /// version of this whole feature that called `WindowManager:: + /// arrange_workspace` from inside the drag, which reads `self.order` + /// itself fresh every time) silently applies the resulting ratio + /// change to the *wrong* column: the window that's actually being + /// dragged shrinks while its neighbour grows, backwards from what the + /// mouse is doing. Caught by this method's own test coverage's fuller + /// assertion (checking the *other* window's width too, not just the + /// grabbed one), not by inspection. + fn tiling_ratio_drag(&self, id: WindowId, edge: ResizeEdge) -> Option> { + if !(edge.has_left() || edge.has_right()) { + return None; + } + let w = self.windows.get(&id)?; + if w.floating || w.fullscreen { + return None; + } + if self.workspace(w.workspace).map(|ws| ws.layout.as_str()) != Some("tiling") { + return None; + } + // Mirrors `arrange_workspace`'s own grouping exactly - the same + // window set, same order, is what decides which windows are + // "master" vs "stack" there, so this has to agree with it or the + // ratio drag would trigger (or fail to) inconsistently with what + // is actually on screen. + let ids: Vec = self + .order + .iter() + .copied() + .filter(|&oid| self.windows.get(&oid).is_some_and(|ow| ow.workspace == w.workspace && ow.monitor == w.monitor && !ow.minimized && !ow.floating && !ow.fullscreen)) + .collect(); + let pos = ids.iter().position(|&oid| oid == id)?; + let master_count = self.tiling.master_count.max(1).min(ids.len()); + if ids.len() <= master_count { + return None; + } + ((pos < master_count && edge.has_right()) || (pos >= master_count && edge.has_left())).then_some(ids) + } + + /// Applies a tiling ratio-drag's raw pixel delta `dx` (positive = + /// dragged right = master column grows) against `orig_ratio` -- + /// `ResizeState::orig_master_ratio`, the ratio as it was when this + /// resize *started*, not `self.tiling.master_ratio`'s own live value -- + /// the same "cumulative delta against a fixed starting snapshot" + /// shape `update_drag`/`update_resize`'s own geometry math already + /// uses for `orig`. Using the live value instead would compound: every + /// tick would add the *whole* cumulative `dx` on top of whatever the + /// previous tick already added, not just that tick's own incremental + /// motion. + /// + /// Re-arranges every window in `ids` immediately against the new + /// ratio, not just the grabbed one - the entire point of this being a + /// *ratio* rather than one window's own rect is that every master and + /// every stack window visibly resizes together, the same live + /// feedback dwm/i3/Hyprland all give while dragging this exact + /// boundary. + /// + /// Applies `MasterStackLayout` directly against `ids` - the frozen + /// pre-focus snapshot `tiling_ratio_drag` returned - rather than + /// calling `arrange_workspace`, which re-derives its own window list + /// from `self.order` fresh every time it runs. By the time this method + /// runs, `start_resize`'s own `focus_window` call has already raised + /// `id` to the end of `self.order`; re-deriving membership from that + /// live order here would silently apply the ratio change to + /// whichever window *now* occupies the position `id` used to be in, + /// not to `id` and its real neighbours - the exact bug this + /// snapshot-based approach exists to avoid (see `tiling_ratio_drag`'s + /// own doc comment for the full story, including how a first, + /// `arrange_workspace`-based version of this method got caught by + /// this file's own tests). + fn adjust_master_ratio_for_drag(&mut self, id: WindowId, ids: &[WindowId], dx: i32, orig_ratio: f32) { + let Some(w) = self.windows.get(&id) else { return }; + let Some(monitor) = self.monitor_for(w.monitor).cloned() else { return }; + let area_width = monitor.geometry.inset(self.tiling.gap_outer).width.max(1); + let delta_ratio = dx as f32 / area_width as f32; + // Clamped well short of 0.0/1.0 - either extreme would hand one + // column all (or none) of the width, which `MasterStackLayout` + // itself never guards against (a `0`-width stack column is a + // degenerate, not-actually-tiled state, not a valid extreme of + // the slider). + self.tiling.master_ratio = (orig_ratio + delta_ratio).clamp(0.1, 0.9); + for (placed_id, rect) in MasterStackLayout.arrange(ids, &monitor, &self.tiling) { + if let Some(w) = self.windows.get_mut(&placed_id) { + w.geometry = rect; + } + } + } + pub fn end_resize(&mut self) { // Remembers this app's new size for its *next* window - see // `remembered_sizes`' own doc comment for why this is the one diff --git a/crates/core/src/manager/input_pin.rs b/crates/core/src/manager/input_pin.rs index e3a7fbf..9280004 100644 --- a/crates/core/src/manager/input_pin.rs +++ b/crates/core/src/manager/input_pin.rs @@ -32,6 +32,38 @@ impl WindowManager { pub fn drain_pin_input_requests(&mut self) -> Vec<(i32, Option)> { std::mem::take(&mut self.pin_input_requests) } + + /// Records `pid`'s *actual current* pin state, once the Wayland + /// backend has genuinely applied it (`CompState:: + /// set_virtual_pointer_pin`) - not the request queue above, which is + /// drained and forgotten the instant the backend picks it up. Without + /// this there was no readback path at all: an IPC caller could ask to + /// pin a window blind, but never confirm the pin actually took, or ask + /// "is pid X pinned to anything right now" later. Flagged directly by + /// the AGS peer session as exactly this gap. + pub fn set_pinned_window(&mut self, pid: i32, window: Option) { + match window { + Some(w) => { + self.pinned_windows.insert(pid, w); + } + None => { + self.pinned_windows.remove(&pid); + } + } + } + + /// `pid`'s currently pinned window, if any - the read side of + /// `set_pinned_window`. + pub fn pinned_window(&self, pid: i32) -> Option { + self.pinned_windows.get(&pid).copied() + } + + /// Every currently pinned pid and its window - what the IPC + /// `"pinned_inputs"` query lists in full, rather than requiring a + /// caller to already know which pids to ask about individually. + pub fn all_pinned_windows(&self) -> impl Iterator + '_ { + self.pinned_windows.iter().map(|(&pid, &w)| (pid, w)) + } } #[cfg(test)] diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs index c9c77b2..e4ac1c2 100644 --- a/crates/core/src/manager/mod.rs +++ b/crates/core/src/manager/mod.rs @@ -49,6 +49,23 @@ struct ResizeState { start_x: i32, start_y: i32, orig: Rect, + /// `self.tiling.master_ratio` at the moment this resize started -- + /// unconditionally captured (cheap, one `f32` copy) even for a resize + /// that turns out not to touch it, the same way `orig` itself is + /// captured regardless of whether the drag ends up floating or tiled. + /// See `WindowManager::adjust_master_ratio_for_drag`'s own doc comment + /// for why a live tiling resize needs its own *starting* ratio, not + /// just the live one mutated in place tick by tick. + orig_master_ratio: f32, + /// `WindowManager::tiling_ratio_drag`'s result, decided once here, + /// *before* `start_resize` calls `focus_window` - `Some(membership)` + /// for a tiling master/stack ratio drag, `None` for an ordinary + /// (floating, or non-boundary-edge) resize. See that method's own doc + /// comment for why this has to be captured now rather than + /// re-derived later: focusing the target re-stacks it in `self.order`, + /// the exact list membership is read from, and re-deriving after that + /// point silently answers for the wrong window. + ratio_drag_ids: Option>, } /// The platform-independent core of srdwm: owns window/workspace/monitor @@ -80,6 +97,12 @@ pub struct WindowManager { /// ever learn) to a specific window. See `input_pin.rs`'s own doc /// comment. pin_input_requests: Vec<(i32, Option)>, + /// `pid`'s *actual current* pin state, as last reported by `Comp + /// State::set_virtual_pointer_pin` once it's genuinely applied -- + /// distinct from `pin_input_requests` above, which is a one-shot queue + /// drained and forgotten the moment the backend picks it up. See + /// `set_pinned_window`'s own doc comment. + pinned_windows: HashMap, /// 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 @@ -465,6 +488,7 @@ impl WindowManager { monitors: Vec::new(), output_position_requests: Vec::new(), pin_input_requests: Vec::new(), + pinned_windows: HashMap::new(), create_fake_monitor_requests: Vec::new(), remove_fake_monitor_requests: Vec::new(), output_enable_requests: Vec::new(), diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs index 3d628a8..860af89 100644 --- a/crates/core/src/manager/tests.rs +++ b/crates/core/src/manager/tests.rs @@ -287,6 +287,108 @@ assert_eq!(wm.window(a).unwrap().monitor, 1, "monitor must update live during the resize"); } + #[test] + fn dragging_the_master_columns_right_edge_grows_master_ratio_live() { + // Live report: "tiling needs a lot of work" - dragging a tiled + // window's border used to write raw geometry that the very next + // `arrange_workspace` call silently discarded. Two windows: window + // `a` is the sole master (index 0 < master_count 1), `b` is the + // whole stack. + let mut wm = wm_with_monitor(); + wm.set_layout(wm.current_workspace(), "tiling"); + wm.tiling.gap_outer = 0; + wm.tiling.gap_inner = 0; + wm.tiling.master_ratio = 0.5; + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "b")); + + let before = wm.window(a).unwrap().geometry.width; + wm.start_resize(a, ResizeEdge::Right, 0, 0); + wm.update_resize(200, 0); // dragged 200px to the right + let after = wm.window(a).unwrap().geometry.width; + assert!(after > before, "the master column must visibly grow while dragging its own right edge"); + assert!(wm.tiling.master_ratio > 0.5, "master_ratio itself must have grown, not just this one window's rect"); + // The stack window must have shrunk by the matching amount -- + // this is a real ratio change, not a floating-style resize that + // only touched the grabbed window. + assert!(wm.window(b).unwrap().geometry.width < 960); + } + + #[test] + fn dragging_a_stack_windows_left_edge_adjusts_the_same_shared_boundary() { + // The mirror case: grabbing the *stack* column's own left edge is + // the same physical boundary as the master column's right edge, + // approached from the other side. + let mut wm = wm_with_monitor(); + wm.set_layout(wm.current_workspace(), "tiling"); + wm.tiling.gap_outer = 0; + wm.tiling.gap_inner = 0; + wm.tiling.master_ratio = 0.5; + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "b")); + + wm.start_resize(b, ResizeEdge::Left, 0, 0); + wm.update_resize(200, 0); // dragged the shared boundary right + assert!(wm.tiling.master_ratio > 0.5, "master must grow when the boundary is dragged right, regardless of which side initiated it"); + } + + #[test] + fn dragging_a_floating_windows_edge_never_touches_master_ratio() { + let mut wm = wm_with_monitor(); + wm.set_layout(wm.current_workspace(), "tiling"); + wm.tiling.master_ratio = 0.5; + let a = wm.alloc_window_id(); + let mut w = Window::new(a, "a"); + w.floating = true; + w.geometry = Rect::new(100, 100, 300, 200); + wm.add_window(w); + + wm.start_resize(a, ResizeEdge::Right, 0, 0); + wm.update_resize(200, 0); + assert_eq!(wm.tiling.master_ratio, 0.5, "a floating window's own resize must behave exactly as before - no tiling ratio involved"); + assert_eq!(wm.window(a).unwrap().geometry.width, 500, "the floating window itself must still resize normally"); + } + + #[test] + fn dragging_a_tiled_windows_vertical_edge_does_not_touch_master_ratio() { + // Only the shared master/stack *horizontal* boundary is a ratio + // drag - a vertical edge has no equivalent concept in this + // layout (stack windows split height evenly, with no per-window + // override), so it must fall through to the ordinary (if + // ultimately overwritten) geometry path rather than doing nothing + // useful either way. + let mut wm = wm_with_monitor(); + wm.set_layout(wm.current_workspace(), "tiling"); + wm.tiling.master_ratio = 0.5; + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "b")); + + wm.start_resize(a, ResizeEdge::Bottom, 0, 0); + wm.update_resize(0, 100); + assert_eq!(wm.tiling.master_ratio, 0.5); + } + + #[test] + fn dragging_the_only_windows_edge_with_no_stack_does_not_touch_master_ratio() { + // A lone master window has nothing on the other side of any + // boundary - there is no stack to trade width with. + let mut wm = wm_with_monitor(); + wm.set_layout(wm.current_workspace(), "tiling"); + wm.tiling.master_ratio = 0.5; + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + + wm.start_resize(a, ResizeEdge::Right, 0, 0); + wm.update_resize(200, 0); + assert_eq!(wm.tiling.master_ratio, 0.5); + } + #[test] fn ending_a_resize_remembers_the_new_size_for_the_apps_next_window() { let mut wm = wm_with_monitor(); diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs index 882326f..4dbf96d 100644 --- a/crates/core/src/theme.rs +++ b/crates/core/src/theme.rs @@ -194,6 +194,16 @@ pub fn parse_hex_color(s: &str) -> Option<(u8, u8, u8)> { Some((r, g, b)) } +/// [`parse_hex_color`]'s exact inverse - `#rrggbb`, lowercase, always six +/// hex digits (`{:02x}` per channel, so a channel below `0x10` doesn't +/// collapse to a five-character string). Exists for settings readback: a +/// caller reading `border_color` back over IPC should get the identical +/// string shape `srd set border_color` itself accepts, not a different +/// representation of the same colour. +pub fn format_hex_color(rgb: (u8, u8, u8)) -> String { + format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2) +} + #[cfg(test)] mod tests { use super::*; @@ -215,6 +225,18 @@ mod tests { assert_eq!(parse_hex_color("#zzzzzz"), None); } + #[test] + fn format_hex_color_round_trips_through_parse_hex_color() { + for rgb in [(0x88, 0xc0, 0xd0), (0, 0, 0), (0xff, 0xff, 0xff), (0x05, 0x0a, 0x0f)] { + assert_eq!(parse_hex_color(&format_hex_color(rgb)), Some(rgb)); + } + } + + #[test] + fn format_hex_color_pads_low_channel_values() { + assert_eq!(format_hex_color((0x05, 0x0a, 0x0f)), "#050a0f"); + } + #[test] fn default_matches_the_legacy_hardcoded_nord_palette() { let t = ThemeConfig::default(); diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs index ae82b49..a8de352 100644 --- a/crates/core/src/window.rs +++ b/crates/core/src/window.rs @@ -705,6 +705,20 @@ impl ResizeEdge { r } + /// Whether this edge has a left-hand component (`Left`, `TopLeft`, + /// `BottomLeft`) - used by tiling's own master/stack ratio-drag + /// detection (`WindowManager::tiling_ratio_drag`) to recognize a stack + /// column window's left edge as the same shared boundary a master + /// column window's own right edge is. + pub fn has_left(self) -> bool { + matches!(self, ResizeEdge::Left | ResizeEdge::TopLeft | ResizeEdge::BottomLeft) + } + + /// [`Self::has_left`]'s mirror, for `Right`/`TopRight`/`BottomRight`. + pub fn has_right(self) -> bool { + matches!(self, ResizeEdge::Right | ResizeEdge::TopRight | ResizeEdge::BottomRight) + } + /// Re-derives one dimension of `delta_applied` (the result of /// `apply_delta`, already reflecting this drag's pointer motion) so /// the rect holds `ratio` (`width, height`) - `Window::aspect_ratio`'s diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs index fb896a3..fb7196c 100644 --- a/crates/ctl/src/main.rs +++ b/crates/ctl/src/main.rs @@ -145,6 +145,8 @@ fn build_request(args: &[String]) -> Result { Some("monitors") => Ok(r#"{"cmd":"monitors"}"#.to_string()), Some("workspaces") => Ok(r#"{"cmd":"workspaces"}"#.to_string()), Some("settings") => Ok(r#"{"cmd":"settings"}"#.to_string()), + Some("pinned") if args.get(1).map(String::as_str) == Some("inputs") => Ok(r#"{"cmd":"pinned_inputs"}"#.to_string()), + Some("pinned") => Err("did you mean 'srd pinned inputs'?".to_string()), Some("keyboard") if args.get(1).map(String::as_str) == Some("layout") => Ok(r#"{"cmd":"keyboard_layout"}"#.to_string()), Some("keyboard") => Err("did you mean 'srd keyboard layout'?".to_string()), Some("subscribe") => Ok(r#"{"cmd":"subscribe"}"#.to_string()), @@ -184,13 +186,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/phone_mode/multi_cursor/decoration_mode)", + "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/master_ratio/master_count/shadows/rounded_corners/animations/night_light/reading_mode/phone_mode/multi_cursor/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" => { + "border_width" | "corner_radius" | "gap_inner" | "gap_outer" | "master_count" => { raw.parse::().map_err(|_| format!("{key} needs a numeric value"))?.to_string() } + "master_ratio" => raw.parse::().map_err(|_| format!("{key} needs a numeric value"))?.to_string(), "shadows" | "rounded_corners" | "animations" | "night_light" | "reading_mode" | "phone_mode" | "multi_cursor" => match raw.as_str() { "true" | "false" => raw.clone(), _ => return Err(format!("{key} needs 'true' or 'false'")), @@ -394,6 +397,7 @@ fn print_usage() { eprintln!(" srd monitors"); eprintln!(" srd workspaces"); eprintln!(" srd settings"); + eprintln!(" srd pinned inputs"); eprintln!(" srd keyboard layout"); eprintln!(" srd subscribe"); eprintln!(" srd dispatch focus "); @@ -654,6 +658,24 @@ mod tests { fn settings_query_needs_no_further_arguments() { assert_eq!(build_request(&args(&["settings"])).unwrap(), r#"{"cmd":"settings"}"#); } + + #[test] + fn pinned_inputs_query_reads_as_two_words() { + assert_eq!(build_request(&args(&["pinned", "inputs"])).unwrap(), r#"{"cmd":"pinned_inputs"}"#); + assert!(build_request(&args(&["pinned"])).is_err()); + } + + #[test] + fn set_master_ratio_accepts_a_fractional_value() { + assert_eq!(build_request(&args(&["set", "master_ratio", "0.65"])).unwrap(), r#"{"cmd":"set","key":"master_ratio","value":0.65}"#); + assert!(build_request(&args(&["set", "master_ratio", "not-a-number"])).is_err()); + } + + #[test] + fn set_master_count_accepts_a_plain_integer() { + assert_eq!(build_request(&args(&["set", "master_count", "2"])).unwrap(), r#"{"cmd":"set","key":"master_count","value":2}"#); + assert!(build_request(&args(&["set", "master_count", "not-a-number"])).is_err()); + } } #[cfg(unix)] diff --git a/crates/platform/src/ipc/dispatch.rs b/crates/platform/src/ipc/dispatch.rs index 918b7e0..3dcca57 100644 --- a/crates/platform/src/ipc/dispatch.rs +++ b/crates/platform/src/ipc/dispatch.rs @@ -28,6 +28,14 @@ pub(crate) fn handle_request(line: &[u8], wm: &std::rc::Rc { + let pinned: Vec = wm.borrow().all_pinned_windows().map(|(pid, id)| PinnedInputInfo { pid, id }).collect(); + (serde_json::to_vec(&PinnedInputsResponse { pinned }).unwrap_or_default(), false) + } // `{"cmd":"create_fake_monitor","name":,"width":, // "height":}` - a fully virtual `wl_output` with no real // hardware behind it, applied by whichever backend owns real @@ -498,6 +514,35 @@ fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc` / `srd set master_count ` -- + // the two `TilingConfig` fields a resize-drag on the master/stack + // boundary (`WindowManager::adjust_master_ratio_for_drag`) already + // live-adjusts interactively; this is the same thing for a + // keybinding or script instead of a mouse drag, e.g. dwm's + // `mod+h`/`mod+l` grow/shrink-master or `mod+i`/`mod+d` add/remove- + // a-master-window conventions. Unlike `gap_inner`/`gap_outer` + // above, which accept "takes effect whenever the workspace next + // re-arranges anyway" (a cosmetic preference under no time + // pressure), this re-arranges the current workspace immediately -- + // a keybind pressed to grow the master column is expected to show + // the result at once, the same instant feedback the drag path + // already gives. + "master_ratio" => { + let Some(v) = value.and_then(|v| v.as_f64()) else { return (err("master_ratio needs a numeric value"), false) }; + let mut wm = wm.borrow_mut(); + wm.tiling.master_ratio = (v as f32).clamp(0.1, 0.9); + let current = wm.current_workspace(); + wm.arrange_workspace(current); + (ok(), true) + } + "master_count" => { + let Some(v) = value.and_then(|v| v.as_u64()) else { return (err("master_count needs a numeric value"), false) }; + let mut wm = wm.borrow_mut(); + wm.tiling.master_count = (v as usize).max(1); + let current = wm.current_workspace(); + wm.arrange_workspace(current); + (ok(), true) + } "shadows" => { let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("shadows needs a boolean value"), false) }; wm.borrow_mut().shadows_enabled = v; diff --git a/crates/platform/src/ipc/tests.rs b/crates/platform/src/ipc/tests.rs index 109a9ec..13ab4c2 100644 --- a/crates/platform/src/ipc/tests.rs +++ b/crates/platform/src/ipc/tests.rs @@ -735,3 +735,89 @@ fn a_oneshot_clients_request_still_closes_the_connection_as_before() { Err(e) => assert_eq!(e.kind(), ErrorKind::WouldBlock), } } + +#[test] +fn settings_reports_the_readback_fields_flagged_as_missing() { + // Confirms the whole batch at once rather than one test per field -- + // these were all added together for the same reason (a settings + // panel could set any of them blind but never read the current value + // back). + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"settings\"}\n").unwrap(); + server.poll(&wm); + let line = read_line(&mut std::io::BufReader::new(client)); + let parsed: serde_json::Value = serde_json::from_str(&line).unwrap(); + for field in ["border_width", "border_color", "corner_radius", "decoration_mode_server", "gap_inner", "gap_outer", "master_ratio", "master_count"] { + assert!(parsed.get(field).is_some(), "settings response is missing '{field}'"); + } + assert_eq!(parsed["border_color"].as_str().unwrap().chars().next(), Some('#'), "border_color must be a hex string, matching what srd set border_color itself accepts"); +} + +#[test] +fn set_master_ratio_is_reflected_immediately_by_settings() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + wm.borrow_mut().set_monitors(vec![srdwm_core::Monitor::new(0, "eDP-1", srdwm_core::Rect::new(0, 0, 1920, 1080))]); + + let mut set_client = UnixStream::connect(&server.path).unwrap(); + set_client.write_all(b"{\"cmd\":\"set\",\"key\":\"master_ratio\",\"value\":0.7}\n").unwrap(); + server.poll(&wm); + let _ = read_line(&mut std::io::BufReader::new(set_client)); + + let mut settings_client = UnixStream::connect(&server.path).unwrap(); + settings_client.write_all(b"{\"cmd\":\"settings\"}\n").unwrap(); + server.poll(&wm); + let line = read_line(&mut std::io::BufReader::new(settings_client)); + assert!(line.contains(r#""master_ratio":0.7"#)); +} + +#[test] +fn set_master_ratio_clamps_to_a_sane_range() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"set\",\"key\":\"master_ratio\",\"value\":1.5}\n").unwrap(); + server.poll(&wm); + let _ = read_line(&mut std::io::BufReader::new(client)); + + assert_eq!(wm.borrow().tiling.master_ratio, 0.9, "a value past the sane range must clamp, not be accepted verbatim"); +} + +#[test] +fn pinned_inputs_lists_nothing_before_any_pin_is_applied() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"pinned_inputs\"}\n").unwrap(); + server.poll(&wm); + let line = read_line(&mut std::io::BufReader::new(client)); + assert_eq!(line.trim_end(), r#"{"pinned":[]}"#); +} + +#[test] +fn pinned_inputs_reports_a_pin_once_the_backend_has_applied_it() { + // `pin_input` only ever queues a *request* - `WindowManager:: + // set_pinned_window` is the backend's own confirmation that it was + // genuinely applied, called directly here to simulate that (the real + // caller is `CompState::set_virtual_pointer_pin` in the wayland + // crate, unreachable from a platform-crate test). + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + wm.borrow_mut().set_pinned_window(12345, Some(7)); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"pinned_inputs\"}\n").unwrap(); + server.poll(&wm); + let line = read_line(&mut std::io::BufReader::new(client)); + assert_eq!(line.trim_end(), r#"{"pinned":[{"pid":12345,"id":7}]}"#); +} diff --git a/crates/platform/src/ipc/types.rs b/crates/platform/src/ipc/types.rs index df11592..24e3148 100644 --- a/crates/platform/src/ipc/types.rs +++ b/crates/platform/src/ipc/types.rs @@ -127,6 +127,26 @@ pub(crate) struct WorkspacesResponse { pub(crate) workspaces: Vec, } +/// One `pid`/pinned-window pair - `WindowManager::all_pinned_windows`'s +/// own doc comment. `id` matches the plain `WindowId` every other +/// dispatch already reads/writes, not a separate type. +#[derive(Serialize)] +pub(crate) struct PinnedInputInfo { + pub(crate) pid: i32, + pub(crate) id: WindowId, +} + +/// `"pinned_inputs"`'s one-shot reply - every pid Multi-cursor Phase 2 +/// (`srd dispatch pin input`) currently has pinned to a window, and which +/// one. Added because pinning had no readback at all: a caller could ask +/// to pin a window blind, but never confirm the pin actually took, or +/// list what's pinned right now without already knowing which pids to +/// ask about. +#[derive(Serialize)] +pub(crate) struct PinnedInputsResponse { + pub(crate) pinned: Vec, +} + /// `"settings"`'s one-shot reply - the live-settable toggles `"set"` /// accepts, so a migrated toggle script (night-light, reading-mode, /// hypr-performance-profile) can read current state back instead of @@ -150,6 +170,34 @@ pub(crate) struct SettingsResponse { pub(crate) phone_mode: bool, /// `WindowManager::multi_cursor_enabled`'s own doc comment. pub(crate) multi_cursor: bool, + /// The theme/tiling values `srd set` can already change live + /// (`border_width`, `border_color`, `corner_radius`, `decoration_ + /// mode`, `gap_inner`, `gap_outer`, `master_ratio`, `master_count`) + /// had no way to read the *current* value back at all - a settings + /// panel could set any of these blind, but not honestly show its own + /// control's starting position, or confirm a set actually took. + /// Flagged directly by the AGS peer session as the common shape behind + /// several separate gaps at once: "a control whose value cannot be + /// read back is a control that lies on every restart." + pub(crate) border_width: u32, + /// `#rrggbb`, matching the exact string shape `srd set border_color` + /// itself accepts (`srdwm_core::parse_hex_color`'s own format) - a + /// caller can feed this straight back into another `set` unchanged. + pub(crate) border_color: String, + pub(crate) corner_radius: u32, + /// `true` when new windows default to a server-drawn titlebar + /// (`general.decoration_mode`/`srd set decoration_mode`'s own "server" + /// value), `false` for "client" (CSD-only default). + pub(crate) decoration_mode_server: bool, + pub(crate) gap_inner: u32, + pub(crate) gap_outer: u32, + /// `TilingConfig::master_ratio`/`master_count` - see `WindowManager:: + /// adjust_master_ratio_for_drag`'s own doc comment for the live + /// interactive path (a resize-drag on the master/stack boundary) that + /// also mutates this, in addition to `srd set master_ratio`/ + /// `master_count`. + pub(crate) master_ratio: f32, + pub(crate) master_count: usize, } /// `"keyboard_layout"`'s one-shot reply shape - the active XKB layout's diff --git a/crates/wayland/src/virtual_pointer.rs b/crates/wayland/src/virtual_pointer.rs index e626e7d..f30d7fb 100644 --- a/crates/wayland/src/virtual_pointer.rs +++ b/crates/wayland/src/virtual_pointer.rs @@ -354,6 +354,11 @@ impl CompState { /// later way `set_output_position` already is (`WindowManager::drain_ /// pin_input_requests`). pub(crate) fn set_virtual_pointer_pin(&mut self, pid: i32, window: Option) { + // Real, applied state - not just the request that led here - so + // an IPC caller can read back "is pid X pinned to a window right + // now" instead of only ever writing blind. See `WindowManager:: + // set_pinned_window`'s own doc comment. + self.wm.borrow_mut().set_pinned_window(pid, window); self.virtual_pointers.retain(|p| p.is_alive()); let matching: Vec = self .virtual_pointers diff --git a/docs/TODO.md b/docs/TODO.md index fbe5ec9..785e799 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,33 @@ # TODO / planned features - master checklist +## Tiling: master/stack ratio is now live, and interactive resize actually does something (2026-08-28) + +Investigated "what happened to tiling" directly. `MasterStackLayout` itself (dwm/i3-style master+stack columns) was already real, tested and correct - gaps live-adjustable, directional swap working, floating/fullscreen correctly excluded. The actual gap: dragging or resizing a tiled window did nothing durable. `start_resize`/`update_resize` had zero tiling awareness - grabbing and dragging a tiled window's border wrote raw geometry exactly like a floating window, which the very next `arrange_workspace` call (triggered by almost anything: a window closing, a focus change) silently discarded, since tiling always fully recomputes every non-floating window's geometry from the master/stack math. On top of that, `master_ratio`/`master_count` were config-file-only - no keybind, no `srd set`, no way to adjust either live the way dwm/i3/Hyprland all let you drag the master/stack boundary or bump master count with a key. + +Fixed both. A resize-drag on the shared master/stack boundary (the master column's own right edge, or any stack window's own left edge - both name the same physical line) now live-adjusts `TilingConfig::master_ratio` and re-arranges every window in the group immediately, the same visual feedback every comparable tiling WM gives; `srd set master_ratio <0.0-1.0>` / `srd set master_count ` do the same for a keybind or script (dwm's `mod+h`/`mod+l`/`mod+i`/`mod+d` conventions), re-arranging the current workspace on the spot rather than waiting for gaps' own "next arrange, whenever that happens" laziness. + +**A real bug found and fixed while building this, not just designing it**: `start_resize` already calls `focus_window`, which raises its target to the *end* of `self.order` - the exact list `arrange_workspace` groups master/stack membership from. A first version decided "is this a ratio drag" lazily, inside `update_resize`, *after* that raise had already happened - so grabbing an actual master window's right edge measured its position in the *post*-raise order, where it now looked like the stack's own last slot, and silently misclassified every such drag as a non-drag (falling through to the discarded-raw-geometry path). Caught by this feature's own test suite, not by inspection: a fuller assertion (checking the *other* window's width moved too, not just the grabbed one) failed even though the shallower "did this window's own rect grow" check passed by coincidence via the wrong code path. Fixed by deciding ratio-drag status, and freezing the master/stack membership snapshot it depends on, in `start_resize` itself - *before* the focus-raise - and having the drag apply `MasterStackLayout` directly against that frozen snapshot rather than re-deriving membership from the live (by-then-reordered) `self.order`. + +Live-verified in a nested compositor (`WAYLAND_DISPLAY=wayland-1`, `SRDWM_CONFIG_PATH` pointed at a throwaway `default_layout = "tiling"` config - see this file's own "validate in a nested compositor" convention), not just unit-tested: two real Alacritty windows tiled correctly (master ~60%/stack ~40% at the default ratio), and `srd set master_ratio 0.8` visibly grew the master column from 466px to 623px and shrank the stack from 310px to 153px, confirmed via both `srd clients` and a real `grim` screenshot. Full workspace build/test/clippy clean (231 core tests, +5 for this feature). + +## SettingsResponse readback: everything `srd set` can change can now be read back (2026-08-28) + +Flagged directly by the AGS peer session, who named the shared pattern behind several separate gaps at once: "a control whose value cannot be read back is a control that lies on every restart." `border_width`, `border_color`, `corner_radius`, `decoration_mode`, `gap_inner`, `gap_outer` were all live-settable via `srd set` with no way to read the *current* value back at all - a settings panel could set any of them blind, but never confirm a set took or show its own control's honest starting position. `master_ratio`/`master_count` (this session's own tiling work, just above) got the same treatment from the start rather than repeating the gap. + +Also closed the same way: Multi-cursor Phase 2 (`srd dispatch pin input`) had no readback either - a caller could pin a window blind but never ask "is pid X pinned to anything right now." `CompState::set_virtual_pointer_pin` (the Wayland backend's own confirmation that a pin was genuinely applied, not just requested) now also mirrors the pinned/unpinned state into a new `WindowManager::pinned_windows` map, readable via a new `{"cmd":"pinned_inputs"}` query (`srd pinned inputs`). + +`border_color`'s readback is a `#rrggbb` string via a new `srdwm_core::format_hex_color` - `parse_hex_color`'s exact inverse, so a caller can feed a read-back value straight into another `set` unchanged. + +Full workspace build/test/clippy clean (39 platform tests, up from 34). + +## X11: maximize left a window sitting past the screen edge with a real border (2026-08-28) + +Found and fixed on a report from the `aegis-fc` peer session (a Rust AGS-successor client, testing srdwm's own layer-shell strut handling): a maximized X11 client sat 4-8px past the right and bottom screen edges whenever its border was nonzero. Root cause: `set_border_width` sets the frame window's *native* X11 `border_width` attribute, which the X server draws *outside* a window's own declared width/height on all four sides - unlike every other backend's own border rendering in this compositor (Wayland's `decoration.rs`, ordinary pixels drawn *inside* the allocated geometry rect). `apply_geometry` configured the frame at `geometry`'s own x/y/width/height verbatim, so a nonzero native border pushed the frame's true, visible footprint `2 * border_width` past every edge of what `geometry` actually promised. + +Fixed by shifting the configured origin inward and the configured size down by `border_width` on both axes (`frame_geometry_for`, pulled out as a pure function the same way `modmask_for_keycode_in_mod_slots` already was, so it's unit-tested without a real X11 connection) - the *visible* footprint, native border included, now lands exactly on `geometry`, matching what every other border-drawing path already guarantees. `border_width == 0` (undecorated/CSD windows, the common case) reduces to exactly the prior behaviour. + +Full workspace build/test/clippy clean (13 x11 tests, up from 10). Not yet live-verified against a real X11 client on this machine specifically (aegis's own repro used `Xvfb :55` + a nested `srdwm --x11` + alacritty) - the fix is a direct, mechanical correction of confirmed-wrong arithmetic, not a guess, but flagged per this file's own standing policy of saying so plainly rather than implying more confidence than a fix actually has. + ## Three real bugs found from one screenshot: window memory never saved on close, split screens duplicated desktop icons, split parts all claimed primary (2026-08-27) Asked directly why windows always spawn top-left and don't remember placement/size, and to screenshot the just-split display since it "doesn't look like 2 more monitors, just showing double desktop icons." Took a real `grim` screenshot rather than guessing from code, and it showed both reported symptoms at once plus revealed why the first one happens at all. -- cgit v1.2.3