diff options
| author | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
| commit | 0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch) | |
| tree | 7672d0af277664f457c6c9462925c0005fe35dcf /crates/core/src/manager | |
| parent | 413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff) | |
| download | srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip | |
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has
diverged with its own separate fixes today. Nothing here is reviewed
or curated yet - this exists purely so none of this work can be lost
to a git operation, disk issue, or worktree cleanup while that
reconciliation happens.
Diffstat (limited to 'crates/core/src/manager')
| -rw-r--r-- | crates/core/src/manager/dragresize.rs | 54 | ||||
| -rw-r--r-- | crates/core/src/manager/hittest.rs | 53 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 182 | ||||
| -rw-r--r-- | crates/core/src/manager/monitors.rs | 171 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 395 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 59 | ||||
| -rw-r--r-- | crates/core/src/manager/workspaces.rs | 115 |
7 files changed, 1000 insertions, 29 deletions
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) } } |