srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/core/src/manager/dragresize.rs47
-rw-r--r--crates/core/src/manager/input_pin.rs64
-rw-r--r--crates/core/src/manager/mod.rs111
-rw-r--r--crates/core/src/manager/tests.rs151
-rw-r--r--crates/core/src/manager/windows.rs131
-rw-r--r--crates/core/src/rules.rs6
-rw-r--r--crates/core/src/window.rs110
7 files changed, 559 insertions, 61 deletions
diff --git a/crates/core/src/manager/dragresize.rs b/crates/core/src/manager/dragresize.rs
index 912c267..1c1b4df 100644
--- a/crates/core/src/manager/dragresize.rs
+++ b/crates/core/src/manager/dragresize.rs
@@ -94,6 +94,17 @@ impl WindowManager {
if let (Some(zone), Some(w)) = (snapped, self.windows.get_mut(&drag.window)) {
w.geometry = zone;
}
+ // Remembers this app's new position (not just `end_resize`'s
+ // size) for its *next* window - see `remembered_geometry`'s
+ // own doc comment. Deliberately reads geometry *after* the
+ // snap-zone check just above: a drag that ends in a snap
+ // remembers the snapped position/size, matching what the user
+ // actually sees settle, not the raw pre-snap drop point.
+ if let Some(w) = self.windows.get(&drag.window) {
+ if !w.app_id.is_empty() {
+ self.remembered_geometry.insert(w.app_id.clone(), (w.geometry.x, w.geometry.y, w.geometry.width, w.geometry.height));
+ }
+ }
}
}
@@ -111,7 +122,14 @@ impl WindowManager {
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 new_geom = r.edge.apply_delta(r.orig, dx, dy, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
+ let mut new_geom = r.edge.apply_delta(r.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);
+ }
// Same live `w.monitor` correction as `update_drag`'s own doc
// comment explains - a resize can cross a monitor boundary at
// the edge being dragged just as easily as a drag can carry the
@@ -138,13 +156,38 @@ impl WindowManager {
if let Some(r) = &self.resize {
if let Some(w) = self.windows.get(&r.window) {
if !w.app_id.is_empty() {
- self.remembered_sizes.insert(w.app_id.clone(), (w.geometry.width, w.geometry.height));
+ self.remembered_geometry.insert(w.app_id.clone(), (w.geometry.x, w.geometry.y, w.geometry.width, w.geometry.height));
}
}
}
self.resize = None;
}
+ /// The remembered position+size for `app_id`, if any - read by
+ /// `add_window` when placing a fresh window, and by `crates/wayland/
+ /// src/window_memory.rs` to decide what still needs persisting after a
+ /// live update. See `remembered_geometry`'s own doc comment.
+ pub fn remembered_geometry(&self, app_id: &str) -> Option<(i32, i32, u32, u32)> {
+ self.remembered_geometry.get(app_id).copied()
+ }
+
+ /// Seeds (or overwrites) the remembered position+size for `app_id`
+ /// directly, bypassing the normal "only an interactive drag/resize
+ /// updates this" rule - the one legitimate reason to do that is
+ /// `crates/wayland/src/window_memory.rs` restoring what was persisted
+ /// from a *previous* session at startup, before any real drag/resize
+ /// has happened this run.
+ pub fn set_remembered_geometry(&mut self, app_id: String, geometry: (i32, i32, u32, u32)) {
+ self.remembered_geometry.insert(app_id, geometry);
+ }
+
+ /// Every remembered `app_id` and its geometry - what `window_memory.rs`
+ /// iterates to persist the full table (e.g. on a clean shutdown), not
+ /// just whatever changed most recently.
+ pub fn all_remembered_geometry(&self) -> impl Iterator<Item = (&str, (i32, i32, u32, u32))> {
+ self.remembered_geometry.iter().map(|(k, &v)| (k.as_str(), v))
+ }
+
pub fn is_resizing(&self) -> bool {
self.resize.is_some()
}
diff --git a/crates/core/src/manager/input_pin.rs b/crates/core/src/manager/input_pin.rs
new file mode 100644
index 0000000..e3a7fbf
--- /dev/null
+++ b/crates/core/src/manager/input_pin.rs
@@ -0,0 +1,64 @@
+//! Requesting a virtual-pointer pin to a specific window - Phase 2 of
+//! this project's own multi-cursor plan (see `docs/TODO.md`'s "Multi-
+//! cursor Phase 2" entry, and `crates/wayland/src/virtual_pointer.rs`'s
+//! own module doc comment for the full design). Split out the same way
+//! `lock.rs` is: everything here is plain `impl WindowManager` methods:
+//! see `super` (`mod.rs`) for `WindowManager`'s field definitions.
+
+use super::*;
+
+impl WindowManager {
+ /// Queues a request to pin (`window` is `Some`) or unpin (`None`)
+ /// every virtual pointer object owned by the client with process id
+ /// `pid` - the only caller today is the IPC `"pin_input"` dispatch,
+ /// the compositor-agnostic side of `srd dispatch pin input`/`unpin
+ /// input`. Core has no real Wayland protocol object to reach into
+ /// itself (that's backend-owned, same as `output_position_requests`);
+ /// the Wayland backend drains and applies this on its own next poll.
+ ///
+ /// Replaces (not accumulates) any still-pending request for the same
+ /// `pid`, the same "last write wins" semantics `request_output_
+ /// position` already has - only the *latest* requested pin for a
+ /// given pid matters if several arrive before the backend's next
+ /// drain.
+ pub fn request_pin_input(&mut self, pid: i32, window: Option<WindowId>) {
+ self.pin_input_requests.retain(|(existing, _)| *existing != pid);
+ self.pin_input_requests.push((pid, window));
+ }
+
+ /// Takes every currently-queued pin-input request, leaving the queue
+ /// empty. The backend calls this once per poll, same as `drain_
+ /// output_position_requests`.
+ pub fn drain_pin_input_requests(&mut self) -> Vec<(i32, Option<WindowId>)> {
+ std::mem::take(&mut self.pin_input_requests)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_pin_request_is_reported_once_then_the_queue_is_empty() {
+ let mut wm = WindowManager::new();
+ assert!(wm.drain_pin_input_requests().is_empty());
+ wm.request_pin_input(1234, Some(7));
+ assert_eq!(wm.drain_pin_input_requests(), vec![(1234, Some(7))]);
+ assert!(wm.drain_pin_input_requests().is_empty());
+ }
+
+ #[test]
+ fn a_second_request_for_the_same_pid_replaces_the_first_before_a_drain() {
+ let mut wm = WindowManager::new();
+ wm.request_pin_input(1234, Some(7));
+ wm.request_pin_input(1234, Some(9));
+ assert_eq!(wm.drain_pin_input_requests(), vec![(1234, Some(9))]);
+ }
+
+ #[test]
+ fn unpinning_is_a_real_queued_request_too_not_a_no_op() {
+ let mut wm = WindowManager::new();
+ wm.request_pin_input(1234, None);
+ assert_eq!(wm.drain_pin_input_requests(), vec![(1234, None)]);
+ }
+}
diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs
index 848eca5..a5cde36 100644
--- a/crates/core/src/manager/mod.rs
+++ b/crates/core/src/manager/mod.rs
@@ -74,6 +74,13 @@ pub struct WindowManager {
/// backend's next monitor query, same as any other hotplug/reconfigure.
output_position_requests: Vec<(MonitorId, i32, i32)>,
/// Same cross-boundary-request pattern as `output_position_requests`
+ /// just above, for Phase 2 of the multi-cursor plan - pinning a
+ /// virtual pointer object (identified by the owning client's pid, not
+ /// an opaque per-object id nothing outside the Wayland backend could
+ /// ever learn) to a specific window. See `input_pin.rs`'s own doc
+ /// comment.
+ pin_input_requests: Vec<(i32, Option<WindowId>)>,
+ /// Same cross-boundary-request pattern as `output_position_requests`
/// just above, for enable/disable - see `request_output_enabled`'s
/// own doc comment for why this is keyed by name, not `MonitorId`.
output_enable_requests: Vec<(String, bool)>,
@@ -206,6 +213,24 @@ pub struct WindowManager {
/// without touching config - `udev::platform::connect` attempts the
/// probe if *either* this or the env var says to.
pub gpu_enabled: bool,
+ /// Read from `general.phone_mode` - `false` by default. Optional
+ /// single-app-at-a-time placement policy for a phone-shaped display:
+ /// see `add_window`'s own use of this (a new window defaults to
+ /// maximized instead of floating/tiled small, unless a rule says
+ /// otherwise) for the concrete effect. Deliberately just a placement
+ /// default, not a distinct "mode" this crate tracks any other state
+ /// for - toggling it live via `srd set phone_mode <bool>` only
+ /// changes how the *next* new window opens, same as any other
+ /// default-policy config value (`general.animations`, `general.
+ /// shadows`) already behaves, not a live re-layout of every window
+ /// already open. Also exposed read-only via `srd settings` so a shell
+ /// panel (AGS, concretely) can adapt its own chrome to the same
+ /// signal without needing a second, separate way to ask "is this a
+ /// phone-shaped session" - the actual "optional phone mode for AGS"
+ /// half of this ask is real work in *that* project, not this one;
+ /// this is the one thing srdwm itself needed to add so AGS has
+ /// something real to read.
+ pub phone_mode: bool,
/// Whether srdwm draws real desktop icons (Home/Computer/Trash plus one
/// per real `~/Desktop` entry) on the primary output's wallpaper --
/// read from `general.desktop_icons`. Unlike `gpu_enabled`, this
@@ -213,6 +238,44 @@ pub struct WindowManager {
/// feature with no hardware-support question to hedge against, not an
/// experimental backend path that needs an opt-in safety net.
pub desktop_icons_enabled: bool,
+ /// Whether desktop icons are mirrored onto every enabled monitor's own
+ /// corner, or only drawn on the primary monitor - read from `general.
+ /// desktop_icons_all_monitors`. Defaults to `true`, matching real macOS
+ /// convention (each display gets its own Desktop icons view) rather
+ /// than the older Windows-style "icons live on monitor 1 only" - a
+ /// directly reported gap ("in other monitor it's not showing the
+ /// desktop icons"), not a hardware question to hedge on like `gpu_
+ /// enabled`. The same underlying icon set/cells are shared across every
+ /// mirror: dragging a copy on one monitor moves the one real icon,
+ /// which then shows in its new cell on every monitor it's mirrored to.
+ pub desktop_icons_all_monitors: bool,
+ /// Static minimum space reserved on each edge of every monitor,
+ /// logical pixels, read from `general.reserve_top`/`_bottom`/`_left`/
+ /// `_right` - `0` (no static reservation) by default. Exists for the
+ /// gap between "the compositor starts rendering/placing things" and
+ /// "the bar/dock has actually connected and called `set_exclusive_
+ /// zone`": a layer-shell client's own reserved strip only exists once
+ /// that client has mapped a real surface, which is reliably *after*
+ /// this compositor's own first render pass and first-window placement
+ /// decisions (autostart spawns the compositor's own children, which
+ /// then have to connect, negotiate, and commit before their zone is
+ /// real). Desktop icons already re-derive their own origin every frame
+ /// so they self-correct once the real zone lands (see `ensure_desktop_
+ /// icons`'s own doc comment) - but a *window* placed in that gap gets
+ /// a one-time placement decision, not a continuously-corrected one, so
+ /// it can end up spawned under where the bar will render, with nothing
+ /// to nudge it out afterward. Set this to the bar/dock's own known
+ /// height/width (whatever `~/.config/ags` or another panel actually
+ /// reserves) so every usable-area computation (`Platform::monitors()`)
+ /// already accounts for it from the very first call, before any real
+ /// client has connected at all. Takes the *larger* of this and
+ /// whatever real exclusive zone currently exists per edge, never the
+ /// smaller - so a real, larger bar still wins once it registers, and
+ /// this is a floor, not a competing claim.
+ pub reserve_top: u32,
+ pub reserve_bottom: u32,
+ pub reserve_left: u32,
+ pub reserve_right: u32,
/// External program desktop icons open into, read from `general.
/// file_manager`. Empty (the default) means "shell out to `xdg-open
/// <path>`" - the de-facto standard dispatcher to whatever the user's
@@ -265,23 +328,27 @@ pub struct WindowManager {
drag: Option<DragState>,
resize: Option<ResizeState>,
rules: Vec<WindowRule>,
- /// Last floating size a user interactively resized each `app_id` to,
- /// applied to that app's *next* new window instead of the fixed
- /// 800x600 every backend otherwise hardcodes - see `end_resize` (where
- /// this is recorded) and `add_window` (where it's read). Keyed by
- /// `app_id` alone, not per-window: the ask is "my terminal should open
- /// at the size I last used a terminal at", not per-window-instance
- /// memory. Only an interactive drag-resize (`end_resize`) updates this
- /// - not a maximize/fullscreen toggle (that's a separate, temporary
- /// state with its own `restore_geometry`, not a new "size I want to
- /// keep using") and not a drag-to-edge snap (a deliberate one-off
- /// snap to a half/quarter of the screen isn't "the size I'll want my
- /// next terminal to open at" either). Session-lifetime only, not
- /// persisted to disk - a real per-app-size-memory feature that
- /// survives a restart would need a config-file-backed store, which is
- /// meaningfully more machinery than "remember it while running" asks
- /// for.
- remembered_sizes: HashMap<String, (u32, u32)>,
+ /// Last floating position+size a user interactively moved/resized each
+ /// `app_id` to, applied to that app's *next* new window instead of the
+ /// fixed 800x600-near-centre every backend otherwise hardcodes - see
+ /// `end_resize`/`end_drag` (where this is recorded) and `add_window`
+ /// (where it's read). Keyed by `app_id` alone, not per-window: the ask
+ /// is "my terminal should open where/how big I last left one", not
+ /// per-window-instance memory. Only an interactive drag/resize updates
+ /// this - not a maximize/fullscreen toggle (that's a separate,
+ /// temporary state with its own `restore_geometry`, not a new
+ /// "position/size I want to keep using") and not a drag-to-edge snap (a
+ /// deliberate one-off snap to a half/quarter of the screen isn't "where
+ /// I'll want my next terminal to open" either).
+ ///
+ /// In-memory here (this struct has no file I/O of its own - see
+ /// `srdwm_core`'s own module doc comment on why core stays pure logic);
+ /// `crates/wayland/src/window_memory.rs` is what actually persists this
+ /// to `$XDG_STATE_HOME/srd/window-memory.json` and re-seeds it via
+ /// `set_remembered_geometry` at startup, the same load/save-at-the-
+ /// platform-layer split `monitor_layout.rs`/`desktop_icons_state.rs`
+ /// already use for their own per-feature state.
+ remembered_geometry: HashMap<String, (i32, i32, u32, u32)>,
/// Windows a client-close was requested for, drained once per tick by
/// `main.rs`'s event loop and forwarded to `Platform::close`. Needed
/// because `WindowManager` is platform-agnostic and has no way to send
@@ -342,6 +409,7 @@ impl WindowManager {
focused: None,
monitors: Vec::new(),
output_position_requests: Vec::new(),
+ pin_input_requests: Vec::new(),
output_enable_requests: Vec::new(),
disabled_monitors: HashMap::new(),
monitor_splits: HashMap::new(),
@@ -398,7 +466,13 @@ impl WindowManager {
resize_margin: RESIZE_MARGIN,
rounded_corners_enabled: None,
gpu_enabled: false,
+ phone_mode: false,
desktop_icons_enabled: true,
+ desktop_icons_all_monitors: true,
+ reserve_top: 0,
+ reserve_bottom: 0,
+ reserve_left: 0,
+ reserve_right: 0,
file_manager: String::new(),
desktop_icon_single_click: false,
terminal: String::new(),
@@ -410,7 +484,7 @@ impl WindowManager {
drag: None,
resize: None,
rules: Vec::new(),
- remembered_sizes: HashMap::new(),
+ remembered_geometry: HashMap::new(),
close_requests: Vec::new(),
keyboard_layout: String::new(),
keyboard_layout_cycle_requests: 0,
@@ -438,6 +512,7 @@ mod capture;
mod dragresize;
mod focus;
mod hittest;
+mod input_pin;
mod layout;
mod lock;
mod monitors;
diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs
index 4739278..725af1f 100644
--- a/crates/core/src/manager/tests.rs
+++ b/crates/core/src/manager/tests.rs
@@ -32,6 +32,54 @@
}
#[test]
+ fn phone_mode_maximizes_a_new_window_by_default() {
+ let mut wm = wm_with_monitor();
+ wm.phone_mode = true;
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+ assert!(wm.window(id).unwrap().maximized);
+ }
+
+ #[test]
+ fn phone_mode_does_not_maximize_a_window_a_rule_floats() {
+ let mut wm = wm_with_monitor();
+ wm.phone_mode = true;
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { class: Some("popup".into()), ..Default::default() },
+ actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "a");
+ w.app_id = "popup".into();
+ wm.add_window(w);
+ assert!(!wm.window(id).unwrap().maximized, "a window a rule explicitly floats is meant to stay small, phone mode or not");
+ }
+
+ #[test]
+ fn a_rules_explicit_maximized_false_still_wins_in_phone_mode() {
+ let mut wm = wm_with_monitor();
+ wm.phone_mode = true;
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { class: Some("widget".into()), ..Default::default() },
+ actions: crate::rules::WindowRuleActions { maximized: Some(false), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "a");
+ w.app_id = "widget".into();
+ wm.add_window(w);
+ assert!(!wm.window(id).unwrap().maximized, "an explicit rule action must win over phone mode's own default");
+ }
+
+ #[test]
+ fn phone_mode_off_leaves_ordinary_placement_unaffected() {
+ let mut wm = wm_with_monitor();
+ assert!(!wm.phone_mode, "sanity: default is off");
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+ assert!(!wm.window(id).unwrap().maximized);
+ }
+
+ #[test]
fn a_rules_decorated_action_still_overrides_the_theme_default() {
let mut wm = wm_with_monitor();
wm.theme.default_decorated = false;
@@ -47,6 +95,36 @@
}
#[test]
+ fn a_decorated_false_rule_applies_once_app_id_becomes_known_after_creation() {
+ // The real native-Wayland scenario `Window::rules_applied`'s own
+ // doc comment describes: `add_window` sees an empty title/app_id
+ // (xdg_toplevel's own set_app_id/set_title requests land on a
+ // later commit, not at surface creation), so the real rule match
+ // has to wait for `reapply_rules_if_pending` - this is the one
+ // path `a_rules_decorated_action_still_overrides_the_theme_default`
+ // above does NOT cover, since that test sets `app_id` before ever
+ // calling `add_window` at all.
+ let mut wm = wm_with_monitor();
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { class: Some("firefox".into()), ..Default::default() },
+ actions: crate::rules::WindowRuleActions { decorated: Some(false), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ let w = Window::new(id, "");
+ wm.add_window(w);
+ assert!(wm.window(id).unwrap().decorated, "nothing could have matched yet with an empty app_id - still the theme default (true)");
+ assert!(!wm.window(id).unwrap().rules_applied, "must stay pending, not falsely marked settled");
+
+ if let Some(win) = wm.window_mut(id) {
+ win.app_id = "firefox".into();
+ win.title = "Mozilla Firefox".into();
+ }
+ let reapplied = wm.reapply_rules_if_pending(id);
+ assert!(reapplied, "the now-real app_id should let the firefox rule match");
+ assert!(!wm.window(id).unwrap().decorated, "the rule's decorated=false must actually take effect");
+ }
+
+ #[test]
fn tiling_workspace_arranges_two_windows_side_by_side() {
let mut wm = wm_with_monitor();
wm.set_layout(wm.current_workspace(), "tiling");
@@ -259,6 +337,55 @@
}
#[test]
+ fn a_dragged_window_remembers_its_new_position_for_the_next_same_app_window() {
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "tiling");
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.app_id = "alacritty".into();
+ w.geometry = Rect::new(100, 100, 300, 200);
+ wm.add_window(w);
+ wm.start_drag(a, 150, 150);
+ wm.update_drag(650, 550);
+ wm.end_drag();
+ let dragged_to = wm.window(a).unwrap().geometry;
+
+ let b = wm.alloc_window_id();
+ let mut w2 = Window::new(b, "b");
+ w2.app_id = "alacritty".into();
+ w2.geometry = Rect::new(0, 0, 800, 600);
+ wm.add_window(w2);
+ let placed = wm.window(b).unwrap().geometry;
+ assert_eq!((placed.x, placed.y), (dragged_to.x, dragged_to.y), "the second alacritty window must open where the first was dragged to");
+ }
+
+ #[test]
+ fn a_remembered_position_on_a_monitor_that_no_longer_exists_falls_back_to_placement() {
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "dynamic");
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.app_id = "alacritty".into();
+ w.geometry = Rect::new(100, 100, 300, 200);
+ wm.add_window(w);
+ wm.start_drag(a, 150, 150);
+ wm.update_drag(150, 150);
+ wm.end_drag();
+ // Simulate the monitor that position was remembered on being gone
+ // (e.g. an external display unplugged since the last session) --
+ // the only monitor left doesn't cover the remembered point at all.
+ wm.set_monitors(vec![Monitor::new(1, "different", Rect::new(5000, 5000, 1920, 1080))]);
+
+ let b = wm.alloc_window_id();
+ let mut w2 = Window::new(b, "b");
+ w2.app_id = "alacritty".into();
+ w2.geometry = Rect::new(0, 0, 800, 600);
+ wm.add_window(w2);
+ let placed = wm.window(b).unwrap().geometry;
+ assert!(placed.x >= 5000, "an invalid remembered position must fall back to placement on a real, currently-connected monitor, not be reused blindly");
+ }
+
+ #[test]
fn maximizing_then_unmaximizing_does_not_change_the_remembered_size() {
// Only an interactive drag-resize should update `remembered_sizes` --
// maximize/fullscreen have their own separate `restore_geometry` and
@@ -1178,12 +1305,20 @@
}
#[test]
- fn a_focused_window_still_wins_over_the_pointers_monitor() {
- // The pointer is only a fallback for when nothing is focused --
- // see `add_window`'s own doc comment for why focus stays the
- // primary signal (matches every mainstream desktop's "new window
- // opens where you're working" convention, which is about the
- // focused context, not incidental cursor position).
+ fn the_pointers_monitor_wins_over_a_stale_focused_window() {
+ // Real bug, reported live: with a window focused on the first
+ // monitor but the pointer now over the *second* monitor's bare
+ // desktop (an empty workspace, or hovering a panel/dock that isn't
+ // a core-tracked window - neither ever changes `self.focused`), a
+ // freshly launched app still landed on the first monitor, where
+ // the stale focus pointed, not the second monitor the user was
+ // demonstrably at. `self.focused` only updates when a real window
+ // is actually focused, so it can't tell "still working over there"
+ // apart from "attention moved elsewhere, nothing there has been
+ // focused yet" - `pointer_monitor` can, since it updates on every
+ // motion event, so it wins first. See `add_window`'s own doc
+ // comment for the full reasoning and the comparable-compositor
+ // precedent (Hyprland, Mutter, sway's `focus_follows_mouse`).
let mut wm = WindowManager::new();
wm.set_monitors(two_monitors());
@@ -1193,8 +1328,8 @@
wm.set_pointer_monitor(Some(1));
let second = wm.alloc_window_id();
- wm.add_window(Window::new(second, "should-still-follow-focus"));
- assert_eq!(wm.window(second).unwrap().monitor, 0, "a focused window's monitor must win over the pointer's");
+ wm.add_window(Window::new(second, "should-follow-the-pointer"));
+ assert_eq!(wm.window(second).unwrap().monitor, 1, "the pointer's monitor must win over a stale focused window's");
}
// ---- Fullscreen ------------------------------------------------------
diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs
index 7e2046d..ff6c4c8 100644
--- a/crates/core/src/manager/windows.rs
+++ b/crates/core/src/manager/windows.rs
@@ -28,6 +28,14 @@ impl WindowManager {
window.corner_radius = self.theme.default_corner_radius;
window.decorated = self.theme.default_decorated && !likely_draws_own_titlebar(&window.app_id);
let actions = self.rules.iter().find(|r| r.matcher.matches(&window)).map(|r| r.actions.clone());
+ log::warn!(
+ "DECO-DIAG add_window id={id} app_id={:?} title={:?} rules_count={} actions_found={} decorated_before_actions={}",
+ window.app_id,
+ window.title,
+ self.rules.len(),
+ actions.is_some(),
+ window.decorated
+ );
// See `Window::rules_applied`'s doc comment: a native Wayland window
// still has empty title/app_id at this point, so a real (if
// inconclusive) match attempt needs to wait for `reapply_rules_if_pending`.
@@ -60,52 +68,82 @@ impl WindowManager {
if let Some(margin) = a.resize_margin {
window.resize_margin = Some(margin);
}
+ if let Some(ratio) = a.aspect_ratio {
+ window.aspect_ratio = Some(ratio);
+ }
}
- // A remembered size (`remembered_sizes`' own doc comment) wins over
- // whatever fixed default a backend hardcoded into `window.geometry`
- // before calling this - but a rule's explicit `geometry` action
- // below still wins over *this*, since that's a deliberate per-app
- // override, more specific than "whatever I last resized this app
- // to". Clamped to the same minimums a live resize itself can never
- // go below, so a corrupted/stale entry can't hand a new window a
- // degenerate size.
+ // A remembered size (`remembered_geometry`'s own doc comment) wins
+ // over whatever fixed default a backend hardcoded into `window.
+ // geometry` before calling this - but a rule's explicit `geometry`
+ // action below still wins over *this*, since that's a deliberate
+ // per-app override, more specific than "whatever I last left this
+ // app at". Clamped to the same minimums a live resize itself can
+ // never go below, so a corrupted/stale entry can't hand a new
+ // window a degenerate size. Position (see `remembered_position`
+ // below, computed after `target_monitor` exists) is a separate
+ // question from size: a size is always safe to reapply verbatim,
+ // but a *position* needs checking against the monitors that
+ // actually exist right now before it's safe to reuse.
+ let mut remembered_position: Option<(i32, i32)> = None;
if !window.app_id.is_empty() {
- if let Some(&(w, h)) = self.remembered_sizes.get(&window.app_id) {
+ if let Some((x, y, w, h)) = self.remembered_geometry.get(&window.app_id).copied() {
window.geometry.width = w.max(MIN_WINDOW_WIDTH);
window.geometry.height = h.max(MIN_WINDOW_HEIGHT);
+ remembered_position = Some((x, y));
}
}
// Every new window used to land on the *primary* monitor
// unconditionally, regardless of which monitor the user was
// actually working on - reported live as "why do all windows only
// open on the first monitor" once a second, non-primary monitor
- // was actually in use. Placing on the *focused* window's monitor
- // instead matches every mainstream desktop's own convention (a new
- // window opens where you're currently working, not wherever
- // "primary" happens to be), and needs no new state: `self.focused`
- // already exists for exactly this kind of "what's the user looking
- // at right now" question.
+ // was actually in use.
//
- // Falling all the way back to the primary monitor whenever nothing
- // is focused was still wrong for a second, later-reported case:
- // nothing focused *on the monitor the user is actually at* - an
- // empty desktop there, or the last-focused window happening to sit
- // on a different monitor than the one just clicked/hovered before
- // launching something new - landed the new window on primary
- // regardless of which monitor was genuinely in use. `pointer_
- // monitor` (see its own doc comment) is a second, better fallback
- // for exactly that gap, checked before giving up to primary
- // entirely - which stays the last resort for the one case neither
- // signal can answer, a fresh session's very first window before any
- // pointer motion has been reported at all.
+ // This used to check the *focused* window's monitor first and the
+ // pointer only as a fallback, on the reasoning that focus is the
+ // stronger "where is the user working" signal. Reported live as
+ // still wrong the same way: launching an app while the pointer sat
+ // on a second monitor's bare desktop (nothing focused *there* --
+ // the panel/launcher that started it isn't a core-tracked window,
+ // and the last *focused* window was still whatever had been open on
+ // the first monitor) put the new window back on the first monitor
+ // regardless. `self.focused` only changes when a real window is
+ // focused, so it goes stale the moment the user's attention moves
+ // to empty desktop, a panel, or a dock - exactly the case that
+ // matters here. The pointer's own current monitor has no such
+ // staleness: `set_pointer_monitor` is updated on every motion
+ // event, so it always reflects where the user physically is right
+ // now. Checked first for that reason, matching this compositor's
+ // own mouse-first design (see `docs/DEFAULTS.md`) and the same
+ // "active output follows the cursor" default every comparable
+ // dynamic/floating compositor (Hyprland, Mutter/GNOME, sway's
+ // `focus_follows_mouse`) ships. Falling back to the focused
+ // window's monitor when the pointer's own is unknown, then all the
+ // way back to primary as the last resort for the one case neither
+ // signal can answer - a fresh session's very first window, before
+ // any pointer motion has been reported at all.
let target_monitor = self
- .focused
- .and_then(|id| self.windows.get(&id))
- .and_then(|w| self.monitors.iter().find(|m| m.id == w.monitor))
- .or_else(|| self.pointer_monitor.and_then(|id| self.monitors.iter().find(|m| m.id == id)))
+ .pointer_monitor
+ .and_then(|id| self.monitors.iter().find(|m| m.id == id))
+ .or_else(|| self.focused.and_then(|id| self.windows.get(&id)).and_then(|w| self.monitors.iter().find(|m| m.id == w.monitor)))
.or_else(|| self.primary_monitor());
- if let Some(monitor) = target_monitor {
+ // A remembered position (see just above) wins over both the
+ // pointer/focus-based `target_monitor` heuristic and smart
+ // placement - real desktop convention (Windows, macOS) is "reopen
+ // exactly where I left this app", not "wherever the mouse happens
+ // to be right now", *provided* that position still lands on a
+ // monitor that actually exists this run - a laptop undocked since
+ // the position was saved, say, must not place a window off in
+ // space on a monitor that's no longer there. Checked against every
+ // current monitor's own *full* geometry (not the exclusive-zone-
+ // shrunk usable one): a remembered position under where a bar now
+ // sits is still "a real monitor, just partly covered", not invalid.
+ let remembered_monitor = remembered_position.and_then(|(x, y)| self.monitors.iter().find(|m| m.full_geometry.contains_point(x, y)));
+ if let (Some((x, y)), Some(monitor)) = (remembered_position, remembered_monitor) {
+ window.monitor = monitor.id;
+ window.geometry.x = x;
+ window.geometry.y = y;
+ } else if let Some(monitor) = target_monitor {
window.monitor = monitor.id;
let layout_name = self.workspace(workspace).map(|w| w.layout.clone()).unwrap_or_default();
if layout_name != "tiling" {
@@ -117,7 +155,18 @@ impl WindowManager {
if let Some(geometry) = actions.as_ref().and_then(|a| a.geometry) {
window.geometry = geometry;
}
- let maximize = actions.as_ref().and_then(|a| a.maximized).unwrap_or(false);
+ // `general.phone_mode`'s own real default (see its doc comment on
+ // `WindowManager` for the full "optional phone mode" reasoning):
+ // every ordinary new window opens maximized, since a phone-shaped
+ // screen has no real room for multiple windows side by side. A
+ // rule's own explicit `maximized` action (`Some(true)` or
+ // `Some(false)`) always wins regardless - this only supplies the
+ // *default* `None` would otherwise fall back to. `window.floating`
+ // already reflects any rule's own `floating` action by this point
+ // (applied above) - a floating window (a picture-in-picture
+ // popup, a dialog) is floating *because* it's meant to stay small,
+ // so phone mode leaves it alone rather than maximizing it anyway.
+ let maximize = actions.as_ref().and_then(|a| a.maximized).unwrap_or(self.phone_mode && !window.floating);
self.windows.insert(id, window);
self.order.push(id);
@@ -154,9 +203,21 @@ impl WindowManager {
pub fn reapply_rules_if_pending(&mut self, id: WindowId) -> bool {
let Some(window) = self.windows.get(&id) else { return false };
if window.rules_applied || (window.title.is_empty() && window.app_id.is_empty()) {
+ log::warn!(
+ "DECO-DIAG reapply_rules_if_pending id={id} SKIPPED rules_applied={} app_id={:?} title={:?}",
+ window.rules_applied,
+ window.app_id,
+ window.title
+ );
return false;
}
let actions = self.rules.iter().find(|r| r.matcher.matches(window)).map(|r| r.actions.clone());
+ log::warn!(
+ "DECO-DIAG reapply_rules_if_pending id={id} app_id={:?} actions_found={} actions_decorated={:?}",
+ window.app_id,
+ actions.is_some(),
+ actions.as_ref().and_then(|a| a.decorated)
+ );
let Some(window) = self.windows.get_mut(&id) else { return false };
window.rules_applied = true;
// `add_window`'s matching fallback only ever sees this once
@@ -176,6 +237,7 @@ impl WindowManager {
if let Some(decorated) = actions.decorated {
window.decorated = decorated;
}
+ log::warn!("DECO-DIAG reapply_rules_if_pending id={id} FINAL window.decorated={}", window.decorated);
if let Some(color) = actions.border_color {
window.border_color = color;
}
@@ -191,6 +253,9 @@ impl WindowManager {
if let Some(margin) = actions.resize_margin {
window.resize_margin = Some(margin);
}
+ if let Some(ratio) = actions.aspect_ratio {
+ window.aspect_ratio = Some(ratio);
+ }
if let Some(geometry) = actions.geometry {
window.geometry = geometry;
}
diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs
index ec096be..ea83501 100644
--- a/crates/core/src/rules.rs
+++ b/crates/core/src/rules.rs
@@ -94,6 +94,12 @@ pub struct WindowRuleActions {
/// Per-window resize-grab-margin override, in logical pixels
/// (Hyprland's per-window `extend_border_grab_area`).
pub resize_margin: Option<i32>,
+ /// `(width, height)` ratio to hold while resizing - see `Window::
+ /// aspect_ratio`'s own doc comment for the "phone monitor" use case
+ /// this exists for. Parsed from a `"9:16"`-shaped string by the Lua
+ /// binding (`crates/config`), not here - this crate has no config
+ /// engine of its own to own that parsing.
+ pub aspect_ratio: Option<(u32, u32)>,
}
#[derive(Debug, Clone, Default)]
diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs
index 99d8de7..ae82b49 100644
--- a/crates/core/src/window.rs
+++ b/crates/core/src/window.rs
@@ -206,6 +206,20 @@ pub struct Window {
/// (a `windowrule`); this is the equivalent, set via a rule's
/// `resize_margin` action or `srd.window.set_resize_margin()`.
pub resize_margin: Option<i32>,
+ /// `(width, height)` ratio to hold while floating and being
+ /// interactively resized (`ResizeEdge::apply_aspect_ratio`), `None` to
+ /// resize freely. Set via a rule's `aspect_ratio` action (`"9:16"`) --
+ /// the "phone monitor / special workspace" ask's own real, scoped
+ /// answer: a VM/emulator/`scrcpy` window tagged this way keeps a
+ /// phone-shaped frame through a drag, without srdwm needing to know
+ /// anything about the specific app driving it (matches by `app_id`,
+ /// the same rule mechanism `decorated`/`floating`/`pinned` already
+ /// use - this is not Android-specific in any way). A real, if
+ /// narrower, precedent for this already exists outside this project:
+ /// ICCCM's `WM_NORMAL_HINTS` min/max aspect, which some X11 clients
+ /// set themselves - this is the compositor-rule equivalent for
+ /// clients (most Wayland ones, concretely) that don't.
+ pub aspect_ratio: Option<(u32, u32)>,
pub workspace: usize,
pub monitor: u32,
/// Whether `WindowManager`'s class/title-matched rules have already
@@ -255,6 +269,7 @@ impl Window {
corner_radius: 6,
opacity: 1.0,
resize_margin: None,
+ aspect_ratio: None,
// Always overwritten by `WindowManager::add_window` before this
// is ever read for real (to the current workspace, or a rule's
// own `workspace` action) - `1`, not `0`, only because
@@ -689,6 +704,45 @@ impl ResizeEdge {
}
r
}
+
+ /// 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
+ /// own doc comment explains why this exists at all.
+ ///
+ /// A pure vertical edge (`Top`/`Bottom`) derives *width* from the new
+ /// height: that is the one dimension the user is actually dragging on
+ /// that edge, so deriving it back from a width that never changed
+ /// would leave the edge under the cursor not tracking the cursor.
+ /// Every other edge (a horizontal edge or a corner) derives *height*
+ /// from width instead, for the mirrored reason - `Left`/`Right` only
+ /// ever change width in `apply_delta` to begin with, and a corner's
+ /// own diagonal drag has no single "the" dimension, so width (the
+ /// axis every non-vertical-only edge here actually touches) is the
+ /// one reasonable, consistent choice.
+ ///
+ /// `TopLeft`/`TopRight` additionally re-anchor `y` the same way
+ /// `apply_delta` itself anchors height for those two edges (keep the
+ /// *bottom* edge fixed) - otherwise a locked-ratio window dragged
+ /// from its top would grow downward instead of upward, the one
+ /// direction that edge is actually supposed to move.
+ pub fn apply_aspect_ratio(self, delta_applied: Rect, ratio: (u32, u32), min_w: u32, min_h: u32) -> Rect {
+ if ratio.0 == 0 || ratio.1 == 0 {
+ return delta_applied;
+ }
+ let mut r = delta_applied;
+ if matches!(self, ResizeEdge::Top | ResizeEdge::Bottom) {
+ let new_w = ((r.height as u64 * ratio.0 as u64) / ratio.1 as u64).max(min_w as u64) as u32;
+ r.width = new_w;
+ } else {
+ let new_h = ((r.width as u64 * ratio.1 as u64) / ratio.0 as u64).max(min_h as u64) as u32;
+ if matches!(self, ResizeEdge::TopLeft | ResizeEdge::TopRight) {
+ r.y = delta_applied.bottom() - new_h as i32;
+ }
+ r.height = new_h;
+ }
+ r
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1219,4 +1273,60 @@ mod tests {
let out = ResizeEdge::Right.apply_delta(r, -500, 0, 50, 50);
assert_eq!(out.width, 50);
}
+
+ #[test]
+ fn aspect_ratio_derives_height_from_width_on_a_horizontal_edge() {
+ let r = Rect::new(0, 0, 900, 300);
+ let out = ResizeEdge::Right.apply_aspect_ratio(r, (9, 16), 1, 1);
+ assert_eq!(out, Rect::new(0, 0, 900, 1600));
+ }
+
+ #[test]
+ fn aspect_ratio_derives_width_from_height_on_a_pure_vertical_edge() {
+ // Bottom only ever changes height in `apply_delta` - deriving
+ // width back from a height that never changed would leave the
+ // edge under the cursor not tracking the cursor, the actual bug
+ // this split exists to avoid.
+ let r = Rect::new(0, 0, 300, 1600);
+ let out = ResizeEdge::Bottom.apply_aspect_ratio(r, (9, 16), 1, 1);
+ assert_eq!(out, Rect::new(0, 0, 900, 1600));
+ }
+
+ #[test]
+ fn aspect_ratio_on_top_left_keeps_the_bottom_right_corner_fixed() {
+ // TopLeft's own `apply_delta` anchor is the bottom-right corner
+ // (dragging up-left grows the window while its bottom-right stays
+ // put); the aspect-ratio pass must keep that same corner fixed
+ // when it re-derives height, or a locked-ratio window dragged
+ // from its top would visibly grow the wrong way.
+ //
+ // Simulates a diagonal drag already processed by `apply_delta`:
+ // dragged left by 100 (width 400 -> 500, x 0 -> -100) and up by
+ // 300 (height 900 -> 1200, y 0 -> -300).
+ let delta_applied = Rect::new(-100, -300, 500, 1200);
+ let out = ResizeEdge::TopLeft.apply_aspect_ratio(delta_applied, (9, 16), 1, 1);
+ // height is derived from the (unchanged-by-this-pass) width: 500 * 16 / 9 = 888 (floor).
+ assert_eq!(out.height, 888);
+ // The bottom-right corner - not `y` itself - is what must be
+ // preserved, and matches the *original* rect's own bottom (900)
+ // too, since `apply_delta`'s own TopLeft anchor already keeps
+ // bottom fixed at 900 before this pass ever runs.
+ assert_eq!(out.y + out.height as i32, delta_applied.bottom());
+ assert_eq!(delta_applied.bottom(), 900);
+ assert_eq!(out.bottom(), 900);
+ }
+
+ #[test]
+ fn aspect_ratio_never_shrinks_below_the_given_minimum() {
+ let r = Rect::new(0, 0, 10, 10);
+ let out = ResizeEdge::Right.apply_aspect_ratio(r, (9, 16), 50, 50);
+ assert!(out.height >= 50);
+ }
+
+ #[test]
+ fn a_zero_component_ratio_is_a_no_op_not_a_division_by_zero() {
+ let r = Rect::new(0, 0, 300, 900);
+ assert_eq!(ResizeEdge::Right.apply_aspect_ratio(r, (0, 16), 1, 1), r);
+ assert_eq!(ResizeEdge::Right.apply_aspect_ratio(r, (9, 0), 1, 1), r);
+ }
}