diff options
Diffstat (limited to 'crates/core/src')
| -rw-r--r-- | crates/core/src/manager/dragresize.rs | 47 | ||||
| -rw-r--r-- | crates/core/src/manager/input_pin.rs | 64 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 111 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 151 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 131 | ||||
| -rw-r--r-- | crates/core/src/rules.rs | 6 | ||||
| -rw-r--r-- | crates/core/src/window.rs | 110 |
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); + } } |