From c4dc99cc3211c4dc1a461f228402be1593b883c9 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:18:00 +0200 Subject: 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. --- crates/core/src/lib.rs | 2 +- crates/core/src/manager/dragresize.rs | 64 +++++++++++++- crates/core/src/manager/hittest.rs | 14 +++- crates/core/src/manager/lock.rs | 43 ++++++++++ crates/core/src/manager/mod.rs | 17 +++- crates/core/src/manager/tests.rs | 154 ++++++++++++++++++++++++++++++++++ crates/core/src/manager/windows.rs | 31 ++++++- crates/core/src/placement.rs | 25 ++++++ crates/core/src/theme.rs | 19 +++++ crates/core/src/window.rs | 135 +++++++++++++++++++++-------- 10 files changed, 465 insertions(+), 39 deletions(-) (limited to 'crates/core/src') diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ed24e78..30fc020 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,7 +19,7 @@ pub use layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig}; pub use lock_config::LockConfig; pub use manager::{CaptureRequest, ColorFilter, Direction, WindowManager}; pub use monitor::{Monitor, MonitorId}; -pub use placement::{PlacementConfig, SmartPlacement, SnapZoneKind}; +pub use placement::{centered_in, PlacementConfig, SmartPlacement, SnapZoneKind, SNAP_FLYOUT_EDGE}; pub use regex::Regex; pub use rules::{WindowMatch, WindowRule, WindowRuleActions}; pub use theme::{format_hex_color, parse_hex_color, ThemeConfig}; 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 { + 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 { + 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)] @@ -40,6 +66,23 @@ mod tests { assert!(!wm.drain_lock_request(), "must not report the same request twice"); } + #[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(); 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, resize: Option, rules: Vec, @@ -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; diff --git a/crates/core/src/placement.rs b/crates/core/src/placement.rs index e2b3d04..91c317c 100644 --- a/crates/core/src/placement.rs +++ b/crates/core/src/placement.rs @@ -51,6 +51,19 @@ impl Default for PlacementConfig { } } +/// How close to a monitor's top edge the drag pointer has to get before +/// the Snap-Layouts flyout drops down, in logical pixels. +/// +/// Much larger than [`PlacementConfig::snap_threshold`] (8) on purpose, and +/// they measure different things: `snap_threshold` measures the dragged +/// *window's* edge against the screen edge and decides whether to commit a +/// snap, so it has to be tight or an ordinary reposition near the top +/// silently maximizes. This measures the *pointer* and only decides whether +/// to offer a menu, which costs nothing if ignored - the user throws the +/// cursor at the top of the screen, the way Windows 11's own gesture works, +/// and a tight band would just make it feel unreliable. +pub const SNAP_FLYOUT_EDGE: i32 = 12; + /// The six fixed screen positions offered by the Snap-Layouts flyout /// (`crates/wayland/src/snap_flyout.rs`, opened by right-clicking a /// titlebar's maximize button) - the click-driven equivalent of dragging a @@ -117,6 +130,18 @@ impl SnapZoneKind { } } +/// A `width` x `height` rect centred in `area`, clamped so it never starts +/// outside `area` even when it is larger than it. +/// +/// Used for dialogs (see `WindowManager::add_window`). Integer division +/// biases a one-pixel remainder toward the top-left, which is the standard +/// convention and invisible in practice. +pub fn centered_in(area: Rect, width: u32, height: u32) -> Rect { + let x = area.x + (area.width as i32 - width as i32) / 2; + let y = area.y + (area.height as i32 - height as i32) / 2; + Rect::new(x.max(area.x), y.max(area.y), width, height) +} + pub struct SmartPlacement; impl SmartPlacement { diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs index 4dbf96d..026688a 100644 --- a/crates/core/src/theme.rs +++ b/crates/core/src/theme.rs @@ -157,6 +157,24 @@ pub struct ThemeConfig { /// this convention's own (Windows/GNOME) maximize icon rather than /// borrowing the other convention's. pub traffic_light_buttons: bool, + /// Show only the titlebar buttons the window can actually use, rather + /// than a fixed three. + /// + /// Today that means one rule: a window whose client pinned its minimum + /// and maximum size to the same value (`Window::resizable` is `false`) + /// does not get a Maximize button, because pressing it can do nothing. + /// A dialog's Close-only titlebar is a separate, older rule that + /// applies regardless of this setting. + /// + /// On by default. A button that visibly does nothing when pressed is a + /// defect rather than a preference, and GNOME, KDE and Windows all + /// hide or disable maximize in exactly this case. `theme.button_mode = + /// "fixed"` restores the always-three behaviour for anyone who wants + /// the titlebar to look identical on every window. + /// + /// Asked for as titlebars able to use "decorations/buttons of the + /// program/dynamic". + pub dynamic_buttons: bool, } impl Default for ThemeConfig { @@ -175,6 +193,7 @@ impl Default for ThemeConfig { button_order: None, button_glyph_always: false, traffic_light_buttons: true, + dynamic_buttons: true, } } } diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs index a3d48a2..ee9fd23 100644 --- a/crates/core/src/window.rs +++ b/crates/core/src/window.rs @@ -182,6 +182,21 @@ pub struct Window { /// lights - see `hit_test`'s and `decoration::render_titlebar`'s own /// use of this for what actually changes. pub is_dialog: bool, + /// Whether the client says it can actually be resized - `false` when + /// it pinned its minimum and maximum size to the same value (a native + /// `xdg_toplevel`'s `set_min_size`/`set_max_size`, or an XWayland + /// window's ICCCM size hints). Backend-set on every decoration redraw, + /// same as `is_dialog` above, for the same reason: `core` has no + /// protocol of its own to read it from. Defaults to `true`, so a + /// client that never declares limits - the common case - is treated + /// as resizable. + /// + /// Consumed by the `dynamic` titlebar button mode: a window that + /// cannot be resized cannot meaningfully be maximized either, so + /// offering the button is offering a no-op. Every mainstream desktop + /// (GNOME, KDE, Windows) hides or disables it in exactly this case. + /// Asked for as titlebars with "buttons of the program/dynamic". + pub resizable: bool, /// `decorated`'s value from just before entering fullscreen, restored /// on exit - see `WindowManager::toggle_fullscreen`'s doc comment on /// why this can't just hardcode `true` back. @@ -286,6 +301,7 @@ impl Window { restore_geometry: None, decorated: true, is_dialog: false, + resizable: true, restore_decorated: None, floating: false, minimized: false, @@ -503,6 +519,12 @@ impl ResizeEdge { // other" trap every other button-geometry value here already has // to avoid. is_dialog: bool, + // Whether a Maximize button is shown at all - `theme. + // dynamic_buttons && !window.resizable` resolves to `false`. Same + // "must stay in exact agreement with `decoration::render_titlebar`" + // contract as `is_dialog` directly above, and for the same reason: + // the two sides compute button slots independently. + show_maximize: bool, ) -> Option { // Border strips render *outside* `frame` (`decoration:: // border_strips`, `border_width` pixels past each edge) - without @@ -579,10 +601,20 @@ impl ResizeEdge { [TitlebarButton::Close, TitlebarButton::Maximize, TitlebarButton::Minimize] }) }; + // Maximize dropped from the slot list entirely rather than + // left in place and ignored: leaving a hole would put a dead + // gap between the two remaining buttons, and the renderer + // closes the gap, so hit-testing has to close it identically + // or every button after it is offset by one slot. + let order: Vec = if show_maximize { + order.to_vec() + } else { + order.iter().copied().filter(|b| *b != TitlebarButton::Maximize).collect() + }; // A dialog only ever recognizes the first slot, matching // `decoration::render_titlebar` only ever drawing the one // button there too. - let button_count = if is_dialog { 1 } else { 3 }; + let button_count = if is_dialog { 1 } else { order.len() }; if buttons_left { let left = frame.x + BUTTON_CLUSTER_MARGIN as i32; // `x >= left` excludes the dead `BUTTON_CLUSTER_MARGIN` @@ -1000,14 +1032,14 @@ mod tests { // margin is a real dead strip now (see its own doc comment) - a // point only `5` in from the raw edge landed inside it, not on the // button. - let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Close)); } #[test] fn maximize_is_left_of_close() { let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Maximize)); } @@ -1023,12 +1055,12 @@ mod tests { // undecorated windows. let f = frame(); let maximize_spot = f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5; - let hit = ResizeEdge::hit_test(f, maximize_spot, f.y + 5, true, 0, RESIZE_MARGIN, false, None, true); + let hit = ResizeEdge::hit_test(f, maximize_spot, f.y + 5, true, 0, RESIZE_MARGIN, false, None, true, true); assert_ne!(hit, Some(TitlebarHit::Maximize), "a dialog must not have a Maximize hit zone at all"); assert_ne!(hit, Some(TitlebarHit::Minimize), "a dialog must not have a Minimize hit zone at all"); // The one real button (Close) must still be exactly where it always // is - `is_dialog` removes the other two, not shifts this one. - let close_hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, true); + let close_hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, true, true); assert_eq!(close_hit, Some(TitlebarHit::Close)); } @@ -1041,7 +1073,7 @@ mod tests { // it get used. let f = frame(); let order = [TitlebarButton::Maximize, TitlebarButton::Minimize, TitlebarButton::Close]; - let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), true); + let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), true, true); assert_eq!(hit, Some(TitlebarHit::Close)); } @@ -1053,7 +1085,7 @@ mod tests { // not just what gets drawn. let f = frame(); let order = [TitlebarButton::Close, TitlebarButton::Minimize, TitlebarButton::Maximize]; - let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), false); + let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), false, true); assert_eq!(hit, Some(TitlebarHit::Minimize)); } @@ -1066,8 +1098,8 @@ mod tests { // on. let f = frame(); let order = [TitlebarButton::Maximize, TitlebarButton::Minimize, TitlebarButton::Close]; - let left_hit = ResizeEdge::hit_test(f, f.x + BUTTON_CLUSTER_MARGIN as i32 + 5, f.y + 5, true, 0, RESIZE_MARGIN, true, Some(order), false); - let right_hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), false); + let left_hit = ResizeEdge::hit_test(f, f.x + BUTTON_CLUSTER_MARGIN as i32 + 5, f.y + 5, true, 0, RESIZE_MARGIN, true, Some(order), false, true); + let right_hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), false, true); assert_eq!(left_hit, Some(TitlebarHit::Maximize)); assert_eq!(right_hit, Some(TitlebarHit::Maximize)); } @@ -1081,17 +1113,54 @@ mod tests { // of_titlebar_resizes_not_drags` for that zone's own coverage. let f = frame(); let (cx, _) = f.center(); - let hit = ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Drag)); } #[test] fn bottom_right_corner_is_resize() { let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::BottomRight))); } + #[test] + fn a_non_resizable_window_has_no_maximize_button_to_click() { + // `show_maximize = false` - the dynamic button mode's one rule. + let f = Rect::new(0, 0, 400, 300); + // Buttons on the right, default order Close, Maximize, Minimize. + // Slot 0 is Close either way. + let slot = |i: i32| f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 * i - 1; + let hit = |x: i32, show_max: bool| ResizeEdge::hit_test(f, x, f.y + TITLEBAR_HEIGHT as i32 / 2, true, 0, RESIZE_MARGIN, false, None, false, show_max); + + assert_eq!(hit(slot(1), true), Some(TitlebarHit::Maximize), "resizable: slot 1 is Maximize"); + // With Maximize dropped, Minimize moves up into slot 1 - it must + // not leave a dead gap there. + assert_eq!(hit(slot(1), false), Some(TitlebarHit::Minimize), "non-resizable: Minimize closes the gap"); + assert_eq!(hit(slot(0), false), Some(TitlebarHit::Close), "Close stays put"); + } + + #[test] + fn a_non_resizable_window_still_has_exactly_two_buttons() { + let f = Rect::new(0, 0, 400, 300); + let slot = |i: i32| f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 * i - 1; + let hit = |x: i32| ResizeEdge::hit_test(f, x, f.y + TITLEBAR_HEIGHT as i32 / 2, true, 0, RESIZE_MARGIN, false, None, false, false); + // Slot 2 held Minimize when there were three; with two it is past + // the cluster and must be a drag, not a phantom third button. + assert_eq!(hit(slot(2)), Some(TitlebarHit::Drag), "no third button exists any more"); + } + + #[test] + fn a_dialog_is_close_only_regardless_of_the_dynamic_button_mode() { + let f = Rect::new(0, 0, 400, 300); + let slot = |i: i32| f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 * i - 1; + for show_max in [true, false] { + let hit = |x: i32| ResizeEdge::hit_test(f, x, f.y + TITLEBAR_HEIGHT as i32 / 2, true, 0, RESIZE_MARGIN, false, None, true, show_max); + assert_eq!(hit(slot(0)), Some(TitlebarHit::Close), "show_maximize={show_max}"); + assert_eq!(hit(slot(1)), Some(TitlebarHit::Drag), "a dialog has no second button, show_maximize={show_max}"); + } + } + /// The bug this guards against: an undecorated window's own content in /// its top `TITLEBAR_HEIGHT` band (Firefox's tab strip/URL bar, once /// `decorated = false` actually applies to it) was silently swallowed @@ -1105,7 +1174,7 @@ mod tests { // outside RESIZE_MARGIN, so a real resize edge can't also explain a // `None` here - undecorated, this must not be treated as // decoration (or a resize edge) at all, just plain content. - let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, None); } @@ -1113,7 +1182,7 @@ mod tests { fn undecorated_window_still_resizes_from_every_edge_including_top() { let f = frame(); let (cx, _) = f.center(); - let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top))); } @@ -1133,9 +1202,9 @@ mod tests { // resize zone at all, which is no longer true. let f = frame(); let (cx, _) = f.center(); - assert_eq!(ResizeEdge::hit_test(f, cx, f.y + 5, false, 0, RESIZE_MARGIN, false, None, false), None, "5px in: past the narrow undecorated band, must reach the client"); + assert_eq!(ResizeEdge::hit_test(f, cx, f.y + 5, false, 0, RESIZE_MARGIN, false, None, false, true), None, "5px in: past the narrow undecorated band, must reach the client"); assert_eq!( - ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false), + ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Drag), "decorated: past its own (wider) top resize margin, still plain drag" ); @@ -1151,7 +1220,7 @@ mod tests { // within the old, decorated-window-sized corner zone // (`CORNER_MARGIN * RESIZE_MARGIN` = 18px) before this fix. let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 10, f.y + 10, false, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - 10, f.y + 10, false, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, None, "past the narrow undecorated corner margin on both axes, must reach the client"); } @@ -1161,7 +1230,7 @@ mod tests { // content near - nothing about `CORNER_MARGIN`'s widening should // survive for an undecorated window at any corner. let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 10, f.bottom() - 10, false, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - 10, f.bottom() - 10, false, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, None, "past the narrow undecorated corner margin on both axes, must reach the client"); } @@ -1174,14 +1243,14 @@ mod tests { let f = frame(); let (_, cy) = f.center(); let (cx, _) = f.center(); - assert_eq!(ResizeEdge::hit_test(f, f.right() - 1, cy, false, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::Right))); - assert_eq!(ResizeEdge::hit_test(f, cx, f.bottom() - 1, false, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::Bottom))); + assert_eq!(ResizeEdge::hit_test(f, f.right() - 1, cy, false, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::Right))); + assert_eq!(ResizeEdge::hit_test(f, cx, f.bottom() - 1, false, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::Bottom))); } #[test] fn outside_frame_is_none() { let f = frame(); - assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0, RESIZE_MARGIN, false, None, false), None); + assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0, RESIZE_MARGIN, false, None, false, true), None); } #[test] @@ -1196,16 +1265,16 @@ mod tests { let border_width = 2; // One pixel into the border strip, past the left edge. let x = f.x - 1; - assert_eq!(ResizeEdge::hit_test(f, x, cy, true, 0, RESIZE_MARGIN, false, None, false), None, "sanity check: with no border, this point really is outside the window"); + assert_eq!(ResizeEdge::hit_test(f, x, cy, true, 0, RESIZE_MARGIN, false, None, false, true), None, "sanity check: with no border, this point really is outside the window"); assert_eq!( - ResizeEdge::hit_test(f, x, cy, true, border_width, RESIZE_MARGIN, false, None, false), + ResizeEdge::hit_test(f, x, cy, true, border_width, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::Left)), "one pixel into the actual drawn border must still register as the left edge" ); // Just past the border entirely (border_width + 1 outside frame) is // still nothing - the fix widens the dead zone's boundary, it // doesn't remove it. - assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width, RESIZE_MARGIN, false, None, false), None); + assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width, RESIZE_MARGIN, false, None, false, true), None); } #[test] @@ -1222,7 +1291,7 @@ mod tests { assert!(corner_reach > RESIZE_MARGIN, "the whole point of this test is that corner reach exceeds a plain edge's"); let x = f.x + corner_reach - 1; let y = f.bottom() - corner_reach + 1; - assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::BottomLeft))); + assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::BottomLeft))); } #[test] @@ -1233,7 +1302,7 @@ mod tests { // - must read as a plain bottom edge, not a corner. let x = f.x + corner_reach + 5; let y = f.bottom() - 1; - assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::Bottom))); + assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::Bottom))); } #[test] @@ -1244,7 +1313,7 @@ mod tests { // `resize_edge_at` never even ran for a y inside the titlebar. let f = frame(); let corner_reach = CORNER_MARGIN * RESIZE_MARGIN; - let hit = ResizeEdge::hit_test(f, f.x + corner_reach - 1, f.y + corner_reach - 1, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.x + corner_reach - 1, f.y + corner_reach - 1, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::TopLeft))); } @@ -1259,7 +1328,7 @@ mod tests { // the raw corner itself now sits in that real dead strip (see its // own doc comment), which correctly falls through to drag/resize, // not Close. - let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 1, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 1, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Close)); } @@ -1276,7 +1345,7 @@ mod tests { // frame's own edge, nowhere near where Close's hitbox starts) and // within `DECORATED_TOP_RESIZE_MARGIN` vertically. let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::TopRight))); } @@ -1289,7 +1358,7 @@ mod tests { // this only ever claims the true corner, not the whole column // beside Close. let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Drag)); } @@ -1300,7 +1369,7 @@ mod tests { // corner, so the dead strip and its own corner-resize target move // to the frame's left edge instead. let f = frame(); - let hit = ResizeEdge::hit_test(f, f.x + 1, f.y + 1, true, 0, RESIZE_MARGIN, true, None, false); + let hit = ResizeEdge::hit_test(f, f.x + 1, f.y + 1, true, 0, RESIZE_MARGIN, true, None, false, true); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::TopLeft))); } @@ -1316,7 +1385,7 @@ mod tests { // boxes, so this is testing the plain top edge specifically. let f = frame(); let x = f.x + f.width as i32 / 2; - let hit = ResizeEdge::hit_test(f, x, f.y + DECORATED_TOP_RESIZE_MARGIN - 1, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, x, f.y + DECORATED_TOP_RESIZE_MARGIN - 1, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top))); } @@ -1328,7 +1397,7 @@ mod tests { // silently grown a resize zone everywhere. let f = frame(); let x = f.x + f.width as i32 / 2; - let hit = ResizeEdge::hit_test(f, x, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, x, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Drag)); } @@ -1341,7 +1410,7 @@ mod tests { // avoid. Right-aligned close button's box starts at `right - 30`; // well inside it, at the very top row. let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 15, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false); + let hit = ResizeEdge::hit_test(f, f.right() - 15, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false, true); assert_eq!(hit, Some(TitlebarHit::Close)); } -- cgit v1.2.3