srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src/manager
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-11-07 14:04:00 +0200
committersrdusr <[email protected]>2025-11-07 14:04:00 +0200
commitea78f94027ecd2c690904a6d7729e9e3cc190a20 (patch)
treefe622c0cd12a2fe077501a26f0077c51d41b4209 /crates/core/src/manager
parenta8991f65602abc5ecee740c443c58fa96ecd15e1 (diff)
downloadsrdwm-ea78f94027ecd2c690904a6d7729e9e3cc190a20.tar.gz
srdwm-ea78f94027ecd2c690904a6d7729e9e3cc190a20.zip
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.
Diffstat (limited to 'crates/core/src/manager')
-rw-r--r--crates/core/src/manager/dragresize.rs152
-rw-r--r--crates/core/src/manager/input_pin.rs32
-rw-r--r--crates/core/src/manager/mod.rs24
-rw-r--r--crates/core/src/manager/tests.rs102
4 files changed, 303 insertions, 7 deletions
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/