diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/core/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/core/src/manager/dragresize.rs | 152 | ||||
| -rw-r--r-- | crates/core/src/manager/input_pin.rs | 32 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 24 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 102 | ||||
| -rw-r--r-- | crates/core/src/theme.rs | 22 | ||||
| -rw-r--r-- | crates/core/src/window.rs | 14 | ||||
| -rw-r--r-- | crates/ctl/src/main.rs | 26 | ||||
| -rw-r--r-- | crates/platform/src/ipc/dispatch.rs | 45 | ||||
| -rw-r--r-- | crates/platform/src/ipc/tests.rs | 86 | ||||
| -rw-r--r-- | crates/platform/src/ipc/types.rs | 48 | ||||
| -rw-r--r-- | crates/wayland/src/virtual_pointer.rs | 5 |
12 files changed, 548 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<Vec<WindowId>> { + 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<WindowId> = 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<WindowId>)> { 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<WindowId>) { + 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<WindowId> { + 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<Item = (i32, WindowId)> + '_ { + 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<Vec<WindowId>>, } /// 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<WindowId>)>, + /// `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<i32, 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 @@ -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 @@ -288,6 +288,108 @@ } #[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(); // Tiling layout, so `add_window` skips `SmartPlacement`'s grid/ 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::*; @@ -216,6 +226,18 @@ mod tests { } #[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(); assert_eq!(t.titlebar_bg, (0x2e, 0x34, 0x40)); 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<String, String> { 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<String, String> { // 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::<u64>().map_err(|_| format!("{key} needs a numeric value"))?.to_string() } + "master_ratio" => raw.parse::<f64>().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 <id>"); @@ -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<std::cell::RefCell<Wi reading_mode: wm.color_filter == srdwm_core::ColorFilter::ReadingMode, phone_mode: wm.phone_mode, multi_cursor: wm.multi_cursor_enabled, + border_width: wm.theme.default_border_width, + border_color: srdwm_core::format_hex_color(wm.theme.default_border_color), + corner_radius: wm.theme.default_corner_radius, + decoration_mode_server: wm.theme.default_decorated, + gap_inner: wm.tiling.gap_inner, + gap_outer: wm.tiling.gap_outer, + master_ratio: wm.tiling.master_ratio, + master_count: wm.tiling.master_count, }; (serde_json::to_vec(&settings).unwrap_or_default(), false) } @@ -127,6 +135,14 @@ pub(crate) fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<Wi wm.borrow_mut().request_pin_input(pid as i32, id); (ok(), true) } + // `{"cmd":"pinned_inputs"}` - every pid currently pinned and which + // window, read from `WindowManager::all_pinned_windows` (the + // backend's own confirmation that a `pin_input` request was + // genuinely applied, not the one-shot request queue itself). + "pinned_inputs" => { + let pinned: Vec<PinnedInputInfo> = 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":<string>,"width":<u32>, // "height":<u32>}` - 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<std::cell::RefCell<Windo wm.borrow_mut().tiling.gap_outer = v as u32; (ok(), true) } + // `srd set master_ratio <0.0..1.0>` / `srd set master_count <n>` -- + // 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<WorkspaceInfo>, } +/// 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<PinnedInputInfo>, +} + /// `"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<WindowId>) { + // 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<ZwlrVirtualPointerV1> = self .virtual_pointers |