srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/core/src/lib.rs2
-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
-rw-r--r--crates/core/src/theme.rs22
-rw-r--r--crates/core/src/window.rs14
-rw-r--r--crates/ctl/src/main.rs26
-rw-r--r--crates/platform/src/ipc/dispatch.rs45
-rw-r--r--crates/platform/src/ipc/tests.rs86
-rw-r--r--crates/platform/src/ipc/types.rs48
-rw-r--r--crates/wayland/src/virtual_pointer.rs5
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