diff options
Diffstat (limited to 'crates/core/src/manager')
| -rw-r--r-- | crates/core/src/manager/dragresize.rs | 64 | ||||
| -rw-r--r-- | crates/core/src/manager/hittest.rs | 14 | ||||
| -rw-r--r-- | crates/core/src/manager/lock.rs | 43 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 17 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 154 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 31 |
6 files changed, 318 insertions, 5 deletions
diff --git a/crates/core/src/manager/dragresize.rs b/crates/core/src/manager/dragresize.rs index 7181bb3..2efca7c 100644 --- a/crates/core/src/manager/dragresize.rs +++ b/crates/core/src/manager/dragresize.rs @@ -10,12 +10,16 @@ impl WindowManager { pub fn start_drag(&mut self, id: WindowId, x: i32, y: i32) { if let Some(w) = self.windows.get(&id) { - self.drag = Some(DragState { window: id, start_x: x, start_y: y, orig: w.geometry }); + self.drag = Some(DragState { window: id, start_x: x, start_y: y, orig: w.geometry, last_x: x, last_y: y }); self.focus_window(id); } } pub fn update_drag(&mut self, x: i32, y: i32) { + if let Some(drag) = &mut self.drag { + drag.last_x = x; + drag.last_y = y; + } let Some(drag) = &self.drag else { return }; let (dx, dy) = (x - drag.start_x, y - drag.start_y); let mut new_geom = drag.orig; @@ -108,10 +112,68 @@ impl WindowManager { } } + /// Where the currently-dragged window would land if the button came up + /// right now, or `None` when the drag is not in a snap zone. + /// + /// Deliberately calls the very same `SmartPlacement::snap_zone` that + /// `end_drag` does, on the same inputs, rather than re-deriving the + /// zones: a preview that can disagree with what release actually does + /// is worse than no preview, and any future change to the zone + /// geometry updates both at once by construction. + /// + /// Reported twice as missing: "if you move the window to absolute + /// north it show you layout options" and "why do i still not see the + /// windows layout or windows change layout when moved to areas of + /// screen like in windows". Edge snapping itself already worked - it + /// just committed silently on release with nothing shown beforehand, + /// so there was no way to tell it was going to happen, or where. + pub fn drag_snap_preview(&self) -> Option<Rect> { + let drag = self.drag.as_ref()?; + let w = self.windows.get(&drag.window)?; + let m = self.monitor_for(w.monitor)?; + SmartPlacement::snap_zone(w.geometry, m, &self.placement) + } + + /// The monitor whose top edge the drag pointer is currently within + /// [`SNAP_FLYOUT_EDGE`] of, or `None`. + /// + /// Measured against `full_geometry`, not `geometry`: the trigger band + /// is the physical top of the screen, which is exactly where a bar + /// usually sits. Using the exclusive-zone-shrunk rect would put the + /// band *below* the bar, so on a machine with a top bar the gesture + /// would only fire after the pointer had already travelled past it. + /// + /// The pointer, not the window's own top edge, because the two differ + /// by however far down the titlebar the drag grabbed - and it is the + /// pointer the user is actually aiming. + /// + /// Not `Rect::contains_point`: that would also reject a pointer *above* + /// the monitor's top edge, which is the one direction this gesture is + /// aimed in. A real seat clamps the cursor to the output, so `y < 0` + /// should not arise on hardware - but `update_drag` clamps only the + /// window, so nothing in this type's own API guarantees it, and + /// "thrown past the edge" is the strongest possible form of the intent + /// this is trying to detect. Horizontal containment is still required, + /// as is being above the monitor's bottom, so a pointer on a different + /// output never matches. + pub fn drag_top_edge_monitor(&self) -> Option<&Monitor> { + let drag = self.drag.as_ref()?; + let (x, y) = (drag.last_x, drag.last_y); + self.monitors.iter().find(|m| { + let g = m.full_geometry; + x >= g.x && x < g.right() && y < g.bottom() && y - g.y <= SNAP_FLYOUT_EDGE + }) + } + pub fn is_dragging(&self) -> bool { self.drag.is_some() } + /// The window the current drag is moving, if any. + pub fn dragged_window(&self) -> Option<WindowId> { + self.drag.as_ref().map(|d| d.window) + } + pub fn start_resize(&mut self, id: WindowId, edge: ResizeEdge, x: i32, y: i32) { if let Some(w) = self.windows.get(&id) { // Decided *before* `focus_window` below re-stacks `id` -- diff --git a/crates/core/src/manager/hittest.rs b/crates/core/src/manager/hittest.rs index ab19a5c..9e0a8e1 100644 --- a/crates/core/src/manager/hittest.rs +++ b/crates/core/src/manager/hittest.rs @@ -6,6 +6,18 @@ use super::*; impl WindowManager { + /// Whether `w` gets a Maximize button, resolving `theme.dynamic_buttons` + /// against the window's own declared resizability. + /// + /// Lives here, next to the hit-test that consumes it, so the renderer + /// and the hit-test are reading one shared answer rather than each + /// deriving their own - these two have drifted apart before, and the + /// failure mode (buttons drawn in one set of slots, clicks resolved + /// against another) is silent. + pub fn show_maximize(&self, w: &Window) -> bool { + !self.theme.dynamic_buttons || w.resizable + } + // ---- Hit testing ------------------------------------------------------ /// Topmost window whose frame contains `(x, y)`, along with what part of @@ -59,7 +71,7 @@ impl WindowManager { } let margin = w.resize_margin.unwrap_or(self.resize_margin); let geometry = geometry_for(w.id, w.geometry); - if let Some(hit) = ResizeEdge::hit_test(geometry, x, y, w.decorated, w.border_width, margin, self.theme.buttons_left, self.theme.button_order, w.is_dialog) { + if let Some(hit) = ResizeEdge::hit_test(geometry, x, y, w.decorated, w.border_width, margin, self.theme.buttons_left, self.theme.button_order, w.is_dialog, self.show_maximize(w)) { return Some((w.id, hit)); } // Not a titlebar/border/resize-margin hit on `w` - but if the diff --git a/crates/core/src/manager/lock.rs b/crates/core/src/manager/lock.rs index 07735c6..0ffe0ca 100644 --- a/crates/core/src/manager/lock.rs +++ b/crates/core/src/manager/lock.rs @@ -25,6 +25,32 @@ impl WindowManager { pub fn drain_lock_request(&mut self) -> bool { std::mem::take(&mut self.lock_requested) } + + /// Queues a request for the config layer to re-read `init.lua` and fire + /// the `srd.on("refresh", ...)` handler. + /// + /// Same core/backend split as `request_lock` above, for the same + /// reason: core owns no Lua state, so it cannot reload a config or run + /// a handler itself. The desktop menu's own "Refresh" row is the + /// caller. + /// + /// Asked for as "does refresh refresh configs in a function list in the + /// config ie refresh os, etc, ags/aegis/polybar/waybar". Refresh used + /// to re-scan the desktop icon grid and nothing else, so there was no + /// way to make it reload anything the user actually cared about. What + /// "refresh" *means* beyond srdwm's own config is deliberately the + /// config's decision, not a hardcoded list of other people's tools -- + /// this compositor has no business knowing whether the user runs + /// waybar or AGS. + pub fn request_refresh(&mut self) { + self.refresh_requested = true; + } + + /// Takes the current refresh request, if any. Drained once per poll, + /// same as `drain_lock_request`. + pub fn drain_refresh_request(&mut self) -> bool { + std::mem::take(&mut self.refresh_requested) + } } #[cfg(test)] @@ -41,6 +67,23 @@ mod tests { } #[test] + fn drain_refresh_request_is_true_once_then_false() { + let mut wm = WindowManager::new(); + assert!(!wm.drain_refresh_request(), "nothing requested yet"); + wm.request_refresh(); + assert!(wm.drain_refresh_request(), "must report the pending request"); + assert!(!wm.drain_refresh_request(), "must not report the same request twice"); + } + + #[test] + fn a_refresh_request_is_independent_of_a_lock_request() { + let mut wm = WindowManager::new(); + wm.request_refresh(); + assert!(!wm.drain_lock_request(), "refresh must not look like a lock"); + assert!(wm.drain_refresh_request()); + } + + #[test] fn requesting_lock_twice_before_a_drain_is_still_just_one_pending_request() { let mut wm = WindowManager::new(); wm.request_lock(); diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs index 9265885..7454ce2 100644 --- a/crates/core/src/manager/mod.rs +++ b/crates/core/src/manager/mod.rs @@ -1,7 +1,7 @@ use crate::geometry::Rect; use crate::layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig}; use crate::monitor::{DisabledMonitor, Monitor, MonitorId, MonitorSplit}; -use crate::placement::{PlacementConfig, SmartPlacement, SnapZoneKind, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH}; +use crate::placement::{centered_in, PlacementConfig, SmartPlacement, SnapZoneKind, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SNAP_FLYOUT_EDGE}; use crate::rules::WindowRule; #[cfg(test)] use crate::rules::{WindowMatch, WindowRuleActions}; @@ -43,6 +43,17 @@ struct DragState { start_x: i32, start_y: i32, orig: Rect, + /// Where the pointer was on the last `update_drag` tick, global space. + /// Seeded from the drag's own start point so it is never meaningless, + /// even for a drag that ends before a single motion event arrives. + /// + /// Only the *pointer* can answer "is the user reaching for the top of + /// the screen right now" - `orig`/`Window::geometry` answer "where is + /// the window", which is a different question during a drag, because + /// the window hangs below the grab point by however far down its + /// titlebar the user took hold of it. See `drag_top_edge_monitor`. + last_x: i32, + last_y: i32, } struct ResizeState { @@ -423,6 +434,9 @@ pub struct WindowManager { /// Read from `theme.lock.*`. See `LockConfig`'s own doc comment for /// why this isn't just folded into `theme` above. pub lock: LockConfig, + /// Set by `request_refresh`, drained by the main loop. Same + /// cross-boundary queued-request shape as `lock_requested`. + refresh_requested: bool, drag: Option<DragState>, resize: Option<ResizeState>, rules: Vec<WindowRule>, @@ -586,6 +600,7 @@ impl WindowManager { auto_raise: false, theme: ThemeConfig::default(), lock: LockConfig::default(), + refresh_requested: false, drag: None, resize: None, rules: Vec::new(), diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs index 6a3ba3f..fade3aa 100644 --- a/crates/core/src/manager/tests.rs +++ b/crates/core/src/manager/tests.rs @@ -700,6 +700,160 @@ assert_eq!(wm.window(a).unwrap().geometry, original); } + /// Adds a window and drags it by exactly `(dx, dy)`, leaving the drag + /// open. Deltas rather than absolute targets because `add_window` runs + /// `SmartPlacement`, so a window's real starting rect is chosen by the + /// placement policy, not by whatever the test set before adding it. + fn drag_by(wm: &mut WindowManager, dx: i32, dy: i32) -> (WindowId, Rect) { + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let start = wm.window(a).unwrap().geometry; + // Grab well down the window so the pointer and the window's own + // top edge are never accidentally the same measurement. + let (px, py) = (start.x + 40, start.y + 40); + wm.start_drag(a, px, py); + wm.update_drag(px + dx, py + dy); + (a, start) + } + + #[test] + fn dragging_to_the_left_edge_previews_the_left_half_before_release() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let start = wm.window(a).unwrap().geometry; + assert_eq!(wm.drag_snap_preview(), None, "no drag in progress"); + + let (px, py) = (start.x + 40, start.y + 40); + wm.start_drag(a, px, py); + assert_eq!(wm.drag_snap_preview(), None, "still where it started: nothing to preview"); + + // Exactly enough to put the window's own left edge on x=0. + wm.update_drag(px - start.x, py); + assert_eq!(wm.drag_snap_preview(), Some(Rect::new(0, 0, 960, 1080)), "left half"); + } + + #[test] + fn the_drag_preview_is_exactly_what_release_then_commits() { + // The preview and the commit must never be able to disagree -- + // both go through SmartPlacement::snap_zone on the same inputs. + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let start = wm.window(a).unwrap().geometry; + let (px, py) = (start.x + 40, start.y + 40); + wm.start_drag(a, px, py); + wm.update_drag(px - start.x, py); + let previewed = wm.drag_snap_preview().expect("a zone was previewed"); + wm.end_drag(); + assert_eq!(wm.window(a).unwrap().geometry, previewed); + } + + #[test] + fn a_drag_that_is_not_near_any_edge_previews_nothing() { + let mut wm = wm_with_monitor(); + let (_, _) = drag_by(&mut wm, 300, 300); + assert_eq!(wm.drag_snap_preview(), None); + } + + #[test] + fn the_flyout_trigger_follows_the_pointer_not_the_windows_own_top_edge() { + // A drag grabbed 40px down its titlebar holds the window's top + // edge 40px below the pointer, and `update_drag` clamps the window + // to the monitor while the pointer is free to reach y=0. Aiming + // the cursor at the top of the screen has to be enough on its own. + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let start = wm.window(a).unwrap().geometry; + let (px, py) = (start.x + 40, start.y + 40); + wm.start_drag(a, px, py); + + wm.update_drag(px, py + 200); + assert!(wm.drag_top_edge_monitor().is_none(), "mid-screen"); + + wm.update_drag(px, 2); + assert_eq!(wm.drag_top_edge_monitor().map(|m| m.id), Some(0), "pointer is at the top edge"); + } + + #[test] + fn the_flyout_trigger_needs_the_pointer_actually_at_the_edge() { + let mut wm = wm_with_monitor(); + drag_by(&mut wm, 0, -10_000); + // Clamped hard against the top: the pointer went with it, so this + // one legitimately does trigger. + assert!(wm.drag_top_edge_monitor().is_some()); + + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let start = wm.window(a).unwrap().geometry; + wm.start_drag(a, start.x + 40, start.y + 40); + wm.update_drag(start.x + 40, SNAP_FLYOUT_EDGE + 1); + assert!(wm.drag_top_edge_monitor().is_none(), "one pixel outside the band"); + } + + #[test] + fn there_is_no_flyout_trigger_when_nothing_is_being_dragged() { + let wm = wm_with_monitor(); + assert!(wm.drag_top_edge_monitor().is_none()); + } + + #[test] + fn a_dialog_opens_centered_not_cascaded_into_the_corner() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + let mut d = Window::new(a, "dialog"); + d.is_dialog = true; + d.geometry = Rect::new(0, 0, 400, 300); + wm.add_window(d); + // 1920x1080 monitor, 400x300 dialog. + assert_eq!(wm.window(a).unwrap().geometry, Rect::new(760, 390, 400, 300)); + } + + #[test] + fn a_dialog_ignores_the_apps_remembered_position() { + // `remembered_geometry` is keyed by app_id, which a dialog shares + // with the window that spawned it - without the dialog branch + // running first, a dialog lands wherever that app's last main + // window sat. + let mut wm = wm_with_monitor(); + wm.set_remembered_geometry("someapp".to_string(), (50, 60, 900, 700)); + let a = wm.alloc_window_id(); + let mut d = Window::new(a, "dialog"); + d.app_id = "someapp".into(); + d.is_dialog = true; + d.geometry = Rect::new(0, 0, 400, 300); + wm.add_window(d); + let g = wm.window(a).unwrap().geometry; + assert_eq!((g.x, g.y), (760, 390), "centred, not restored to (50,60)"); + } + + #[test] + fn an_ordinary_window_still_uses_its_remembered_position() { + // The dialog branch must not have stolen the normal path. + let mut wm = wm_with_monitor(); + wm.set_remembered_geometry("someapp".to_string(), (50, 60, 900, 700)); + let a = wm.alloc_window_id(); + let mut w = Window::new(a, "main"); + w.app_id = "someapp".into(); + wm.add_window(w); + let g = wm.window(a).unwrap().geometry; + assert_eq!((g.x, g.y), (50, 60)); + } + + #[test] + fn a_dialog_larger_than_the_screen_still_starts_on_screen() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + let mut d = Window::new(a, "dialog"); + d.is_dialog = true; + d.geometry = Rect::new(0, 0, 4000, 3000); + wm.add_window(d); + let g = wm.window(a).unwrap().geometry; + assert!(g.x >= 0 && g.y >= 0, "clamped to the monitor origin, got {g:?}"); + } + #[test] fn apply_snap_zone_resizes_to_the_named_zones_rect() { let mut wm = wm_with_monitor(); diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs index 3271dee..bd13b0c 100644 --- a/crates/core/src/manager/windows.rs +++ b/crates/core/src/manager/windows.rs @@ -77,8 +77,15 @@ impl WindowManager { // 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. + // + // Skipped entirely for a dialog: `remembered_geometry` is keyed by + // `app_id`, which a dialog shares with the ordinary window that + // spawned it, so reapplying that entry would resize a small + // confirmation prompt to the size of the app's main window. Its + // own requested size is the right one. See the dialog branch + // further down for the matching position half. let mut remembered_position: Option<(i32, i32)> = None; - if !window.app_id.is_empty() { + if !window.app_id.is_empty() && !window.is_dialog { 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); @@ -131,7 +138,27 @@ impl WindowManager { // 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) { + // A dialog is centred, and neither cascaded nor restored to a + // remembered spot. Reported as "even dialog/starter windows spawn + // that side, most times should be centered". + // + // Ahead of the remembered-position branch on purpose: `remembered_ + // geometry` is keyed by `app_id`, which a dialog shares with the + // ordinary window that spawned it, so a dialog would otherwise be + // dropped at wherever that app's last *main* window happened to + // sit - and would then overwrite that memory with its own small + // rect on close. Centring is also what every mainstream desktop + // does with a transient: the user's attention is already at the + // middle of the screen, not at a cascade origin. + // + // `geometry`, not `full_geometry`: a dialog centred in the usable + // area sits clear of a bar or dock, which is where a modal belongs. + if window.is_dialog { + if let Some(monitor) = target_monitor { + window.monitor = monitor.id; + window.geometry = centered_in(monitor.geometry, window.geometry.width, window.geometry.height); + } + } else if let (Some((x, y)), Some(monitor)) = (remembered_position, remembered_monitor) { window.monitor = monitor.id; window.geometry.x = x; window.geometry.y = y; |