diff options
| author | srdusr <[email protected]> | 2026-04-27 00:18:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-04-27 00:18:00 +0200 |
| commit | c4dc99cc3211c4dc1a461f228402be1593b883c9 (patch) | |
| tree | 10a85d60acb863ca98b7e70118dec5cda357efa6 /crates/core/src/manager | |
| parent | 436d42da6ef61a5ea20d5102c4baed7bf0993606 (diff) | |
| download | srdwm-c4dc99cc3211c4dc1a461f228402be1593b883c9.tar.gz srdwm-c4dc99cc3211c4dc1a461f228402be1593b883c9.zip | |
Build the eight asks recovered from the previous session's transcript
The punch list was not the whole record. These are the owner's own typed
requests, read back out of the previous session's transcript rather than
guessed at, then each checked against the code before being treated as open.
Three they suspected were already done really were: dialogs already had a
Close-only titlebar, inactive dimming already existed, and the corner resize
hitbox had already been tuned.
Snap layouts on drag, asked for twice. Edge snapping worked but committed
silently on release with nothing shown first, so there was no way to know it
would happen or where. A translucent drop-target preview now follows the
drag, and throwing the pointer at a monitor's top edge drops down the
existing six-cell grid to aim at. The preview calls the same snap_zone that
end_drag does, so the two cannot disagree. Two defects found by screenshot
before landing: moving down onto the flyout closed it, and its labels
overflowed at a fixed cell width - the same "text goes out of view" fault
already fixed once for the context menu.
New File now offers real types, chosen by extension, with the de-duplication
counter placed before the extension so the file stays what it says it is.
Refresh re-reads init.lua and fires a new srd.on("refresh") handler instead
of only re-scanning the icon grid. What refresh means beyond srdwm's own
config stays the config's decision.
general.config_reload_on_write (default on) applies an edited config on save,
via an mtime sweep rather than an inotify watch: no new dependency, same
behaviour on every target, and unaffected by editors that write through a
temp file.
A real bug behind "what happens when our config fails": you lost every
keybinding. do_reload cleared the binding, handler and repeat tables before
re-executing and never restored them, so a syntax error left neither the old
config nor the new one, and the only key still working was the reload combo
nobody thinks to press. The tables are now restored on any failure and
config errors reach notify-send, not just the log.
srd.lock() and a default Mod4+Ctrl+l binding: the built-in lock screen could
not be reached from Lua at all. Native rather than shelling out, because a
lock key that shells out fails silently when the binary is not on PATH.
Default bindings added for srd.window.move and a dynamic/tiling toggle, both
of which existed with no way to reach them, plus srd.layout.get() so the
toggle reads the live workspace rather than the configured default.
Dialogs open centred, and are excluded from remembered geometry in both
directions - that table is keyed by app_id, which a dialog shares with the
window that spawned it, so dialogs inherited an unrelated position and size
and then overwrote it with their own.
theme.decorations.title_bar.button_mode (dynamic by default, or fixed) drops
the Maximize button on a window whose client pinned min == max size, where
pressing it can do nothing. Maximize is removed from the slot list rather
than skipped in place on both the render and hit-test sides, so the remaining
buttons close the gap identically; three tests pin that agreement, which is
what fails silently when it drifts.
Also: the nested backend's screencopy pass now draws both menus, the flyout
and the drag preview. Four investigations in one day started from a
screenshot missing a tier, so that pass carries an explicit list of what it
still omits and the on-screen loop points at it.
512 tests pass, clippy clean.
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; |