srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-02-15 14:56:00 +0200
committersrdusr <[email protected]>2025-02-15 14:56:00 +0200
commit0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch)
tree7672d0af277664f457c6c9462925c0005fe35dcf /crates/core/src
parent413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff)
downloadsrdwm-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.rs7
-rw-r--r--crates/core/src/manager/dragresize.rs54
-rw-r--r--crates/core/src/manager/hittest.rs53
-rw-r--r--crates/core/src/manager/mod.rs182
-rw-r--r--crates/core/src/manager/monitors.rs171
-rw-r--r--crates/core/src/manager/tests.rs395
-rw-r--r--crates/core/src/manager/windows.rs59
-rw-r--r--crates/core/src/manager/workspaces.rs115
-rw-r--r--crates/core/src/monitor.rs239
-rw-r--r--crates/core/src/theme.rs122
-rw-r--r--crates/core/src/window.rs643
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));
}