diff options
| author | srdusr <[email protected]> | 2024-11-30 23:03:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-11-30 23:03:00 +0200 |
| commit | 583063c3094ca0cf9f6cceba92d710b238547f6b (patch) | |
| tree | a1ff8f181027892f4a45846b67898beb136a9312 /crates/core/src | |
| parent | 87f5c04b2593dc49409644e69e7962b73d26b9d8 (diff) | |
| download | srdwm-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.
Diffstat (limited to 'crates/core/src')
| -rw-r--r-- | crates/core/src/event.rs | 48 | ||||
| -rw-r--r-- | crates/core/src/lib.rs | 8 | ||||
| -rw-r--r-- | crates/core/src/manager/capture.rs | 78 | ||||
| -rw-r--r-- | crates/core/src/manager/focus.rs | 29 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 59 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 251 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 5 | ||||
| -rw-r--r-- | crates/core/src/manager/winops.rs | 54 | ||||
| -rw-r--r-- | crates/core/src/rules.rs | 1 | ||||
| -rw-r--r-- | crates/core/src/theme.rs | 30 |
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, } } } |