diff options
Diffstat (limited to 'crates/core/src')
| -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 |
7 files changed, 340 insertions, 8 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 |