diff options
| author | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
| commit | 0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch) | |
| tree | 7672d0af277664f457c6c9462925c0005fe35dcf /crates/core/src | |
| parent | 413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff) | |
| download | srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip | |
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has
diverged with its own separate fixes today. Nothing here is reviewed
or curated yet - this exists purely so none of this work can be lost
to a git operation, disk issue, or worktree cleanup while that
reconciliation happens.
Diffstat (limited to 'crates/core/src')
| -rw-r--r-- | crates/core/src/lib.rs | 7 | ||||
| -rw-r--r-- | crates/core/src/manager/dragresize.rs | 54 | ||||
| -rw-r--r-- | crates/core/src/manager/hittest.rs | 53 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 182 | ||||
| -rw-r--r-- | crates/core/src/manager/monitors.rs | 171 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 395 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 59 | ||||
| -rw-r--r-- | crates/core/src/manager/workspaces.rs | 115 | ||||
| -rw-r--r-- | crates/core/src/monitor.rs | 239 | ||||
| -rw-r--r-- | crates/core/src/theme.rs | 122 | ||||
| -rw-r--r-- | crates/core/src/window.rs | 643 |
11 files changed, 1922 insertions, 118 deletions
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 56f8779..7905504 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -15,11 +15,14 @@ pub use event::{canonicalize_key_combo, key_combo_string, parse_key_combo, Event pub use geometry::Rect; pub use layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig}; pub use lock_config::LockConfig; -pub use manager::{CaptureRequest, Direction, WindowManager}; +pub use manager::{CaptureRequest, ColorFilter, Direction, WindowManager}; pub use monitor::{Monitor, MonitorId}; pub use placement::{PlacementConfig, SmartPlacement, SnapZoneKind}; pub use regex::Regex; pub use rules::{WindowMatch, WindowRule, WindowRuleActions}; pub use theme::{parse_hex_color, ThemeConfig}; -pub use window::{classify_menu_source, GlobalMenu, MenuSource, ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN, TITLEBAR_HEIGHT}; +pub use window::{ + classify_menu_source, parse_button_order, ButtonOrder, GlobalMenu, MenuSource, ResizeEdge, TitlebarButton, TitlebarHit, Window, WindowId, + BUTTON_CLUSTER_MARGIN, BUTTON_PITCH, RESIZE_MARGIN, TITLEBAR_HEIGHT, +}; pub use workspace::{Workspace, WorkspaceId}; diff --git a/crates/core/src/manager/dragresize.rs b/crates/core/src/manager/dragresize.rs index 06a6db9..b3132a5 100644 --- a/crates/core/src/manager/dragresize.rs +++ b/crates/core/src/manager/dragresize.rs @@ -28,7 +28,16 @@ impl WindowManager { // maximize avoid it. Clamping a drag to the shrunk usable area // made it physically impossible to ever drag a window past a // dock, at any speed or angle. - let monitor_bounds = self.windows.get(&drag.window).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.full_geometry); + // + // `all_monitors_bounds`, not `monitor_for(w.monitor)` (the window's + // own *starting* monitor, looked up once and never updated as the + // drag moves) - see that function's own doc comment for the real + // multi-monitor bug this fixes: the old single-monitor clamp made + // it mathematically impossible to ever drag a window from one + // monitor onto another, confirmed live with two real monitors + // connected, one of them otherwise fully working at the + // compositor/DRM level. + let monitor_bounds = self.all_monitors_bounds(); if let Some(bounds) = monitor_bounds { new_geom.x = new_geom.x.clamp(bounds.x - new_geom.width as i32 + 40, bounds.right() - 40); new_geom.y = new_geom.y.clamp(bounds.y, bounds.bottom() - 40); @@ -43,6 +52,25 @@ impl WindowManager { /// near a monitor edge. pub fn end_drag(&mut self) { if let Some(drag) = self.drag.take() { + // `w.monitor` only ever gets set at window creation (or by + // `set_monitors`, reactively, on the *next* hotplug) - a drag + // that crossed onto a different monitor leaves it stale + // pointing at wherever the window *started*, same gap + // `set_monitors`'s own doc comment already documents for the + // hotplug-rehoming case. Corrected here, before computing the + // snap zone below, not after - using the stale value there + // would check the *wrong* monitor's snap zones (e.g. still + // snapping against monitor 1's left edge for a window that's + // now actually sitting near monitor 2's), the same bug this is + // fixing for maximize/fullscreen one level up. + if let Some(w) = self.windows.get(&drag.window) { + if let Some(now_on) = self.monitors.iter().find(|m| m.geometry.overlaps(&w.geometry)) { + let now_on_id = now_on.id; + if let Some(w) = self.windows.get_mut(&drag.window) { + w.monitor = now_on_id; + } + } + } let snapped = self.windows.get(&drag.window).and_then(|w| { self.monitor_for(w.monitor).and_then(|m| SmartPlacement::snap_zone(w.geometry, m, &self.placement)) }); @@ -73,6 +101,20 @@ impl WindowManager { } pub fn end_resize(&mut self) { + // Remembers this app's new size for its *next* window - see + // `remembered_sizes`' own doc comment for why this is the one + // resize-ending path that updates it (not maximize/fullscreen, not + // a drag-to-edge snap). Keyed by `app_id`, so a window that never + // got one (a backend/client that hasn't reported it yet) simply + // isn't remembered - no worse than today, and consistent with how + // window rules already treat an empty `app_id` as unmatchable. + 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.resize = None; } @@ -80,6 +122,16 @@ impl WindowManager { self.resize.is_some() } + /// Which window is currently being interactively resized, if any - so + /// a backend can skip an expensive-but-cosmetic per-window effect + /// (content corner-masking, concretely - see its own call site's + /// comment) for just that one window while its content is reflowing + /// on every single frame, without touching every *other* window's own + /// masking. + pub fn resizing_window(&self) -> Option<WindowId> { + self.resize.as_ref().map(|r| r.window) + } + /// The edge currently being dragged, if a resize is in progress - so a /// backend can keep showing the matching resize cursor for the whole /// drag, not just while the pointer happens to still be hovering that diff --git a/crates/core/src/manager/hittest.rs b/crates/core/src/manager/hittest.rs index 06ac659..ab19a5c 100644 --- a/crates/core/src/manager/hittest.rs +++ b/crates/core/src/manager/hittest.rs @@ -20,14 +20,65 @@ impl WindowManager { /// happened to occupy the same screen coordinates sent the click to the /// invisible one. pub fn hit_test(&self, x: i32, y: i32) -> Option<(WindowId, TitlebarHit)> { + self.hit_test_with(x, y, |_, geometry| geometry) + } + + /// Same as [`Self::hit_test`], but lets the caller substitute a + /// different rect than `w.geometry` for whichever window is being + /// tested - `geometry_for(id, w.geometry)` is called once per window in + /// the same topmost-first order, and its return value is what actually + /// gets tested instead of `w.geometry` directly. + /// + /// This exists for exactly one reason: a backend that animates window + /// geometry (currently only the Wayland one, via `window_anims` in + /// `CompState`) draws the border/titlebar at the *interpolated* rect + /// every frame (`WindowAnim::current_rect`), but `w.geometry` here is + /// always the animation's *target* - core has no concept of animation + /// at all, deliberately (`Window.geometry` is meant to be the single + /// source of truth every other subsystem reads). Calling plain + /// `hit_test` during an active animation (toggling maximize/fullscreen, + /// a Snap-Layouts zone, or a new window's open-slide - see + /// `WindowManager::toggle_maximize`/`apply_snap_zone`/ + /// `toggle_fullscreen` for where `anim_from` gets set) meant the + /// decoration/resize-margin hit-test used the window's *final* position + /// while the border was still visibly animating toward it - reported + /// live as "the border isn't always truly on the edge of the window", + /// i.e. hovering what you can see as the edge doesn't match what's + /// actually clickable there for as long as `animation_duration_ms` + /// (200ms by default) hasn't elapsed since the last toggle/snap/open. + /// Content clicks never had this problem - `space.map_element` already + /// maps the client's surface at the same interpolated rect the border + /// draws at (`state/geometry.rs::sync_geometry`), so `space.element_ + /// under` and the border were already agreeing with each other; only + /// this compositor's own decoration hit-test was reading a different + /// number than what it was drawing on screen. + pub fn hit_test_with(&self, x: i32, y: i32, geometry_for: impl Fn(WindowId, Rect) -> Rect) -> Option<(WindowId, TitlebarHit)> { for w in self.order.iter().rev().filter_map(|id| self.windows.get(id)) { if w.minimized || w.workspace != self.current_workspace { continue; } let margin = w.resize_margin.unwrap_or(self.resize_margin); - if let Some(hit) = ResizeEdge::hit_test(w.geometry, x, y, w.decorated, w.border_width, 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) { return Some((w.id, hit)); } + // Not a titlebar/border/resize-margin hit on `w` - but if the + // point still falls inside `w`'s own plain content rect, `w`'s + // real, opaque content is what's actually drawn there (this is + // topmost-first order, so nothing checked so far is above it), + // and continuing the loop into a *lower* window's own border/ + // resize zone at this same point would return a hit for + // something the user cannot see or reach - `w`'s content is + // in the way regardless of whether `w` itself claimed the + // point as one of its own edges. Reported live as being able + // to grab a resize edge, or trigger a titlebar-adjacent action, + // on a window fully covered by another one on top of it. + // Content clicks (the content-hit branch in `input.rs`) are + // unaffected - they already resolve via `Space::element_under`, + // smithay's own real Z-order, which never had this gap. + if geometry.contains_point(x, y) { + return None; + } } None } diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs index 38e0bbb..74be06b 100644 --- a/crates/core/src/manager/mod.rs +++ b/crates/core/src/manager/mod.rs @@ -1,11 +1,11 @@ use crate::geometry::Rect; use crate::layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig}; -use crate::monitor::{Monitor, MonitorId}; +use crate::monitor::{DisabledMonitor, Monitor, MonitorId, MonitorSplit}; use crate::placement::{PlacementConfig, SmartPlacement, SnapZoneKind, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH}; use crate::rules::WindowRule; use crate::lock_config::LockConfig; use crate::theme::ThemeConfig; -use crate::window::{ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN}; +use crate::window::{likely_draws_own_titlebar, ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN}; use crate::workspace::{Workspace, WorkspaceId}; use std::collections::HashMap; @@ -17,6 +17,25 @@ pub enum Direction { Down, } +/// A whole-screen colour treatment, drawn by each Wayland backend as a +/// translucent full-output overlay above every window but below the +/// cursor - see `srdwm_wayland::color_filter` for the actual overlay +/// colour/alpha each variant maps to, and why an alpha-blended overlay +/// rather than a true per-pixel shader was chosen at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ColorFilter { + #[default] + None, + /// Warm tint, reduces perceived blue light. Ported from a Hyprland + /// `decoration:screen_shader` config that multiplied the framebuffer + /// by `vec3(1.0, 0.82, 0.60)`. + NightLight, + /// Desaturating tint, for reduced visual noise during long-form + /// reading. Ported from a Hyprland `decoration:screen_shader` config + /// that replaced every pixel with its own luminance (flat grayscale). + ReadingMode, +} + struct DragState { window: WindowId, start_x: i32, @@ -55,6 +74,31 @@ 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 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)>, + /// The opposite direction of `output_enable_requests`: not a request + /// *to* the backend, but the backend *reporting* an administratively- + /// disabled-but-still-connected output's last-known state, purely for + /// listing purposes - see `set_disabled_monitor`'s own doc comment + /// for why this deliberately never touches `monitors`/real placement + /// at all. + disabled_monitors: HashMap<String, DisabledMonitor>, + /// `srd.monitor.split(name, parts, direction)` requests, by connector + /// name - read by a backend's own `monitors()` query to divide one + /// real output's rectangle into several logical `Monitor` entries. See + /// [`MonitorSplit`]'s own doc comment for what this deliberately does + /// and does not give a client (no new `wl_output`). + monitor_splits: HashMap<String, MonitorSplit>, + /// `srd.monitor.scale(name, factor)` requests, by connector name -- + /// read once by a backend when it brings a head up (startup, hotplug, + /// or re-enable), so a physically large, low-DPI monitor can run + /// below `1.0` to show more logical desktop space instead of just + /// larger text at the same pixel count. srdwm otherwise always drove + /// every real output at a hardcoded `1.0`, with no way to change that + /// short of a client speaking wlr-output-management itself. + monitor_scales: HashMap<String, f64>, + /// Same cross-boundary-request pattern as `output_position_requests` /// just above - core has no way to actually blank the screen and /// start drawing srdwm's own lock UI itself (that's real compositor /// rendering, backend-owned), so an IPC `"lock"` dispatch queues the @@ -68,11 +112,15 @@ pub struct WindowManager { /// screencopy protocol can see). capture_requests: Vec<capture::CaptureRequest>, workspaces: Vec<Workspace>, - /// One flat value shared by every monitor - not per-output. Unlike - /// Hyprland, srdwm has no notion of an independent workspace set per - /// monitor; switching workspace changes what's visible on every screen - /// at once. See `visible_windows`'s doc comment for the filter this - /// actually drives. + /// The shared-mode value, used directly when `per_monitor_workspaces` + /// is `false` (the default - unlike Hyprland, srdwm's original design + /// has no notion of an independent workspace set per monitor; + /// switching workspace changes what's visible on every screen at + /// once). Still meaningful even when `per_monitor_workspaces` is `true` + /// - it's the fallback `workspace_for_monitor` returns for a monitor + /// that has never had its own workspace switched independently yet, + /// and what a plain `current_workspace()` call reports either way. See + /// `visible_windows`'s doc comment for the filter this actually drives. current_workspace: WorkspaceId, /// Whichever workspace was current immediately before the current one /// became current - see `switch_workspace`'s doc comment. @@ -82,6 +130,34 @@ pub struct WindowManager { /// instead - sway's `workspace_auto_back_and_forth` behavior, a quick /// "jump back to whatever I was just on" toggle on a single keybinding. pub auto_back_and_forth: bool, + /// Read from `workspace.per_monitor` - `false` (the default) keeps + /// srdwm's original single-shared-workspace design exactly as it was; + /// `true` switches to Hyprland/niri-style independent per-monitor + /// workspace sets, where each monitor tracks and displays its own + /// current workspace, switchable without affecting any other monitor. + /// Explicitly requested as a configurable choice, not a hardcoded + /// switch to one model or the other - see `workspace_for_monitor` and + /// `switch_workspace_on_monitor` for what this actually gates. + pub per_monitor_workspaces: bool, + /// Read from `monitor.primary_layout`/`monitor.secondary_layout` -- + /// validated/defaulted config keys that were never read anywhere + /// before (same dead-config shape as `general.default_layout`'s own + /// siblings). Empty string means "not set, no override". Applied by + /// `set_monitors` to whichever workspace `workspace_for_monitor` + /// resolves for each connected monitor - which only ever *differs* + /// between monitors when `per_monitor_workspaces` is `true` (every + /// monitor shares one workspace otherwise, so a primary/secondary + /// split has nothing distinct to apply to and is skipped). + pub primary_layout: String, + pub secondary_layout: String, + /// Each monitor's own current workspace, when `per_monitor_workspaces` + /// is `true`. A monitor with no entry here yet (never had its + /// workspace switched independently - e.g. right after the mode was + /// turned on, or a newly connected monitor) falls back to + /// `current_workspace`, the same shared value shared-mode always uses + /// - see `workspace_for_monitor`. Unused, and left empty, whenever + /// `per_monitor_workspaces` is `false`. + monitor_workspaces: HashMap<MonitorId, WorkspaceId>, next_workspace_id: WorkspaceId, next_window_id: WindowId, layouts: HashMap<String, Box<dyn Layout>>, @@ -113,6 +189,14 @@ pub struct WindowManager { /// redraws constantly - see `crates/wayland/src/rounded_corners.rs`). /// `Some(_)` only when the user explicitly set it, and wins either way. pub rounded_corners_enabled: Option<bool>, + /// The whole-screen colour treatment currently active (night light's + /// warm tint or reading mode's desaturation), live-settable via `srd + /// set night_light`/`srd set reading_mode` - see [`ColorFilter`]. Off + /// by default; the two are mutually exclusive by construction (one + /// enum, not two independent bools), matching the ported Hyprland + /// scripts this replaces, which pointed the same single + /// `screen_shader` slot at one file or the other. + pub color_filter: ColorFilter, /// Whether hovering a window (no click needed) focuses it, read from /// `general.focus_follows_mouse`. Off by default - matches /// `general.focus_follows_mouse`'s own documented default, and every @@ -133,6 +217,23 @@ 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)>, /// 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 @@ -155,6 +256,23 @@ pub struct WindowManager { /// `close_requests`, for the same reason: `WindowManager` has no real /// keyboard/seat handle of its own to cycle. keyboard_layout_cycle_requests: u32, + /// Which monitor the pointer is currently over, as last reported by + /// `set_pointer_monitor` - core has no pointer of its own (backend- + /// agnostic, same reason `close_requests` exists instead of a direct + /// client call), so a real backend's own pointer-motion handler is the + /// only thing that can ever know this. `add_window`'s own target- + /// monitor fallback chain reads it: a new window already preferred the + /// *focused* window's monitor over the primary one (see that fix's own + /// doc comment, `add_window`) - correct when something is focused on + /// the monitor the user is actually at, but not when nothing is (an + /// empty desktop there, or the last-focused window happens to sit on a + /// *different* monitor than the one the user is currently pointing at + /// while launching something new). Reported live: opening an + /// application while on a non-primary monitor with nothing focused + /// there still opened it on the primary one. `None` until the first + /// real pointer-motion event arrives (matches `focused`'s own `None`- + /// until-something-happens shape). + pointer_monitor: Option<MonitorId>, } impl Default for WindowManager { @@ -176,13 +294,52 @@ impl WindowManager { focused: None, monitors: Vec::new(), output_position_requests: Vec::new(), + output_enable_requests: Vec::new(), + disabled_monitors: HashMap::new(), + monitor_splits: HashMap::new(), + monitor_scales: HashMap::new(), lock_requested: false, capture_requests: Vec::new(), - workspaces: vec![Workspace::new(0, "1", "dynamic")], - current_workspace: 0, - previous_workspace: 0, + // 1-based, not 0-based: workspace ids match the human-visible + // numbers (`workspace.names` defaults to "1".."9","0", + // `apply_workspace_count` names workspace `i+1` "i+1") - an id + // of `0` for the first workspace, with everything display-side + // calling it "1", was a standing off-by-one between what a user + // types/sees and the id `srd dispatch activate workspace <n>` + // (and AGS's workspace switcher, which sends the same number it + // shows) actually has to send. Matches how Hyprland's own + // workspace ids already work (natively 1-based, no translation + // layer needed) rather than niri's split id/idx or the + // 0-based-plus-AGS-side-`+1` scheme this used to be - both + // AGS integrations for those two compositors were checked + // before choosing this, and neither needs hand-rolled offset + // arithmetic the way srdwm's old 0-based ids forced `lib/ + // srdwm.ts` to. + // + // Rolling this out requires `crates/config`'s shipped default, + // this user's own `~/.config/srd/keybindings.lua`, and AGS's + // `lib/srdwm.ts`/`service/wsPreview.ts` to all agree with core + // at the same time - they cannot update atomically with a + // single srdwm restart, so whichever of AGS/srdwm is running + // the *other* scheme during that window will visibly + // misbehave (confirmed live: AGS's Overview padding + // `workspace.count` slots and matching real workspaces onto + // them by id showed one extra/unmatched slot while AGS's own + // code had already been updated to assume 1-based ids but the + // live srdwm process was still 0-based). AGS's side is + // deliberately reverted back to its old `+1` offset for now, + // matching the still-running old build, and must be re-applied + // in the same breath as the next real srdwm restart - not + // before. + workspaces: vec![Workspace::new(1, "1", "dynamic")], + current_workspace: 1, + previous_workspace: 1, auto_back_and_forth: false, - next_workspace_id: 1, + per_monitor_workspaces: false, + primary_layout: String::new(), + secondary_layout: String::new(), + monitor_workspaces: HashMap::new(), + next_workspace_id: 2, next_window_id: 1, layouts, tiling: TilingConfig::default(), @@ -192,6 +349,7 @@ impl WindowManager { shadows_enabled: true, resize_margin: RESIZE_MARGIN, rounded_corners_enabled: None, + color_filter: ColorFilter::None, focus_follows_mouse: false, auto_raise: false, theme: ThemeConfig::default(), @@ -199,9 +357,11 @@ impl WindowManager { drag: None, resize: None, rules: Vec::new(), + remembered_sizes: HashMap::new(), close_requests: Vec::new(), keyboard_layout: String::new(), keyboard_layout_cycle_requests: 0, + pointer_monitor: None, } } diff --git a/crates/core/src/manager/monitors.rs b/crates/core/src/manager/monitors.rs index 9783e14..bff3bc9 100644 --- a/crates/core/src/manager/monitors.rs +++ b/crates/core/src/manager/monitors.rs @@ -82,6 +82,48 @@ impl WindowManager { window.geometry = target; } } + self.apply_monitor_layouts(); + } + + /// Applies `primary_layout`/`secondary_layout` to whichever workspace + /// [`Self::workspace_for_monitor`] resolves for the primary monitor, + /// and for any other monitor that has *already* been given its own + /// distinct workspace via an independent switch - see those fields' + /// own doc comments for why this is a no-op outside `per_monitor_ + /// workspaces` mode. Deliberately skips a non-primary monitor still + /// showing the same fallback workspace as the primary (nothing + /// distinct to apply `secondary_layout` to yet without also + /// clobbering what `primary_layout` just set on that same shared + /// workspace). + /// + /// Runs on every `set_monitors` call (startup and every hotplug + /// alike) rather than on every workspace switch - applying it + /// continuously would fight a workspace's own manually-set layout + /// every time a monitor switched back to it. + fn apply_monitor_layouts(&mut self) { + if !self.per_monitor_workspaces || (self.primary_layout.is_empty() && self.secondary_layout.is_empty()) { + return; + } + let Some(primary_id) = self.primary_monitor().map(|m| m.id) else { return }; + let primary_ws = self.workspace_for_monitor(primary_id); + let registered: Vec<String> = self.available_layouts().iter().map(|s| s.to_string()).collect(); + if !self.primary_layout.is_empty() && registered.contains(&self.primary_layout) { + self.set_layout(primary_ws, self.primary_layout.clone()); + } + if self.secondary_layout.is_empty() || !registered.contains(&self.secondary_layout) { + return; + } + let monitors = self.monitors.clone(); + for m in &monitors { + if m.id == primary_id { + continue; + } + let ws = self.workspace_for_monitor(m.id); + if ws == primary_ws { + continue; + } + self.set_layout(ws, self.secondary_layout.clone()); + } } pub fn monitors(&self) -> &[Monitor] { @@ -117,6 +159,98 @@ impl WindowManager { std::mem::take(&mut self.output_position_requests) } + /// Queues a request to enable or disable the output named `name` -- + /// "primary only"/a per-display toggle, the two AGS monitor-layout + /// panel rows gated pending this. Same "core has no real output + /// handle, the backend drains and applies on its own next poll" shape + /// as `request_output_position` above, and the same reasoning: + /// turning a real CRTC's power state on or off is backend/hardware + /// work, not something this crate can do itself. + /// + /// By *name*, not `MonitorId` like `request_output_position` - a + /// disabled output is administratively removed from `monitors()` + /// entirely (the same real unplug/replug code path a genuine hotplug + /// already goes through, see the udev platform's own drain site), so + /// its id - an index into whatever's currently connected - stops + /// meaning anything the moment it's disabled. The connector's own + /// name survives the round trip; nothing else does. + pub fn request_output_enabled(&mut self, name: String, enabled: bool) { + self.output_enable_requests.retain(|(existing, _)| *existing != name); + self.output_enable_requests.push((name, enabled)); + } + + /// [`Self::drain_output_position_requests`]'s counterpart for + /// enable/disable requests. + pub fn drain_output_enable_requests(&mut self) -> Vec<(String, bool)> { + std::mem::take(&mut self.output_enable_requests) + } + + /// Reports (or updates) `name`'s last-known state as an + /// administratively-disabled-but-still-connected output - called by + /// the backend at the moment it disables a connector, purely so `srd + /// monitors`/the `monitors` subscribe event can keep listing it (as + /// requested directly by the AGS peer session: a control that removes + /// its own target from view the moment it's used is one-way, not a + /// toggle). Deliberately separate from `monitors`/`set_monitors` -- + /// see `DisabledMonitor`'s own doc comment for why this must never + /// touch real placement. + pub fn set_disabled_monitor(&mut self, name: String, geometry: Rect, full_geometry: Rect, primary: bool) { + self.disabled_monitors.insert(name, DisabledMonitor { geometry, full_geometry, primary }); + } + + /// Clears `name`'s disabled-monitor record - called by the backend + /// once it re-enables the connector (it's live again, `monitors()` + /// itself will report it) or discovers it's been genuinely unplugged + /// while disabled (nothing left to offer re-enabling at all; see + /// `reprobe_outputs`'s own doc comment on why "off" and "not + /// connected" have to be reported differently). + pub fn clear_disabled_monitor(&mut self, name: &str) { + self.disabled_monitors.remove(name); + } + + /// Every currently-known disabled-but-connected output, by name - see + /// `set_disabled_monitor`'s own doc comment. + pub fn disabled_monitors(&self) -> impl Iterator<Item = (&str, &DisabledMonitor)> { + self.disabled_monitors.iter().map(|(name, m)| (name.as_str(), m)) + } + + /// `srd.monitor.split(name, parts, direction)` - divides connector + /// `name`'s real output into `parts` equal logical monitors from the + /// next time a backend queries `monitors()`. `parts <= 1` clears any + /// existing split for `name` rather than storing a meaningless + /// one-part split. + pub fn set_monitor_split(&mut self, name: String, parts: u32, rows: bool) { + if parts <= 1 { + self.monitor_splits.remove(&name); + } else { + self.monitor_splits.insert(name, MonitorSplit { parts, rows }); + } + } + + /// `name`'s current split request, if any - read by a backend's own + /// `monitors()` query. + pub fn monitor_split(&self, name: &str) -> Option<MonitorSplit> { + self.monitor_splits.get(name).copied() + } + + /// `srd.monitor.scale(name, factor)` - a backend applies this the + /// next time it brings connector `name`'s head up (startup, hotplug, + /// or re-enable). `factor <= 0.0` clears any existing override rather + /// than storing a meaningless non-positive scale. + pub fn set_monitor_scale(&mut self, name: String, factor: f64) { + if factor > 0.0 { + self.monitor_scales.insert(name, factor); + } else { + self.monitor_scales.remove(&name); + } + } + + /// `name`'s current scale override, if any - read by a backend when + /// bringing that connector's head up. + pub fn monitor_scale(&self, name: &str) -> Option<f64> { + self.monitor_scales.get(name).copied() + } + pub fn primary_monitor(&self) -> Option<&Monitor> { self.monitors.iter().find(|m| m.primary).or_else(|| self.monitors.first()) } @@ -125,4 +259,41 @@ impl WindowManager { self.monitors.iter().find(|m| m.id == id).or_else(|| self.primary_monitor()) } + /// Records which monitor the pointer is over right now - see `pointer_ + /// monitor`'s own doc comment for why core needs to be told this rather + /// than knowing it already, and `add_window`'s target-monitor fallback + /// chain for the one thing it's actually used for. Called from a real + /// backend's pointer-motion handler; never `srd`/IPC-driven (nothing + /// external has a legitimate reason to claim where the pointer is). + pub fn set_pointer_monitor(&mut self, id: Option<MonitorId>) { + self.pointer_monitor = id; + } + + /// The bounding rect of every registered monitor's own `full_geometry` + /// combined - the whole multi-monitor desktop's real screen area, not + /// just one output's. `None` only when there are no monitors at all + /// (never true in practice once startup has run). + /// + /// Exists specifically so `update_drag` can clamp a dragged window to + /// "somewhere on some real screen" instead of "within the one monitor + /// it happened to start the drag on" - the latter (what this + /// replaced) made it *mathematically impossible* to drag a window from + /// one monitor to another at all: the clamp bounds were computed once, + /// from `w.monitor` at drag-start, and never updated as the drag + /// crossed into a different monitor's own screen space, so `new_geom.x` + /// could never exceed the starting monitor's own right edge no matter + /// how far or fast the pointer moved. Reported live: a second monitor + /// connected and fully working at the compositor/DRM level (`srd + /// monitors` listed it, hotplug brought it up) still couldn't receive + /// a dragged window at all. + pub(super) fn all_monitors_bounds(&self) -> Option<Rect> { + self.monitors.iter().map(|m| m.full_geometry).reduce(|a, b| { + let x = a.x.min(b.x); + let y = a.y.min(b.y); + let right = a.right().max(b.right()); + let bottom = a.bottom().max(b.bottom()); + Rect::new(x, y, (right - x) as u32, (bottom - y) as u32) + }) + } + } diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs index aa2fb1a..9af0181 100644 --- a/crates/core/src/manager/tests.rs +++ b/crates/core/src/manager/tests.rs @@ -154,6 +154,114 @@ } #[test] + fn ending_a_resize_remembers_the_new_size_for_the_apps_next_window() { + let mut wm = wm_with_monitor(); + // Tiling layout, so `add_window` skips `SmartPlacement`'s grid/ + // cascade sizing entirely and the asserted geometry below reflects + // only the remembered-size lookup itself, not incidental grid math. + 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_resize(a, ResizeEdge::BottomRight, 400, 300); + wm.update_resize(500, 400); + wm.end_resize(); + + let b = wm.alloc_window_id(); + let mut w2 = Window::new(b, "b"); + w2.app_id = "alacritty".into(); + // Whatever a backend would have hardcoded before calling add_window -- + // the remembered size must win over this, not just supplement it. + w2.geometry = Rect::new(0, 0, 800, 600); + wm.add_window(w2); + let placed = wm.window(b).unwrap().geometry; + assert_eq!((placed.width, placed.height), (400, 300), "the second alacritty window must open at the size the first was resized to"); + } + + #[test] + fn remembered_size_is_keyed_by_app_id_not_shared_across_different_apps() { + let mut wm = wm_with_monitor(); + // Tiling layout, so `add_window` skips `SmartPlacement`'s grid/ + // cascade sizing entirely and the asserted geometry below reflects + // only the remembered-size lookup itself, not incidental grid math. + 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_resize(a, ResizeEdge::BottomRight, 400, 300); + wm.update_resize(500, 400); + wm.end_resize(); + + let b = wm.alloc_window_id(); + let mut w2 = Window::new(b, "b"); + w2.app_id = "firefox".into(); + w2.geometry = Rect::new(0, 0, 800, 600); + wm.add_window(w2); + let placed = wm.window(b).unwrap().geometry; + assert_eq!((placed.width, placed.height), (800, 600), "a different app's default size must be untouched by alacritty's remembered size"); + } + + #[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 + // are not "a size the user wants their next window to open at". + let mut wm = wm_with_monitor(); + // Tiling layout, so `add_window` skips `SmartPlacement`'s grid/ + // cascade sizing entirely and the asserted geometry below reflects + // only the remembered-size lookup itself, not incidental grid math. + 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.toggle_maximize(a); + wm.toggle_maximize(a); + + 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.width, placed.height), (800, 600), "maximize/unmaximize alone must not have remembered anything"); + } + + #[test] + fn a_rules_explicit_geometry_still_wins_over_a_remembered_size() { + let mut wm = wm_with_monitor(); + // Tiling layout, so `add_window` skips `SmartPlacement`'s grid/ + // cascade sizing entirely and the asserted geometry below reflects + // only the remembered-size lookup itself, not incidental grid math. + 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_resize(a, ResizeEdge::BottomRight, 400, 300); + wm.update_resize(500, 400); + wm.end_resize(); + + wm.add_rule(WindowRule { + matcher: crate::rules::WindowMatch { class: Some("alacritty".into()), ..Default::default() }, + actions: crate::rules::WindowRuleActions { geometry: Some(Rect::new(0, 0, 640, 480)), ..Default::default() }, + }); + 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.width, placed.height), (640, 480), "a rule's explicit geometry is more specific and must win"); + } + + #[test] fn toggle_maximize_restores_original_geometry() { let mut wm = wm_with_monitor(); let a = wm.alloc_window_id(); @@ -294,6 +402,30 @@ } #[test] + fn hit_test_does_not_see_through_a_covering_windows_content_to_a_lower_windows_edge() { + // Reported live: a resize edge (or other titlebar/border zone) + // could still be grabbed on a window that was fully covered by + // another window on top of it, as long as the covering window's + // own edges didn't happen to land on that exact point. `a`'s left + // resize edge sits at x=0; `b` is stacked on top and covers that + // point with its own real content, but `b`'s own edges are far + // away (left at x=-100, nowhere near x=0), so `b` itself doesn't + // register a hit there - the bug was falling through to `a`'s + // edge underneath instead of stopping at `b`'s opaque content. + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + let mut wa = Window::new(a, "a"); + wa.geometry = Rect::new(0, 0, 400, 300); + wm.add_window(wa); + let b = wm.alloc_window_id(); + let mut wb = Window::new(b, "b"); + wb.geometry = Rect::new(-100, 0, 600, 300); // added later -> on top, fully covers a + wm.add_window(wb); + + assert_eq!(wm.hit_test(0, 150), None, "a's edge must not be reachable through b's opaque content"); + } + + #[test] fn per_window_resize_margin_overrides_the_wm_wide_default() { // Hyprland's per-window `extend_border_grab_area` equivalent. let mut wm = wm_with_monitor(); @@ -389,10 +521,10 @@ let ws2 = wm.add_workspace("2", "dynamic"); wm.switch_workspace(ws2); assert_eq!(wm.current_workspace(), ws2); - // Re-selecting the already-active workspace jumps back to 0, the + // Re-selecting the already-active workspace jumps back to 1, the // one that was active right before. wm.switch_workspace(ws2); - assert_eq!(wm.current_workspace(), 0); + assert_eq!(wm.current_workspace(), 1); } #[test] @@ -405,6 +537,129 @@ } #[test] + fn switching_to_a_workspace_with_a_window_focuses_it() { + // Regression test: `switch_workspace` used to only ever touch + // `current_workspace`, never `self.focused` - reported live as + // switching to a workspace with an open window leaving that window + // unfocused while whatever was focused *before* the switch (now + // invisible, off on the old workspace) kept receiving real + // keyboard input. + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let ws2 = wm.add_workspace("2", "dynamic"); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "b")); + wm.move_window_to_workspace(b, ws2); + wm.focus_window(a); + assert_eq!(wm.focused_id(), Some(a), "sanity: a is focused on the original workspace"); + + wm.switch_workspace(ws2); + assert_eq!(wm.focused_id(), Some(b), "switching to a workspace with a window must focus it, not leave the old workspace's window focused"); + } + + #[test] + fn switching_to_an_empty_workspace_clears_focus() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + wm.focus_window(a); + let empty_ws = wm.add_workspace("2", "dynamic"); + + wm.switch_workspace(empty_ws); + assert_eq!(wm.focused_id(), None, "no window on the new workspace to focus, and the old one is no longer visible"); + } + + #[test] + fn per_monitor_workspaces_off_by_default_switch_workspace_still_moves_every_monitor() { + // Sanity: the new `per_monitor_workspaces` field must default to + // `false` and leave shared-mode behaviour completely unchanged -- + // every existing workspace test above this one relies on that. + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + assert!(!wm.per_monitor_workspaces, "shared mode must be the default"); + let ws2 = wm.add_workspace("2", "dynamic"); + wm.switch_workspace(ws2); + assert_eq!(wm.workspace_for_monitor(0), ws2); + assert_eq!(wm.workspace_for_monitor(1), ws2, "shared mode: every monitor must agree"); + } + + #[test] + fn per_monitor_workspaces_on_switching_one_monitor_leaves_the_other_alone() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + wm.per_monitor_workspaces = true; + let ws2 = wm.add_workspace("2", "dynamic"); + + wm.switch_workspace_on_monitor(ws2, 1); + + assert_eq!(wm.workspace_for_monitor(1), ws2, "monitor 1 switched"); + assert_eq!(wm.workspace_for_monitor(0), 1, "monitor 0 must still fall back to current_workspace, untouched"); + } + + #[test] + fn per_monitor_workspaces_on_visible_windows_respects_each_monitors_own_workspace() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + wm.per_monitor_workspaces = true; + let ws2 = wm.add_workspace("2", "dynamic"); + + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "on-monitor-0-workspace-1")); + wm.window_mut(a).unwrap().monitor = 0; + + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "on-monitor-1-workspace-2")); + wm.window_mut(b).unwrap().monitor = 1; + wm.move_window_to_workspace(b, ws2); + + // Before switching monitor 1 to workspace 2, b isn't visible yet + // (monitor 1 still falls back to workspace 1). + assert!(!wm.visible_windows().any(|w| w.id == b)); + + wm.switch_workspace_on_monitor(ws2, 1); + + let visible: Vec<_> = wm.visible_windows().map(|w| w.id).collect(); + assert!(visible.contains(&a), "monitor 0's own window must still be visible"); + assert!(visible.contains(&b), "monitor 1's window must become visible once its monitor switches to workspace 2"); + } + + #[test] + fn per_monitor_workspaces_on_multiple_workspaces_can_be_active_at_once() { + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + wm.per_monitor_workspaces = true; + let ws2 = wm.add_workspace("2", "dynamic"); + + wm.switch_workspace_on_monitor(ws2, 1); + + assert!(wm.is_workspace_visible(1), "monitor 0 is still showing workspace 1"); + assert!(wm.is_workspace_visible(ws2), "monitor 1 is showing workspace 2"); + } + + #[test] + fn switching_to_a_workspace_where_the_already_focused_window_lives_is_a_no_op_for_focus() { + // The auto-focus-on-switch behavior above must not fight + // `focus_window`'s own workspace-follow call into `switch_workspace` + // (see that function's doc comment): when a window on another + // workspace is focused directly, that window - not merely "the + // topmost window on its workspace" - must end up focused, even if + // it isn't the topmost one. + let mut wm = wm_with_monitor(); + let ws2 = wm.add_workspace("2", "dynamic"); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + wm.move_window_to_workspace(a, ws2); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "b")); + wm.move_window_to_workspace(b, ws2); + // b was added after a, so it's topmost - focusing a directly must + // still result in a being focused, not b. + wm.focus_window(a); + assert_eq!(wm.focused_id(), Some(a)); + } + + #[test] fn focusing_a_window_on_another_workspace_switches_to_it() { // Regression test: `focus_window` used to mark the target focused // without ever touching `current_workspace` - reported live @@ -421,7 +676,7 @@ let id = wm.alloc_window_id(); wm.add_window(Window::new(id, "a")); wm.move_window_to_workspace(id, ws2); - assert_eq!(wm.current_workspace(), 0, "sanity: still on the default workspace"); + assert_eq!(wm.current_workspace(), 1, "sanity: still on the default workspace"); wm.focus_window(id); assert_eq!(wm.current_workspace(), ws2, "focusing a window must bring its workspace along"); @@ -482,7 +737,7 @@ // workspace id that was never really visited. wm.auto_back_and_forth = true; wm.switch_workspace(ws2); - assert_eq!(wm.current_workspace(), 0); + assert_eq!(wm.current_workspace(), 1); } #[test] @@ -642,6 +897,75 @@ } #[test] + fn disabled_monitor_is_reported_but_never_shows_up_in_monitors() { + // The whole point of keeping this separate from `set_monitors`: + // real placement (`monitors()`) must never see a disabled output, + // even though `srd monitors`/AGS's panel now needs to list it. + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + wm.set_disabled_monitor("HDMI-A-1".to_string(), Rect::new(1920, 0, 1920, 1080), Rect::new(1920, 0, 1920, 1080), false); + + assert_eq!(wm.monitors().len(), 2, "disabled_monitors must not leak into real placement's monitor list"); + let disabled: Vec<_> = wm.disabled_monitors().collect(); + assert_eq!(disabled.len(), 1); + assert_eq!(disabled[0].0, "HDMI-A-1"); + } + + #[test] + fn re_enabling_clears_the_disabled_monitor_record() { + let mut wm = WindowManager::new(); + wm.set_disabled_monitor("HDMI-A-1".to_string(), Rect::new(0, 0, 1920, 1080), Rect::new(0, 0, 1920, 1080), false); + assert_eq!(wm.disabled_monitors().count(), 1); + + wm.clear_disabled_monitor("HDMI-A-1"); + assert_eq!(wm.disabled_monitors().count(), 0); + } + + #[test] + fn primary_secondary_layout_is_a_no_op_outside_per_monitor_workspaces_mode() { + // Shared mode: every monitor shows the same one workspace, so a + // primary/secondary split has nothing distinct to apply to. + let mut wm = WindowManager::new(); + wm.primary_layout = "dynamic".to_string(); + wm.secondary_layout = "tiling".to_string(); + wm.set_monitors(two_monitors()); + assert_eq!(wm.workspace(1).unwrap().layout, "dynamic", "must not touch the shared workspace's layout"); + } + + #[test] + fn primary_secondary_layout_applies_once_workspaces_are_split_per_monitor() { + let mut wm = WindowManager::new(); + wm.per_monitor_workspaces = true; + wm.primary_layout = "dynamic".to_string(); + wm.secondary_layout = "tiling".to_string(); + wm.set_monitors(two_monitors()); + // Give the secondary monitor its own workspace, same as a real + // independent per-monitor switch would. + let ws2 = wm.add_workspace("2", "dynamic"); + wm.switch_workspace_on_monitor(ws2, 1); + // Re-applied on the next monitor-list refresh (a hotplug or + // restart), not continuously - see `apply_monitor_layouts`'s own + // doc comment for why it doesn't hook every workspace switch. + wm.set_monitors(two_monitors()); + + assert_eq!(wm.workspace(wm.workspace_for_monitor(0)).unwrap().layout, "dynamic"); + assert_eq!(wm.workspace(ws2).unwrap().layout, "tiling"); + } + + #[test] + fn primary_secondary_layout_does_not_clobber_the_still_shared_workspace() { + // Neither monitor has been independently switched yet - both + // still resolve to the same fallback workspace. secondary_layout + // must not stomp what primary_layout just set on it. + let mut wm = WindowManager::new(); + wm.per_monitor_workspaces = true; + wm.primary_layout = "dynamic".to_string(); + wm.secondary_layout = "tiling".to_string(); + wm.set_monitors(two_monitors()); + assert_eq!(wm.workspace(1).unwrap().layout, "dynamic"); + } + + #[test] fn unplugging_a_monitor_rehomes_its_windows_to_the_primary() { let mut wm = WindowManager::new(); wm.set_monitors(two_monitors()); @@ -749,6 +1073,69 @@ ); } + #[test] + fn a_new_window_lands_on_the_focused_windows_monitor_not_always_primary() { + // Real bug, reported live: "why do all windows only open on the + // first monitor" - `add_window` used to resolve its target + // monitor via `primary_monitor()` unconditionally, so a second + // monitor being the one the user was actually working on never + // mattered at all. + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + + let first = wm.alloc_window_id(); + wm.add_window(Window::new(first, "on-primary")); + assert_eq!(wm.window(first).unwrap().monitor, 0, "sanity: nothing focused yet falls back to primary"); + + // `add_window` itself focuses whatever it just added, so moving + // this window onto the secondary monitor and leaving it focused is + // enough to make it "the window the user is currently on" for the + // next one. + wm.window_mut(first).unwrap().monitor = 1; + + let second = wm.alloc_window_id(); + wm.add_window(Window::new(second, "should-follow-focus")); + assert_eq!(wm.window(second).unwrap().monitor, 1, "a new window must land on the focused window's monitor, not primary"); + } + + #[test] + fn a_new_window_lands_on_the_pointers_monitor_when_nothing_is_focused_there() { + // Real bug, reported live: with nothing focused (a fresh session, + // or the last-focused window sitting on a *different* monitor than + // the one just clicked/hovered), a new window still fell all the + // way back to primary - even though the user was demonstrably at + // the second monitor when they launched it. `set_pointer_monitor` + // is what a real backend's pointer-motion handler calls to tell + // core this. + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + wm.set_pointer_monitor(Some(1)); + + let id = wm.alloc_window_id(); + wm.add_window(Window::new(id, "should-follow-pointer")); + assert_eq!(wm.window(id).unwrap().monitor, 1, "a new window must land on the pointer's monitor when nothing is focused, not primary"); + } + + #[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). + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + + let first = wm.alloc_window_id(); + wm.add_window(Window::new(first, "focused-on-primary")); + wm.window_mut(first).unwrap().monitor = 0; + 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"); + } + // ---- Fullscreen ------------------------------------------------------ #[test] diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs index d73dec1..7e2046d 100644 --- a/crates/core/src/manager/windows.rs +++ b/crates/core/src/manager/windows.rs @@ -26,7 +26,7 @@ impl WindowManager { window.border_color = self.theme.default_border_color; window.border_width = self.theme.default_border_width; window.corner_radius = self.theme.default_corner_radius; - window.decorated = self.theme.default_decorated; + 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()); // See `Window::rules_applied`'s doc comment: a native Wayland window // still has empty title/app_id at this point, so a real (if @@ -62,7 +62,50 @@ impl WindowManager { } } - if let Some(monitor) = self.primary_monitor() { + // 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. + if !window.app_id.is_empty() { + if let Some(&(w, h)) = self.remembered_sizes.get(&window.app_id) { + window.geometry.width = w.max(MIN_WINDOW_WIDTH); + window.geometry.height = h.max(MIN_WINDOW_HEIGHT); + } + } + // 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. + // + // 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. + 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))) + .or_else(|| self.primary_monitor()); + 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" { @@ -116,7 +159,17 @@ impl WindowManager { let actions = self.rules.iter().find(|r| r.matcher.matches(window)).map(|r| r.actions.clone()); let Some(window) = self.windows.get_mut(&id) else { return false }; window.rules_applied = true; - let Some(actions) = actions else { return false }; + // `add_window`'s matching fallback only ever sees this once + // `app_id` is actually known - for a native Wayland window that's + // usually after creation (`set_app_id` lands later), which is + // exactly why this needs its own check here too, not just there. + // A rule's own `decorated` action, if any, still wins below. + let mut heuristic_changed = false; + if actions.as_ref().and_then(|a| a.decorated).is_none() && window.decorated && likely_draws_own_titlebar(&window.app_id) { + window.decorated = false; + heuristic_changed = true; + } + let Some(actions) = actions else { return heuristic_changed }; if let Some(floating) = actions.floating { window.floating = floating; } diff --git a/crates/core/src/manager/workspaces.rs b/crates/core/src/manager/workspaces.rs index 6ffb21b..f502d6b 100644 --- a/crates/core/src/manager/workspaces.rs +++ b/crates/core/src/manager/workspaces.rs @@ -30,7 +30,12 @@ impl WindowManager { if self.workspaces.len() <= 1 { return; } - let fallback = self.workspaces.iter().map(|w| w.id).find(|&w| w != id).unwrap_or(0); + // `unwrap_or(1)` is unreachable in practice - the `len() <= 1` + // guard above means `find` always has at least one other workspace + // to return - but `1`, not `0`, since workspace ids are 1-based + // (see `WindowManager::new`'s own doc comment) and `0` is no longer + // a real workspace id this could plausibly fall back to. + let fallback = self.workspaces.iter().map(|w| w.id).find(|&w| w != id).unwrap_or(1); for w in self.windows.values_mut().filter(|w| w.workspace == id) { w.workspace = fallback; } @@ -53,6 +58,23 @@ impl WindowManager { if self.workspaces.iter().any(|w| w.id == target) && target != self.current_workspace { self.previous_workspace = self.current_workspace; self.current_workspace = target; + // Keyboard focus otherwise stayed on whatever was focused + // *before* the switch - this function only ever touched + // `current_workspace`, never `self.focused` - so real input + // kept going to a window that had just gone invisible while + // whatever's now on screen, if anything, received nothing. + // Reported live: switching to a workspace with an open window + // left that window unfocused and the previous workspace's + // window still receiving keystrokes. Only reassigns focus when + // the currently-focused window isn't actually on the new + // workspace - an already-correct focus (e.g. `focus_window`'s + // own workspace-follow call into this function, where the + // target window IS what should end up focused) must not get + // silently overridden by "pick the topmost window instead". + let focus_still_valid = self.focused.and_then(|id| self.windows.get(&id)).is_some_and(|w| w.workspace == target); + if !focus_still_valid { + self.focused = self.window_ids_on_workspace_front_to_back(target).into_iter().next(); + } } } @@ -60,6 +82,76 @@ impl WindowManager { self.current_workspace } + /// The workspace actually showing on `monitor` right now - `current_ + /// workspace` directly when `per_monitor_workspaces` is `false` (every + /// monitor always agrees, by construction, since only `switch_ + /// workspace` - never `switch_workspace_on_monitor` - can run in that + /// mode); otherwise this monitor's own independently-switched + /// workspace, or `current_workspace` as the fallback for a monitor + /// that has never had one switched independently yet (freshly + /// connected, or the mode was just turned on). + pub fn workspace_for_monitor(&self, monitor: MonitorId) -> WorkspaceId { + if self.per_monitor_workspaces { + self.monitor_workspaces.get(&monitor).copied().unwrap_or(self.current_workspace) + } else { + self.current_workspace + } + } + + /// Whether `id` is showing on *any* currently-connected monitor right + /// now - what `srd workspaces`/AGS's own workspace pills should treat + /// as "active" (`crates/platform/src/ipc.rs::workspace_snapshot`). + /// Structurally allows more than one workspace to be active at once, + /// which only actually happens in `per_monitor_workspaces` mode with + /// two monitors on different workspaces - shared mode (the default) + /// always has exactly one, same as before this existed. + pub fn is_workspace_visible(&self, id: WorkspaceId) -> bool { + if self.per_monitor_workspaces { + self.monitors.iter().any(|m| self.workspace_for_monitor(m.id) == id) + } else { + id == self.current_workspace + } + } + + /// The `per_monitor_workspaces`-aware counterpart to `switch_ + /// workspace`: switches `monitor`'s own workspace to `id` without + /// affecting any other monitor, when the mode is on. Falls straight + /// through to the ordinary shared-mode `switch_workspace` (ignoring + /// `monitor` entirely) when it's off, so a caller can always use this + /// one entry point regardless of which mode is active rather than + /// branching on the config flag itself - see its own call site in + /// `crates/platform/src/ipc.rs`'s `activate_workspace` handler. + /// + /// `monitor` is "whichever monitor this switch should apply to", not + /// necessarily where the pointer is - the caller decides that (the + /// focused window's own monitor, in practice), same as real per-output + /// keybinding routing in Hyprland/niri. + pub fn switch_workspace_on_monitor(&mut self, id: WorkspaceId, monitor: MonitorId) { + if !self.per_monitor_workspaces { + self.switch_workspace(id); + return; + } + let current = self.workspace_for_monitor(monitor); + let target = if self.auto_back_and_forth && id == current { + self.monitor_workspaces.get(&monitor).copied().unwrap_or(self.previous_workspace) + } else { + id + }; + if !self.workspaces.iter().any(|w| w.id == target) || target == current { + return; + } + self.previous_workspace = current; + self.monitor_workspaces.insert(monitor, target); + // Same reasoning as `switch_workspace`'s own matching comment: + // reassign focus only when the currently-focused window isn't + // already correctly on the new workspace, so an already-correct + // focus assignment from elsewhere doesn't get silently overridden. + let focus_still_valid = self.focused.and_then(|id| self.windows.get(&id)).is_some_and(|w| w.workspace == target); + if !focus_still_valid { + self.focused = self.window_ids_on_workspace_front_to_back(target).into_iter().next(); + } + } + pub fn workspace(&self, id: WorkspaceId) -> Option<&Workspace> { self.workspaces.iter().find(|w| w.id == id) } @@ -74,15 +166,20 @@ impl WindowManager { } } - /// Windows that should currently be shown to the user: those on the - /// current workspace, and not minimized. + /// Windows that should currently be shown to the user: those on + /// whichever workspace their own monitor is currently showing, and not + /// minimized. /// - /// `current_workspace` is a single value shared by every monitor -- - /// srdwm does not have Hyprland-style independent per-monitor - /// workspaces, so switching workspace changes what's shown on every - /// screen at once. `w.monitor` plays no part in this filter at all. + /// In shared mode (`per_monitor_workspaces` off, the default) every + /// monitor is always showing `current_workspace`, so this reduces to + /// exactly the original single-shared-workspace filter and `w.monitor` + /// plays no part in it - switching workspace still changes what's + /// shown on every screen at once. In per-monitor mode, each window is + /// checked against its *own* monitor's independently-switched + /// workspace (`workspace_for_monitor`) instead, so two monitors on two + /// different workspaces each correctly show only their own. pub fn visible_windows(&self) -> impl Iterator<Item = &Window> { - self.windows.values().filter(|w| w.workspace == self.current_workspace && !w.minimized) + self.windows.values().filter(|w| w.workspace == self.workspace_for_monitor(w.monitor) && !w.minimized) } /// Same windows as [`Self::visible_windows`], but in real front-to-back @@ -95,7 +192,7 @@ impl WindowManager { /// `self.order` reversed is the same "topmost first" convention /// `hit_test`/`window_at` already use. pub fn visible_windows_front_to_back(&self) -> impl Iterator<Item = &Window> { - self.order.iter().rev().filter_map(|id| self.windows.get(id)).filter(|w| w.workspace == self.current_workspace && !w.minimized) + self.order.iter().rev().filter_map(|id| self.windows.get(id)).filter(|w| w.workspace == self.workspace_for_monitor(w.monitor) && !w.minimized) } } diff --git a/crates/core/src/monitor.rs b/crates/core/src/monitor.rs index 01f54cb..6ea053f 100644 --- a/crates/core/src/monitor.rs +++ b/crates/core/src/monitor.rs @@ -42,10 +42,247 @@ pub struct Monitor { pub name: String, pub refresh_rate_mhz: u32, pub primary: bool, + /// `true` when this entry is one part of a real output divided by + /// `srd.monitor.split` - not a second `wl_output`, not a second + /// physical connector. A display-arrangement UI reads this to tell a + /// split part apart from a genuinely separate monitor, so it does not + /// offer to move or extend a physical arrangement onto something that + /// is not a real, independent output. `false` for an ordinary, + /// undivided output. + pub split: bool, + /// This output's real scale factor (automatic, from `srdwm_core:: + /// monitor::auto_scale_for`, or an explicit `srd.monitor.scale` + /// override) - `1.0` for an unscaled output. Every other field on + /// this struct (`geometry`, `full_geometry`, `maximize_geometry`) is + /// in *physical* pixels, not the logical points a Wayland client + /// itself sees; a caller that needs to convert between the two (a + /// display-arrangement UI chaining outputs by their reported size, + /// for instance) multiplies logical by this to get physical, or + /// divides physical by this to get logical. Requested directly by the + /// AGS peer session after a real bug (`srd dispatch set output + /// position` and this compositor's own physical-pixel bookkeeping + /// silently disagreeing with a client's logical one at any scale + /// other than `1.0`) traced back to exactly this missing piece of + /// information. + pub scale: f64, } impl Monitor { pub fn new(id: MonitorId, name: impl Into<String>, geometry: Rect) -> Self { - Self { id, name: name.into(), geometry, full_geometry: geometry, maximize_geometry: geometry, refresh_rate_mhz: 60_000, primary: false } + Self { id, name: name.into(), geometry, full_geometry: geometry, maximize_geometry: geometry, refresh_rate_mhz: 60_000, primary: false, split: false, scale: 1.0 } + } +} + +/// A connector a backend has administratively disabled (`srd dispatch set +/// output enabled <name> false`) but that's still physically connected -- +/// purely informational, reported by the backend via `WindowManager:: +/// set_disabled_monitor` for `srd monitors`/the `monitors` subscribe event +/// to list (so a display-settings UI can offer to turn it back on by +/// name), and deliberately never fed into `WindowManager::monitors()` or +/// any real placement/tiling logic, which continues to see only genuinely +/// live outputs exactly as before this existed. Geometry is a last-known +/// snapshot from the moment it was disabled - stale by construction, and +/// meant to be: a caller wanting to reposition it correctly re-queries +/// once it's actually re-enabled, not from this. +#[derive(Debug, Clone)] +pub struct DisabledMonitor { + pub geometry: Rect, + pub full_geometry: Rect, + pub primary: bool, +} + +/// A `srd.monitor.split(name, parts, direction)` config-time request: +/// divide one real output into `parts` equal (within a pixel) logical +/// [`Monitor`] entries, so placement/tiling can treat them as separate +/// screens without any DRM/`wl_output` involvement - see `split_rect`'s +/// own doc comment for the actual division, and the udev platform's +/// `monitors()` for where this turns into real `Monitor` entries. +/// +/// Deliberately just a division of one real output's rectangle for +/// placement purposes, not a second `wl_output` global - a client +/// fullscreening or querying `wl_output.enter`/scale for a specific +/// sub-region still sees it as part of the one real output. See the +/// "different monitors mode in one" plan for why that's an accepted, +/// explicitly-flagged limitation of this first version. +#[derive(Debug, Clone, Copy)] +pub struct MonitorSplit { + pub parts: u32, + /// `false` (the default): side-by-side columns, splitting width. + /// `true`: stacked rows, splitting height. + pub rows: bool, +} + +/// Divides `rect` into `parts` equal (within one pixel) pieces along one +/// axis, returning piece number `index` (`0..parts`). `rows` chooses which +/// axis: stacked rows (splitting height) when `true`, side-by-side columns +/// (splitting width) when `false`. +/// +/// Any remainder from an uneven division is spread one pixel at a time +/// across the first `remainder` pieces, rather than dumped entirely onto +/// the last one - so a 1919px-wide monitor split into 2 columns yields +/// 960/959, not a lopsided 959/960 vs. a naive 959/960-plus-slack-on-one- +/// side that would leave one part visibly wider for no reason tied to the +/// actual pixel count. +/// +/// `index >= parts` or `parts == 0` returns `rect` unchanged - callers +/// are expected to only iterate `0..parts.max(1)`, this is just a safe +/// fallback rather than a panic for a config-driven value. +pub fn split_rect(rect: Rect, index: u32, parts: u32, rows: bool) -> Rect { + if parts <= 1 || index >= parts { + return rect; + } + let total = if rows { rect.height } else { rect.width }; + let other = if rows { rect.width } else { rect.height }; + let base = total / parts; + let remainder = total % parts; + let size_for = |i: u32| base + if i < remainder { 1 } else { 0 }; + let offset: u32 = (0..index).map(size_for).sum(); + let size = size_for(index); + if rows { + Rect::new(rect.x, rect.y + offset as i32, other, size) + } else { + Rect::new(rect.x + offset as i32, rect.y, size, other) + } +} + +/// The pixel density (in real, physical-size terms) srdwm treats as +/// needing no scale correction at all. `92`, close to the classic desktop +/// "96 DPI" constant - lowered from an initial `109` (roughly a 24" +/// 1920x1080 or 27" 2560x1440 monitor) after live testing on a real 1080p +/// monitor at ~78 PPI: `109` produced a `0.71` scale there, reported as +/// too aggressive a shrink; `92` produces `~0.85`, still a real reduction +/// but closer to what actually reads as "more space", not "suddenly tiny +/// text". +const REFERENCE_PPI: f64 = 92.0; + +/// Automatically derives an output scale from real EDID physical size and +/// native resolution, with no monitor name or fixed size bucket involved +/// anywhere - a large panel with low pixel density (a big monitor at the +/// same resolution as a much smaller one, the concrete case this exists +/// for) gets scaled down smoothly in proportion to how far its real PPI +/// falls below [`REFERENCE_PPI`], clamped to `0.5` so a pathologically +/// large/low-res panel doesn't shrink text into illegibility. Deliberately +/// never scales *above* `1.0` on its own - a high-density panel already +/// benefits from more detail, not less, and plenty of people want native +/// crispness there; `srd.monitor.scale` remains the explicit, manual way +/// to opt into upscaling a specific connector. +/// +/// `physical_mm` of `(0, 0)` (no EDID physical-size descriptor at all -- +/// some VMs/adapters report this) returns `1.0` rather than guessing from +/// nothing. +pub fn auto_scale_for(physical_mm: (i32, i32), resolution_px: (i32, i32)) -> f64 { + let (pw, ph) = physical_mm; + if pw <= 0 || ph <= 0 { + return 1.0; + } + let diagonal_mm = ((pw as f64).powi(2) + (ph as f64).powi(2)).sqrt(); + let diagonal_in = diagonal_mm / 25.4; + let (rw, rh) = resolution_px; + let diagonal_px = ((rw as f64).powi(2) + (rh as f64).powi(2)).sqrt(); + let ppi = diagonal_px / diagonal_in; + if ppi >= REFERENCE_PPI { + 1.0 + } else { + (ppi / REFERENCE_PPI).clamp(0.5, 1.0) + } +} + +#[cfg(test)] +mod auto_scale_tests { + use super::*; + + #[test] + fn a_15_inch_1080p_laptop_panel_needs_no_correction() { + // 340mm x 190mm, ~143 PPI - comfortably above the reference, and + // the concrete real-hardware case this must not regress: this + // laptop's own panel was already correct at 1.0. + assert_eq!(auto_scale_for((340, 190), (1920, 1080)), 1.0); + } + + #[test] + fn a_physically_large_1080p_monitor_scales_down() { + // 600mm x 400mm at the same 1920x1080 as the laptop above -- + // ~78 PPI, well under the reference. The concrete case this whole + // function exists for: reported live as "too big, should utilize + // greater real estate" on exactly this monitor. + let s = auto_scale_for((600, 400), (1920, 1080)); + assert!(s < 1.0 && s > 0.5, "expected a real scale-down, got {s}"); + } + + #[test] + fn a_high_density_panel_is_never_auto_upscaled() { + // A small, very high-resolution panel (e.g. a 13" 4K) - far above + // the reference PPI. Must clamp at 1.0, not scale past it. + assert_eq!(auto_scale_for((290, 170), (3840, 2160)), 1.0); + } + + #[test] + fn an_extreme_low_density_panel_clamps_at_half_scale() { + let s = auto_scale_for((2000, 1200), (1024, 768)); + assert_eq!(s, 0.5); + } + + #[test] + fn missing_physical_size_does_not_guess() { + assert_eq!(auto_scale_for((0, 0), (1920, 1080)), 1.0); + } +} + +#[cfg(test)] +mod split_tests { + use super::*; + + #[test] + fn single_part_returns_the_whole_rect_unchanged() { + let r = Rect::new(0, 0, 1920, 1080); + assert_eq!(split_rect(r, 0, 1, false), r); + } + + #[test] + fn even_columns_split_width_with_no_gap_or_overlap() { + let r = Rect::new(100, 0, 1920, 1080); + let a = split_rect(r, 0, 2, false); + let b = split_rect(r, 1, 2, false); + assert_eq!(a, Rect::new(100, 0, 960, 1080)); + assert_eq!(b, Rect::new(1060, 0, 960, 1080)); + assert_eq!(a.right(), b.x, "no gap or overlap between adjacent parts"); + } + + #[test] + fn uneven_columns_spread_the_remainder_one_pixel_at_a_time() { + let r = Rect::new(0, 0, 1919, 1080); + let a = split_rect(r, 0, 2, false); + let b = split_rect(r, 1, 2, false); + assert_eq!(a.width, 960); + assert_eq!(b.width, 959); + assert_eq!(a.width + b.width, r.width); + assert_eq!(a.right(), b.x); + } + + #[test] + fn rows_split_height_and_leave_width_untouched() { + let r = Rect::new(0, 50, 1920, 1080); + let a = split_rect(r, 0, 2, true); + let b = split_rect(r, 1, 2, true); + assert_eq!(a, Rect::new(0, 50, 1920, 540)); + assert_eq!(b, Rect::new(0, 590, 1920, 540)); + assert_eq!(a.bottom(), b.y); + } + + #[test] + fn three_parts_covers_the_whole_rect_exactly() { + let r = Rect::new(0, 0, 1000, 500); + let parts: Vec<Rect> = (0..3).map(|i| split_rect(r, i, 3, false)).collect(); + let total_width: u32 = parts.iter().map(|p| p.width).sum(); + assert_eq!(total_width, r.width); + for w in parts.windows(2) { + assert_eq!(w[0].right(), w[1].x); + } + } + + #[test] + fn out_of_range_index_returns_the_whole_rect_unchanged() { + let r = Rect::new(0, 0, 1920, 1080); + assert_eq!(split_rect(r, 5, 2, false), r); } } diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs index 1511a1f..882326f 100644 --- a/crates/core/src/theme.rs +++ b/crates/core/src/theme.rs @@ -16,11 +16,30 @@ pub struct ThemeConfig { pub titlebar_fg_focused: (u8, u8, u8), pub titlebar_fg_unfocused: (u8, u8, u8), pub default_border_color: (u8, u8, u8), + /// `4`, not the `2` this used to default to - at `2`, the border + /// strip's own rounded-corner cut (`decoration::render_border_top`/ + /// `_bottom`, continuing the titlebar's larger radius outward) only + /// ever had two rows of pixels to draw an arc into, which - even + /// anti-aliased (`decoration::blend_corner_pixel`) - reads as barely + /// more than a single soft pixel, not a curve. That's most visible on + /// an undecorated/CSD window (no compositor-drawn titlebar to anchor + /// a bigger curve nearby, Firefox concretely): reported live as + /// "not all windows curved". Twice the rows makes the same curve + /// actually legible without touching content rounding at all, which + /// stays a real, deliberate per-backend cost/default tradeoff (see + /// `rounded_corners_pixman`'s module doc comment) rather than + /// something to paper over with a thicker border. pub default_border_width: u32, /// Titlebar/border-strip corner radius, in logical pixels - the same /// value `Window::corner_radius` copies onto every window at creation /// (see `WindowManager::add_window`), which a rule's own `corner_radius` /// action can still override afterward, same as `default_border_width`. + /// `12`, not the original `6`: matches real macOS's own ~0.36 radius-to- + /// titlebar-height proportion rather than this project's original, + /// visibly tighter `0.2` (docs/TODO.md's macOS-comparison research). + /// Moves in step with `TITLEBAR_HEIGHT` (currently `32`, matched + /// directly against a live Firefox window) to keep that same ratio, + /// not a separate size decision of its own. pub default_corner_radius: u32, /// Whether a newly created window gets srdwm's own titlebar /// (server-side decoration) by default, before any `xdg-decoration` @@ -45,6 +64,99 @@ pub struct ThemeConfig { /// examples - see `theme.decorations.default_mode` in the Lua config /// for the persistent equivalent. pub default_decorated: bool, + /// How much an unfocused window's border is dimmed from its own + /// configured colour - `1.0` keeps it identical to focused, `0.0` + /// removes the border entirely when unfocused. Was a hardcoded `0.35` + /// constant in `state::effective_border_color` with no way to change it + /// at all; `theme.decorations.border.inactive_dim` in the Lua config is + /// the first way to actually reach it, closing the exact gap + /// `apply_general_settings`'s own doc comment flagged (`border. + /// inactive_color` was left unwired because setting an *explicit* + /// colour would silently erase the dimming scheme for anyone who never + /// touched it - a *factor* on top of the same scheme has no such + /// footgun: the unconfigured default below reproduces the old + /// hardcoded behaviour exactly). + pub border_inactive_dim: f32, + /// Centers the titlebar's title text instead of the longstanding + /// left-aligned default - `theme.decorations.title_bar.text_align` + /// in the Lua config (`"center"` sets this; anything else, including + /// unset, keeps left-aligned). Explicitly requested as its own + /// config knob, not a hardcoded switch - macOS centers title text by + /// convention, GNOME/Windows both left-align, so neither is a + /// universal default worth forcing. + pub title_centered: bool, + /// Titlebar buttons on the left (macOS convention: close, minimize, + /// maximize, left to right) instead of the longstanding right-aligned + /// default (Windows/GTK convention: minimize, maximize, close) -- + /// `theme.decorations.title_bar.button_side` in the Lua config + /// (`"left"` sets this; anything else, including unset, keeps + /// right-aligned). Researched against mutter's own `button-layout` + /// GSettings key before choosing this shape (one config value, not a + /// bespoke per-button scheme) - see `docs/TODO.md`. + /// + /// This has to stay in perfect agreement with `ResizeEdge::hit_test`'s + /// own `buttons_left` parameter, not just `decoration::render_titlebar`'s + /// rendering - a button that renders on one side but hit-tests on the + /// other is worse than not being configurable at all, since every + /// click would silently miss. + pub buttons_left: bool, + /// An explicit `close,minimize,maximize`-style override for the three + /// buttons' relative order, applied to whichever side `buttons_left` + /// already selects - `theme.decorations.button_order` in the Lua + /// config, parsed by `window::parse_button_order`. `None` (the + /// default, unset) keeps this project's own two built-in defaults + /// exactly as they were before this field existed. + /// + /// Added after `buttons_left`'s own doc comment above had already + /// deliberately chosen "one config value, not a bespoke per-button + /// scheme" - revisited once a real comparison against KWin's + /// `ButtonsOnLeft`/`ButtonsOnRight`, GNOME/Adwaita's own `decoration- + /// layout` (confirmed, contrary to this project's own earlier + /// assumption from Mutter's C source alone, to be a real per-button + /// ordering string, not just a fixed convention), and Openbox's + /// `titlelayout` found all three independently converged on exactly + /// this shape. Additive, not a reversal: `buttons_left` still exists + /// and still means what it always did. + pub button_order: Option<crate::window::ButtonOrder>, + /// The titlebar button glyph (dash/square/X) is always drawn instead + /// of only fading in on hover - `theme.decorations.title_bar. + /// button_glyph` in the Lua config (`"always"` sets this; anything + /// else, including unset, keeps the animated hover-reveal default). + /// + /// Researched (DE-weighted, per explicit request) before defaulting to + /// hover-reveal: real, extracted libadwaita CSS on this machine + /// (`gresource extract` on the installed `.so`, not guessed) shows + /// current GNOME/Adwaita actually keeps the glyph always visible and + /// only animates the background circle's opacity on hover - the + /// "always" mode here matches that. Classic macOS instead hides the + /// glyph entirely at rest and animates it in on hover - the default, + /// per this project's own explicit choice between the two once told + /// they're genuinely different conventions, not the same thing + /// assumed two different ways. + pub button_glyph_always: bool, + /// Whether the three titlebar buttons render as filled, coloured + /// macOS-style traffic lights, or as plain glyphs directly on the + /// titlebar's own background - `theme.decorations.title_bar. + /// button_style` in the Lua config (`"traditional"` sets this to + /// `false`; anything else, including unset, keeps the traffic-light + /// default). + /// + /// Explicitly requested as a separate axis from `buttons_left`: the + /// two defaulted to moving together (macOS convention is traffic + /// lights on the left; this project's own original look was plain + /// glyphs on the right), but neither implies the other - a caller can + /// still combine plain glyphs with left-aligned buttons or the reverse. + /// `false` swaps two things together, both handled in `decoration:: + /// render_titlebar`: no `fill_button_dot` call except a subtle, neutral + /// hover backdrop (there's no coloured circle to brighten on hover + /// instead), and the glyphs themselves draw in `foreground` (this + /// project's original look; readable straight on the titlebar's own + /// dark background) rather than the near-black shade a traffic light's + /// own bright fill needs instead. Maximize also draws as a plain + /// square glyph rather than the macOS "zoom" double-arrow, matching + /// this convention's own (Windows/GNOME) maximize icon rather than + /// borrowing the other convention's. + pub traffic_light_buttons: bool, } impl Default for ThemeConfig { @@ -54,9 +166,15 @@ impl Default for ThemeConfig { titlebar_fg_focused: (0x88, 0xc0, 0xd0), titlebar_fg_unfocused: (0x4c, 0x56, 0x6a), default_border_color: (136, 192, 208), // Nord accent, matches legacy theme default - default_border_width: 2, - default_corner_radius: 6, + default_border_width: 4, + default_corner_radius: 12, default_decorated: true, + border_inactive_dim: 0.35, + title_centered: false, + buttons_left: false, + button_order: None, + button_glyph_always: false, + traffic_light_buttons: true, } } } diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs index 7df7b5c..b30cf7b 100644 --- a/crates/core/src/window.rs +++ b/crates/core/src/window.rs @@ -119,6 +119,31 @@ pub fn classify_menu_source(gtk_menu_path: Option<String>, is_real_gtk_applicati } } +/// Whether `app_id` almost certainly belongs to an application that draws +/// its own header bar (a `GtkHeaderBar`/`Adw.HeaderBar` widget embedded +/// directly in its content, unconditionally) regardless of whatever +/// `xdg-decoration` mode actually gets negotiated - see +/// `crates/wayland/src/protocols.rs`'s `XdgDecorationHandler` doc comment +/// for why the protocol itself can't tell such an app apart from a normal +/// one: both Firefox and Nemo negotiate a decoration mode fine, and still +/// draw a second title row under srdwm's server-side one regardless. +/// Confirmed live for both (a screenshot showing two stacked bars) before +/// either got its own `decorated = false` entry in `rules.lua`. +/// +/// `org.gnome.*` app ids are the one case general enough to catch here +/// instead of needing a `rules.lua` entry added for each one as it's +/// discovered live: the GNOME HIG mandates every one of GNOME's own apps +/// use an embedded header bar, with no exceptions, so the namespace alone +/// is enough to know in advance. Deliberately narrow: a third-party GTK4/ +/// libadwaita app under `io.github.*`, or any other reverse-DNS scheme, is +/// left to `rules.lua`'s per-app list instead - those toolkits don't share +/// GNOME's HIG mandate, so guessing from the app id alone there would +/// misclassify plenty of ordinary, well-behaved server-side-decorated apps +/// that also happen to use a reverse-DNS-style id. +pub fn likely_draws_own_titlebar(app_id: &str) -> bool { + app_id.to_ascii_lowercase().starts_with("org.gnome.") +} + /// State of a single managed window. This is platform-independent: backends /// (X11, Wayland, ...) own the real surface/client handle and keep a `Window` /// in sync with it via `srdwm_core::WindowManager`. @@ -135,6 +160,19 @@ pub struct Window { /// Geometry to restore to when un-maximizing. pub restore_geometry: Option<Rect>, pub decorated: bool, + /// Whether this window declared an `xdg_toplevel` parent (`set_parent`) + /// - a dialog/utility window belonging to another one, not a normal + /// top-level app window. Backend-set (the wayland crate reads the real + /// `ToplevelSurface::parent()`, refreshed on every decoration redraw), + /// same as `decorated` itself; `core` has no protocol concept of its + /// own to derive this from. Only ever `true` for a genuine `xdg_ + /// toplevel` client that set a parent - an XWayland dialog's own + /// `WM_TRANSIENT_FOR` isn't read yet, so this misses those specifically + /// (a real, known gap, not an oversight). Requested directly: a + /// dialog's titlebar should show only a close button, no traffic + /// lights - see `hit_test`'s and `decoration::render_titlebar`'s own + /// use of this for what actually changes. + pub is_dialog: 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. @@ -205,6 +243,7 @@ impl Window { geometry: Rect::new(0, 0, 640, 480), restore_geometry: None, decorated: true, + is_dialog: false, restore_decorated: None, floating: false, minimized: false, @@ -217,7 +256,13 @@ impl Window { corner_radius: 6, opacity: 1.0, resize_margin: None, - workspace: 0, + // 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 + // workspace ids are 1-based now (see `WindowManager::new`'s own + // doc comment), so this placeholder still names a workspace + // that could plausibly exist. + workspace: 1, monitor: 0, rules_applied: false, anim_from: None, @@ -228,7 +273,59 @@ impl Window { /// The height, in pixels, of the drawn title bar. Shared between backends so /// hit-testing and rendering agree on the same band. -pub const TITLEBAR_HEIGHT: u32 = 30; +/// +/// `32`, measured directly against a real, live Firefox window: a +/// side-by-side screenshot of both windows at identical scale, scanned +/// column-by-column for the pixel row where the titlebar's own background +/// colour gives way to the next row down (Firefox's tab strip), put that +/// boundary at row 32 sharp. An earlier value of `38` came from a Nemo +/// headerbar measured the same way (~40px) - Nemo's own headerbar carries +/// extra chrome (a search/menu button row) a bare titlebar doesn't, so it +/// isn't the right reference once Firefox is the thing actually being +/// matched. `ThemeConfig::default_corner_radius` moves with this (see its +/// own doc comment) to keep the same `radius / TITLEBAR_HEIGHT` ratio +/// rather than just looking proportionally smaller on top of already being +/// shorter. +pub const TITLEBAR_HEIGHT: u32 = 32; +/// The centre-to-centre spacing between titlebar buttons, and the size of +/// the square each one's own dot/click-box is drawn/hit-tested inside -- +/// deliberately *not* the same value as `TITLEBAR_HEIGHT` (which used to +/// double as this too). Reported live: with the two tied together, growing +/// `TITLEBAR_HEIGHT` to `38` to match a real GTK headerbar's own *row* +/// height also silently grew the buttons themselves to a visibly bigger +/// scale than that same headerbar's own buttons - a real GTK/Firefox CSD +/// row reserves generous padding above and below a comparatively compact +/// button cluster, not one dimension sized off the other. `24`, matching a +/// real Firefox window's own measured button-to-button spacing (via +/// screenshot, at this system's own scale) - kept a separate constant +/// from `TITLEBAR_HEIGHT` specifically so the two can each move for their +/// own reason without dragging the other along. `decoration::button_box` +/// centres this smaller box vertically inside the taller `TITLEBAR_HEIGHT` +/// band for rendering; `ResizeEdge::hit_test` below has no matching +/// vertical narrowing to do - a click anywhere in the titlebar's own +/// height column-wise inside a button's `BUTTON_PITCH`-wide slice still +/// counts as that button, the same generous-vertical-target convention +/// every mainstream desktop already uses. +pub const BUTTON_PITCH: u32 = 24; +/// The gap, in pixels, between the titlebar's own edge (whichever side the +/// button cluster renders on) and the first button's own click/draw box -- +/// measured directly against a live Firefox window: its visible dot's own +/// left edge sits 17px in from the window's real left edge, while the +/// button's own box margin (`decoration::BUTTON_MARGIN_LEFT`, applied to +/// every box the same way) only accounts for 4px of that. The remaining +/// `13` is this - a real macOS/GTK titlebar's own leading margin is +/// visibly bigger than the gap *between* buttons, not the same value +/// reused for both. Added once, before the first button's own `BUTTON_ +/// PITCH`-spaced offset, on whichever edge `buttons_left` selects (`decoration +/// ::render_titlebar`'s `offset` calculation, and the matching `left`/ +/// `right` base below in `hit_test` - the two have to move together, the +/// same "renders on one side, hit-tests on the other" trap every other +/// button-geometry constant here already has to avoid). Before this +/// existed, the dead strip between the titlebar's real edge and the first +/// visible dot silently counted as a hit on that button (`hit_test`'s +/// slice starts flush with the edge) - clicking blank titlebar background +/// right at the corner closed the window instead of dragging it. +pub const BUTTON_CLUSTER_MARGIN: u32 = 13; /// Default width, in pixels, of the resize grab band along each window /// edge - `WindowManager::resize_margin`'s starting value, read from /// `general.resize_margin`, and what every `hit_test` call in this file's @@ -248,25 +345,53 @@ pub const TITLEBAR_HEIGHT: u32 = 30; /// edge back. Still configurable per the doc comment above if 6px turns /// out to be too little in the other direction for someone. pub const RESIZE_MARGIN: i32 = 6; -/// Top-edge resize margin for an *undecorated* window specifically -- +/// Resize margin for an *undecorated* window, on every edge and corner -- /// narrower than [`RESIZE_MARGIN`] on purpose. /// -/// An undecorated (client-side-decorated) window has no titlebar band for -/// srdwm to treat as a drag handle - Firefox's own tab strip, concretely -- -/// so the client's own header area sits directly at `frame.y` with nothing -/// srdwm-drawn to grab. The client detects a drag on its own header and -/// asks to be moved via `xdg_toplevel.move`, but only for clicks that -/// actually reach it as a normal button press; the full 10px `RESIZE_MARGIN` -/// swallowed every click within the first 10 rows of the window - including -/// most of a typical natural grab point near the top of a tab strip - as a -/// top-edge resize instead, so the client's own move request never fired. -/// Reported live as "can't drag-move Firefox from its own top bar." -/// Resize-from-the-top-edge still works (a deliberate earlier trade-off -- -/// see `undecorated_window_still_resizes_from_every_edge_including_top`'s -/// own comment - since an undecorated window is still a window), just from -/// a much narrower band that a click meant to grab the tab strip is very -/// unlikely to land in by accident. -pub const UNDECORATED_TOP_RESIZE_MARGIN: i32 = 3; +/// An undecorated (client-side-decorated) window has no srdwm-drawn band +/// anywhere for srdwm to treat as its own - every pixel right up to each +/// edge is the client's real content: Firefox's tab strip at the top, its +/// own window-control dots in a top corner, Nemo's tab-close X hard against +/// its right edge, a minimize button nowhere near any corner at all. The +/// full `RESIZE_MARGIN` (and, at a corner, `CORNER_MARGIN`'s further +/// multiple of it) was tuned for a window srdwm decorates itself, where none +/// of that applies - the whole titlebar band, buttons included, is checked +/// before `resize_edge_at` ever runs, so widening its own resize zone never +/// costs it a click. Applied to an undecorated window instead, that same +/// width competed with the client's own controls for the same pixels on +/// every edge, not just the top - first found as "can't drag-move Firefox +/// from its own top bar" (the original, narrower-top-only version of this +/// margin), then reported again, live, as real mouse clicks on Nemo's own +/// tab-close and minimize buttons - one hard against the right edge, the +/// other not even near a corner - landing "a distance" from the visible +/// button. Resize-from-every-edge still works (a deliberate trade-off - see +/// `undecorated_window_still_resizes_from_every_edge_including_top`'s own +/// comment - since an undecorated window is still a window), just from a +/// much narrower band on every side that a click meant for the client's own +/// content is very unlikely to land in by accident. No corner-widening for +/// an undecorated window at all: that widening exists purely to make a +/// diagonal drag easier to land on a window srdwm itself has no competing +/// content in, which is never true here. +pub const UNDECORATED_RESIZE_MARGIN: i32 = 3; +/// Top-edge resize margin for a *decorated* window's own titlebar band -- +/// unlike [`UNDECORATED_RESIZE_MARGIN`], this has no client content to +/// avoid stealing a click from (the whole titlebar band is srdwm's own +/// drawn UI, not the client's), so it can just reuse [`RESIZE_MARGIN`] +/// outright rather than needing its own narrower value. +/// +/// Reported live as a real gap, not a guess: a decorated window's titlebar +/// had *no* top-edge resize zone at all outside the two tiny diagonal +/// corners - every other pixel of the band, including the top row, +/// resolved to `Drag` unconditionally - while an undecorated window +/// (Firefox) could already be resized from its own top edge via +/// `UNDECORATED_RESIZE_MARGIN` above. "Can't resize tmux's window from +/// the top, but can in Firefox" was the exact live report. `hit_test`'s +/// own decorated-titlebar branch checks a button's x-range *before* this +/// margin, not after, so a button sitting within the first few rows of +/// the titlebar (true for every button, since `decoration::button_box` +/// spans nearly the full titlebar height) still always wins there -- +/// this only ever applies to the button-free part of the band. +pub const DECORATED_TOP_RESIZE_MARGIN: i32 = RESIZE_MARGIN; /// How much wider than [`RESIZE_MARGIN`] a corner's own diagonal-resize /// zone reaches, as a multiplier on whatever margin is actually in effect /// - see `ResizeEdge::resize_edge_at`'s doc comment for why corners need @@ -304,7 +429,25 @@ impl ResizeEdge { /// client. Resize-from-edge still applies either way: an undecorated /// window is still a window, and dragging its (invisible) edge to /// resize is still expected to work. - pub fn hit_test(frame: Rect, x: i32, y: i32, decorated: bool, border_width: u32, resize_margin: i32) -> Option<TitlebarHit> { + #[allow(clippy::too_many_arguments)] + pub fn hit_test( + frame: Rect, + x: i32, + y: i32, + decorated: bool, + border_width: u32, + resize_margin: i32, + buttons_left: bool, + order_override: Option<ButtonOrder>, + // `Window::is_dialog`'s resolved value - see its own doc comment. + // A dialog only ever shows/recognizes Close, never Minimize/ + // Maximize, regardless of `order_override`; must stay in exact + // agreement with `decoration::render_titlebar`'s own `is_dialog` + // parameter, the same "renders on one side, hit-tests on the + // other" trap every other button-geometry value here already has + // to avoid. + is_dialog: bool, + ) -> Option<TitlebarHit> { // Border strips render *outside* `frame` (`decoration:: // border_strips`, `border_width` pixels past each edge) - without // widening the containment check to match, those visible pixels @@ -321,37 +464,110 @@ impl ResizeEdge { return None; } if decorated && y < frame.y + TITLEBAR_HEIGHT as i32 { - // The titlebar's own top-left corner pixels are still the - // window's outer corner - without this, a decorated window's - // top-left diagonal resize was completely unreachable: every y - // inside the titlebar band returned here unconditionally, - // before `resize_edge_at` (checked below, for every other edge) - // ever ran. A genuine small square right at the corner (both x - // *and* y within it), not just "close on one axis" - otherwise - // this would claim the whole left end of the drag area at any - // height within the titlebar, not just its actual corner. + // The titlebar's own outer corner (on whichever side doesn't + // hold the buttons) is still the window's outer corner -- + // without this, a decorated window's diagonal resize there was + // completely unreachable: every y inside the titlebar band + // returned here unconditionally, before `resize_edge_at` + // (checked below, for every other edge) ever ran. A genuine + // small square right at the corner (both x *and* y within it), + // not just "close on one axis" - otherwise this would claim + // the whole drag area at any height within the titlebar, not + // just its actual corner. // - // The top-right corner deliberately does *not* get the same - // treatment: it's where the close button already lives, and - // every mainstream desktop's convention is that the corner of a - // titlebar closes the window, not resizes it. Adding a - // competing resize zone there would trade a real, expected - // target (close) for a rarely-wanted one at exactly the spot a - // miss is most costly. + // The corner *with* the buttons deliberately does not get the + // same treatment: every mainstream desktop's convention is + // that the corner of a titlebar closes the window, not + // resizes it - see `decoration::button_box`'s own doc + // comment for the matching rendering-side placement this has + // to agree with. Adding a competing resize zone there would + // trade a real, expected target (close) for a rarely-wanted + // one at exactly the spot a miss is most costly. `buttons_left` + // flips which corner gets which treatment, not just where the + // buttons render - the two have to move together. let corner_zone = CORNER_MARGIN * resize_margin; - if x <= frame.x + corner_zone && y <= frame.y + corner_zone { + if !buttons_left && x <= frame.x + corner_zone && y <= frame.y + corner_zone { return Some(TitlebarHit::Resize(ResizeEdge::TopLeft)); } - const BUTTON: i32 = TITLEBAR_HEIGHT as i32; - let right = frame.right(); - if x >= right - BUTTON { - return Some(TitlebarHit::Close); + if buttons_left && x >= frame.right() - corner_zone && y <= frame.y + corner_zone { + return Some(TitlebarHit::Resize(ResizeEdge::TopRight)); + } + // Box size is *not* bigger when left-aligned, even though the + // visible dot is (see `decoration::BUTTON_MARGIN_LEFT`) - the + // box is already capped at `BUTTON_PITCH` vertically by + // `decoration::button_box`'s own centring, so a genuinely + // bigger *box* would draw a dot that gets clipped top/bottom + // against it. A bigger dot within the same click box gets the + // requested "bigger" look with no such clipping risk, and + // keeps this hit-test box in exact agreement with `decoration:: + // button_box`'s own size, not just its side. `BUTTON_PITCH`, + // not `TITLEBAR_HEIGHT` - see that constant's own doc comment + // for why the two aren't the same value. + let button: i32 = BUTTON_PITCH as i32; + // Closest-to-the-aligned-edge first - see `ButtonOrder`'s own + // doc comment for why the two built-in defaults are genuinely + // different relative orderings, not mirrors of each other, + // and why an explicit override applies identically regardless + // of side rather than trying to preserve that asymmetry. + // A dialog always recognizes Close, full stop - not just + // whichever button an `order_override` would otherwise put + // first, or Minimize/Maximize could still end up the one hit- + // testable button. Matches `decoration::render_titlebar`'s own + // identical override for the same reason. + let order: ButtonOrder = if is_dialog { + [TitlebarButton::Close; 3] + } else { + order_override.unwrap_or(if buttons_left { + [TitlebarButton::Close, TitlebarButton::Minimize, TitlebarButton::Maximize] + } else { + [TitlebarButton::Close, TitlebarButton::Maximize, TitlebarButton::Minimize] + }) + }; + // 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 }; + if buttons_left { + let left = frame.x + BUTTON_CLUSTER_MARGIN as i32; + // `x >= left` excludes the dead `BUTTON_CLUSTER_MARGIN` + // strip between the titlebar's real edge and the first + // button - without this guard, `x < left + button` (the + // first iteration below) is trivially true for any `x` + // left of `left` too, so that whole blank strip silently + // counted as a Close hit. + if x >= left { + for (i, b) in order.iter().take(button_count).enumerate() { + if x < left + button * (i as i32 + 1) { + return Some(match b { + TitlebarButton::Close => TitlebarHit::Close, + TitlebarButton::Minimize => TitlebarHit::Minimize, + TitlebarButton::Maximize => TitlebarHit::Maximize, + }); + } + } + } + if y <= frame.y + DECORATED_TOP_RESIZE_MARGIN { + return Some(TitlebarHit::Resize(ResizeEdge::Top)); + } + return Some(TitlebarHit::Drag); } - if x >= right - BUTTON * 2 { - return Some(TitlebarHit::Maximize); + let right = frame.right() - BUTTON_CLUSTER_MARGIN as i32; + // Same guard, mirrored: `x <= right` excludes the dead strip + // between the first (rightmost) button and the titlebar's real + // right edge. + if x <= right { + for (i, b) in order.iter().take(button_count).enumerate() { + if x >= right - button * (i as i32 + 1) { + return Some(match b { + TitlebarButton::Close => TitlebarHit::Close, + TitlebarButton::Minimize => TitlebarHit::Minimize, + TitlebarButton::Maximize => TitlebarHit::Maximize, + }); + } + } } - if x >= right - BUTTON * 3 { - return Some(TitlebarHit::Minimize); + if y <= frame.y + DECORATED_TOP_RESIZE_MARGIN { + return Some(TitlebarHit::Resize(ResizeEdge::Top)); } return Some(TitlebarHit::Drag); } @@ -360,27 +576,24 @@ impl ResizeEdge { } fn resize_edge_at(frame: Rect, x: i32, y: i32, decorated: bool, resize_margin: i32) -> Option<ResizeEdge> { - let m = resize_margin; - let top_m = if decorated { m } else { UNDECORATED_TOP_RESIZE_MARGIN }; - // A corner resize gets a bigger, prioritized hit zone than a plain - // single edge, checked first - a diagonal drag is a harder target - // to land than a straight edge, and several desktops (GNOME, KDE) - // give it noticeably more room for exactly that reason. Reported - // live: corners felt like they had no priority over sides at all, - // which tracked - a corner previously only registered in the exact - // pixel square where both edges' own `resize_margin` zones happened - // to overlap (6x6px at the default margin), nothing wider. - // `corner_top_m` still respects the undecorated window's much - // narrower top reach (`UNDECORATED_TOP_RESIZE_MARGIN`) rather than - // widening it too - this must not reopen the "can't grab Firefox's - // tab strip" bug near a top corner, only the horizontal reach grows - // there. - let corner_m = CORNER_MARGIN * m; - let corner_top_m = if decorated { corner_m } else { UNDECORATED_TOP_RESIZE_MARGIN }; + // single edge for a *decorated* window - a diagonal drag is a + // harder target to land than a straight edge, and several desktops + // (GNOME, KDE) give it noticeably more room for exactly that reason. + // Reported live: corners felt like they had no priority over sides + // at all, which tracked - a corner previously only registered in + // the exact pixel square where both edges' own `resize_margin` zones + // happened to overlap (6x6px at the default margin), nothing wider. + // + // None of that widening applies to an *undecorated* window, on any + // edge or corner - see [`UNDECORATED_RESIZE_MARGIN`]'s own doc + // comment for why a single small, uniform margin is what's actually + // correct there. + let (m, corner_m) = if decorated { (resize_margin, CORNER_MARGIN * resize_margin) } else { (UNDECORATED_RESIZE_MARGIN, UNDECORATED_RESIZE_MARGIN) }; + let corner_left = x <= frame.x + corner_m; let corner_right = x >= frame.right() - corner_m; - let corner_top = y <= frame.y + corner_top_m; + let corner_top = y <= frame.y + corner_m; let corner_bottom = y >= frame.bottom() - corner_m; if corner_left && corner_top { return Some(ResizeEdge::TopLeft); @@ -397,7 +610,7 @@ impl ResizeEdge { let near_left = x <= frame.x + m; let near_right = x >= frame.right() - m; - let near_top = y <= frame.y + top_m; + let near_top = y <= frame.y + m; let near_bottom = y >= frame.bottom() - m; match (near_left, near_right, near_top, near_bottom) { (true, false, false, false) => Some(ResizeEdge::Left), @@ -455,6 +668,112 @@ pub enum TitlebarHit { Resize(ResizeEdge), } +/// One of the three titlebar buttons, for [`ButtonOrder`] - a narrower +/// type than [`TitlebarHit`] on purpose: `TitlebarHit` also carries +/// `Drag`/`Resize`, neither of which is a button a layout can place. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TitlebarButton { + Close, + Minimize, + Maximize, +} + +/// A custom `close,minimize,maximize`-style ordering for the three +/// titlebar buttons, overriding this project's own default order for +/// whichever side `buttons_left` already selects - KWin's `ButtonsOnLeft`/ +/// `ButtonsOnRight`, GNOME/Adwaita's `decoration-layout`, and Openbox's +/// `titlelayout` each independently converged on exactly this "ordered +/// list of button names" idea, confirmed this session by reading each +/// project's own real config docs/source rather than assuming. +/// +/// Positions are read closest-to-the-aligned-edge first, same as this +/// project's own two built-in defaults already are: on the left, index 0 +/// sits at the window's own left edge; on the right, index 0 sits at the +/// window's own right edge. This project's two defaults (macOS-style +/// close-minimize-maximize on the left, Windows/GTK-style close-maximize- +/// minimize on the right) are genuinely different *relative* orderings, +/// not mirrors of each other - seem `hit_test`'s own doc comment on why +/// that's deliberate - so `None` (the default, no override configured) +/// keeps using whichever of those two already applies rather than this +/// type imposing one order on both sides. +pub type ButtonOrder = [TitlebarButton; 3]; + +/// Parses a `srd.set("theme.decorations.button_order", "...")` value like +/// `"close,minimize,maximize"` into a [`ButtonOrder`] - `None` if it +/// doesn't name each of the three buttons exactly once (a typo'd or +/// partial list falls back to this project's own built-in default rather +/// than silently hiding a button or drawing one twice). +pub fn parse_button_order(s: &str) -> Option<ButtonOrder> { + let mut close = None; + let mut minimize = None; + let mut maximize = None; + for (i, part) in s.split(',').map(str::trim).enumerate() { + match part.to_ascii_lowercase().as_str() { + "close" => close = Some(i), + "minimize" | "minimise" => minimize = Some(i), + "maximize" | "maximise" => maximize = Some(i), + _ => return None, + } + } + let (Some(c), Some(mn), Some(mx)) = (close, minimize, maximize) else { return None }; + let mut order = [TitlebarButton::Close; 3]; + for (slot, button) in [(c, TitlebarButton::Close), (mn, TitlebarButton::Minimize), (mx, TitlebarButton::Maximize)] { + if slot >= 3 { + return None; + } + order[slot] = button; + } + // Every slot must have been assigned exactly once - three distinct + // source indices (0, 1, 2) covering three slots guarantees that; a + // repeated button name (e.g. "close,close,maximize") would have + // reused one slot index and left another unset, which range 0..3 + // alone can't catch. + let mut seen = [false; 3]; + for i in [c, mn, mx] { + if seen[i] { + return None; + } + seen[i] = true; + } + Some(order) +} + +#[cfg(test)] +mod button_order_tests { + use super::*; + + #[test] + fn parses_a_well_formed_order() { + assert_eq!(parse_button_order("close,minimize,maximize"), Some([TitlebarButton::Close, TitlebarButton::Minimize, TitlebarButton::Maximize])); + assert_eq!(parse_button_order("maximize,minimize,close"), Some([TitlebarButton::Maximize, TitlebarButton::Minimize, TitlebarButton::Close])); + } + + #[test] + fn is_case_insensitive_and_trims_whitespace() { + assert_eq!(parse_button_order(" Close, MINIMIZE ,Maximize"), Some([TitlebarButton::Close, TitlebarButton::Minimize, TitlebarButton::Maximize])); + } + + #[test] + fn accepts_the_british_spelling() { + assert_eq!(parse_button_order("close,minimise,maximise"), Some([TitlebarButton::Close, TitlebarButton::Minimize, TitlebarButton::Maximize])); + } + + #[test] + fn rejects_a_missing_button() { + assert_eq!(parse_button_order("close,minimize"), None); + } + + #[test] + fn rejects_a_duplicated_button() { + assert_eq!(parse_button_order("close,close,maximize"), None); + } + + #[test] + fn rejects_an_unknown_token() { + assert_eq!(parse_button_order("close,minimize,help"), None); + } +} + #[cfg(test)] mod tests { use super::*; @@ -499,29 +818,99 @@ mod tests { #[test] fn close_button_is_top_right_corner_of_titlebar() { let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5, true, 0, RESIZE_MARGIN); + // `BUTTON_CLUSTER_MARGIN` back from the edge, not just `- 5`: that + // 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); assert_eq!(hit, Some(TitlebarHit::Close)); } #[test] fn maximize_is_left_of_close() { let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - TITLEBAR_HEIGHT as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN); + 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); assert_eq!(hit, Some(TitlebarHit::Maximize)); } #[test] + fn a_dialog_only_ever_recognizes_close_not_minimize_or_maximize() { + // The whole point of `is_dialog`: the same point that hits Maximize + // for a normal window (see `maximize_is_left_of_close` just above) + // must not hit anything at all for a dialog - that button was + // never drawn there in the first place (`decoration:: + // render_titlebar`'s own `is_dialog` branch), so a phantom hit zone + // there would be exactly the "click does nothing, or worse, hits + // the wrong control" bug this codebase already fixed once for + // 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); + 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); + assert_eq!(close_hit, Some(TitlebarHit::Close)); + } + + #[test] + fn a_dialog_recognizes_close_even_with_a_button_order_override_that_does_not_start_with_it() { + // An explicit `button_order` still must not be able to put + // Minimize/Maximize where a dialog's one real button (Close) is -- + // see `hit_test`'s own doc comment on why `is_dialog` ignores + // `order_override` outright rather than just capping how many of + // 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); + assert_eq!(hit, Some(TitlebarHit::Close)); + } + + #[test] + fn an_explicit_button_order_moves_the_hit_zones_to_match() { + // Same point `maximize_is_left_of_close` above hits as Maximize + // under the built-in default - an override putting minimize + // there instead must change what a click there actually does, + // 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); + assert_eq!(hit, Some(TitlebarHit::Minimize)); + } + + #[test] + fn a_button_order_override_applies_the_same_way_on_either_side() { + // The whole point of an explicit override: unlike the two built- + // in defaults (genuinely different relative orderings per side, + // see `ButtonOrder`'s own doc comment), a caller-specified order + // reads closest-to-edge-first the same way whichever side it's + // 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); + assert_eq!(left_hit, Some(TitlebarHit::Maximize)); + assert_eq!(right_hit, Some(TitlebarHit::Maximize)); + } + + #[test] fn middle_of_titlebar_is_drag() { + // Past `DECORATED_TOP_RESIZE_MARGIN`, not just `f.y + 5` (this + // test's original y) - once the titlebar's own thin top edge + // gained a resize zone, a point that shallow no longer tests + // "plain drag area" at all. See `decorated_window_very_top_edge_ + // 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 + 5, true, 0, RESIZE_MARGIN); + let hit = ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false); 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); + let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0, RESIZE_MARGIN, false, None, false); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::BottomRight))); } @@ -538,7 +927,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); + let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0, RESIZE_MARGIN, false, None, false); assert_eq!(hit, None); } @@ -546,7 +935,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); + let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0, RESIZE_MARGIN, false, None, false); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top))); } @@ -557,23 +946,64 @@ mod tests { // click meant to drag-move it via the client's own `xdg_toplevel. // move` has to actually reach the client - the full `RESIZE_MARGIN` // (10px) swallowed most of a natural grab point near the top as a - // resize instead. A *decorated* window's top band is unaffected -- - // it already has TITLEBAR_HEIGHT worth of unambiguous drag space - // above where `RESIZE_MARGIN` even starts to matter. + // resize instead. A *decorated* window's own top band gained a + // matching (if wider) resize margin of its own since this test was + // first written - see `DECORATED_TOP_RESIZE_MARGIN`'s own doc + // comment - so this now checks *past* both margins, where the two + // must still agree (undecorated: reaches the client; decorated: + // plain drag), rather than claiming the decorated band has no top + // 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), 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), None, "5px in: past the narrow undecorated band, must reach the client"); assert_eq!( - ResizeEdge::hit_test(f, cx, f.y + 5, true, 0, RESIZE_MARGIN), + ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Drag), - "decorated: 5px in is still well inside the titlebar band, not a resize edge" + "decorated: past its own (wider) top resize margin, still plain drag" ); } #[test] + fn undecorated_corner_resize_is_not_widened_either() { + // Same regression as the top-margin test above, but for a corner -- + // a real live report was a click on Nemo's own tab-close button, + // hard against the window's right edge, near the top, landing on a + // phantom resize instead of the client. This point is well past + // `UNDECORATED_RESIZE_MARGIN` (3px) on both axes but was still + // 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); + assert_eq!(hit, None, "past the narrow undecorated corner margin on both axes, must reach the client"); + } + + #[test] + fn undecorated_bottom_right_corner_also_uses_the_narrow_margin() { + // The top corners aren't the only ones a CSD client can draw real + // 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); + assert_eq!(hit, None, "past the narrow undecorated corner margin on both axes, must reach the client"); + } + + #[test] + fn undecorated_window_resizes_from_right_and_bottom_edges_within_the_narrow_margin() { + // Confirms the narrow margin is still a real, working resize zone on + // every edge, not just the top - this fix must not trade "buttons + // near an edge are clickable" for "can't resize from that edge at + // all". + 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))); + } + + #[test] fn outside_frame_is_none() { let f = frame(); - assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0, RESIZE_MARGIN), None); + assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0, RESIZE_MARGIN, false, None, false), None); } #[test] @@ -588,16 +1018,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), 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), 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), + ResizeEdge::hit_test(f, x, cy, true, border_width, RESIZE_MARGIN, false, None, false), 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), None); + assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width, RESIZE_MARGIN, false, None, false), None); } #[test] @@ -614,7 +1044,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), Some(TitlebarHit::Resize(ResizeEdge::BottomLeft))); + assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::BottomLeft))); } #[test] @@ -625,7 +1055,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), Some(TitlebarHit::Resize(ResizeEdge::Bottom))); + assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::Bottom))); } #[test] @@ -636,7 +1066,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); + let hit = ResizeEdge::hit_test(f, f.x + corner_reach - 1, f.y + corner_reach - 1, true, 0, RESIZE_MARGIN, false, None, false); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::TopLeft))); } @@ -647,7 +1077,52 @@ mod tests { // convention, rather than competing with a resize zone at exactly // the spot a miss is most costly. let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + 1, true, 0, RESIZE_MARGIN); + // Just inside `BUTTON_CLUSTER_MARGIN`, not the raw corner pixel -- + // 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); + assert_eq!(hit, Some(TitlebarHit::Close)); + } + + #[test] + fn decorated_window_very_top_edge_of_titlebar_resizes_not_drags() { + // The actual regression: reported live as "can't resize tmux's + // window from the top, but can in Firefox" - a decorated window's + // titlebar band claimed *every* button-free pixel as `Drag` + // unconditionally, with no top-edge resize zone at all outside the + // two tiny diagonal corners, unlike an undecorated window's own + // (narrower) top-edge margin. `x` is the titlebar's horizontal + // middle, clear of both the corner zones and either side's button + // 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); + assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top))); + } + + #[test] + fn decorated_window_titlebar_below_the_top_margin_still_drags() { + // Sanity check for the fix above: only the thin top margin itself + // gained a resize zone - the rest of the titlebar (where most + // real drags actually start) must still read as `Drag`, not have + // 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); + assert_eq!(hit, Some(TitlebarHit::Drag)); + } + + #[test] + fn decorated_window_top_edge_over_a_button_still_hits_the_button() { + // The other half of the fix: the new top-margin resize zone must + // not swallow clicks meant for a button just because that button + // also happens to sit within the first few rows of the titlebar -- + // exactly the "buttons... not swallowing" risk this was written to + // 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); assert_eq!(hit, Some(TitlebarHit::Close)); } |