srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-11-30 23:03:00 +0200
committersrdusr <[email protected]>2024-11-30 23:03:00 +0200
commit583063c3094ca0cf9f6cceba92d710b238547f6b (patch)
treea1ff8f181027892f4a45846b67898beb136a9312
parent87f5c04b2593dc49409644e69e7962b73d26b9d8 (diff)
downloadsrdwm-583063c3094ca0cf9f6cceba92d710b238547f6b.tar.gz
srdwm-583063c3094ca0cf9f6cceba92d710b238547f6b.zip
Accumulate core-crate additions: capture requests, focus/workspace fixes, test coverage
Bundles several related changes to crates/core built up over this session rather than committed incrementally: - WindowManager::request_capture_workspace/drain_capture_requests (new manager/capture.rs) - backend-agnostic queuing for an off-screen workspace render, see the wayland-side commit for why this exists. - focus_window now switches workspace as a side effect when the target isn't on the current one, matching Hyprland's focuswindow convention (manager/focus.rs). - Assorted window/rules/theme field additions and their test coverage. Left less granular than the repo's usual one-purpose-per-commit convention deliberately: these accumulated across a long session without being committed as they landed, and are too entangled line-by-line to safely split apart now without risking mis-attributing changes to the wrong commit message.
-rw-r--r--crates/core/src/event.rs48
-rw-r--r--crates/core/src/lib.rs8
-rw-r--r--crates/core/src/manager/capture.rs78
-rw-r--r--crates/core/src/manager/focus.rs29
-rw-r--r--crates/core/src/manager/mod.rs59
-rw-r--r--crates/core/src/manager/tests.rs251
-rw-r--r--crates/core/src/manager/windows.rs5
-rw-r--r--crates/core/src/manager/winops.rs54
-rw-r--r--crates/core/src/rules.rs1
-rw-r--r--crates/core/src/theme.rs30
10 files changed, 533 insertions, 30 deletions
diff --git a/crates/core/src/event.rs b/crates/core/src/event.rs
index ad96f73..0bfadc0 100644
--- a/crates/core/src/event.rs
+++ b/crates/core/src/event.rs
@@ -40,7 +40,31 @@ pub enum MouseButton {
/// A key combination, e.g. "Mod4+Shift+Return", used both as the canonical
/// string form for Lua keybindings and as the lookup key at dispatch time.
+///
+/// Lowercases a single ASCII letter's name before building the string --
+/// `Shift+r` and `Shift+R` are genuinely different X11 keysyms (`XK_r`/
+/// `XK_R`), not case variants of one shared symbol, so a real Shift+<letter>
+/// keypress's `modified_sym()` (see `input.rs::handle_keyboard_key_event`)
+/// reports the uppercase name even though `Shift` is *also* tracked as its
+/// own bit in `modifiers` - meaning without this, "Super+Shift+r" (how
+/// every config writes it, lowercase) could never match a real Shift+R
+/// keypress's actual combo string ("Shift+Mod4+R"). `canonicalize_key_
+/// combo`'s own case-fixing (for named keys like "Space") explicitly
+/// leaves plain letters alone, so it doesn't catch this either - this is
+/// the one place both the config-registration path (`canonicalize_key_
+/// combo` calls this) and the real-dispatch path converge, so normalizing
+/// here is the single fix for both. Confirmed live: a real keypress
+/// reached srdwm fine (a sibling Alt+Tab binding in the same config
+/// fired), but every `Shift+<letter>` binding - including the reload key
+/// this was found chasing - silently never did.
pub fn key_combo_string(modifiers: Modifiers, key_name: &str) -> String {
+ let lowercased;
+ let key_name = if key_name.chars().count() == 1 && key_name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
+ lowercased = key_name.to_ascii_lowercase();
+ lowercased.as_str()
+ } else {
+ key_name
+ };
format!("{modifiers}{key_name}")
}
@@ -185,6 +209,30 @@ mod tests {
}
#[test]
+ fn a_real_shifted_letter_keypress_matches_the_lowercase_written_binding() {
+ // `Shift+r` and `Shift+R` are different X11 keysyms (`XK_r`/`XK_R`),
+ // not case variants of one shared symbol - a real Shift+<letter>
+ // keypress's dispatch-time key name is the *uppercase* one, even
+ // though Shift is also tracked as its own bit in `modifiers`. Every
+ // config writes bindings lowercase ("Super+Shift+r"), so the two
+ // sides have to agree despite that: this is what silently broke
+ // every `Shift+<letter>` binding (found chasing a dead reload key)
+ // until `key_combo_string` started normalizing single letters.
+ let config_side = canonicalize_key_combo("Mod4+Shift+r");
+ let dispatch_side = key_combo_string(Modifiers::SHIFT | Modifiers::SUPER, "R");
+ assert_eq!(config_side, dispatch_side);
+ assert_eq!(config_side, "Shift+Mod4+r");
+ }
+
+ #[test]
+ fn named_keys_are_unaffected_by_the_letter_lowercasing() {
+ // Multi-character key names ("Space", "Return", ...) don't change
+ // identity with Shift the way a single letter's keysym does, so
+ // they must not be touched by the same normalization.
+ assert_eq!(key_combo_string(Modifiers::SHIFT, "Space"), "Shift+Space");
+ }
+
+ #[test]
fn lowercase_named_key_still_reaches_the_capitalized_dispatch_form() {
// `srd.bind("Super+space", ...)` (lowercase, as a real config had
// it) must resolve to the exact same string a live Space keypress
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 471fece..56f8779 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -2,6 +2,7 @@ pub mod event;
pub mod geometry;
pub mod keysyms;
pub mod layout;
+pub mod lock_config;
pub mod manager;
pub mod monitor;
pub mod placement;
@@ -13,11 +14,12 @@ pub mod workspace;
pub use event::{canonicalize_key_combo, key_combo_string, parse_key_combo, Event, MouseButton, Modifiers};
pub use geometry::Rect;
pub use layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
-pub use manager::{Direction, WindowManager};
+pub use lock_config::LockConfig;
+pub use manager::{CaptureRequest, Direction, WindowManager};
pub use monitor::{Monitor, MonitorId};
-pub use placement::{PlacementConfig, SmartPlacement};
+pub use placement::{PlacementConfig, SmartPlacement, SnapZoneKind};
pub use regex::Regex;
pub use rules::{WindowMatch, WindowRule, WindowRuleActions};
pub use theme::{parse_hex_color, ThemeConfig};
-pub use window::{GlobalMenu, MenuSource, ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN, TITLEBAR_HEIGHT};
+pub use window::{classify_menu_source, GlobalMenu, MenuSource, ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN, TITLEBAR_HEIGHT};
pub use workspace::{Workspace, WorkspaceId};
diff --git a/crates/core/src/manager/capture.rs b/crates/core/src/manager/capture.rs
new file mode 100644
index 0000000..41fe0cd
--- /dev/null
+++ b/crates/core/src/manager/capture.rs
@@ -0,0 +1,78 @@
+//! Requesting an off-screen render of a workspace's window tree to a file.
+//! Split out the same way `lock.rs` is - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions.
+
+use super::*;
+
+/// One queued `srd capture workspace <id> <path> [WxH]` request. Core has
+/// no renderer of its own (that's backend-owned, same boundary
+/// `request_lock`/`request_output_position` already cross) - this is
+/// just the request's data, drained and acted on by whichever backend is
+/// actually running.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CaptureRequest {
+ pub workspace: WorkspaceId,
+ pub path: String,
+ /// `None` renders at the workspace's own monitor resolution.
+ pub size: Option<(u32, u32)>,
+}
+
+impl WindowManager {
+ /// Queues an off-screen capture of `workspace`'s window tree, written
+ /// to `path` as a PPM image once the backend's next poll drains it.
+ /// Exists for exactly one reason: a workspace switcher (AGS's Overview)
+ /// wanting a thumbnail of a workspace that is not the one currently on
+ /// screen. `wlr-screencopy` (what `grim`, and this compositor's own
+ /// `screencopy.rs`, use) can only ever see what an output is actually
+ /// presenting - it has no way to see a workspace that is not the
+ /// active one, which is exactly the case a workspace-switcher preview
+ /// needs most. This is not a re-implementation of screencopy; it is
+ /// the one thing screencopy structurally cannot do, requested the same
+ /// cross-boundary way `request_lock` is.
+ ///
+ /// Multiple requests for the same workspace queue independently (unlike
+ /// `request_lock`'s single flag) - a caller asking for two different
+ /// sizes, or overwriting a previous request for the same workspace
+ /// before the backend gets to it, are both legitimate.
+ pub fn request_capture_workspace(&mut self, workspace: WorkspaceId, path: String, size: Option<(u32, u32)>) {
+ self.capture_requests.push(CaptureRequest { workspace, path, size });
+ }
+
+ /// Takes every currently-queued capture request, leaving none pending.
+ /// The backend calls this once per poll, same as
+ /// `drain_output_position_requests`.
+ pub fn drain_capture_requests(&mut self) -> Vec<CaptureRequest> {
+ std::mem::take(&mut self.capture_requests)
+ }
+
+ /// Front-to-back window ids for an arbitrary (not necessarily current)
+ /// workspace - the same "topmost first" convention
+ /// `visible_windows_front_to_back` already gives the current one, but
+ /// that method is hardcoded to `self.current_workspace`, and a capture
+ /// request's whole reason for existing is targeting a workspace that
+ /// usually is *not* the current one. `order` is back-to-front (see its
+ /// own field doc comment), hence the same `.rev()` that method uses.
+ pub fn window_ids_on_workspace_front_to_back(&self, workspace: WorkspaceId) -> Vec<WindowId> {
+ self.order.iter().rev().filter(|&&id| self.windows.get(&id).is_some_and(|w| w.workspace == workspace && !w.minimized)).copied().collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn capture_requests_drain_in_arrival_order() {
+ let mut wm = WindowManager::new();
+ wm.request_capture_workspace(0, "/tmp/a.ppm".into(), None);
+ wm.request_capture_workspace(1, "/tmp/b.ppm".into(), Some((100, 100)));
+ assert_eq!(
+ wm.drain_capture_requests(),
+ vec![
+ CaptureRequest { workspace: 0, path: "/tmp/a.ppm".into(), size: None },
+ CaptureRequest { workspace: 1, path: "/tmp/b.ppm".into(), size: Some((100, 100)) },
+ ]
+ );
+ assert!(wm.drain_capture_requests().is_empty(), "must not report the same requests twice");
+ }
+}
diff --git a/crates/core/src/manager/focus.rs b/crates/core/src/manager/focus.rs
index c281d7c..6dae20a 100644
--- a/crates/core/src/manager/focus.rs
+++ b/crates/core/src/manager/focus.rs
@@ -17,7 +17,34 @@ impl WindowManager {
}
pub fn focus_window(&mut self, id: WindowId) {
- if self.windows.contains_key(&id) {
+ if let Some(workspace) = self.windows.get(&id).map(|w| w.workspace) {
+ // Switch to the target's workspace first if it isn't already
+ // the active one - without this, focusing a window elsewhere
+ // (Alt-Tab, a dock icon, anything that ends up calling this)
+ // marked it focused while leaving it genuinely off-screen:
+ // `visible_windows`/rendering both gate on `workspace ==
+ // current_workspace`, so keyboard focus landed on a window the
+ // user could not see, while whatever was actually on screen
+ // kept looking focused-ish. Reported live (relayed from the
+ // AGS peer session, who measured it directly over IPC):
+ // `srd dispatch focus` on a window from a different workspace
+ // left `current_workspace` unchanged and the target `visible:
+ // false`. Every caller of `focus_window` gets this for free
+ // rather than each one remembering to switch workspaces
+ // itself first - matches the convention the AGS shell was
+ // already built against (Hyprland's `focuswindow` switches
+ // workspace as a side effect of focusing). Guarded on
+ // `w.workspace != self.current_workspace` specifically, not
+ // just always calling `switch_workspace`: that function's own
+ // `auto_back_and_forth` handling treats being asked to
+ // "switch" to the *already*-current workspace as a deliberate
+ // toggle-to-previous gesture, which an ordinary redundant
+ // focus call (the common case: re-focusing whatever is
+ // already focused and already visible) must not trigger as a
+ // surprise workspace jump.
+ if workspace != self.current_workspace {
+ self.switch_workspace(workspace);
+ }
self.focused = Some(id);
self.raise_window(id);
}
diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs
index 6285736..38e0bbb 100644
--- a/crates/core/src/manager/mod.rs
+++ b/crates/core/src/manager/mod.rs
@@ -1,8 +1,9 @@
use crate::geometry::Rect;
use crate::layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
use crate::monitor::{Monitor, MonitorId};
-use crate::placement::{PlacementConfig, SmartPlacement, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH};
+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::workspace::{Workspace, WorkspaceId};
@@ -40,6 +41,32 @@ pub struct WindowManager {
order: Vec<WindowId>,
focused: Option<WindowId>,
monitors: Vec<Monitor>,
+ /// Backend-agnostic "please move this output" requests, queued by
+ /// `request_output_position` (an IPC `set_output_position` dispatch is
+ /// the only caller today) and drained by whichever backend actually
+ /// owns real output hardware (`drain_output_position_requests`) on its
+ /// own next poll. Core has no way to reposition a real `Output` itself
+ /// - monitor geometry flows one direction, backend into core, via
+ /// `set_monitors` - so a request from an IPC caller (an AGS display-
+ /// settings panel wanting to set up monitor mirroring, concretely) has
+ /// to cross back over that boundary the same indirect way window
+ /// geometry changes do in the other direction: queued here, applied by
+ /// the backend, and `set_monitors` reports the result back on the
+ /// 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 - 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
+ /// intent here via `request_lock` and whichever backend is running
+ /// drains it (`drain_lock_request`) on its own next poll.
+ lock_requested: bool,
+ /// Same cross-boundary-request pattern as `output_position_requests`
+ /// again - see `capture::CaptureRequest`'s own doc comment for why
+ /// this exists at all (a workspace switcher needing a thumbnail of a
+ /// workspace that isn't the one currently presented, which no Wayland
+ /// 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
@@ -100,6 +127,9 @@ pub struct WindowManager {
/// Default decoration colours and border width, read from `theme.colors.*`/
/// `theme.decorations.*`. See `ThemeConfig`'s own doc comment.
pub theme: ThemeConfig,
+ /// Read from `theme.lock.*`. See `LockConfig`'s own doc comment for
+ /// why this isn't just folded into `theme` above.
+ pub lock: LockConfig,
drag: Option<DragState>,
resize: Option<ResizeState>,
rules: Vec<WindowRule>,
@@ -108,6 +138,23 @@ pub struct WindowManager {
/// because `WindowManager` is platform-agnostic and has no way to send
/// a client its close request directly - see `close_window`.
close_requests: Vec<WindowId>,
+ /// The active XKB layout's own name (e.g. `"English (US)"`, whatever
+ /// `xkb_keymap_layout_get_name` reports) - set by the platform once at
+ /// startup and again after every `take_keyboard_layout_cycle_requests`
+ /// is acted on. Empty until the platform has reported it at least once
+ /// (a nested/test `WindowManager::new()` with no real keyboard, most
+ /// of core's own tests). Read-only from an external caller's point of
+ /// view (an AGS peer session's keyboard-layout badge, over `srd`); the
+ /// only way to change it is a real layout cycle.
+ pub keyboard_layout: String,
+ /// How many `srd dispatch cycle_keyboard_layout` requests have arrived
+ /// since the last drain - a count, not a flag, so two IPC requests in
+ /// one tick both take effect rather than the second being silently
+ /// swallowed. Same "core records the intent, `main.rs`'s `sync()`
+ /// forwards it to the platform that can actually act on it" shape as
+ /// `close_requests`, for the same reason: `WindowManager` has no real
+ /// keyboard/seat handle of its own to cycle.
+ keyboard_layout_cycle_requests: u32,
}
impl Default for WindowManager {
@@ -128,6 +175,9 @@ impl WindowManager {
order: Vec::new(),
focused: None,
monitors: Vec::new(),
+ output_position_requests: Vec::new(),
+ lock_requested: false,
+ capture_requests: Vec::new(),
workspaces: vec![Workspace::new(0, "1", "dynamic")],
current_workspace: 0,
previous_workspace: 0,
@@ -145,10 +195,13 @@ impl WindowManager {
focus_follows_mouse: false,
auto_raise: false,
theme: ThemeConfig::default(),
+ lock: LockConfig::default(),
drag: None,
resize: None,
rules: Vec::new(),
close_requests: Vec::new(),
+ keyboard_layout: String::new(),
+ keyboard_layout_cycle_requests: 0,
}
}
@@ -168,14 +221,18 @@ impl WindowManager {
}
+mod capture;
mod dragresize;
mod focus;
mod hittest;
mod layout;
+mod lock;
mod monitors;
mod windows;
mod winops;
mod workspaces;
+pub use capture::CaptureRequest;
+
#[cfg(test)]
mod tests;
diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs
index e1518f1..1c80304 100644
--- a/crates/core/src/manager/tests.rs
+++ b/crates/core/src/manager/tests.rs
@@ -23,6 +23,30 @@
}
#[test]
+ fn add_window_picks_up_the_configured_default_decoration_mode() {
+ let mut wm = wm_with_monitor();
+ wm.theme.default_decorated = false;
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+ assert!(!wm.window(id).unwrap().decorated, "must pick up the live theme default, not Window::new's own hardcoded one");
+ }
+
+ #[test]
+ fn a_rules_decorated_action_still_overrides_the_theme_default() {
+ let mut wm = wm_with_monitor();
+ wm.theme.default_decorated = false;
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { class: Some("nemo".into()), ..Default::default() },
+ actions: crate::rules::WindowRuleActions { decorated: Some(true), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "a");
+ w.app_id = "nemo".into();
+ wm.add_window(w);
+ assert!(wm.window(id).unwrap().decorated, "an explicit rule must still win over the theme-wide default");
+ }
+
+ #[test]
fn tiling_workspace_arranges_two_windows_side_by_side() {
let mut wm = wm_with_monitor();
wm.set_layout(wm.current_workspace(), "tiling");
@@ -144,6 +168,30 @@
}
#[test]
+ fn apply_snap_zone_resizes_to_the_named_zones_rect() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.geometry = Rect::new(50, 50, 300, 200);
+ wm.add_window(w);
+ wm.apply_snap_zone(a, SnapZoneKind::LeftHalf);
+ assert_eq!(wm.window(a).unwrap().geometry, Rect::new(0, 0, 960, 1080));
+ }
+
+ #[test]
+ fn apply_snap_zone_on_a_maximized_window_un_maximizes_it() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ wm.toggle_maximize(a);
+ assert!(wm.window(a).unwrap().maximized);
+ wm.apply_snap_zone(a, SnapZoneKind::TopRightQuarter);
+ let w = wm.window(a).unwrap();
+ assert!(!w.maximized, "snapping a maximized window must clear the maximized flag");
+ assert_eq!(w.geometry, Rect::new(960, 0, 960, 540));
+ }
+
+ #[test]
fn maximize_records_anim_from_when_animations_enabled() {
let mut wm = wm_with_monitor();
let a = wm.alloc_window_id();
@@ -357,6 +405,51 @@
}
#[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
+ // (relayed from the AGS peer session, measured directly over IPC):
+ // `srd dispatch focus <id>` on a window from a different workspace
+ // left the active workspace unchanged and the newly-"focused"
+ // window `visible: false`, so keyboard input had nowhere visible
+ // to go while whatever was actually on screen kept looking
+ // focused. Reachable by ordinary Alt-Tab, a dock icon, or anything
+ // else that ends up calling `focus_window` on a window that isn't
+ // on the current workspace.
+ let mut wm = wm_with_monitor();
+ let ws2 = wm.add_workspace("2", "dynamic");
+ 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");
+
+ wm.focus_window(id);
+ assert_eq!(wm.current_workspace(), ws2, "focusing a window must bring its workspace along");
+ assert_eq!(wm.focused_id(), Some(id));
+ }
+
+ #[test]
+ fn refocusing_an_already_visible_window_does_not_trigger_auto_back_and_forth() {
+ // The fix above must not call `switch_workspace` unconditionally --
+ // `switch_workspace`'s own `auto_back_and_forth` handling treats
+ // being asked to "switch" to the *already*-current workspace as a
+ // deliberate toggle-to-previous gesture. An ordinary redundant
+ // `focus_window` call (re-focusing something already focused and
+ // already visible - ordinary mouse click traffic, not a workspace
+ // switch request) must not be misread as that gesture and jump the
+ // user to `previous_workspace` as a surprise side effect.
+ let mut wm = wm_with_monitor();
+ wm.auto_back_and_forth = true;
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+
+ wm.focus_window(id);
+ assert_eq!(wm.current_workspace(), ws2, "must stay put - this is not a workspace-switch request");
+ }
+
+ #[test]
fn switching_to_a_nonexistent_workspace_does_not_move_or_touch_previous() {
let mut wm = wm_with_monitor();
let ws2 = wm.add_workspace("2", "dynamic");
@@ -372,6 +465,31 @@
}
#[test]
+ fn output_position_requests_drain_in_arrival_order() {
+ let mut wm = wm_with_monitor();
+ wm.request_output_position(0, 100, 0);
+ wm.request_output_position(1, 0, 0);
+ assert_eq!(wm.drain_output_position_requests(), vec![(0, 100, 0), (1, 0, 0)]);
+ // Draining empties the queue - a second drain with nothing new
+ // queued in between must come back empty, not repeat the same
+ // requests the backend already applied.
+ assert!(wm.drain_output_position_requests().is_empty());
+ }
+
+ #[test]
+ fn a_second_output_position_request_for_the_same_output_replaces_the_first() {
+ // Only the latest requested position for a given output should
+ // survive to the next drain - e.g. a display-settings panel
+ // dragging a monitor preview around fires many requests for the
+ // same output before the user lets go; the backend only needs to
+ // apply where it ended up, not replay the whole drag.
+ let mut wm = wm_with_monitor();
+ wm.request_output_position(0, 100, 0);
+ wm.request_output_position(0, 200, 50);
+ assert_eq!(wm.drain_output_position_requests(), vec![(0, 200, 50)]);
+ }
+
+ #[test]
fn rename_workspace_is_a_no_op_for_an_id_that_does_not_exist() {
let mut wm = wm_with_monitor();
wm.rename_workspace(9999, "ghost");
@@ -419,6 +537,29 @@
}
#[test]
+ fn scratchpad_show_brings_it_back_even_when_minimized_through_a_different_path() {
+ // A scratchpad window can be minimized several ways besides the
+ // `scratchpad_show` toggle-off branch itself - a titlebar minimize
+ // button, a client's own `minimize_request` (both ultimately call
+ // this same `minimize_window`). `scratchpad` is pool membership,
+ // tracked independently of *how* the window ended up minimized, so
+ // pressing the scratchpad binding afterward must still find and
+ // show it - not treat it as "already handled" just because
+ // something other than `scratchpad_show` did the hiding.
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "term"));
+ wm.scratchpad_add(a);
+ wm.scratchpad_show(); // shown + focused
+ wm.minimize_window(a); // hidden via the generic path, not the toggle
+ assert!(wm.window(a).unwrap().scratchpad, "must still be pool-managed after an ordinary minimize");
+ wm.scratchpad_show();
+ let w = wm.window(a).unwrap();
+ assert!(!w.minimized, "the binding must show it again, not treat it as already visible");
+ assert_eq!(wm.focused_id(), Some(a));
+ }
+
+ #[test]
fn scratchpad_show_moves_the_window_onto_the_current_workspace() {
let mut wm = wm_with_monitor();
let a = wm.alloc_window_id();
@@ -630,8 +771,14 @@
let id = wm.alloc_window_id();
let mut w = Window::new(id, "firefox");
w.geometry = Rect::new(100, 100, 400, 300);
- w.decorated = false;
wm.add_window(w);
+ // Set after `add_window`, not before - `add_window` now applies
+ // `theme.default_decorated` unconditionally (same as `corner_radius`/
+ // `border_color` already did), matching how a real client's
+ // negotiated CSD mode actually lands in production too:
+ // `set_decorated_from_mode` runs against an already-added window,
+ // never folded into the `Window` passed into `add_window` itself.
+ wm.window_mut(id).unwrap().decorated = false;
wm.toggle_fullscreen(id);
assert!(!wm.window(id).unwrap().decorated, "fullscreen itself must still drop the titlebar");
@@ -647,6 +794,22 @@
fn monitor_with_dock() -> Monitor {
let mut m = Monitor::new(0, "primary", Rect::new(0, 0, 1920, 1020));
m.full_geometry = Rect::new(0, 0, 1920, 1080);
+ // No top bar in this fixture - maximize ignores the dock the same
+ // way fullscreen does, so it's the same rect as `full_geometry`.
+ m.maximize_geometry = Rect::new(0, 0, 1920, 1080);
+ m.primary = true;
+ m
+ }
+
+ /// A monitor with *both* a bottom dock's exclusive zone and a top bar's,
+ /// distinguishing `maximize_geometry` (stops at the bar, ignores the
+ /// dock) from `full_geometry` (ignores both) and `geometry` (stops at
+ /// both) - `monitor_with_dock` alone can't tell these apart since it
+ /// has no bar to stop at.
+ fn monitor_with_dock_and_bar() -> Monitor {
+ let mut m = Monitor::new(0, "primary", Rect::new(0, 34, 1920, 986));
+ m.full_geometry = Rect::new(0, 0, 1920, 1080);
+ m.maximize_geometry = Rect::new(0, 34, 1920, 1046);
m.primary = true;
m
}
@@ -654,12 +817,14 @@
#[test]
fn fullscreen_covers_the_full_monitor_ignoring_a_dock_reservation() {
// Regression test: fullscreen used to target `Monitor::geometry`
- // (the usable, exclusive-zone-shrunk area), the same field maximize
- // correctly uses - so a fullscreened window stopped short of a
- // dock's reserved strip instead of covering (or going under) it
- // like fullscreen does everywhere else. `full_geometry` is what
- // fixes that; `geometry` must stay untouched so maximize keeps
- // respecting the dock.
+ // (the usable, exclusive-zone-shrunk area) - so a fullscreened
+ // window stopped short of a dock's reserved strip instead of
+ // covering (or going under) it like fullscreen does everywhere
+ // else. `full_geometry` is what fixes that. `toggle_maximize` now
+ // targets the same rect (see `maximize_also_covers_the_full_monitor_
+ // ignoring_a_dock_reservation` below) - on the user's own request,
+ // not a bug fix - so this is no longer the one place `full_geometry`
+ // matters, just the first.
let mut wm = WindowManager::new();
wm.set_monitors(vec![monitor_with_dock()]);
let id = wm.alloc_window_id();
@@ -670,42 +835,78 @@
}
#[test]
- fn maximize_still_respects_the_dock_reservation() {
+ fn maximize_also_covers_the_full_monitor_ignoring_a_dock_reservation() {
+ // `toggle_maximize` used to target `Monitor::geometry` (the usable,
+ // exclusive-zone-shrunk area), deliberately different from
+ // fullscreen's `full_geometry` - several desktops' convention of a
+ // maximized window stopping short of a persistent dock. Changed on
+ // the user's own request ("maximize should still go past dock
+ // area/no dock in that mode"): maximize now covers the same full
+ // rect fullscreen does, the only remaining difference being
+ // `decorated`. A layer-shell client with its own overlap-based
+ // auto-hide (AGS's dock) can react to the window now genuinely
+ // overlapping its band - nothing here forces the dock/bar to hide.
let mut wm = WindowManager::new();
wm.set_monitors(vec![monitor_with_dock()]);
let id = wm.alloc_window_id();
wm.add_window(Window::new(id, "a"));
wm.toggle_maximize(id);
- assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1020), "maximize must still stop at the dock, unlike fullscreen");
+ assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1080), "maximize must reach the true monitor edge, past the dock, same as fullscreen");
}
#[test]
- fn maximized_window_grows_when_the_dock_drops_its_reservation_live() {
- // Regression test: a dock that hides/reduces its exclusive zone
- // while a window is already maximized (an auto-hide dock reacting
- // to monocle/maximize, exactly the scenario an AGS peer session hit
- // live) used to leave that window stuck at its stale, dock-shrunk
- // size - `set_monitors` updated `Monitor::geometry` correctly but
- // never touched already-maximized/fullscreen windows' `geometry`,
- // so nothing re-grew until the window was manually un-maximized and
- // re-maximized.
+ fn maximize_covers_a_dock_but_still_stops_at_a_top_bar() {
+ // Live-tested regression: making maximize target `full_geometry`
+ // (the test above) fixed "maximize stops at the dock" but as a side
+ // effect also let it extend behind a top bar's reserved strip,
+ // which was never asked for and was reported back once the user
+ // actually tried it. `maximize_geometry` is the fix - distinct
+ // from both `geometry` (stops at everything) and `full_geometry`
+ // (stops at nothing).
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![monitor_with_dock_and_bar()]);
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+
+ wm.toggle_maximize(id);
+ assert_eq!(
+ wm.window(id).unwrap().geometry,
+ Rect::new(0, 34, 1920, 1046),
+ "maximize must cover the dock's strip but still stop at the top bar's"
+ );
+ }
+
+ #[test]
+ fn maximized_window_live_tracks_a_monitor_geometry_change() {
+ // Regression test: `set_monitors` updated `Monitor::geometry`/
+ // `full_geometry` correctly but never touched already-maximized/
+ // fullscreen windows' own `geometry`, so an already-maximized
+ // window stayed stuck at its stale size until manually
+ // un-maximized and re-maximized - reported live as "maximize does
+ // not extend past the dock" even after the dock's own zone change
+ // (or, now, monitor resize/reconnect) had already taken effect in
+ // every other respect.
let mut wm = WindowManager::new();
wm.set_monitors(vec![monitor_with_dock()]);
let id = wm.alloc_window_id();
wm.add_window(Window::new(id, "a"));
wm.toggle_maximize(id);
- assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1020));
+ assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1080));
- // The dock drops its exclusive zone to 0.
- let mut freed = Monitor::new(0, "primary", Rect::new(0, 0, 1920, 1080));
- freed.full_geometry = Rect::new(0, 0, 1920, 1080);
- freed.primary = true;
- wm.set_monitors(vec![freed]);
+ // The monitor's real geometry changes (a resize, a reconnect at a
+ // different resolution - the same code path a dock dropping its
+ // exclusive zone used to exercise before maximize stopped
+ // respecting that zone at all).
+ let mut resized = Monitor::new(0, "primary", Rect::new(0, 0, 2560, 1420));
+ resized.full_geometry = Rect::new(0, 0, 2560, 1440);
+ resized.maximize_geometry = Rect::new(0, 0, 2560, 1440);
+ resized.primary = true;
+ wm.set_monitors(vec![resized]);
assert_eq!(
wm.window(id).unwrap().geometry,
- Rect::new(0, 0, 1920, 1080),
+ Rect::new(0, 0, 2560, 1440),
"an already-maximized window must live-track a monitor geometry change, not just windows placed afterward"
);
}
diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs
index 4b4c0c8..d73dec1 100644
--- a/crates/core/src/manager/windows.rs
+++ b/crates/core/src/manager/windows.rs
@@ -25,6 +25,8 @@ impl WindowManager {
// whatever a backend's `Window::new` happened to hardcode.
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;
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
@@ -46,6 +48,9 @@ impl WindowManager {
if let Some(width) = a.border_width {
window.border_width = width;
}
+ if let Some(radius) = a.corner_radius {
+ window.corner_radius = radius;
+ }
if let Some(pinned) = a.pinned {
window.always_on_top = pinned;
}
diff --git a/crates/core/src/manager/winops.rs b/crates/core/src/manager/winops.rs
index 6f4d53e..1deed3e 100644
--- a/crates/core/src/manager/winops.rs
+++ b/crates/core/src/manager/winops.rs
@@ -20,6 +20,28 @@ impl WindowManager {
std::mem::take(&mut self.close_requests)
}
+ /// Queues a real layout cycle - see `keyboard_layout_cycle_requests`'s
+ /// own doc comment for why this is a count `main.rs` drains rather than
+ /// something core does itself.
+ pub fn request_keyboard_layout_cycle(&mut self) {
+ self.keyboard_layout_cycle_requests += 1;
+ }
+
+ /// Drains the count queued by `request_keyboard_layout_cycle` since the
+ /// last call. The caller (`main.rs`) is expected to call `Platform::
+ /// cycle_keyboard_layout` this many times, then report the real result
+ /// back via `set_keyboard_layout`.
+ pub fn take_keyboard_layout_cycle_requests(&mut self) -> u32 {
+ std::mem::take(&mut self.keyboard_layout_cycle_requests)
+ }
+
+ /// Sets `keyboard_layout` to whatever the platform actually reports --
+ /// called once at startup and again after every real cycle, never
+ /// guessed at from within core, which has no seat/keyboard of its own.
+ pub fn set_keyboard_layout(&mut self, name: impl Into<String>) {
+ self.keyboard_layout = name.into();
+ }
+
pub fn minimize_window(&mut self, id: WindowId) {
if let Some(w) = self.windows.get_mut(&id) {
w.minimized = true;
@@ -124,6 +146,38 @@ impl WindowManager {
}
}
+ /// Applies one of the Snap-Layouts flyout's fixed half/quarter
+ /// positions directly (`crates/wayland/src/snap_flyout.rs`) - the
+ /// click-driven equivalent of dragging the window to that same edge or
+ /// corner and releasing near it, which is what `SmartPlacement::
+ /// snap_zone` (used by `end_drag`) already computes from a live drag
+ /// position instead of an explicit choice.
+ ///
+ /// Clears `maximized`/`fullscreen` first if either was set - opening
+ /// the flyout from an already-maximized window (via its own maximize
+ /// button) and picking a half is a real, expected use, and without this
+ /// the window would keep reporting itself maximized while visually only
+ /// occupying half the screen. `restore_geometry` is cleared alongside
+ /// rather than left stale: it only means anything while `maximized` is
+ /// still true, and the next real `toggle_maximize` sets it fresh anyway.
+ pub fn apply_snap_zone(&mut self, id: WindowId, zone: SnapZoneKind) {
+ let monitor_geom = self.windows.get(&id).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry);
+ let animations_enabled = self.animations_enabled;
+ let Some(area) = monitor_geom else { return };
+ let target = zone.rect(area);
+ let Some(w) = self.windows.get_mut(&id) else { return };
+ let from = w.geometry;
+ if w.maximized || w.fullscreen {
+ w.maximized = false;
+ w.fullscreen = false;
+ w.restore_geometry = None;
+ }
+ w.geometry = target;
+ if animations_enabled && w.geometry != from {
+ w.anim_from = Some(from);
+ }
+ }
+
/// Fullscreen: the window covers its whole monitor with no decoration.
///
/// Distinct from [`Self::toggle_maximize`], which keeps the titlebar (and
diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs
index 7d9ee31..ec096be 100644
--- a/crates/core/src/rules.rs
+++ b/crates/core/src/rules.rs
@@ -86,6 +86,7 @@ pub struct WindowRuleActions {
pub decorated: Option<bool>,
pub border_color: Option<(u8, u8, u8)>,
pub border_width: Option<u32>,
+ pub corner_radius: Option<u32>,
/// Always-on-top (Hyprland's `pin`).
pub pinned: Option<bool>,
/// Content opacity, `0.0`..=`1.0` (Hyprland's `windowrule = opacity`).
diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs
index 1717fe9..1511a1f 100644
--- a/crates/core/src/theme.rs
+++ b/crates/core/src/theme.rs
@@ -17,6 +17,34 @@ pub struct ThemeConfig {
pub titlebar_fg_unfocused: (u8, u8, u8),
pub default_border_color: (u8, u8, u8),
pub default_border_width: u32,
+ /// Titlebar/border-strip corner radius, in logical pixels - the same
+ /// value `Window::corner_radius` copies onto every window at creation
+ /// (see `WindowManager::add_window`), which a rule's own `corner_radius`
+ /// action can still override afterward, same as `default_border_width`.
+ pub default_corner_radius: u32,
+ /// Whether a newly created window gets srdwm's own titlebar
+ /// (server-side decoration) by default, before any `xdg-decoration`
+ /// negotiation or rule gets a say. Also what the Wayland backend
+ /// initially *offers* a client that creates a decoration object but
+ /// has no strong preference of its own (`XdgDecorationHandler::
+ /// new_decoration`) - a client that explicitly asks for the other
+ /// mode is still honored regardless of this value (see that handler's
+ /// own doc comment).
+ ///
+ /// `true` (server-side) is srdwm's own longstanding default, matching
+ /// the Windows/macOS-style consistent OS-drawn chrome this compositor
+ /// is going for - and, among real desktop environments that still
+ /// have titlebars at all, KDE/KWin's own choice too (confirmed via
+ /// research, not assumed: KWin supports both and defaults to
+ /// server-side). `false` (client-side) matches GNOME/Mutter's
+ /// approach instead - srdwm steps back and lets every window draw its
+ /// own chrome, including ones with no titlebar opinion of their own,
+ /// which then get none at all. Live-settable (`srd set decoration_mode
+ /// server|client`) specifically so both can be A/B tested against a
+ /// real, broad set of installed apps rather than guessed at from two
+ /// examples - see `theme.decorations.default_mode` in the Lua config
+ /// for the persistent equivalent.
+ pub default_decorated: bool,
}
impl Default for ThemeConfig {
@@ -27,6 +55,8 @@ impl Default for ThemeConfig {
titlebar_fg_unfocused: (0x4c, 0x56, 0x6a),
default_border_color: (136, 192, 208), // Nord accent, matches legacy theme default
default_border_width: 2,
+ default_corner_radius: 6,
+ default_decorated: true,
}
}
}