srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src/manager
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/manager
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/manager')
-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
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)
}
}