diff options
Diffstat (limited to 'crates')
44 files changed, 8881 insertions, 252 deletions
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 6a0eeea..57fe582 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -96,6 +96,27 @@ impl Engine { self.exec_file(&path) } + /// Re-executes `init.lua` from scratch: clears keybindings, event + /// handlers and the repeat-key set first, so a binding or handler + /// removed from the edited config doesn't linger from the previous + /// load. `values` (`srd.set` keys) are deliberately left alone -- + /// `platform.backend`/`platform.os` are published once by `main.rs` + /// before the *first* `load_init` and nothing in Lua ever re-sets them, + /// so clearing `values` here would silently break every + /// `if srd.get("platform.backend") == ...` branch in the reloaded + /// config. + /// + /// Does *not* re-grab/re-register the reloaded key set with the + /// platform backend - `main.rs` reads `bound_keys()` once, before + /// connecting, to build the X11 `XGrabKey` list / Wayland intercept + /// set. A binding whose *combo* is unchanged from startup picks up a + /// reload immediately; a config that adds a brand new combo needs a + /// real restart before the backend will ever hand that keypress to + /// srdwm instead of the focused client. + pub fn reload(&self) -> Result<()> { + do_reload(&self.lua, &self.state) + } + pub fn exec_file(&self, path: &Path) -> Result<()> { let src = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { path: path.to_path_buf(), source })?; self.lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?; @@ -118,6 +139,17 @@ impl Engine { self.get(key).and_then(|v| v.as_f64()).unwrap_or(default) } + /// Sets a config value from Rust rather than Lua - used by `main.rs` + /// to publish facts the *host* determined (which backend was picked, + /// which OS this is) before `load_init` runs `init.lua`, so config + /// files can read them back via `srd.get(key)` and branch on them + /// (`if srd.get("platform.backend") == "wayland" then ... end`). + /// Writing straight into `values` is the same thing `srd.set` does from + /// the Lua side, just without going through the interpreter. + pub fn set_string(&self, key: &str, value: impl Into<String>) { + self.state.borrow_mut().values.insert(key.to_string(), ConfigValue::String(value.into())); + } + /// Runs the Lua function bound to `combo` (e.g. `"Mod4+Return"`), if any. /// Returns `true` if a binding existed and ran without erroring. /// Runs the `srd.on(name, ...)` handler for a non-key event, if any. @@ -180,6 +212,7 @@ impl Engine { srd.set("spawn", self.fn_spawn()?)?; srd.set("notify", self.fn_notify()?)?; srd.set("quit", self.fn_quit()?)?; + srd.set("reload", self.fn_reload()?)?; srd.set("validate_config", self.fn_validate_config()?)?; let debug = lua.create_table()?; @@ -207,6 +240,16 @@ impl Engine { window.set("set_floating", self.fn_window_set_floating()?)?; window.set("toggle_floating", self.fn_window_action(WindowAction::ToggleFloating)?)?; window.set("is_floating", self.fn_window_is_floating()?)?; + // `srd.window.scratchpad()` moves the *focused* window into the + // scratchpad pool, hiding it (sway's `move scratchpad`). + // `srd.window.scratchpad_show()` toggles pool visibility and takes + // no target of its own - deliberately not routed through + // `fn_window_action`'s focused-window gate, since showing a hidden + // scratchpad window has to work even when nothing is currently + // focused (an empty workspace, or focus on a different monitor). + // See `WindowManager::scratchpad_show`'s doc comment. + window.set("scratchpad", self.fn_window_action(WindowAction::ScratchpadAdd)?)?; + window.set("scratchpad_show", self.fn_scratchpad_show()?)?; srd.set("window", window)?; let layout = lua.create_table()?; @@ -310,11 +353,18 @@ impl Engine { } /// `srd.on("lid_closed", function() ... end)` - registers a handler for - /// a non-key event. Currently `"lid_closed"` and `"lid_open"`. + /// a non-key event. `"ready"` fires once, after the platform backend has + /// connected (real Wayland/X11 display available, `WAYLAND_DISPLAY`/ + /// `DISPLAY` set for anything `srd.spawn`ed from the handler to inherit) + /// - see `main.rs`. Config that starts background processes (a bar, + /// wallpaper daemon, clipboard watcher) belongs in a `"ready"` handler, + /// not at a config file's top level: top-level code runs during + /// `load_init`, which is *before* the platform connects, so anything + /// spawned there inherits no display socket to connect to at all. fn fn_on(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); Ok(self.lua.create_function(move |lua, (name, f): (String, mlua::Function)| { - const KNOWN: [&str; 2] = ["lid_closed", "lid_open"]; + const KNOWN: [&str; 3] = ["lid_closed", "lid_open", "ready"]; if !KNOWN.contains(&name.as_str()) { return Err(mlua::Error::RuntimeError(format!( "srd.on: unknown event '{name}' (known: {})", @@ -333,6 +383,7 @@ impl Engine { fn fn_bind_repeat(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); Ok(self.lua.create_function(move |lua, (combo, f): (String, mlua::Function)| { + let combo = srdwm_core::canonicalize_key_combo(&combo); let key = lua.create_registry_value(f)?; let mut s = state.borrow_mut(); s.repeat_keys.insert(combo.clone()); @@ -344,16 +395,35 @@ impl Engine { fn fn_bind(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); Ok(self.lua.create_function(move |lua, (combo, f): (String, mlua::Function)| { + // `key_bindings` is keyed by whatever string dispatch builds + // from a real keypress (`srdwm_core::key_combo_string`, fixed + // Ctrl/Shift/Alt/Mod4 order) - storing the config's own + // literal string here (usually written Super-first, + // "Mod4+Shift+x") meant multi-modifier bindings could never be + // found at dispatch time even though the raw key was correctly + // grabbed/intercepted. See `parse_key_combo`'s doc comment. + let combo = srdwm_core::canonicalize_key_combo(&combo); let key = lua.create_registry_value(f)?; state.borrow_mut().key_bindings.insert(combo, key); Ok(()) })?) } - /// `srd.rule({ title = "...", class = "..." }, { floating = true, workspace = 2, - /// x = .., y = .., width = .., height = .., decorated = false, - /// border_color = {r,g,b}, border_width = 2, maximized = true })`. - /// At least one matcher field is required; unmatched rules apply nothing. + /// `srd.rule({ title = "...", class = "...", title_regex = "...", + /// class_regex = "...", instance = "..." }, { floating = true, + /// workspace = 2, x = .., y = .., width = .., height = .., + /// decorated = false, border_color = {r,g,b}, border_width = 2, + /// maximized = true })`. At least one matcher field is required; + /// unmatched rules apply nothing. + /// + /// `title`/`class` are plain substring/exact match, cheap and cover + /// most rules with no regex syntax to get right. `title_regex`/ + /// `class_regex` (Rust `regex` crate syntax, case-sensitive unless the + /// pattern starts with `(?i)`) and `instance` (X11 `WM_CLASS`'s + /// instance half, matched exactly - see `srdwm_core::WindowMatch`'s + /// doc comment) exist for the cases that need more precision, e.g. + /// disambiguating a specific dialog by title while leaving an app's + /// main window alone. Every field given is ANDed together. fn fn_rule(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); Ok(self.lua.create_function(move |_, (matcher, actions): (Table, Table)| { @@ -362,6 +432,15 @@ impl Engine { Some(c) => Some(c), None => matcher.get("app_id")?, }; + let instance: Option<String> = matcher.get("instance")?; + let title_regex = match matcher.get::<_, Option<String>>("title_regex")? { + Some(pat) => Some(srdwm_core::Regex::new(&pat).map_err(|e| mlua::Error::RuntimeError(format!("srd.rule: invalid title_regex '{pat}': {e}")))?), + None => None, + }; + let class_regex = match matcher.get::<_, Option<String>>("class_regex")? { + Some(pat) => Some(srdwm_core::Regex::new(&pat).map_err(|e| mlua::Error::RuntimeError(format!("srd.rule: invalid class_regex '{pat}': {e}")))?), + None => None, + }; let border_color: Option<(u8, u8, u8)> = match actions.get::<_, Option<Table>>("border_color")? { Some(t) => Some((t.get(1)?, t.get(2)?, t.get(3)?)), @@ -379,7 +458,7 @@ impl Engine { }; let rule = WindowRule { - matcher: WindowMatch { title_contains, class }, + matcher: WindowMatch { title_contains, class, title_regex, class_regex, instance }, actions: WindowRuleActions { floating: actions.get("floating")?, maximized: actions.get("maximized")?, @@ -429,6 +508,21 @@ impl Engine { })?) } + fn fn_reload(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + // `create_function`'s closure is handed the `&Lua` it's being + // called from as its first argument - used directly here instead + // of capturing a cloned handle, since `mlua::Lua` isn't `Clone` in + // this version. + Ok(self.lua.create_function(move |lua, ()| { + match do_reload(lua, &state) { + Ok(()) => log::info!("srd.reload: config reloaded"), + Err(e) => log::error!("srd.reload: {e}"), + } + Ok(()) + })?) + } + fn fn_notify(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); Ok(self.lua.create_function(move |_, (message, level): (String, Option<String>)| { @@ -534,6 +628,7 @@ impl Engine { t.set("floating", w.floating)?; t.set("maximized", w.maximized)?; t.set("minimized", w.minimized)?; + t.set("scratchpad", w.scratchpad)?; Ok(Value::Table(t)) })?) } @@ -551,12 +646,22 @@ impl Engine { WindowAction::Fullscreen => wm.toggle_fullscreen(id), WindowAction::ToggleFloating => wm.toggle_floating(id), WindowAction::TogglePin => wm.toggle_always_on_top(id), + WindowAction::ScratchpadAdd => wm.scratchpad_add(id), } } Ok(()) })?) } + fn fn_scratchpad_show(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |_, ()| { + let wm = state.borrow().wm.clone(); + wm.borrow_mut().scratchpad_show(); + Ok(()) + })?) + } + /// `srd.window.move("left")` - swap the focused window with its /// neighbour in that direction (Hyprland's `movewindow l/r/u/d`). fn fn_window_move_direction(&self) -> Result<mlua::Function<'_>> { @@ -745,6 +850,24 @@ impl Engine { } } +/// Shared between [`Engine::reload`] and `srd.reload()`'s Lua closure -- +/// the closure can't capture `&Engine` itself (it isn't `Clone`/`Rc`, and +/// `mlua::Lua::create_function` needs a `'static` closure), so both go +/// through cloned `Lua`/state handles instead of one calling the other. +fn do_reload(lua: &Lua, state: &Rc<RefCell<SharedState>>) -> Result<()> { + let config_dir = { + let mut s = state.borrow_mut(); + s.key_bindings.clear(); + s.event_handlers.clear(); + s.repeat_keys.clear(); + s.config_dir.clone() + }; + let path = config_dir.join("init.lua"); + let src = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io { path: path.clone(), source })?; + lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?; + Ok(()) +} + /// Shared by `srd.window.focus` and `srd.window.move` so both accept /// exactly the same direction names and report the same error. fn parse_direction(name: &str, caller: &str) -> mlua::Result<Direction> { @@ -765,6 +888,7 @@ enum WindowAction { Fullscreen, ToggleFloating, TogglePin, + ScratchpadAdd, } /// Recursively flattens a Lua table into dotted config keys, e.g. @@ -914,7 +1038,7 @@ fn default_config() -> HashMap<String, ConfigValue> { set("layout.tiling.behavior.auto_balance", Bool(true)); set("layout.tiling.behavior.preserve_ratio", Bool(true)); - set("layout.dynamic.snap_threshold", Number(50.0)); + set("layout.dynamic.snap_threshold", Number(20.0)); set("layout.dynamic.grid_size", Number(6.0)); set("layout.dynamic.cascade_offset", Number(30.0)); set("layout.dynamic.smart_placement", Bool(true)); @@ -1169,6 +1293,74 @@ mod tests { } #[test] + fn srd_window_scratchpad_hides_the_focused_window_and_show_brings_it_back() { + let dir = tempfile::tempdir().unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + let id = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(Window::new(id, "term")); + id + }; + let engine = Engine::new(wm.clone(), dir.path()).unwrap(); + engine.lua.load(r#"srd.window.scratchpad()"#).exec().unwrap(); + assert!(wm.borrow().window(id).unwrap().minimized); + assert!(wm.borrow().window(id).unwrap().scratchpad); + engine.lua.load(r#"srd.window.scratchpad_show()"#).exec().unwrap(); + assert!(!wm.borrow().window(id).unwrap().minimized); + assert_eq!(wm.borrow().focused_id(), Some(id)); + } + + #[test] + fn srd_rule_title_regex_matches_a_specific_dialog_not_the_main_window() { + let dir = tempfile::tempdir().unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + let engine = Engine::new(wm.clone(), dir.path()).unwrap(); + engine.lua.load(r#"srd.rule({ title_regex = "^Save File$" }, { floating = true })"#).exec().unwrap(); + let dialog = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "Save File")); + id + }; + let main = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "Save File - GIMP")); + id + }; + assert!(wm.borrow().is_floating(dialog)); + assert!(!wm.borrow().is_floating(main)); + } + + #[test] + fn srd_rule_instance_matches_independently_of_class() { + let dir = tempfile::tempdir().unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + let engine = Engine::new(wm.clone(), dir.path()).unwrap(); + engine.lua.load(r#"srd.rule({ instance = "firefox" }, { pinned = true })"#).exec().unwrap(); + let id = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + let mut w = srdwm_core::Window::new(id, "Mozilla Firefox"); + w.app_id = "Navigator".into(); + w.instance = "firefox".into(); + wm.add_window(w); + id + }; + assert!(wm.borrow().window(id).unwrap().always_on_top); + } + + #[test] + fn srd_rule_rejects_an_invalid_regex_with_a_lua_error() { + let dir = tempfile::tempdir().unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + let engine = Engine::new(wm.clone(), dir.path()).unwrap(); + let result = engine.lua.load(r#"srd.rule({ title_regex = "(unclosed" }, { floating = true })"#).exec(); + assert!(result.is_err()); + } + + #[test] fn load_init_runs_the_users_init_lua() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("init.lua"), r#"srd.set("general.window_gap", 4)"#).unwrap(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 7a2fb39..d06905c 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -8,5 +8,6 @@ description = "Platform-independent window/workspace/layout state for srdwm" [dependencies] log.workspace = true bitflags = "2" +regex = "1" [dev-dependencies] diff --git a/crates/core/src/event.rs b/crates/core/src/event.rs index eb2d8aa..5822d3f 100644 --- a/crates/core/src/event.rs +++ b/crates/core/src/event.rs @@ -44,6 +44,53 @@ pub fn key_combo_string(modifiers: Modifiers, key_name: &str) -> String { format!("{modifiers}{key_name}") } +/// Parses a `"Mod4+Shift+Return"`-style combo string into modifiers plus the +/// bare key name, accepting the modifier tokens in *any* order. +/// +/// This matters because [`key_combo_string`]/[`Modifiers`]'s `Display` only +/// ever produce one fixed order (Ctrl, Shift, Alt, Mod4) - but every +/// shipped keybinding is written the conventional "Mod4+Shift+x" way +/// (Super first, matching Hyprland's own `SUPER, SHIFT, x` convention). +/// `srd.bind` used to store the combo string exactly as the Lua config +/// wrote it, and dispatch always looked it up by the canonical +/// Ctrl/Shift/Alt/Mod4 order built from the real keypress - so any +/// binding combining more than one modifier in a different order than that +/// fixed one could never fire: X11 grabbed the physical key correctly +/// (`grab_keybindings` already parsed order-independently, duplicating +/// this logic) but dispatch found nothing to run, and on Wayland the combo +/// was not even recognized as bound at all, so the keypress was forwarded +/// straight to the focused client instead of reaching srdwm. Confirmed +/// against the shipped `keybindings.lua`: every multi-modifier binding +/// there (`Mod4+Shift+*`, `Mod4+Ctrl+k`, `Alt+Shift+Tab`, ...) is written +/// Super/Alt-first, which never matched the canonical order. Returns +/// `None` for an empty combo (no key name at all). +pub fn parse_key_combo(combo: &str) -> Option<(Modifiers, &str)> { + let parts: Vec<&str> = combo.split('+').collect(); + let (key_name, mod_parts) = parts.split_last()?; + let mut modifiers = Modifiers::empty(); + for m in mod_parts { + modifiers |= match *m { + "Ctrl" => Modifiers::CTRL, + "Shift" => Modifiers::SHIFT, + "Alt" => Modifiers::ALT, + "Mod4" | "Super" => Modifiers::SUPER, + _ => Modifiers::empty(), + }; + } + Some((modifiers, key_name)) +} + +/// Re-orders a combo string into the canonical form [`key_combo_string`] +/// produces, regardless of what order its modifiers were written in. +/// Unparseable input (empty string) is returned unchanged, so a caller that +/// can't do anything better with it still has *something* to store/log. +pub fn canonicalize_key_combo(combo: &str) -> String { + match parse_key_combo(combo) { + Some((modifiers, key_name)) => key_combo_string(modifiers, key_name), + None => combo.to_string(), + } +} + #[derive(Debug, Clone)] pub enum Event { WindowCreated(WindowId), @@ -67,4 +114,67 @@ pub enum Event { /// can lock and suspend, which is otherwise impossible: a laptop that /// does nothing on lid-close is a real problem, not a nicety. LidSwitch { closed: bool }, + /// `WindowManager::current_workspace` changed. Carries no id: every + /// consumer that cares (`main.rs`'s `sync()`) re-reads whichever + /// workspace is current now rather than trusting a stale snapshot from + /// whenever this event was queued. + /// + /// Exists purely so `sync()` actually runs after a switch - without a + /// `dirty`-setting event, `WindowManager::switch_workspace` alone only + /// changes core's own bookkeeping; nothing shows or hides a single + /// window for the new workspace until `sync()` runs, which only + /// happens when a polled event sets `dirty`. Every switch path (a + /// keybinding, `SUPER`+scroll, the `ext_workspace_v1` protocol's + /// `activate` request) needs this pushed after it changes + /// `current_workspace`, or the switch is invisible. + WorkspaceChanged, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn super_first_combo_canonicalizes_to_dispatch_order() { + // The shipped keybindings.lua writes every combo Super-first + // ("Mod4+Shift+m"), matching Hyprland's own convention - but + // `key_combo_string`'s Display order is fixed Ctrl/Shift/Alt/Mod4. + // A binding registered under its literal Lua string could never be + // found by a real keypress, which always dispatches through the + // canonical order. This is exactly the bug `canonicalize_key_combo` + // exists to close. + assert_eq!(canonicalize_key_combo("Mod4+Shift+m"), "Shift+Mod4+m"); + assert_eq!(canonicalize_key_combo("Mod4+Ctrl+k"), "Ctrl+Mod4+k"); + assert_eq!(canonicalize_key_combo("Alt+Shift+Tab"), "Shift+Alt+Tab"); + } + + #[test] + fn already_canonical_combo_is_unchanged() { + assert_eq!(canonicalize_key_combo("Ctrl+Mod4+k"), "Ctrl+Mod4+k"); + } + + #[test] + fn single_modifier_combo_is_unaffected() { + // No ordering ambiguity with one modifier - this case always + // worked, before and after the fix. + assert_eq!(canonicalize_key_combo("Mod4+Return"), "Mod4+Return"); + } + + #[test] + fn parse_key_combo_accepts_modifiers_in_any_order() { + let (mods, key) = parse_key_combo("Mod4+Shift+m").unwrap(); + assert_eq!(key, "m"); + assert!(mods.contains(Modifiers::SUPER) && mods.contains(Modifiers::SHIFT)); + + let (mods2, key2) = parse_key_combo("Shift+Mod4+m").unwrap(); + assert_eq!(key2, "m"); + assert_eq!(mods, mods2); + } + + #[test] + fn parse_key_combo_with_no_modifiers_is_bare_key() { + let (mods, key) = parse_key_combo("Return").unwrap(); + assert_eq!(key, "Return"); + assert_eq!(mods, Modifiers::empty()); + } } diff --git a/crates/core/src/geometry.rs b/crates/core/src/geometry.rs index 3dd205a..62d4369 100644 --- a/crates/core/src/geometry.rs +++ b/crates/core/src/geometry.rs @@ -31,6 +31,66 @@ impl Rect { || other.bottom() <= self.y) } + /// The overlapping region of two rects, or `None` if they don't + /// overlap at all (matches `overlaps`' own half-open semantics: a rect + /// that only touches another along an edge or corner does not count). + pub fn intersection(&self, other: &Rect) -> Option<Rect> { + if !self.overlaps(other) { + return None; + } + let x = self.x.max(other.x); + let y = self.y.max(other.y); + let right = self.right().min(other.right()); + let bottom = self.bottom().min(other.bottom()); + Some(Rect::new(x, y, (right - x) as u32, (bottom - y) as u32)) + } + + /// `self` minus `other`, as the (up to 4) axis-aligned pieces left + /// over - the standard top/bottom/left/right sliver decomposition + /// around the intersection. An empty `Vec` means `other` fully covers + /// `self`; a one-element `Vec` equal to `self` means they don't + /// overlap at all. + fn subtract_one(&self, other: &Rect) -> Vec<Rect> { + let Some(ix) = self.intersection(other) else { return vec![*self] }; + let mut out = Vec::with_capacity(4); + // Top sliver: full width, above the intersection. + if ix.y > self.y { + out.push(Rect::new(self.x, self.y, self.width, (ix.y - self.y) as u32)); + } + // Bottom sliver: full width, below the intersection. + if ix.bottom() < self.bottom() { + out.push(Rect::new(self.x, ix.bottom(), self.width, (self.bottom() - ix.bottom()) as u32)); + } + // Left/right slivers are constrained to the intersection's own + // y-range (not self's full height), so the top/bottom slivers + // above don't get double-counted at the corners. + if ix.x > self.x { + out.push(Rect::new(self.x, ix.y, (ix.x - self.x) as u32, ix.height)); + } + if ix.right() < self.right() { + out.push(Rect::new(ix.right(), ix.y, (self.right() - ix.right()) as u32, ix.height)); + } + out + } + + /// `self` minus every rect in `occluders` that overlaps it, as the + /// disjoint pieces still left over. Used to keep a window's border + /// from rendering on top of another window's content that's actually + /// stacked in front of it - see `crates/wayland/src/elements.rs`'s + /// `visible_border_fragments` doc comment for the fuller story on why + /// that's needed at all. An empty result means `occluders` between + /// them fully cover `self`. + pub fn subtract_all(&self, occluders: &[Rect]) -> Vec<Rect> { + let mut remaining = vec![*self]; + for occluder in occluders { + if remaining.is_empty() { + break; + } + remaining = remaining.iter().flat_map(|r| r.subtract_one(occluder)).collect(); + } + remaining + } + /// Shrinks the rect on all sides by `margin`, saturating at zero size. pub fn inset(&self, margin: u32) -> Rect { let m = margin as i32; @@ -91,4 +151,90 @@ mod tests { assert!(!r.contains_point(10, 10)); assert!(r.contains_point(9, 9)); } + + #[test] + fn intersection_of_non_overlapping_rects_is_none() { + let a = Rect::new(0, 0, 10, 10); + let b = Rect::new(20, 20, 10, 10); + assert_eq!(a.intersection(&b), None); + } + + #[test] + fn intersection_is_the_overlapping_region() { + let a = Rect::new(0, 0, 100, 100); + let b = Rect::new(50, 50, 100, 100); + assert_eq!(a.intersection(&b), Some(Rect::new(50, 50, 50, 50))); + } + + #[test] + fn subtract_all_with_no_occluders_returns_the_rect_unchanged() { + let r = Rect::new(0, 0, 100, 100); + assert_eq!(r.subtract_all(&[]), vec![r]); + } + + #[test] + fn subtract_all_with_a_non_overlapping_occluder_returns_the_rect_unchanged() { + let r = Rect::new(0, 0, 100, 100); + let occluder = Rect::new(200, 200, 10, 10); + assert_eq!(r.subtract_all(&[occluder]), vec![r]); + } + + #[test] + fn subtract_all_with_a_fully_covering_occluder_returns_nothing() { + let r = Rect::new(10, 10, 20, 20); + let occluder = Rect::new(0, 0, 100, 100); + assert!(r.subtract_all(&[occluder]).is_empty()); + } + + /// This is the exact bug this whole mechanism exists to fix, found live: + /// a tall vertical border strip on a background window (e.g. its right + /// edge) with a foreground window's content covering its middle, + /// leaving only a sliver above and below visible - rather than the + /// border rendering straight through the foreground window's content. + #[test] + fn subtract_all_splits_a_tall_strip_around_a_covering_window_into_two_slivers() { + // A 3px-wide, 630px-tall right border strip... + let border = Rect::new(890, 126, 3, 630); + // ...with a foreground window covering its middle vertically. + let foreground = Rect::new(240, 277, 800, 630); + let pieces = border.subtract_all(&[foreground]); + // Only the sliver above the foreground window's top edge and the + // sliver below its bottom edge should remain - the foreground + // window's own height (630) exceeds the border's, so in this case + // the whole thing is covered from y=277 down; only the top sliver + // (126..277) survives. + assert_eq!(pieces, vec![Rect::new(890, 126, 3, 277 - 126)]); + } + + #[test] + fn subtract_all_leaves_a_gap_when_the_occluder_only_covers_the_middle() { + let strip = Rect::new(0, 0, 5, 100); + let occluder = Rect::new(0, 30, 5, 20); // covers y in [30, 50) + let pieces = strip.subtract_all(&[occluder]); + assert_eq!(pieces.len(), 2); + assert!(pieces.contains(&Rect::new(0, 0, 5, 30))); + assert!(pieces.contains(&Rect::new(0, 50, 5, 50))); + } + + #[test] + fn subtract_all_handles_multiple_occluders_in_sequence() { + let strip = Rect::new(0, 0, 5, 100); + let a = Rect::new(0, 10, 5, 10); // [10,20) + let b = Rect::new(0, 40, 5, 10); // [40,50) + let pieces = strip.subtract_all(&[a, b]); + assert_eq!(pieces.len(), 3); + assert!(pieces.contains(&Rect::new(0, 0, 5, 10))); + assert!(pieces.contains(&Rect::new(0, 20, 5, 20))); + assert!(pieces.contains(&Rect::new(0, 50, 5, 50))); + } + + #[test] + fn subtract_all_handles_a_partial_side_overlap_without_losing_area() { + // Occluder only covers the left half of the rect - the right half + // (a "right sliver") must survive intact. + let r = Rect::new(0, 0, 100, 50); + let occluder = Rect::new(-10, -10, 60, 70); // covers x in [0,50) + let pieces = r.subtract_all(&[occluder]); + assert_eq!(pieces, vec![Rect::new(50, 0, 50, 50)]); + } } diff --git a/crates/core/src/keysyms.rs b/crates/core/src/keysyms.rs index b166159..e610ecb 100644 --- a/crates/core/src/keysyms.rs +++ b/crates/core/src/keysyms.rs @@ -21,6 +21,7 @@ pub fn keysym_to_name(keysym: u32) -> Option<String> { 0xff56 => "Next".to_string(), 0xff50 => "Home".to_string(), 0xff57 => "End".to_string(), + 0xff61 => "Print".to_string(), 0xffbe..=0xffc9 => format!("F{}", keysym - 0xffbe + 1), // Laptop/media keys. Values taken from the system's own // <X11/XF86keysym.h>, not guessed - a wrong constant here fails @@ -61,6 +62,7 @@ pub fn name_to_keysym(name: &str) -> Option<u32> { "next" | "pagedown" => return Some(0xff56), "home" => return Some(0xff50), "end" => return Some(0xff57), + "print" => return Some(0xff61), // Must stay in sync with `keysym_to_name` above: the X11 backend // resolves names through here to pass to `XGrabKey`, so a key // missing from *this* direction can be pressed but never grabbed. @@ -127,7 +129,7 @@ mod tests { #[test] fn named_keys_roundtrip() { - for name in ["Return", "Escape", "Tab", "Left", "Right", "Up", "Down", "F1", "F12"] { + for name in ["Return", "Escape", "Tab", "Left", "Right", "Up", "Down", "Print", "F1", "F12"] { let ks = name_to_keysym(name).unwrap(); assert_eq!(keysym_to_name(ks), Some(name.to_string())); } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0fc2fad..471fece 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,15 +6,18 @@ pub mod manager; pub mod monitor; pub mod placement; pub mod rules; +pub mod theme; pub mod window; pub mod workspace; -pub use event::{key_combo_string, Event, MouseButton, Modifiers}; +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 monitor::{Monitor, MonitorId}; pub use placement::{PlacementConfig, SmartPlacement}; +pub use regex::Regex; pub use rules::{WindowMatch, WindowRule, WindowRuleActions}; -pub use window::{ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN, TITLEBAR_HEIGHT}; +pub use theme::{parse_hex_color, ThemeConfig}; +pub use window::{GlobalMenu, MenuSource, ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN, TITLEBAR_HEIGHT}; pub use workspace::{Workspace, WorkspaceId}; diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs index 3856a25..8696508 100644 --- a/crates/core/src/manager.rs +++ b/crates/core/src/manager.rs @@ -3,6 +3,7 @@ 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::rules::WindowRule; +use crate::theme::ThemeConfig; use crate::window::{ResizeEdge, TitlebarHit, Window, WindowId}; use crate::workspace::{Workspace, WorkspaceId}; use std::collections::HashMap; @@ -41,14 +42,36 @@ pub struct WindowManager { monitors: Vec<Monitor>, workspaces: Vec<Workspace>, current_workspace: WorkspaceId, + /// Whichever workspace was current immediately before the current one + /// became current - see `switch_workspace`'s doc comment. + previous_workspace: WorkspaceId, + /// Read from `workspace.auto_back_and_forth`. When set, switching to + /// the workspace that's already active switches to `previous_workspace` + /// 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, next_workspace_id: WorkspaceId, next_window_id: WindowId, layouts: HashMap<String, Box<dyn Layout>>, pub tiling: TilingConfig, pub placement: PlacementConfig, + /// Whether geometry changes made via `toggle_maximize`/`toggle_fullscreen` + /// should be animated. Read from `general.animations`; a backend's open + /// animation is gated on this too, since core has no notion of "open". + pub animations_enabled: bool, + /// Tween duration in milliseconds, read from `general.animation_duration`. + pub animation_duration_ms: u32, + /// Default decoration colours and border width, read from `theme.colors.*`/ + /// `theme.decorations.*`. See `ThemeConfig`'s own doc comment. + pub theme: ThemeConfig, drag: Option<DragState>, resize: Option<ResizeState>, rules: Vec<WindowRule>, + /// 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 + /// a client its close request directly - see `close_window`. + close_requests: Vec<WindowId>, } impl Default for WindowManager { @@ -71,14 +94,20 @@ impl WindowManager { monitors: Vec::new(), workspaces: vec![Workspace::new(0, "1", "dynamic")], current_workspace: 0, + previous_workspace: 0, + auto_back_and_forth: false, next_workspace_id: 1, next_window_id: 1, layouts, tiling: TilingConfig::default(), placement: PlacementConfig::default(), + animations_enabled: true, + animation_duration_ms: 200, + theme: ThemeConfig::default(), drag: None, resize: None, rules: Vec::new(), + close_requests: Vec::new(), } } @@ -147,6 +176,28 @@ impl WindowManager { } } } + // A maximized/fullscreen window's geometry was set to a snapshot of + // its monitor's usable/full rect at the moment it was toggled on -- + // it is not live-bound to that rect afterward. Without this, a bar + // or dock changing its exclusive zone while a window is maximized + // (the live case: a dock dropping its reservation to 0 so a + // maximized window can cover its area) grows or shrinks `Monitor:: + // geometry`/`full_geometry` here, but the already-maximized window + // keeps its stale pre-change size until manually un-maximized and + // re-maximized - reported as "maximize does not extend past the + // dock" even though the dock's own zone change took effect + // immediately in every other respect (new windows placed correctly, + // `Monitor::geometry` itself correct if queried fresh). + for window in self.windows.values_mut() { + if !window.maximized && !window.fullscreen { + continue; + } + let Some(monitor) = live.iter().find(|m| m.id == window.monitor) else { continue }; + let target = if window.fullscreen { monitor.full_geometry } else { monitor.geometry }; + if window.geometry != target { + window.geometry = target; + } + } } pub fn monitors(&self) -> &[Monitor] { @@ -175,7 +226,16 @@ impl WindowManager { /// it's left for the next `arrange_workspace` call to place. pub fn add_window(&mut self, mut window: Window) -> WindowId { let id = window.id; + // Applied before rule matching below, which still wins when a rule + // sets its own `border_color`/`border_width` - this only replaces + // 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; 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 + // inconclusive) match attempt needs to wait for `reapply_rules_if_pending`. + window.rules_applied = actions.is_some() || !(window.title.is_empty() && window.app_id.is_empty()); let workspace = actions.as_ref().and_then(|a| a.workspace).unwrap_or(self.current_workspace); window.workspace = workspace; @@ -222,6 +282,63 @@ impl WindowManager { id } + /// Retries rule matching for a window `add_window` couldn't conclusively + /// match yet (see `Window::rules_applied`'s doc comment) - a backend + /// calls this once a native Wayland window's real `title`/`app_id` + /// become known, typically on its first real commit. A no-op once + /// `rules_applied` is already `true`, so this is safe to call on every + /// subsequent metadata change without rules re-applying repeatedly. + /// + /// Returns whether a rule actually matched and was applied - distinct + /// from simply "ran" (this is a no-op past the first call regardless). + /// A backend uses this to decide whether a follow-up geometry/decoration + /// sync is warranted: `sync_geometry` re-stacks the window to the top + /// via smithay's `Space::map_element` as a side effect of updating its + /// tracked position (`map_element` always does this, `activate` or + /// not - there is no "move without restacking" in this smithay + /// version), so calling it on *every* title/app_id change - which + /// happens constantly for perfectly ordinary reasons (a browser tab + /// finishing a page load) long after the window's own creation - would + /// silently yank an unfocused, unrelated window back to the front any + /// time its title happened to update. Reported live as exactly that: + /// an older window jumping in front of a newer, focused one with no + /// user action to explain it. + pub fn reapply_rules_if_pending(&mut self, id: WindowId) -> bool { + let Some(window) = self.windows.get(&id) else { return false }; + if window.rules_applied || (window.title.is_empty() && window.app_id.is_empty()) { + return false; + } + 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 }; + if let Some(floating) = actions.floating { + window.floating = floating; + } + if let Some(decorated) = actions.decorated { + window.decorated = decorated; + } + if let Some(color) = actions.border_color { + window.border_color = color; + } + if let Some(width) = actions.border_width { + window.border_width = width; + } + if let Some(pinned) = actions.pinned { + window.always_on_top = pinned; + } + if let Some(geometry) = actions.geometry { + window.geometry = geometry; + } + if let Some(workspace) = actions.workspace { + self.move_window_to_workspace(id, workspace); + } + if actions.maximized.unwrap_or(false) { + self.toggle_maximize(id); + } + true + } + pub fn remove_window(&mut self, id: WindowId) -> Option<Window> { self.order.retain(|&w| w != id); if self.focused == Some(id) { @@ -259,6 +376,22 @@ impl WindowManager { self.restack_pinned(); } + /// Sends a window to the back of the stack - the middle-click-titlebar + /// convention most X11 WMs (twm, fvwm, IceWM) have always had and this + /// one never did. Doesn't touch focus: lowering the window you're + /// currently looking at out from under the pointer without also moving + /// keyboard focus elsewhere would leave input going to a window that's + /// no longer visible under the cursor, which is more surprising than + /// useful. `restack_pinned` still runs afterward so a pinned window + /// can't accidentally end up buried by this either. + pub fn lower_window(&mut self, id: WindowId) { + if let Some(pos) = self.order.iter().position(|&w| w == id) { + let id = self.order.remove(pos); + self.order.insert(0, id); + } + self.restack_pinned(); + } + /// Toggles "always on top" for a window (Hyprland's `pin`), used for /// picture-in-picture and small HUD overlays that must stay visible /// while you work in something else. @@ -430,6 +563,14 @@ impl WindowManager { pub fn close_window(&mut self, id: WindowId) { log::info!("close_window({id})"); + self.close_requests.push(id); + } + + /// Drains windows queued by `close_window` since the last call. Core + /// has no way to reach a client itself - the caller (`main.rs`) is + /// expected to forward each id to `Platform::close`. + pub fn take_close_requests(&mut self) -> Vec<WindowId> { + std::mem::take(&mut self.close_requests) } pub fn minimize_window(&mut self, id: WindowId) { @@ -447,9 +588,68 @@ impl WindowManager { } } + /// Moves a window into the scratchpad pool, hiding it immediately -- + /// sway's `move scratchpad`. The single most-used "quick terminal" + /// pattern in tiling window managers, and srdwm had no equivalent at + /// all before this. + /// + /// Also floats the window: tiling something that's meant to pop in and + /// out on demand doesn't make sense, and would otherwise fight + /// `arrange_workspace` every time it's shown. Reuses `minimized` for + /// the actual show/hide gating rather than introducing a second + /// visibility flag - `scratchpad` here is purely a marker of *pool + /// membership*, kept separate so `scratchpad_show` knows which hidden + /// windows are its own to bring back, as opposed to an ordinarily + /// minimized one. + pub fn scratchpad_add(&mut self, id: WindowId) { + if let Some(w) = self.windows.get_mut(&id) { + w.scratchpad = true; + w.floating = true; + } + self.minimize_window(id); + } + + /// Removes a window from the scratchpad pool without changing its + /// current visibility - for a rule or script that wants to opt a + /// window back into ordinary window management. + pub fn scratchpad_remove(&mut self, id: WindowId) { + if let Some(w) = self.windows.get_mut(&id) { + w.scratchpad = false; + } + } + + /// Toggles the scratchpad - sway's `scratchpad show`, meant for one + /// keybinding a user presses repeatedly. If the focused window is + /// itself a currently-shown scratchpad window, hides it; otherwise + /// shows (and focuses) the most recently added hidden scratchpad + /// window, if any, moving it onto whichever workspace is current so it + /// follows the user rather than staying pinned to wherever it was + /// added from - sway's own behavior. "Most recently added" is `id` + /// order, since ids are allocated monotonically and no separate + /// timestamp is tracked; only ever one window is shown/hidden per + /// call, deliberately not sway's full multi-window cycling, which + /// needs its own remembered order and is a rarer need than a single + /// scratchpad window covers. + pub fn scratchpad_show(&mut self) { + if let Some(id) = self.focused { + if self.windows.get(&id).is_some_and(|w| w.scratchpad && !w.minimized) { + self.minimize_window(id); + return; + } + } + let Some(id) = self.windows.values().filter(|w| w.scratchpad && w.minimized).map(|w| w.id).max() else { return }; + if let Some(w) = self.windows.get_mut(&id) { + w.workspace = self.current_workspace; + } + self.restore_window(id); + self.focus_window(id); + } + pub fn toggle_maximize(&mut self, id: WindowId) { 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(w) = self.windows.get_mut(&id) else { return }; + let from = w.geometry; if w.maximized { if let Some(restore) = w.restore_geometry.take() { w.geometry = restore; @@ -460,6 +660,9 @@ impl WindowManager { w.geometry = geom; w.maximized = true; } + if animations_enabled && w.geometry != from { + w.anim_from = Some(from); + } } /// Fullscreen: the window covers its whole monitor with no decoration. @@ -469,15 +672,38 @@ impl WindowManager { /// they are mutually exclusive - toggling one off restores whatever the /// window's geometry was before *either* was applied, and entering /// fullscreen from a maximised window doesn't lose the original size. + /// + /// `decorated` is saved and restored the same way, via + /// `restore_decorated` - exiting used to hardcode `w.decorated = true` + /// unconditionally, which is only correct for a window that was + /// decorated to begin with. Any window a rule sets `decorated = false` + /// for (client-side-decorated apps like Firefox, matched via + /// `srd.rule({ class = "firefox" }, { decorated = false })`) that ever + /// goes fullscreen - an HTML5 video, a PDF presentation, plain F11 -- + /// came back from it permanently `decorated = true`, with no further + /// event to ever set it back. Since border/titlebar redraw fresh from + /// live `Window.decorated` every frame but the *hit-testing* band this + /// wrongly turned on doesn't correspond to anything the client is + /// actually drawing there, every click in what srdwm now (incorrectly) + /// treats as the titlebar band got swallowed as a drag/button hit + /// instead of ever reaching the client - reported live as a click on + /// Firefox's back button minimizing the window instead. pub fn toggle_fullscreen(&mut self, id: WindowId) { - let monitor_geom = self.windows.get(&id).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry); + // Unlike `toggle_maximize`, fullscreen uses the monitor's true + // full rect, not the exclusive-zone-shrunk usable area - a + // fullscreen window should cover (or go under) a bar/dock like + // everywhere else, not stop short of it. See `Monitor:: + // full_geometry`'s doc comment. + let monitor_geom = self.windows.get(&id).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.full_geometry); + let animations_enabled = self.animations_enabled; let Some(w) = self.windows.get_mut(&id) else { return }; + let from = w.geometry; if w.fullscreen { if let Some(restore) = w.restore_geometry.take() { w.geometry = restore; } w.fullscreen = false; - w.decorated = true; + w.decorated = w.restore_decorated.take().unwrap_or(true); } else if let Some(geom) = monitor_geom { // Only remember the pre-fullscreen geometry if we aren't already // maximised, otherwise the monitor rect would overwrite the real @@ -488,8 +714,12 @@ impl WindowManager { w.maximized = false; w.geometry = geom; w.fullscreen = true; + w.restore_decorated = Some(w.decorated); w.decorated = false; } + if animations_enabled && w.geometry != from { + w.anim_from = Some(from); + } } pub fn is_fullscreen(&self, id: WindowId) -> bool { @@ -529,7 +759,7 @@ impl WindowManager { if w.minimized { continue; } - if let Some(hit) = ResizeEdge::hit_test(w.geometry, x, y) { + if let Some(hit) = ResizeEdge::hit_test(w.geometry, x, y, w.decorated, w.border_width) { return Some((w.id, hit)); } } @@ -578,7 +808,13 @@ impl WindowManager { new_geom.x += dx; new_geom.y += dy; - let monitor_bounds = self.windows.get(&drag.window).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry); + // `full_geometry`, not `geometry`: a floating window being dragged + // must be able to cross into (or land under/over) the strip a + // bar/dock reserves - only *placement* of a brand-new window and + // 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); 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); @@ -630,6 +866,15 @@ impl WindowManager { self.resize.is_some() } + /// 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 + /// exact edge (which it usually isn't, once the drag is actually + /// underway). + pub fn resize_edge(&self) -> Option<ResizeEdge> { + self.resize.as_ref().map(|r| r.edge) + } + // ---- Workspaces ----------------------------------------------------- pub fn add_workspace(&mut self, name: impl Into<String>, layout: impl Into<String>) -> WorkspaceId { @@ -639,6 +884,17 @@ impl WindowManager { id } + /// Sets a workspace's display name - used to apply `workspace.names` + /// at startup (`crates/srdwm/src/main.rs`'s `apply_workspace_count`), + /// since `WindowManager::new`/`add_workspace` otherwise leave every + /// workspace named after its own 1-based index regardless of what a + /// config asked for. A no-op if `id` doesn't exist. + pub fn rename_workspace(&mut self, id: WorkspaceId, name: impl Into<String>) { + if let Some(w) = self.workspaces.iter_mut().find(|w| w.id == id) { + w.name = name.into(); + } + } + pub fn remove_workspace(&mut self, id: WorkspaceId) { if self.workspaces.len() <= 1 { return; @@ -653,9 +909,19 @@ impl WindowManager { } } + /// Switches to `id`, unless `auto_back_and_forth` is set and `id` is + /// already the current workspace - in which case this jumps to + /// `previous_workspace` instead, sway's `workspace_auto_back_and_forth` + /// behavior. `previous_workspace` itself always tracks "whatever was + /// current right before this call changed it", updated on every real + /// switch regardless of the setting, so turning the setting on later + /// (or a client-driven switch, e.g. `ext_workspace_v1`'s `activate`) + /// doesn't need its own separate bookkeeping. pub fn switch_workspace(&mut self, id: WorkspaceId) { - if self.workspaces.iter().any(|w| w.id == id) { - self.current_workspace = id; + let target = if self.auto_back_and_forth && id == self.current_workspace { self.previous_workspace } else { id }; + if self.workspaces.iter().any(|w| w.id == target) && target != self.current_workspace { + self.previous_workspace = self.current_workspace; + self.current_workspace = target; } } @@ -683,6 +949,19 @@ impl WindowManager { self.windows.values().filter(|w| w.workspace == self.current_workspace && !w.minimized) } + /// Same windows as [`Self::visible_windows`], but in real front-to-back + /// stacking order (topmost first) instead of arbitrary `HashMap` + /// iteration order. Needed anywhere a backend composites more than one + /// window's elements (content, decoration, border) together and their + /// relative order across *different* windows actually matters - unlike + /// `visible_windows`, which is fine for anything per-window in + /// isolation (border color, geometry) where order never came up. + /// `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) + } + // ---- Layout ----------------------------------------------------------- pub fn set_layout(&mut self, workspace: WorkspaceId, layout_name: impl Into<String>) { @@ -854,7 +1133,7 @@ mod tests { w.geometry = Rect::new(500, 500, 400, 300); wm.add_window(w); wm.start_drag(a, 510, 510); - wm.update_drag(20, 510); // drag far left, within snap threshold of edge 0 + wm.update_drag(15, 510); // drag far left, landing within snap_threshold (8px) of edge 0 wm.end_drag(); let g = wm.window(a).unwrap().geometry; assert_eq!(g, Rect::new(0, 0, 960, 1080)); @@ -891,6 +1170,42 @@ mod tests { } #[test] + fn maximize_records_anim_from_when_animations_enabled() { + 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); + let placed = wm.window(a).unwrap().geometry; + wm.toggle_maximize(a); + assert_eq!(wm.window(a).unwrap().anim_from, Some(placed)); + } + + #[test] + fn maximize_does_not_record_anim_from_when_animations_disabled() { + let mut wm = wm_with_monitor(); + wm.animations_enabled = false; + 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.toggle_maximize(a); + assert_eq!(wm.window(a).unwrap().anim_from, None); + } + + #[test] + fn fullscreen_records_anim_from_covering_the_full_monitor() { + 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); + let placed = wm.window(a).unwrap().geometry; + wm.toggle_fullscreen(a); + assert_eq!(wm.window(a).unwrap().anim_from, Some(placed)); + } + + #[test] fn directional_focus_picks_nearest_window_in_that_direction() { let mut wm = wm_with_monitor(); wm.set_layout(wm.current_workspace(), "tiling"); @@ -950,7 +1265,7 @@ mod tests { let mut wm = wm_with_monitor(); wm.set_layout(wm.current_workspace(), "tiling"); wm.add_rule(WindowRule { - matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), class: None }, + matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), ..Default::default() }, actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() }, }); let id = wm.alloc_window_id(); @@ -962,7 +1277,7 @@ mod tests { fn non_matching_rule_leaves_window_untouched() { let mut wm = wm_with_monitor(); wm.add_rule(WindowRule { - matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), class: None }, + matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), ..Default::default() }, actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() }, }); let id = wm.alloc_window_id(); @@ -975,7 +1290,7 @@ mod tests { let mut wm = wm_with_monitor(); let target = wm.add_workspace("scratch", "dynamic"); wm.add_rule(WindowRule { - matcher: crate::rules::WindowMatch { title_contains: None, class: Some("scratchpad".into()) }, + matcher: crate::rules::WindowMatch { class: Some("scratchpad".into()), ..Default::default() }, actions: crate::rules::WindowRuleActions { workspace: Some(target), ..Default::default() }, }); let id = wm.alloc_window_id(); @@ -997,6 +1312,150 @@ mod tests { assert!(wm.workspace(ws2).is_none()); } + #[test] + fn rename_workspace_changes_the_display_name() { + let mut wm = wm_with_monitor(); + let ws2 = wm.add_workspace("2", "dynamic"); + wm.rename_workspace(ws2, "code"); + assert_eq!(wm.workspace(ws2).unwrap().name, "code"); + } + + #[test] + fn auto_back_and_forth_jumps_to_the_previous_workspace_when_reselecting_the_active_one() { + let mut wm = wm_with_monitor(); + wm.auto_back_and_forth = true; + 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 + // one that was active right before. + wm.switch_workspace(ws2); + assert_eq!(wm.current_workspace(), 0); + } + + #[test] + fn without_auto_back_and_forth_reselecting_the_active_workspace_is_a_plain_no_op() { + let mut wm = wm_with_monitor(); + let ws2 = wm.add_workspace("2", "dynamic"); + wm.switch_workspace(ws2); + wm.switch_workspace(ws2); + assert_eq!(wm.current_workspace(), ws2); + } + + #[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"); + wm.switch_workspace(ws2); + wm.switch_workspace(9999); + assert_eq!(wm.current_workspace(), ws2); + // The failed switch must not have overwritten `previous_workspace` + // either - auto_back_and_forth would otherwise jump to a + // workspace id that was never really visited. + wm.auto_back_and_forth = true; + wm.switch_workspace(ws2); + assert_eq!(wm.current_workspace(), 0); + } + + #[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"); + assert!(wm.workspaces().iter().all(|w| w.name != "ghost")); + } + + // ---- Scratchpad -------------------------------------------------------- + + #[test] + fn scratchpad_add_hides_the_window_and_marks_pool_membership() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "term")); + wm.scratchpad_add(a); + let w = wm.window(a).unwrap(); + assert!(w.scratchpad); + assert!(w.minimized); + assert!(w.floating); + assert!(!wm.visible_windows().any(|w| w.id == a)); + } + + #[test] + fn scratchpad_show_brings_back_the_hidden_window_and_focuses_it() { + 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(); + let w = wm.window(a).unwrap(); + assert!(!w.minimized); + assert_eq!(wm.focused_id(), Some(a)); + assert!(wm.visible_windows().any(|w| w.id == a)); + } + + #[test] + fn scratchpad_show_hides_again_when_the_shown_scratchpad_window_is_focused() { + 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(); // shows + focuses + wm.scratchpad_show(); // toggles back off + assert!(wm.window(a).unwrap().minimized); + assert!(!wm.visible_windows().any(|w| w.id == a)); + } + + #[test] + fn scratchpad_show_moves_the_window_onto_the_current_workspace() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "term")); + wm.scratchpad_add(a); + let ws2 = wm.add_workspace("2", "dynamic"); + wm.switch_workspace(ws2); + wm.scratchpad_show(); + assert_eq!(wm.window(a).unwrap().workspace, ws2); + assert!(wm.visible_windows().any(|w| w.id == a)); + } + + #[test] + fn scratchpad_show_with_no_scratchpad_windows_is_a_no_op() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "normal")); + wm.scratchpad_show(); + assert_eq!(wm.focused_id(), Some(a)); + assert!(!wm.window(a).unwrap().minimized); + } + + #[test] + fn scratchpad_show_picks_the_most_recently_added_hidden_window() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "old")); + wm.scratchpad_add(a); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "new")); + wm.scratchpad_add(b); + wm.scratchpad_show(); + assert_eq!(wm.focused_id(), Some(b)); + assert!(wm.window(a).unwrap().minimized); + } + + #[test] + fn scratchpad_remove_leaves_current_visibility_untouched_but_drops_pool_membership() { + 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_remove(a); + assert!(!wm.window(a).unwrap().scratchpad); + assert!(wm.window(a).unwrap().minimized); + // No longer scratchpad-managed, so a later `scratchpad_show` must + // not touch it. + wm.scratchpad_show(); + assert!(wm.window(a).unwrap().minimized); + } + // ---- Monitor hotplug ------------------------------------------------- fn two_monitors() -> Vec<Monitor> { @@ -1140,6 +1599,203 @@ mod tests { } #[test] + fn fullscreen_round_trip_restores_a_client_side_decorated_window_to_undecorated() { + // Regression test: exiting fullscreen used to hardcode + // `decorated = true` unconditionally, which is only correct for a + // window that was decorated to begin with. A window a rule sets + // `decorated = false` for (client-side-decorated apps like + // Firefox) that goes fullscreen and back used to come back + // permanently `decorated = true` - with nothing to ever set it + // back, since the client only negotiates its decoration mode once. + // Since border/titlebar hit-testing is keyed off `Window.decorated` + // directly, this made srdwm swallow every click near the top of + // the window as a fake titlebar hit instead of forwarding it to + // the client. + let mut wm = WindowManager::new(); + wm.set_monitors(two_monitors()); + 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); + + wm.toggle_fullscreen(id); + assert!(!wm.window(id).unwrap().decorated, "fullscreen itself must still drop the titlebar"); + + wm.toggle_fullscreen(id); + assert!(!wm.window(id).unwrap().decorated, "must restore the pre-fullscreen decorated=false, not default to true"); + } + + /// A monitor whose usable `geometry` is shrunk by a bottom dock's + /// exclusive zone, distinct from its true `full_geometry` - the shape + /// every real backend reports once a bar/dock has claimed space (see + /// `Monitor::full_geometry`'s doc comment). + 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); + m.primary = true; + m + } + + #[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. + 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_fullscreen(id); + assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1080), "fullscreen must reach the true monitor edge, past the dock"); + } + + #[test] + fn maximize_still_respects_the_dock_reservation() { + 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"); + } + + #[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. + 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)); + + // 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]); + + assert_eq!( + wm.window(id).unwrap().geometry, + Rect::new(0, 0, 1920, 1080), + "an already-maximized window must live-track a monitor geometry change, not just windows placed afterward" + ); + } + + #[test] + fn fullscreen_window_also_live_tracks_a_monitor_geometry_change() { + 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_fullscreen(id); + assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1080)); + + let mut resized = Monitor::new(0, "primary", Rect::new(0, 0, 2560, 1420)); + resized.full_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, 2560, 1440), "fullscreen must live-track the true full rect, not the usable one"); + } + + #[test] + fn a_non_maximized_window_is_left_alone_by_a_monitor_geometry_change() { + // set_monitors' new re-sync pass is gated on maximized/fullscreen -- + // must not clobber an ordinary floating/tiled window's geometry just + // because the monitor rect changed underneath it. + let mut wm = WindowManager::new(); + wm.set_monitors(vec![monitor_with_dock()]); + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "a"); + w.geometry = Rect::new(100, 100, 400, 300); + wm.add_window(w); + wm.window_mut(id).unwrap().geometry = Rect::new(100, 100, 400, 300); + + 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]); + + assert_eq!(wm.window(id).unwrap().geometry, Rect::new(100, 100, 400, 300)); + } + + #[test] + fn dragging_a_window_can_cross_into_the_dock_reserved_strip() { + // Regression test: `update_drag`'s clamp used to also use + // `Monitor::geometry` (the shrunk usable area), which made it + // physically impossible to ever drag a floating window into the + // strip a dock reserves - not just discouraged, genuinely + // unreachable at any drag speed or angle. `full_geometry` is what + // makes that space reachable again; the dock still renders on top + // as an overlay, same as it does everywhere else. + let mut wm = WindowManager::new(); + wm.set_monitors(vec![monitor_with_dock()]); + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "a"); + w.geometry = Rect::new(500, 500, 200, 200); + wm.add_window(w); + + wm.start_drag(id, 600, 600); + // Drag far down - past the old usable-area bottom (1020) and + // toward the true monitor bottom (1080). + wm.update_drag(600, 5000); + let g = wm.window(id).unwrap().geometry; + // Old behavior (clamped to `geometry`, bottom 1020) would stop at + // y=980; clamped to `full_geometry` (bottom 1080), it reaches 1040. + assert_eq!(g.y, 1040, "must clamp against the true monitor bottom, not the dock-shrunk usable area"); + } + + #[test] + fn class_rule_applies_once_app_id_is_known_after_creation() { + // Regression test: `add_window` matches rules against whatever + // `app_id`/`title` the window already has - for a native Wayland + // client those are still empty at that moment (the real values + // only arrive on a later commit, well after `new_toplevel`), so + // every class-based rule - including `srd.rule({ class = + // "firefox" }, { decorated = false })`, meant to stop srdwm + // drawing a second titlebar over Firefox's own - silently never + // matched. `reapply_rules_if_pending` is the retry a backend calls + // once the real app_id is known. + let mut wm = wm_with_monitor(); + wm.add_rule(WindowRule { + matcher: crate::rules::WindowMatch { class: Some("firefox".into()), ..Default::default() }, + actions: crate::rules::WindowRuleActions { decorated: Some(false), ..Default::default() }, + }); + let id = wm.alloc_window_id(); + // Empty app_id, exactly as a fresh native Wayland toplevel has it. + wm.add_window(Window::new(id, "")); + assert!(wm.window(id).unwrap().decorated, "no app_id yet, so no match - must not have flipped early"); + + let w = wm.window_mut(id).unwrap(); + w.app_id = "firefox".into(); + wm.reapply_rules_if_pending(id); + assert!(!wm.window(id).unwrap().decorated, "app_id now known - the rule must apply on retry"); + + // A later, unrelated title change (e.g. a browser tab switching) + // must not re-match and re-apply - rule actions apply once. + let w = wm.window_mut(id).unwrap(); + w.decorated = true; + w.title = "a new tab title".into(); + wm.reapply_rules_if_pending(id); + assert!(wm.window(id).unwrap().decorated, "rules_applied is already true - must not re-run the match"); + } + + #[test] fn fullscreen_from_maximized_still_restores_the_pre_maximize_size() { // Both share `restore_geometry`; entering fullscreen from a // maximised window must not overwrite it with the monitor rect, or @@ -1305,4 +1961,34 @@ mod tests { wm.raise_window(b); assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(b)); } + + #[test] + fn lower_window_sends_it_to_the_back_of_the_stack() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let b = wm.alloc_window_id(); + wm.add_window(Window::new(b, "b")); + let c = wm.alloc_window_id(); + wm.add_window(Window::new(c, "c")); + assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(c), "precondition: c is on top after being added last"); + + wm.lower_window(c); + let order: Vec<_> = wm.stacking_order().map(|w| w.id).collect(); + assert_eq!(order, vec![c, a, b], "c must be at the very back, a/b unchanged relative to each other"); + } + + #[test] + fn lower_window_never_buries_a_pinned_window() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "a")); + let pinned = wm.alloc_window_id(); + wm.add_window(Window::new(pinned, "pinned")); + wm.toggle_always_on_top(pinned); + assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(pinned)); + + wm.lower_window(a); + assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(pinned), "a pinned window must stay on top even after an unrelated lower_window call"); + } } diff --git a/crates/core/src/monitor.rs b/crates/core/src/monitor.rs index aa25fba..b04ee15 100644 --- a/crates/core/src/monitor.rs +++ b/crates/core/src/monitor.rs @@ -5,14 +5,30 @@ pub type MonitorId = u32; #[derive(Debug, Clone)] pub struct Monitor { pub id: MonitorId, - pub name: String, + /// Usable area: the output rect shrunk by any layer-shell exclusive + /// zone (a bar/dock). What placement, tiling and maximize target -- + /// see `full_geometry`'s doc comment for the one thing that + /// deliberately does *not* use this field. pub geometry: Rect, + /// The output's true full rect, ignoring any exclusive zone. + /// + /// Kept separate from `geometry` because "respects the dock" and + /// "doesn't" are two genuinely different behaviors a window needs, + /// not one setting: fullscreen (and a window being interactively + /// dragged) should be able to cover or cross the strip a bar/dock + /// reserves - the bar just renders on top, as an overlay, the same + /// way it does everywhere else - while a *new* window's placement, + /// tiling and maximize should keep avoiding that strip, same as + /// before. Defaults to `geometry` (no reservation) for any backend + /// that hasn't been taught the distinction yet. + pub full_geometry: Rect, + pub name: String, pub refresh_rate_mhz: u32, pub primary: bool, } impl Monitor { pub fn new(id: MonitorId, name: impl Into<String>, geometry: Rect) -> Self { - Self { id, name: name.into(), geometry, refresh_rate_mhz: 60_000, primary: false } + Self { id, name: name.into(), geometry, full_geometry: geometry, refresh_rate_mhz: 60_000, primary: false } } } diff --git a/crates/core/src/placement.rs b/crates/core/src/placement.rs index ed9e410..15a5ad5 100644 --- a/crates/core/src/placement.rs +++ b/crates/core/src/placement.rs @@ -21,13 +21,33 @@ pub const MIN_WINDOW_HEIGHT: u32 = 150; pub struct PlacementConfig { pub grid_margin: u32, pub cascade_offset: i32, + /// How close (in logical pixels) a dragged window's edge has to end up + /// to a monitor edge on release before `snap_zone` triggers a + /// half/quarter/maximize. A single edge match with no corner match + /// (e.g. top-only) maximizes the *whole* window - see `snap_zone`'s + /// `(false, false, true, false) => area` arm - so this value directly + /// controls how easy it is to accidentally full-maximize a window while + /// just repositioning it near the top of the screen, not only how + /// generous the corner/half-snap zones are. pub snap_threshold: i32, pub max_grid: u32, } impl Default for PlacementConfig { fn default() -> Self { - Self { grid_margin: 10, cascade_offset: 30, snap_threshold: 50, max_grid: 4 } + // `snap_threshold` was 50, then 20 - both live-tested and reported + // as still snapping from an ordinary "move it near an edge" drag, + // not just a deliberate release-at-the-edge one. `update_drag`'s + // clamp used to also cap a dragged window's reach to the + // exclusive-zone-shrunk usable area rather than the monitor's true + // edge (see `Monitor::full_geometry`), which made this worse than + // the number alone suggests: the window could get within 20px of + // `snap_zone`'s comparison edge well before the cursor was + // anywhere near the real screen edge. 8 keeps snapping reachable + // (a window's own edge, not the cursor, is what's measured) while + // requiring it to actually be at the edge, not just closer to it + // than to the middle of the screen. + Self { grid_margin: 10, cascade_offset: 30, snap_threshold: 8, max_grid: 4 } } } diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs index 642179d..148b627 100644 --- a/crates/core/src/rules.rs +++ b/crates/core/src/rules.rs @@ -1,22 +1,46 @@ +use regex::Regex; + use crate::geometry::Rect; use crate::window::Window; use crate::workspace::WorkspaceId; /// Match criteria for a [`WindowRule`]. A matcher with every field `None` /// matches nothing (an accidental `srd.rule({}, {...})` in config should be a -/// silent no-op, not "apply to every window"). +/// silent no-op, not "apply to every window"). Every field that is `Some` +/// must match (AND semantics) - matching the convention i3's multi-criteria +/// rules and bspwm's `class:instance:title` rules both already use. +/// +/// `title_contains`/`class` (plain substring/exact match) are kept +/// alongside the regex fields below rather than folded into them: they +/// cover the large majority of real rules (`srd.rule({ class = "firefox" }, +/// ...)`) with no regex syntax to get right, and are cheaper to evaluate. +/// `title_regex`/`class_regex`/`instance` exist for the cases that need +/// more precision - disambiguating a specific dialog by title while +/// leaving an app's main window alone, the concrete example that motivated +/// adding these - without forcing every simple rule to write one. #[derive(Debug, Clone, Default)] pub struct WindowMatch { /// Case-insensitive substring match against `Window::title`. pub title_contains: Option<String>, - /// Case-insensitive exact match against `Window::app_id` (X11 `WM_CLASS` - /// / Wayland `app_id`). + /// Case-insensitive exact match against `Window::app_id` (X11 `WM_CLASS`'s + /// *class* half / Wayland `app_id`). pub class: Option<String>, + /// Regex match against `Window::title`. Case-sensitive by default -- + /// write `(?i)` at the start of the pattern for case-insensitive, + /// the same convention i3's own criteria use. + pub title_regex: Option<Regex>, + /// Regex match against `Window::app_id`. + pub class_regex: Option<Regex>, + /// Case-insensitive exact match against `Window::instance` (X11 + /// `WM_CLASS`'s *instance* half). Always fails to match on Wayland-native + /// windows, which have no equivalent (`Window::instance` is always + /// empty there) - an X11/XWayland-only criterion, same as bspwm's. + pub instance: Option<String>, } impl WindowMatch { pub fn is_empty(&self) -> bool { - self.title_contains.is_none() && self.class.is_none() + self.title_contains.is_none() && self.class.is_none() && self.title_regex.is_none() && self.class_regex.is_none() && self.instance.is_none() } pub fn matches(&self, window: &Window) -> bool { @@ -33,6 +57,21 @@ impl WindowMatch { return false; } } + if let Some(re) = &self.title_regex { + if !re.is_match(&window.title) { + return false; + } + } + if let Some(re) = &self.class_regex { + if !re.is_match(&window.app_id) { + return false; + } + } + if let Some(i) = &self.instance { + if !window.instance.eq_ignore_ascii_case(i) { + return false; + } + } true } } @@ -70,7 +109,7 @@ mod tests { #[test] fn title_match_is_case_insensitive_substring() { let w = Window::new(1, "Mozilla Firefox"); - let m = WindowMatch { title_contains: Some("firefox".into()), class: None }; + let m = WindowMatch { title_contains: Some("firefox".into()), ..Default::default() }; assert!(m.matches(&w)); } @@ -78,10 +117,69 @@ mod tests { fn class_match_is_case_insensitive_exact() { let mut w = Window::new(1, ""); w.app_id = "Firefox".into(); - let m = WindowMatch { title_contains: None, class: Some("firefox".into()) }; + let m = WindowMatch { class: Some("firefox".into()), ..Default::default() }; assert!(m.matches(&w)); let mut w2 = Window::new(2, ""); w2.app_id = "firefoxx".into(); assert!(!m.matches(&w2)); } + + #[test] + fn title_regex_matches_a_specific_dialog_without_matching_the_main_window() { + // The concrete case that motivated adding regex support at all: + // disambiguating a specific dialog by title while leaving an app's + // main window alone - not reliably possible with substring-only + // matching if the dialog's title is a substring-superset situation + // (or vice versa) that plain `contains` can't express. + let m = WindowMatch { title_regex: Some(Regex::new(r"^Save File$").unwrap()), ..Default::default() }; + let dialog = Window::new(1, "Save File"); + let main = Window::new(2, "Save File - GNU Image Manipulation Program"); + assert!(m.matches(&dialog)); + assert!(!m.matches(&main)); + } + + #[test] + fn class_regex_is_case_sensitive_unless_the_pattern_opts_in() { + let mut w = Window::new(1, ""); + w.app_id = "firefox".into(); + let sensitive = WindowMatch { class_regex: Some(Regex::new(r"^Firefox$").unwrap()), ..Default::default() }; + assert!(!sensitive.matches(&w)); + let insensitive = WindowMatch { class_regex: Some(Regex::new(r"(?i)^Firefox$").unwrap()), ..Default::default() }; + assert!(insensitive.matches(&w)); + } + + #[test] + fn instance_match_is_case_insensitive_exact_and_independent_of_class() { + let mut w = Window::new(1, ""); + w.app_id = "Navigator".into(); + w.instance = "firefox".into(); + let m = WindowMatch { instance: Some("Firefox".into()), ..Default::default() }; + assert!(m.matches(&w)); + let mut w2 = Window::new(2, ""); + w2.instance = "firefoxdeveloperedition".into(); + assert!(!m.matches(&w2)); + } + + #[test] + fn multiple_criteria_are_combined_with_and() { + let mut w = Window::new(1, "Preferences"); + w.app_id = "firefox".into(); + let m = WindowMatch { class: Some("firefox".into()), title_contains: Some("preferences".into()), ..Default::default() }; + assert!(m.matches(&w)); + // Same class, different title - must not match once title is + // also a criterion. + let mut w2 = Window::new(2, "Mozilla Firefox"); + w2.app_id = "firefox".into(); + assert!(!m.matches(&w2)); + } + + #[test] + fn a_nonempty_instance_criterion_does_not_match_a_wayland_native_window() { + // `Window::instance` is always empty on Wayland (no equivalent + // concept), so a real `instance` rule (never an empty-string one -- + // nobody writes `instance = ""`) must not match there. + let w = Window::new(1, ""); + let m = WindowMatch { instance: Some("firefox".into()), ..Default::default() }; + assert!(!m.matches(&w)); + } } diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs new file mode 100644 index 0000000..1717fe9 --- /dev/null +++ b/crates/core/src/theme.rs @@ -0,0 +1,77 @@ +/// Default decoration colours and border width, applied to every window at +/// creation (before rules run, so a rule's own `border_color`/`border_width` +/// still wins - see `WindowManager::add_window`) and read live by a +/// backend's titlebar rendering. +/// +/// Read from `theme.colors.*`/`theme.decorations.*` in `crates/srdwm/src/ +/// main.rs`'s `apply_general_settings`. Before that wiring existed, these +/// were hardcoded Rust constants scattered across `crates/wayland` (the +/// Nord palette every default here still matches, so an unconfigured +/// session looks identical to before) - found the same way `window_gap` +/// and `general.animations` were: config already validated/defaulted these +/// keys, nothing ever read them. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ThemeConfig { + pub titlebar_bg: (u8, u8, u8), + pub titlebar_fg_focused: (u8, u8, u8), + pub titlebar_fg_unfocused: (u8, u8, u8), + pub default_border_color: (u8, u8, u8), + pub default_border_width: u32, +} + +impl Default for ThemeConfig { + fn default() -> Self { + Self { + titlebar_bg: (0x2e, 0x34, 0x40), + titlebar_fg_focused: (0x88, 0xc0, 0xd0), + titlebar_fg_unfocused: (0x4c, 0x56, 0x6a), + default_border_color: (136, 192, 208), // Nord accent, matches legacy theme default + default_border_width: 2, + } + } +} + +/// Parses a `"#rrggbb"` string into its channels. Returns `None` for +/// anything else - `crates/config` already validates this shape at load +/// time (`is_valid_hex_color`) and logs a warning for a malformed value, so +/// a caller here can fall back to a default silently rather than erroring +/// a second time. +pub fn parse_hex_color(s: &str) -> Option<(u8, u8, u8)> { + if s.len() != 7 || !s.starts_with('#') { + return None; + } + let r = u8::from_str_radix(&s[1..3], 16).ok()?; + let g = u8::from_str_radix(&s[3..5], 16).ok()?; + let b = u8::from_str_radix(&s[5..7], 16).ok()?; + Some((r, g, b)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_well_formed_hex_color() { + assert_eq!(parse_hex_color("#88c0d0"), Some((0x88, 0xc0, 0xd0))); + } + + #[test] + fn rejects_missing_hash_or_wrong_length() { + assert_eq!(parse_hex_color("88c0d0"), None); + assert_eq!(parse_hex_color("#88c0d"), None); + assert_eq!(parse_hex_color("#88c0d00"), None); + } + + #[test] + fn rejects_non_hex_digits() { + assert_eq!(parse_hex_color("#zzzzzz"), None); + } + + #[test] + fn default_matches_the_legacy_hardcoded_nord_palette() { + let t = ThemeConfig::default(); + assert_eq!(t.titlebar_bg, (0x2e, 0x34, 0x40)); + assert_eq!(t.titlebar_fg_focused, (0x88, 0xc0, 0xd0)); + assert_eq!(t.default_border_color, (136, 192, 208)); + } +} diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs index 9484709..1a4cab5 100644 --- a/crates/core/src/window.rs +++ b/crates/core/src/window.rs @@ -2,6 +2,61 @@ use crate::geometry::Rect; pub type WindowId = u64; +/// A window's exported application/window menu, as a D-Bus *address* -- +/// bus name plus object paths - never the menu's actual content. +/// +/// The content is a `GMenuModel` already exported over `org.gtk.Menus`/ +/// `org.gtk.Actions`, which GTK4 consumes natively (`Gio.DBusMenuModel`, +/// `Gtk.PopoverMenuBar.new_from_model()`) with full submenus, toggles, +/// accelerators and icons - carrying the model itself over a Wayland +/// protocol instead would mean hand-marshalling and hand-rendering it for +/// strictly worse fidelity. These four strings are the only part a +/// compositor can supply that a client-side global-menu shell can't get +/// any other way: on XWayland this is `_GTK_UNIQUE_BUS_NAME`/ +/// `_GTK_MENUBAR_OBJECT_PATH`/`_GTK_APPLICATION_OBJECT_PATH`/ +/// `_GTK_WINDOW_OBJECT_PATH`; on Wayland-native surfaces it's GTK's own +/// private `gtk_shell1` protocol's `gtk_surface1.set_dbus_properties` +/// request, which carries the identical four fields under different +/// names. See `crates/wayland/src/xwayland.rs` and `gtk_shell.rs` for +/// where each backend actually populates this. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GlobalMenu { + pub bus_name: String, + /// The app or window's menu bar, whichever the client exported -- + /// `menubar_path` (a full menu bar) if set, else `app_menu_path` (just + /// the single app-level menu older/simpler clients export instead). + /// Which of the two (or the pre-`_GTK_*` Unity path) actually won is + /// [`Self::source`] - load-bearing, not cosmetic: see its own doc + /// comment. + pub menu_path: Option<String>, + pub app_path: Option<String>, + pub window_path: Option<String>, + pub source: MenuSource, +} + +/// Which export flavour [`GlobalMenu::menu_path`] actually came from -- +/// the two address their actions under different D-Bus action-group +/// prefixes, and getting this wrong doesn't fail loudly: the menu still +/// renders, every item just comes up permanently insensitive, which reads +/// exactly like an app that exported a broken menu rather than a +/// consumer that resolved the wrong prefix. +/// +/// - [`MenuSource::Gtk`]: a real `GMenuModel`. Items reference actions as +/// `app.xxx`/`win.xxx`; a consumer must insert two action groups, under +/// prefixes `"app"` and `"win"`, from [`GlobalMenu::app_path`]/ +/// [`GlobalMenu::window_path`] respectively. +/// - [`MenuSource::Unity`]: the older Ubuntu Unity-era export +/// (`_UNITY_OBJECT_PATH`, still relevant for some Qt platform-theme +/// builds). Items reference actions as `unity.xxx`, all against one +/// group at the menu's own path - a consumer inserts a single group +/// under prefix `"unity"` instead. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MenuSource { + #[default] + Gtk, + Unity, +} + /// State of a single managed window. This is platform-independent: backends /// (X11, Wayland, ...) own the real surface/client handle and keep a `Window` /// in sync with it via `srdwm_core::WindowManager`. @@ -10,12 +65,26 @@ pub struct Window { pub id: WindowId, pub title: String, pub app_id: String, + /// X11 `WM_CLASS`'s *instance* half (`WM_CLASS` is `"instance\0class\0"`; + /// `app_id` above holds the class half, matching Wayland's `app_id` + /// concept). Always empty on Wayland/XWayland, which has no equivalent. + pub instance: String, pub geometry: Rect, /// Geometry to restore to when un-maximizing. pub restore_geometry: Option<Rect>, pub decorated: bool, + /// `decorated`'s value from just before entering fullscreen, restored + /// on exit - see `WindowManager::toggle_fullscreen`'s doc comment on + /// why this can't just hardcode `true` back. + pub restore_decorated: Option<bool>, pub floating: bool, pub minimized: bool, + /// Whether this window belongs to the scratchpad pool - see + /// `WindowManager::scratchpad_add`/`scratchpad_show`'s doc comments. + /// Persists across show/hide toggles (`minimized` is what actually + /// gates visibility); a window never sets this itself, only `srd.window. + /// scratchpad()`/the equivalent keybinding does. + pub scratchpad: bool, pub maximized: bool, pub fullscreen: bool, pub always_on_top: bool, @@ -23,6 +92,28 @@ pub struct Window { pub border_width: u32, pub workspace: usize, pub monitor: u32, + /// Whether `WindowManager`'s class/title-matched rules have already + /// been evaluated (and, if matched, applied) for this window. + /// + /// `add_window` matches rules once, at creation, but a native Wayland + /// client's `title`/`app_id` are still empty at that moment (they + /// arrive on a later commit - see the Wayland backend's + /// `sync_toplevel_metadata` doc comment); matching then would silently + /// fail every class-based rule. Left `false` so a backend can retry + /// the match once real identity is known, without ever re-matching + /// after that (rule actions apply once, not on every subsequent title + /// change). + pub rules_applied: bool, + /// Set by `WindowManager::toggle_maximize`/`toggle_fullscreen` to the + /// geometry `self.geometry` just moved *from*, whenever that move + /// should be animated. A backend's `sync_geometry` takes (reads and + /// clears) this once per change to start a tween toward the new + /// `geometry`; left `None` for changes that must track 1:1 instead + /// (interactive drag/resize), which never set it. + pub anim_from: Option<Rect>, + /// This window's global-menu D-Bus address, if the client has exported + /// one. See [`GlobalMenu`]'s own doc comment. + pub global_menu: Option<GlobalMenu>, } impl Window { @@ -31,11 +122,14 @@ impl Window { id, title: title.into(), app_id: String::new(), + instance: String::new(), geometry: Rect::new(0, 0, 640, 480), restore_geometry: None, decorated: true, + restore_decorated: None, floating: false, minimized: false, + scratchpad: false, maximized: false, fullscreen: false, always_on_top: false, @@ -43,6 +137,9 @@ impl Window { border_width: 2, workspace: 0, monitor: 0, + rules_applied: false, + anim_from: None, + global_menu: None, } } } @@ -59,6 +156,25 @@ pub const TITLEBAR_HEIGHT: u32 = 30; /// costs a few pixels of client edge; that is the right trade for making /// resize reliably grabbable without a keyboard. pub const RESIZE_MARGIN: i32 = 10; +/// Top-edge resize margin for an *undecorated* window specifically -- +/// narrower than [`RESIZE_MARGIN`] on purpose. +/// +/// An undecorated (client-side-decorated) window has no titlebar band for +/// srdwm to treat as a drag handle - Firefox's own tab strip, concretely -- +/// so the client's own header area sits directly at `frame.y` with nothing +/// srdwm-drawn to grab. The client detects a drag on its own header and +/// asks to be moved via `xdg_toplevel.move`, but only for clicks that +/// actually reach it as a normal button press; the full 10px `RESIZE_MARGIN` +/// swallowed every click within the first 10 rows of the window - including +/// most of a typical natural grab point near the top of a tab strip - as a +/// top-edge resize instead, so the client's own move request never fired. +/// Reported live as "can't drag-move Firefox from its own top bar." +/// Resize-from-the-top-edge still works (a deliberate earlier trade-off -- +/// see `undecorated_window_still_resizes_from_every_edge_including_top`'s +/// own comment - since an undecorated window is still a window), just from +/// a much narrower band that a click meant to grab the tab strip is very +/// unlikely to land in by accident. +pub const UNDECORATED_TOP_RESIZE_MARGIN: i32 = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResizeEdge { @@ -75,11 +191,38 @@ pub enum ResizeEdge { impl ResizeEdge { /// Determine which titlebar button (if any) a point within the titlebar /// band falls on. Buttons are laid out right-aligned: close, maximize, minimize. - pub fn hit_test(frame: Rect, x: i32, y: i32) -> Option<TitlebarHit> { - if !frame.contains_point(x, y) { + /// + /// `decorated` must reflect the window's *actual* current state, not + /// just whether it usually draws one: `frame` always reserves + /// `TITLEBAR_HEIGHT` at the top regardless of whether anything is drawn + /// there (placement never shrinks a window's allocated geometry just + /// because a rule or CSD negotiation later turns decoration off - see + /// `sync_geometry`'s own doc comment on that split). Applying the + /// titlebar-band/button logic unconditionally meant an *undecorated* + /// window's own content in that top band - Firefox's tab strip and URL + /// bar, concretely, once `decorated = false` actually started applying + /// to it - silently ate every click there as a phantom + /// drag/close/maximize/minimize hit instead of ever reaching the + /// client. Resize-from-edge still applies either way: an undecorated + /// window is still a window, and dragging its (invisible) edge to + /// resize is still expected to work. + pub fn hit_test(frame: Rect, x: i32, y: i32, decorated: bool, border_width: u32) -> Option<TitlebarHit> { + // Border strips render *outside* `frame` (`decoration:: + // border_strips`, `border_width` pixels past each edge) - without + // widening the containment check to match, those visible pixels + // were a dead zone: `frame.contains_point` rejected them outright, + // so hovering the border itself (not just just inside it) showed no + // resize cursor and couldn't be grabbed, even though it's what + // visually reads as the window's actual edge. `resize_edge_at` + // itself needs no matching change - its margin comparisons + // (`x <= frame.x + m`, etc.) already treat anything at or outside + // `frame`'s own edge as maximally "near", border pixels included. + let bw = border_width as i32; + let outer = Rect::new(frame.x - bw, frame.y - bw, frame.width + 2 * border_width, frame.height + 2 * border_width); + if !outer.contains_point(x, y) { return None; } - if y < frame.y + TITLEBAR_HEIGHT as i32 { + if decorated && y < frame.y + TITLEBAR_HEIGHT as i32 { const BUTTON: i32 = TITLEBAR_HEIGHT as i32; let right = frame.right(); if x >= right - BUTTON { @@ -93,15 +236,16 @@ impl ResizeEdge { } return Some(TitlebarHit::Drag); } - let edge = Self::resize_edge_at(frame, x, y)?; + let edge = Self::resize_edge_at(frame, x, y, decorated)?; Some(TitlebarHit::Resize(edge)) } - fn resize_edge_at(frame: Rect, x: i32, y: i32) -> Option<ResizeEdge> { + fn resize_edge_at(frame: Rect, x: i32, y: i32, decorated: bool) -> Option<ResizeEdge> { let m = RESIZE_MARGIN; + let top_m = if decorated { m } else { UNDECORATED_TOP_RESIZE_MARGIN }; let near_left = x <= frame.x + m; let near_right = x >= frame.right() - m; - let near_top = y <= frame.y + m; + let near_top = y <= frame.y + top_m; let near_bottom = y >= frame.bottom() - m; Some(match (near_left, near_right, near_top, near_bottom) { (true, _, true, _) => ResizeEdge::TopLeft, @@ -110,6 +254,7 @@ impl ResizeEdge { (_, true, _, true) => ResizeEdge::BottomRight, (true, false, false, false) => ResizeEdge::Left, (false, true, false, false) => ResizeEdge::Right, + (false, false, true, false) => ResizeEdge::Top, (false, false, false, true) => ResizeEdge::Bottom, _ => return None, }) @@ -173,14 +318,14 @@ mod tests { #[test] fn close_button_is_top_right_corner_of_titlebar() { let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5); + let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5, true, 0); assert_eq!(hit, Some(TitlebarHit::Close)); } #[test] fn maximize_is_left_of_close() { let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - TITLEBAR_HEIGHT as i32 - 5, f.y + 5); + let hit = ResizeEdge::hit_test(f, f.right() - TITLEBAR_HEIGHT as i32 - 5, f.y + 5, true, 0); assert_eq!(hit, Some(TitlebarHit::Maximize)); } @@ -188,21 +333,90 @@ mod tests { fn middle_of_titlebar_is_drag() { let f = frame(); let (cx, _) = f.center(); - let hit = ResizeEdge::hit_test(f, cx, f.y + 5); + let hit = ResizeEdge::hit_test(f, cx, f.y + 5, true, 0); assert_eq!(hit, Some(TitlebarHit::Drag)); } #[test] fn bottom_right_corner_is_resize() { let f = frame(); - let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1); + let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0); assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::BottomRight))); } + /// The bug this guards against: an undecorated window's own content in + /// its top `TITLEBAR_HEIGHT` band (Firefox's tab strip/URL bar, once + /// `decorated = false` actually applies to it) was silently swallowed + /// as a phantom drag hit instead of ever reaching the client, since the + /// titlebar-band check used to run unconditionally. + #[test] + fn undecorated_window_has_no_titlebar_band() { + let f = frame(); + let (cx, _) = f.center(); + // Inside the old phantom titlebar band (< TITLEBAR_HEIGHT) but + // outside RESIZE_MARGIN, so a real resize edge can't also explain a + // `None` here - undecorated, this must not be treated as + // decoration (or a resize edge) at all, just plain content. + let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0); + assert_eq!(hit, None); + } + + #[test] + fn undecorated_window_still_resizes_from_every_edge_including_top() { + let f = frame(); + let (cx, _) = f.center(); + let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0); + assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top))); + } + + #[test] + fn undecorated_top_resize_band_is_much_narrower_than_decorated() { + // Regression test: an undecorated window's own header (Firefox's + // tab strip, concretely) has no srdwm-drawn titlebar to grab, so a + // click meant to drag-move it via the client's own `xdg_toplevel. + // move` has to actually reach the client - the full `RESIZE_MARGIN` + // (10px) swallowed most of a natural grab point near the top as a + // resize instead. A *decorated* window's top band is unaffected -- + // it already has TITLEBAR_HEIGHT worth of unambiguous drag space + // above where `RESIZE_MARGIN` even starts to matter. + let f = frame(); + let (cx, _) = f.center(); + assert_eq!(ResizeEdge::hit_test(f, cx, f.y + 5, false, 0), None, "5px in: past the narrow undecorated band, must reach the client"); + assert_eq!( + ResizeEdge::hit_test(f, cx, f.y + 5, true, 0), + Some(TitlebarHit::Drag), + "decorated: 5px in is still well inside the titlebar band, not a resize edge" + ); + } + #[test] fn outside_frame_is_none() { let f = frame(); - assert_eq!(ResizeEdge::hit_test(f, 0, 0), None); + assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0), None); + } + + #[test] + fn border_pixels_are_hoverable_not_a_dead_zone() { + // Regression test: `decoration::border_strips` draws the border + // `border_width` pixels *outside* `frame`, but hit-testing only + // checked `frame` itself - so the visible border was a dead zone + // that showed no resize cursor and couldn't be grabbed, even + // though it's what visually reads as the window's edge. + let f = frame(); + let (_, cy) = f.center(); + let border_width = 2; + // One pixel into the border strip, past the left edge. + let x = f.x - 1; + assert_eq!(ResizeEdge::hit_test(f, x, cy, true, 0), None, "sanity check: with no border, this point really is outside the window"); + assert_eq!( + ResizeEdge::hit_test(f, x, cy, true, border_width), + Some(TitlebarHit::Resize(ResizeEdge::Left)), + "one pixel into the actual drawn border must still register as the left edge" + ); + // Just past the border entirely (border_width + 1 outside frame) is + // still nothing - the fix widens the dead zone's boundary, it + // doesn't remove it. + assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width), None); } #[test] diff --git a/crates/ctl/Cargo.toml b/crates/ctl/Cargo.toml new file mode 100644 index 0000000..bd8f72b --- /dev/null +++ b/crates/ctl/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "srd-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Control CLI for srdwm's Wayland backend: query/dispatch over its IPC socket" + +[[bin]] +name = "srd" +path = "src/main.rs" + +[target.'cfg(unix)'.dependencies] +# Only for `detect()`, so this CLI picks the same WAYLAND_DISPLAY-vs-DISPLAY +# socket-naming key the compositor itself just used to decide which backend +# to run - see `socket_path()`'s doc comment. Deliberately still no JSON +# dependency of its own; that stays server-side. +srdwm-platform.workspace = true diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs new file mode 100644 index 0000000..2c3c3db --- /dev/null +++ b/crates/ctl/src/main.rs @@ -0,0 +1,126 @@ +//! Control CLI for srdwm's control socket (`crates/platform/src/ipc.rs`, +//! shared by every backend that has one). Deliberately dumb: builds one +//! request line from argv, writes it, reads the response(s), prints them. +//! All real logic - and all JSON encoding - lives server-side; this +//! stays a thin pipe so it never needs a JSON dependency of its own. +//! +//! Usage: +//! srd clients list windows, one JSON object +//! srd subscribe like `clients`, then one JSON +//! object per line forever, each time +//! the window list actually changes +//! srd dispatch toggle_visibility ID hide/show a window +//! srd dispatch focus ID +//! srd dispatch close ID +//! +//! The socket is Unix-domain; on platforms without one this always fails +//! cleanly rather than not building at all, since it's one binary in a +//! workspace that otherwise targets Windows and macOS too. + +#[cfg(unix)] +fn main() { + let args: Vec<String> = std::env::args().skip(1).collect(); + let request = match build_request(&args) { + Ok(r) => r, + Err(msg) => { + eprintln!("srd: {msg}"); + print_usage(); + std::process::exit(2); + } + }; + + let result = if args.first().map(String::as_str) == Some("subscribe") { unix::stream(&request) } else { unix::send(&request).map(|r| println!("{r}")) }; + if let Err(e) = result { + eprintln!("srd: {e}"); + std::process::exit(1); + } +} + +#[cfg(not(unix))] +fn main() { + eprintln!("srd: srdwm's control socket is Unix-only; nothing to connect to on this platform"); + std::process::exit(1); +} + +fn build_request(args: &[String]) -> Result<String, String> { + match args.first().map(String::as_str) { + Some("clients") => Ok(r#"{"cmd":"clients"}"#.to_string()), + Some("monitors") => Ok(r#"{"cmd":"monitors"}"#.to_string()), + Some("subscribe") => Ok(r#"{"cmd":"subscribe"}"#.to_string()), + Some("dispatch") => { + let action = args.get(1).ok_or("dispatch needs an action (toggle_visibility/focus/close)")?; + if !matches!(action.as_str(), "toggle_visibility" | "focus" | "close") { + return Err(format!("unknown dispatch action '{action}'")); + } + let id: u64 = args.get(2).ok_or("dispatch needs a window id")?.parse().map_err(|_| "window id must be a number".to_string())?; + Ok(format!(r#"{{"cmd":"{action}","id":{id}}}"#)) + } + _ => Err("expected 'clients', 'monitors', 'subscribe', or 'dispatch <action> <id>'".to_string()), + } +} + +fn print_usage() { + eprintln!("usage:"); + eprintln!(" srd clients"); + eprintln!(" srd monitors"); + eprintln!(" srd subscribe"); + eprintln!(" srd dispatch toggle_visibility <id>"); + eprintln!(" srd dispatch focus <id>"); + eprintln!(" srd dispatch close <id>"); +} + +#[cfg(unix)] +mod unix { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + use std::path::PathBuf; + + /// `<display>` in `srdwm-<display>.sock` is whichever env var the + /// compositor itself used to name it: `WAYLAND_DISPLAY` (Wayland + /// backend) or `DISPLAY` (X11 backend) - see `srdwm_platform:: + /// IpcServer::bind`'s callers in `crates/wayland`/`crates/x11` for the + /// matching naming choice on the server side. Reuses `srdwm_platform:: + /// detect()` rather than re-deriving the same Wayland-vs-X11 decision + /// a second, potentially-drifting way here. + fn socket_path() -> Result<PathBuf, String> { + let dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from).ok_or("XDG_RUNTIME_DIR is not set")?; + let display = match srdwm_platform::detect() { + srdwm_platform::PlatformKind::X11 => std::env::var("DISPLAY").map_err(|_| "DISPLAY is not set".to_string())?, + _ => std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string()), + }; + Ok(dir.join(format!("srdwm-{display}.sock"))) + } + + fn connect() -> Result<UnixStream, String> { + let path = socket_path()?; + UnixStream::connect(&path) + .map_err(|e| format!("can't reach srdwm's control socket at {} ({e}) - is srdwm running, and are WAYLAND_DISPLAY/DISPLAY set correctly?", path.display())) + } + + pub fn send(request: &str) -> Result<String, String> { + let mut stream = connect()?; + stream.write_all(request.as_bytes()).map_err(|e| e.to_string())?; + stream.write_all(b"\n").map_err(|e| e.to_string())?; + let mut line = String::new(); + BufReader::new(&stream).read_line(&mut line).map_err(|e| e.to_string())?; + Ok(line.trim_end().to_string()) + } + + /// `subscribe`'s connection: unlike `send`, this never closes on its + /// own - the server keeps it open and writes one more line every time + /// the window list changes, so this prints each one as it arrives + /// until the process is killed or the compositor closes the socket. + pub fn stream(request: &str) -> Result<(), String> { + let mut conn = connect()?; + conn.write_all(request.as_bytes()).map_err(|e| e.to_string())?; + conn.write_all(b"\n").map_err(|e| e.to_string())?; + let reader = BufReader::new(conn); + for line in reader.lines() { + match line { + Ok(line) => println!("{line}"), + Err(e) => return Err(e.to_string()), + } + } + Ok(()) + } +} diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index d94723e..3408c50 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -9,3 +9,8 @@ description = "Platform abstraction trait shared by all srdwm backends" srdwm-core.workspace = true log.workspace = true thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/platform/src/ipc.rs b/crates/platform/src/ipc.rs new file mode 100644 index 0000000..9b140f9 --- /dev/null +++ b/crates/platform/src/ipc.rs @@ -0,0 +1,504 @@ +//! A tiny local control socket, in the spirit of `hyprctl`/`swaymsg`, so +//! external scripts can query and drive window state without speaking +//! Wayland themselves. Bound at `$XDG_RUNTIME_DIR/srdwm-<display>.sock`. +//! `crates/ctl` (the `srd` binary) is the reference client. +//! +//! Deliberately synchronous and non-blocking-polled from each backend's +//! `poll_events()` tick (see `udev.rs`/`winit.rs`), the same way the +//! Wayland client socket itself is accepted - there is no calloop event +//! loop shared by both backends (`winit.rs` has none at all), so this +//! avoids needing two different registration mechanisms for one feature. +//! An ordinary request is one request/one response/close; nothing here is +//! held open for those, so a stalled or hostile client can only ever leak +//! one never-completed connection object, not block the compositor. +//! +//! `{"cmd":"subscribe"}` is the one exception: instead of closing after its +//! reply, that connection is kept open and pushed a fresh `clients` event +//! every time the window list actually changes, so a dock/panel doesn't +//! have to re-poll `clients` on a timer and diff it itself to notice +//! anything - the single highest-leverage gap found comparing srdwm +//! against sway/i3/Hyprland/bspwm's own IPCs, all of which have an +//! event-subscribe side already. A peer session building an AGS dock hit +//! exactly this wall (see `docs/IMPLEMENTATION_STATUS.md`): with no way to +//! be told about changes, it had to poll `wlr-foreign-toplevel` from a +//! separate Python helper instead of using this socket at all. + +use std::io::{ErrorKind, Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; + +use serde::Serialize; + +use srdwm_core::{GlobalMenu, MenuSource, WindowId, WindowManager}; + +pub struct IpcServer { + listener: UnixListener, + path: PathBuf, + conns: Vec<(UnixStream, Vec<u8>)>, + /// Long-lived connections from `{"cmd":"subscribe"}` - write-only after + /// their initial snapshot, never read from again (a subscriber has no + /// further requests to send; a client wanting both query and push needs + /// two connections, matching Hyprland's separate event socket rather + /// than sway's single multiplexed one, the simpler of the two to keep + /// this connection loop's one-purpose-per-connection shape intact). + subscribers: Vec<UnixStream>, + /// What was last actually sent to subscribers, so a `poll()` tick with + /// no real change (the common case, since this runs every ~16ms) skips + /// serializing and writing anything at all. + last_broadcast: Vec<ClientInfo>, +} + +impl IpcServer { + /// `display_name` is the Wayland socket name (e.g. `wayland-1`) so + /// concurrent nested/test instances - used throughout this project for + /// self-testing - don't collide on one path. + pub fn bind(display_name: &str) -> std::io::Result<Self> { + let dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/tmp")); + Self::bind_in(&dir, display_name) + } + + /// `bind`'s actual logic, parametrized over the runtime directory -- + /// split out so tests can point this at a `tempfile::tempdir()` instead + /// of mutating the process-wide `XDG_RUNTIME_DIR` env var (racy under + /// Rust's default parallel test execution, since every test in this + /// crate shares one process). + fn bind_in(dir: &std::path::Path, display_name: &str) -> std::io::Result<Self> { + let path = dir.join(format!("srdwm-{display_name}.sock")); + // A stale socket left behind by a crashed/killed previous instance + // makes `bind` fail with `AddrInUse` even though nothing is + // listening; a fresh instance always wins over a dead one. + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path)?; + listener.set_nonblocking(true)?; + Ok(Self { listener, path, conns: Vec::new(), subscribers: Vec::new(), last_broadcast: Vec::new() }) + } + + /// Accepts any waiting connections, advances in-progress reads, and + /// pushes a fresh snapshot to every subscriber if the window list + /// actually changed since the last one. Returns `true` if a request + /// mutated window state, so the caller can fold that into its own + /// dirty/`sync()` decision the same as any other event source. + pub fn poll(&mut self, wm: &std::rc::Rc<std::cell::RefCell<WindowManager>>) -> bool { + loop { + match self.listener.accept() { + Ok((stream, _addr)) => { + if stream.set_nonblocking(true).is_ok() { + self.conns.push((stream, Vec::new())); + } + } + Err(e) if e.kind() == ErrorKind::WouldBlock => break, + Err(_) => break, + } + } + + let mut dirty = false; + let mut new_subscribers = Vec::new(); + self.conns.retain_mut(|(stream, buf)| { + let mut chunk = [0u8; 512]; + match stream.read(&mut chunk) { + Ok(0) => return false, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if e.kind() == ErrorKind::WouldBlock => {} + Err(_) => return false, + } + let Some(nl) = buf.iter().position(|&b| b == b'\n') else { + // Cap a request that never terminates - a hostile or + // broken client shouldn't accumulate memory forever. + return buf.len() < 4096; + }; + let line = buf[..nl].to_vec(); + let cmd = serde_json::from_slice::<serde_json::Value>(&line).ok().and_then(|v| v.get("cmd").and_then(|c| c.as_str().map(str::to_string))); + let (response, changed) = handle_request(&line, wm); + dirty |= changed; + let mut out = response; + out.push(b'\n'); + if stream.write_all(&out).is_err() { + return false; + } + if cmd.as_deref() == Some("subscribe") { + // Handed off to `subscribers` below rather than kept here -- + // this connection is done being read from, only ever + // written to from now on. + if let Ok(cloned) = stream.try_clone() { + new_subscribers.push(cloned); + } + return false; + } + // Every other command is still one request/one response/close, + // same as before subscribe existed. + false + }); + self.subscribers.extend(new_subscribers); + + if !self.subscribers.is_empty() { + let current: Vec<ClientInfo> = client_snapshot(wm); + if current != self.last_broadcast { + if let Ok(mut out) = serde_json::to_vec(&ClientsEvent { event: "clients", clients: ¤t }) { + out.push(b'\n'); + self.subscribers.retain_mut(|s| s.write_all(&out).is_ok()); + } + self.last_broadcast = current; + } + } + dirty + } +} + +impl Drop for IpcServer { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[derive(Serialize, Clone, PartialEq)] +struct ClientInfo { + id: u64, + app_id: String, + title: String, + workspace: usize, + focused: bool, + minimized: bool, + visible: bool, + scratchpad: bool, + // Whether the layout placed this window (tiled) or the user positioned + // it directly (floating) - added for an external panel's auto-hide + // logic, which needs to tell a window the layout placed flush against + // its own reserved edge (expected, not an overlap) from one the user + // actually dragged into that space (a real overlap it should react + // to). Geometry alone can't distinguish the two: a tiled window's edge + // sitting exactly at the usable-area boundary looks identical, in x/y/ + // width/height terms, to a floating window a human dragged flush + // against it. + floating: bool, + // Geometry, in the same global logical-pixel space everything else in + // this compositor uses. Added for an external panel's Overview/window- + // switcher, which has no other way to lay out window miniatures to + // scale - neither `zwlr_foreign_toplevel_management_v1` nor + // `ext_foreign_toplevel_list_v1` carries geometry at all, by design of + // those protocols, so this compositor's own IPC is the only place it + // can come from. + x: i32, + y: i32, + width: u32, + height: u32, + // The window's global-menu D-Bus address (bus name + object paths), + // if it has exported one - `null` for the common case of a window + // with no menu at all, which a consumer should treat exactly like a + // missing field: no menu to show, not an error. See `srdwm_core:: + // GlobalMenu`'s own doc comment for why this is an address and never + // the menu's actual content. + global_menu: Option<GlobalMenuInfo>, +} + +#[derive(Serialize, Clone, PartialEq)] +struct GlobalMenuInfo { + bus_name: String, + menu_path: Option<String>, + app_path: Option<String>, + window_path: Option<String>, + // Which export flavour `menu_path` actually came from - "gtk" (a + // real GMenuModel; actions are `app.xxx`/`win.xxx`, resolved against + // `app_path`/`window_path` under those two prefixes) or "unity" (the + // older Ubuntu-era export; actions are `unity.xxx`, all under one + // group at `menu_path` itself). Not cosmetic: a consumer that guesses + // wrong here gets a menu that renders with every item permanently + // insensitive, since it inserted the D-Bus action group under the + // wrong prefix - indistinguishable from a genuinely broken app + // without this field. Some XWayland clients (`appmenu-gtk-module` in + // particular) set both the `_GTK_*` and `_UNITY_OBJECT_PATH` atoms at + // once, so a consumer can't reliably infer this from which paths are + // merely non-null. + source: &'static str, +} + +impl From<&GlobalMenu> for GlobalMenuInfo { + fn from(m: &GlobalMenu) -> Self { + let source = match m.source { + MenuSource::Gtk => "gtk", + MenuSource::Unity => "unity", + }; + Self { bus_name: m.bus_name.clone(), menu_path: m.menu_path.clone(), app_path: m.app_path.clone(), window_path: m.window_path.clone(), source } + } +} + +#[derive(Serialize)] +struct ClientsResponse { + clients: Vec<ClientInfo>, +} + +#[derive(Serialize)] +struct MonitorsResponse { + monitors: Vec<MonitorInfo>, +} + +/// One entry per `srdwm_core::Monitor` - both rects a panel/dock actually +/// needs to answer "does maximize respect my zone" and "does fullscreen +/// ignore it" without reasoning about either indirectly. Requested by an +/// AGS peer session after two separate live-debugging rounds (maximize- +/// past-dock, fullscreen-past-dock) each took several back-and-forth turns +/// that a single read of this would have settled immediately. +#[derive(Serialize)] +struct MonitorInfo { + id: u32, + name: String, + primary: bool, + // The usable area: the output shrunk by any layer-shell exclusive + // zone currently reserved on it (a bar/dock's `set_exclusive_zone`). + // What `toggle_maximize`/new-window placement/tiling all target. + x: i32, + y: i32, + width: u32, + height: u32, + // The output's true full rect, ignoring any exclusive zone - what + // `toggle_fullscreen` targets. Equal to x/y/width/height above when + // nothing on this monitor currently reserves any space at all. + full_x: i32, + full_y: i32, + full_width: u32, + full_height: u32, +} + +/// Pushed to every subscriber (and used as `subscribe`'s own initial +/// reply) instead of `ClientsResponse`'s plain `{"clients": [...]}"` shape, +/// so every line a subscriber ever reads on that connection looks the +/// same - no special-casing the first one. Not used for the one-shot +/// `"clients"` command, whose response shape predates this and stays as-is +/// for existing polling consumers (`crates/ctl`, any external script). +#[derive(Serialize)] +struct ClientsEvent<'a> { + event: &'static str, + clients: &'a [ClientInfo], +} + +/// The same per-window snapshot both `"clients"` and `"subscribe"`/the +/// change-diff in `IpcServer::poll` build - pulled out so the two can +/// never silently drift into reporting different fields. +fn client_snapshot(wm: &std::rc::Rc<std::cell::RefCell<WindowManager>>) -> Vec<ClientInfo> { + let wm = wm.borrow(); + let current = wm.current_workspace(); + let focused = wm.focused_id(); + wm.windows() + .map(|w| ClientInfo { + id: w.id as u64, + app_id: w.app_id.clone(), + title: w.title.clone(), + workspace: w.workspace, + focused: focused == Some(w.id), + minimized: w.minimized, + visible: !w.minimized && w.workspace == current, + scratchpad: w.scratchpad, + floating: w.floating, + x: w.geometry.x, + y: w.geometry.y, + width: w.geometry.width, + height: w.geometry.height, + global_menu: w.global_menu.as_ref().map(GlobalMenuInfo::from), + }) + .collect() +} + +#[derive(Serialize)] +struct OkResponse { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'static str>, +} + +fn ok() -> Vec<u8> { + serde_json::to_vec(&OkResponse { ok: true, error: None }).unwrap_or_default() +} + +fn err(msg: &'static str) -> Vec<u8> { + serde_json::to_vec(&OkResponse { ok: false, error: Some(msg) }).unwrap_or_default() +} + +/// Parses and applies one request line, returning the response body (no +/// trailing newline) and whether it changed window state. +fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<WindowManager>>) -> (Vec<u8>, bool) { + let Ok(req) = serde_json::from_slice::<serde_json::Value>(line) else { + return (err("invalid request"), false); + }; + let cmd = req.get("cmd").and_then(|v| v.as_str()).unwrap_or(""); + let id = req.get("id").and_then(|v| v.as_u64()).map(|v| v as WindowId); + + match cmd { + "clients" => (serde_json::to_vec(&ClientsResponse { clients: client_snapshot(wm) }).unwrap_or_default(), false), + "monitors" => { + let monitors: Vec<MonitorInfo> = wm + .borrow() + .monitors() + .iter() + .map(|m| MonitorInfo { + id: m.id, + name: m.name.clone(), + primary: m.primary, + x: m.geometry.x, + y: m.geometry.y, + width: m.geometry.width, + height: m.geometry.height, + full_x: m.full_geometry.x, + full_y: m.full_geometry.y, + full_width: m.full_geometry.width, + full_height: m.full_geometry.height, + }) + .collect(); + (serde_json::to_vec(&MonitorsResponse { monitors }).unwrap_or_default(), false) + } + // The connection is handed off to `IpcServer::subscribers` by the + // caller (`poll`, which is the only place that can see the raw + // `cmd` string this deep call already consumed) right after this + // reply is written - this arm only has to produce that reply, in + // the same `ClientsEvent` shape every later push uses. + "subscribe" => { + let clients = client_snapshot(wm); + (serde_json::to_vec(&ClientsEvent { event: "clients", clients: &clients }).unwrap_or_default(), false) + } + "toggle_visibility" => { + let Some(id) = id else { return (err("missing id"), false) }; + let mut wm = wm.borrow_mut(); + let current = wm.current_workspace(); + let Some(w) = wm.windows().find(|w| w.id == id) else { + return (err("no such window"), false); + }; + let now_hidden = w.minimized || w.workspace != current; + if now_hidden { + // Follows the caller to whichever workspace is current -- + // matches Hyprland's `special:scratchpad`/Sway's `scratchpad + // show`, which is the behaviour the `scratchpad` script and + // its keybindings are written against. + wm.move_window_to_workspace(id, current); + wm.restore_window(id); + wm.focus_window(id); + } else { + wm.minimize_window(id); + } + (ok(), true) + } + "focus" => { + let Some(id) = id else { return (err("missing id"), false) }; + wm.borrow_mut().focus_window(id); + (ok(), true) + } + "close" => { + let Some(id) = id else { return (err("missing id"), false) }; + wm.borrow_mut().close_window(id); + (ok(), true) + } + _ => (err("unknown command"), false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::io::BufRead; + use std::rc::Rc; + + fn read_line(stream: &mut UnixStream) -> String { + // The server side is set non-blocking, but the client-side handle a + // test holds is left in its default blocking mode - a plain + // `read_line` on it can simply wait for the byte that's about to + // arrive, no polling loop needed here. + let mut reader = std::io::BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + line + } + + #[test] + fn subscribe_gets_an_immediate_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); + server.poll(&wm); + + let line = read_line(&mut client); + assert!(line.contains(r#""event":"clients""#)); + assert!(line.contains(r#""clients":[]"#)); + } + + #[test] + fn subscribe_then_a_window_change_pushes_a_fresh_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); + server.poll(&wm); + let _initial = read_line(&mut client); + + { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "hello")); + } + server.poll(&wm); + + let pushed = read_line(&mut client); + assert!(pushed.contains(r#""event":"clients""#)); + assert!(pushed.contains(r#""title":"hello""#)); + } + + #[test] + fn a_poll_with_no_real_change_pushes_nothing_new() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"subscribe\"}\n").unwrap(); + server.poll(&wm); + let _initial = read_line(&mut client); + + // Nothing changed between these two polls - a second push would + // show up as a second readable line the client isn't expecting. + server.poll(&wm); + server.poll(&wm); + client.set_nonblocking(true).unwrap(); + let mut buf = [0u8; 16]; + match client.read(&mut buf) { + Err(e) if e.kind() == ErrorKind::WouldBlock => {} + other => panic!("expected no further data, got {other:?}"), + } + } + + #[test] + fn a_oneshot_clients_request_still_closes_the_connection_as_before() { + let dir = tempfile::tempdir().unwrap(); + let mut server = IpcServer::bind_in(dir.path(), "test").unwrap(); + let wm = Rc::new(RefCell::new(WindowManager::new())); + + let mut client = UnixStream::connect(&server.path).unwrap(); + client.write_all(b"{\"cmd\":\"clients\"}\n").unwrap(); + server.poll(&wm); + let line = read_line(&mut client); + // The plain, pre-existing shape - no `"event"` field - so + // existing one-shot polling consumers (`crates/ctl`) see no change. + assert!(!line.contains(r#""event""#)); + assert!(line.contains(r#""clients""#)); + + { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "later")); + } + server.poll(&wm); + client.set_nonblocking(true).unwrap(); + let mut buf = [0u8; 16]; + let result = client.read(&mut buf); + // A one-shot connection was never registered as a subscriber, so a + // later change must not be pushed to it - and the server already + // closed its end after the single reply, so a read either sees EOF + // (0 bytes) or, depending on how quickly the close propagates, + // WouldBlock; either is correct, actual new data would not be. + match result { + Ok(n) => assert_eq!(n, 0, "expected EOF, got {n} bytes of unexpected data"), + Err(e) => assert_eq!(e.kind(), ErrorKind::WouldBlock), + } + } +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 23854e8..4be9cc4 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -8,6 +8,9 @@ //! pump, macOS's event taps) into the common [`srdwm_core::Event`] queue - //! everything downstream of that is platform-independent. +mod ipc; +pub use ipc::IpcServer; + use srdwm_core::{Monitor, Rect, Window, WindowId}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/srdwm/Cargo.toml b/crates/srdwm/Cargo.toml index d7af2ee..0d1ffaf 100644 --- a/crates/srdwm/Cargo.toml +++ b/crates/srdwm/Cargo.toml @@ -16,6 +16,14 @@ srdwm-config.workspace = true log.workspace = true env_logger.workspace = true +[target.'cfg(unix)'.dependencies] +# SIGTERM/SIGINT handling only - see `main.rs`'s `install_signal_handlers` +# doc comment. Real POSIX signals exist on both Linux and macOS, unlike +# Windows, hence `cfg(unix)` here rather than the narrower +# `cfg(all(unix, not(target_os = "macos")))` the two lines below use for +# the Wayland/X11-specific backends. +libc = "0.2" + [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] srdwm-x11.workspace = true srdwm-wayland.workspace = true diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs index 5fc762f..737737f 100644 --- a/crates/srdwm/src/main.rs +++ b/crates/srdwm/src/main.rs @@ -4,6 +4,85 @@ use srdwm_platform::{Platform, PlatformKind}; use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Set by `handle_shutdown_signal`, polled once per iteration of the main +/// loop below - see `install_signal_handlers`'s doc comment for why a +/// process-wide `AtomicBool` rather than the loop's own `Rc<Cell<bool>>` +/// (`running`) is what a signal handler is actually allowed to touch. +/// Defined on every platform (the loop below checks it unconditionally) but +/// only ever set to `true` on unix - see `install_signal_handlers`. +static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false); + +/// Async-signal-safe: the one thing a signal handler may safely do without +/// risking undefined behaviour is set a `std::sync::atomic` flag (a plain +/// `Cell`, or anything that allocates/locks/formats, is not safe to touch +/// here - the handler can interrupt the main thread at literally any +/// instruction, including mid-mutation of a non-atomic value). Actually +/// exiting happens back on the main thread, at the top of the next loop +/// iteration in `main`, once this flag is observed. +#[cfg(unix)] +extern "C" fn handle_shutdown_signal(_signum: libc::c_int) { + SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst); +} + +/// Without this, srdwm had no handler for `SIGTERM`/`SIGINT` at all, so the +/// default disposition (immediate termination, bypassing every Rust `Drop` +/// impl - the language runtime never runs) applied. In practice this means +/// a session manager or `systemd-logind` ending the session the ordinary +/// way - not a crash, not a `kill -9` - still skipped `IpcServer::drop` +/// (`crates/wayland/src/ipc.rs`), leaving `$XDG_RUNTIME_DIR/srdwm- +/// <display>.sock` behind with nothing listening on it. Found live: a peer +/// session's AGS work observed exactly that stale file after a session +/// switch away from srdwm, and flagged (correctly) that a socket file +/// existing proves nothing about whether anything is listening. +/// `srd.quit()`'s existing shutdown path (`running.set(false)`, checked by +/// the main loop) already runs every `Drop` impl correctly when *it* +/// triggers the exit - this just gives an external SIGTERM the same fair +/// chance, instead of pre-empting it. Only `SIGTERM`/`SIGINT`: `SIGKILL` +/// cannot be caught by any process, ever, so a session teardown that +/// escalates straight to that (or waits out no grace period at all) is not +/// fixable from in here. +/// +/// Unix only (real POSIX signals) - a no-op on Windows, where `Ctrl+C` +/// handling is a different mechanism (`SetConsoleCtrlHandler`) that +/// `srdwm-windows` is too early-stage to need yet; `SHUTDOWN_REQUESTED` +/// simply never becomes `true` there, so the loop's check below is always +/// cheap and never fires. +#[cfg(unix)] +fn install_signal_handlers() { + unsafe { + libc::signal(libc::SIGTERM, handle_shutdown_signal as *const () as libc::sighandler_t); + libc::signal(libc::SIGINT, handle_shutdown_signal as *const () as libc::sighandler_t); + } +} + +#[cfg(not(unix))] +fn install_signal_handlers() {} + +/// Reload is handled here, before dispatch, rather than purely through +/// `srd.reload()`'s own Lua-exposed closure: `Engine::reload` clears +/// `key_bindings` before re-running `init.lua`, so a syntax error in the +/// edited config would leave *this exact binding* unreachable too if +/// finding it depended on the just-cleared map - permanently, since +/// nothing would be left to retrigger a reload short of restarting the +/// whole process, which is the one thing this feature exists to avoid +/// needing. Handling the combo directly here means the retry path never +/// depends on the Lua state that broke. Still requires this exact combo to +/// be bound via `srd.bind` in whatever config was loaded at *startup* -- +/// that's what gets it into the platform's initial grab/intercept list in +/// the first place (see `Engine::reload`'s own doc comment). +/// +/// Written the conventional Super-first way, same as every combo in the +/// shipped config - NOT the canonical Ctrl/Shift/Alt/Mod4 order +/// `format!("{modifiers}{key_name}")` below actually produces from a real +/// keypress (`"Ctrl+Mod4+r"`). This constant used to be compared against +/// that directly and could never match, for the exact reason +/// `srdwm_core::canonicalize_key_combo`'s doc comment describes for +/// `srd.bind` - this is the one place in this codebase that reads combo +/// strings without going through it. Canonicalized below instead of +/// rewriting the literal, so it stays legible as "what you'd type in Lua". +const RELOAD_COMBO_LITERAL: &str = "Mod4+Ctrl+r"; /// Where the Lua config lives: `$SRDWM_CONFIG_PATH`, else /// `$XDG_CONFIG_HOME/srd`, else `~/.config/srd`. @@ -22,6 +101,85 @@ fn config_dir() -> PathBuf { PathBuf::from("config/srd") } +/// Creates however many workspaces `workspace.count` asks for, beyond the +/// single one `WindowManager::new` already starts with. +/// +/// `workspace.count` was a validated, defaulted config value that nothing +/// ever actually read: `WindowManager` starts with exactly one workspace, +/// and `switch_workspace`/`move_window_to_workspace` silently no-op +/// against a workspace ID that doesn't exist yet - so every +/// `Mod4+2`..`Mod4+9` binding in the shipped config did nothing at all +/// beyond the first workspace, regardless of what `workspace.count` said. +/// Run *before* `apply_default_layout`, so newly created workspaces get +/// the configured default layout too, not the hardcoded `"dynamic"` they're +/// created with here. +fn apply_workspace_count(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) { + let count = engine.get_f64("workspace.count", 1.0).max(1.0) as usize; + // Same dead-config shape as everything else `apply_general_settings` + // fixes: `workspace.names` was validated/defaulted (a 10-entry list, + // "1".."9","0") but nothing ever read it - every workspace was always + // named after its own 1-based index instead, regardless of config. + // Applied by position (`names[i]` names workspace `i+1`), covering + // however many entries the list actually has - a shorter list just + // leaves the remaining workspaces at their default numeric name. + let names = engine.get("workspace.names").and_then(|v| v.as_list().map(|s| s.to_vec())).unwrap_or_default(); + let mut wm = wm.borrow_mut(); + let existing = wm.workspaces().len(); + for i in existing..count { + wm.add_workspace((i + 1).to_string(), "dynamic"); + } + let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect(); + for (id, name) in ids.into_iter().zip(names) { + wm.rename_workspace(id, name); + } + log::info!("{} workspace(s)", wm.workspaces().len()); +} + +/// Reads `general.window_gap`, `general.animations` and +/// `general.animation_duration` into `WindowManager`. +/// +/// Same dead-config bug class as `apply_workspace_count`: all three are +/// validated/defaulted by `crates/config` but nothing ever read them -- +/// `WindowManager::new` always started `tiling` from `TilingConfig::default` +/// (hardcoded `gap_inner`/`gap_outer: 8/16`, coincidentally matching +/// `window_gap`'s own default of `8`, which is why a *default* config never +/// exposed the gap) and `animations_enabled`/`animation_duration_ms` at +/// their own hardcoded defaults, ignoring anything `init.lua` set. +fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) { + let gap = engine.get_f64("general.window_gap", 8.0).max(0.0) as u32; + let animations = engine.get_bool("general.animations", true); + let duration = engine.get_f64("general.animation_duration", 200.0).max(0.0) as u32; + + // Only the three `theme.*` keys with an unambiguous, already-rendered + // counterpart are wired - see `srdwm_core::ThemeConfig`'s doc comment. + // `theme.colors.foreground`/`theme.decorations.title_bar.foreground` + // and `theme.decorations.border.inactive_color` are deliberately left + // alone: their own shipped defaults ("#eceff4", "#2e3440") don't match + // what unfocused text/border actually render as today (an accent- + // dimming scheme, not a second explicit colour), so wiring them in as + // written would silently change - in `border.inactive_color`'s case, + // erase - the unfocused appearance for anyone who never touched + // theme.* at all. A real design decision belongs there, not a guess + // made in passing while sweeping for dead keys. + let mut theme = srdwm_core::ThemeConfig::default(); + if let Some(rgb) = srdwm_core::parse_hex_color(&engine.get_string("theme.decorations.title_bar.background", "#2e3440")) { + theme.titlebar_bg = rgb; + } + let border_width = engine.get_f64("theme.decorations.border.width", 2.0).max(0.0) as u32; + theme.default_border_width = border_width; + if let Some(rgb) = srdwm_core::parse_hex_color(&engine.get_string("theme.decorations.border.active_color", "#88c0d0")) { + theme.default_border_color = rgb; + } + + let mut wm = wm.borrow_mut(); + wm.tiling.gap_inner = gap; + wm.tiling.gap_outer = gap; + wm.animations_enabled = animations; + wm.animation_duration_ms = duration; + wm.theme = theme; + wm.auto_back_and_forth = engine.get_bool("workspace.auto_back_and_forth", false); +} + /// Applies `general.default_layout` to the workspaces that exist at /// startup. /// @@ -47,16 +205,155 @@ fn apply_default_layout(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) { log::info!("default layout: {name}"); } +fn print_usage() { + eprintln!("Usage: srdwm [--wayland | --x11] [--version] [--help]"); + eprintln!(); + eprintln!("With neither flag, srdwm always starts as Wayland (its DRM/udev backend"); + eprintln!("on a bare tty, or nested under a host Wayland/X11 session otherwise)."); + eprintln!("--x11 connects to an already-running X server (DISPLAY must be set);"); + eprintln!("srdwm never starts one itself."); +} + +/// Reads `--wayland`/`--x11` out of the process argv, so the caller (a +/// session manager, `startx`, ...) picks the backend explicitly instead of +/// srdwm guessing it from `DISPLAY`/`WAYLAND_DISPLAY`. `--help`/`--version` +/// exit the process directly, the same as any other CLI tool - there's no +/// reason to run the compositor past either. +/// +/// Returns `Ok(None)` when neither flag was given, meaning "use the +/// platform default" (see `default_platform_kind`). +fn parse_backend_flag(args: &[String]) -> Result<Option<PlatformKind>, String> { + let mut requested = None; + for arg in args { + match arg.as_str() { + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + "--version" => { + println!("srdwm {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + "--wayland" => requested = Some(PlatformKind::Wayland), + "--x11" => requested = Some(PlatformKind::X11), + other => return Err(format!("unrecognized argument '{other}'")), + } + } + Ok(requested) +} + +/// The backend used when neither `--wayland` nor `--x11` is given: always +/// Wayland on unix (its own DRM/udev backend on a bare tty, or the nested winit backend +/// under a host session - see `srdwm_wayland::connect`), the compile-time +/// native backend on Windows/macOS. Previously this fell to +/// `srdwm_platform::detect()`, which inferred X11 from a stray `DISPLAY` +/// env var; that made backend choice implicit and depend on ambient shell +/// state instead of what was actually asked for. `detect()` still exists +/// (and is still tested) for callers that want that heuristic, but the +/// binary no longer uses it as its default. +fn default_platform_kind() -> PlatformKind { + #[cfg(target_os = "windows")] + { + return PlatformKind::Windows; + } + #[cfg(target_os = "macos")] + { + return PlatformKind::MacOS; + } + #[cfg(all(unix, not(target_os = "macos")))] + { + PlatformKind::Wayland + } +} + /// Applies the WindowManager's current layout decisions to the real /// platform: re-tiles the active workspace if needed, then pushes geometry -/// and decoration state for every visible window. +/// and decoration state for every visible window - and, just as +/// importantly, hides every window that *isn't* currently visible. +/// +/// That second half didn't happen at all until this was written: switching +/// workspace only ever updated `WindowManager::current_workspace` and +/// `visible_windows()`'s own filter - nothing downstream of that ever told +/// the platform to unmap a window that just fell off the active workspace, +/// or remap one that just became active. So `srd.workspace.switch()` always +/// "worked" internally, but on screen nothing ever changed: windows from +/// every workspace stayed mapped and visible forever, all sharing the same +/// screen. Every window not currently visible is explicitly hidden via +/// `Platform::minimize` (a pure backend-level map/unmap primitive, entirely +/// separate from `Window.minimized` - see its own doc comment - so this +/// can't be confused with the user's own minimize state); every visible one +/// is explicitly shown via `Platform::restore` before geometry/decoration +/// are pushed, since on X11 `apply_geometry` only reconfigures an existing +/// mapping, it doesn't create one. fn sync(wm: &Rc<RefCell<WindowManager>>, platform: &mut dyn Platform) { + for id in wm.borrow_mut().take_close_requests() { + if let Err(e) = platform.close(id) { + log::warn!("close({id}) failed: {e}"); + } + } + let ws = wm.borrow().current_workspace(); wm.borrow_mut().arrange_workspace(ws); let focused = wm.borrow().focused_id(); - let snapshot: Vec<_> = wm.borrow().visible_windows().cloned().collect(); - for w in snapshot { + // `Platform::focus` was never actually called from anywhere in this + // loop before - every focus-changing path that isn't a direct mouse + // click (`srd dispatch focus`/`toggle_visibility`, `srd.window.focus()`/ + // `.next()`/`.prev()`, the scratchpad feature) only ever touched + // `WindowManager`'s own bookkeeping, since none of those callers can + // reach `CompState`/real Wayland focus themselves (`crates/platform`'s + // `IpcServer` in particular has no way to). A window could render as + // focused (border/titlebar colour already reads live core state) while + // real keyboard input kept going wherever it was before, and on X11, + // `_NET_ACTIVE_WINDOW` never moved - confirmed live: `srd dispatch + // focus` on an XWayland window left it at `0x0`. `Platform::focus`'s + // own impls now go through the same real-focus-sync path a mouse click + // already uses (see their doc comments), so calling it here every tick + // closes the gap for all of those callers at once. Cheap when nothing + // actually changed - `set_keyboard_focus` early-returns if the target + // surface is already focused. + if let Some(id) = focused { + if let Err(e) = platform.focus(id) { + log::warn!("focus({id}) failed: {e}"); + } + } + let (visible, hidden) = { + let wm = wm.borrow(); + // Bottom-to-top stacking order, not `visible_windows()`'s arbitrary + // `HashMap` iteration order. `Platform::apply_geometry`/ + // `redraw_decoration` both end up calling `CompState::sync_geometry` + // on the Wayland backends, which - as a documented side effect of + // smithay's own `Space::map_element` - re-raises whichever window + // it's called for to the top of `Space`'s real render order. With + // an arbitrary iteration order, EVERY call to this function (i.e. + // on essentially any dirty event: a keystroke, a resize frame, a + // workspace poll) re-shuffled every visible window's on-screen + // z-order to whatever `HashMap` happened to yield that tick, + // completely unrelated to which window was actually focused -- + // reported live as focus never visibly "sticking" to a window, + // since within a frame or two of a real focus change, the next + // `sync()` tick's arbitrary-order pass silently raised some other + // window back over it. Iterating bottom-to-top instead means each + // pass's cascade of re-raises ends, deterministically, with the + // true topmost window raised last - restoring the same order it + // started with instead of scrambling it. + let mut visible: Vec<srdwm_core::Window> = wm.visible_windows_front_to_back().cloned().collect(); + visible.reverse(); + let visible_ids: std::collections::HashSet<_> = visible.iter().map(|w| w.id).collect(); + let hidden: Vec<_> = wm.windows().filter(|w| !visible_ids.contains(&w.id)).map(|w| w.id).collect(); + (visible, hidden) + }; + + for id in hidden { + if let Err(e) = platform.minimize(id) { + log::warn!("hiding window {id} (not on the active workspace) failed: {e}"); + } + } + + for w in visible { + if let Err(e) = platform.restore(w.id) { + log::warn!("showing window {} failed: {e}", w.id); + } if let Err(e) = platform.apply_geometry(w.id, w.geometry) { log::warn!("apply_geometry({}) failed: {e}", w.id); } @@ -67,22 +364,45 @@ fn sync(wm: &Rc<RefCell<WindowManager>>, platform: &mut dyn Platform) { } fn main() -> Result<(), Box<dyn std::error::Error>> { + install_signal_handlers(); + let args: Vec<String> = std::env::args().skip(1).collect(); + let requested_backend = match parse_backend_flag(&args) { + Ok(v) => v, + Err(e) => { + eprintln!("srdwm: {e}"); + print_usage(); + std::process::exit(2); + } + }; + env_logger::init(); log::info!("srdwm starting"); + // Decided before the config loads (it needs no engine, just argv/the + // compile target) so `platform.backend`/`platform.os` below are correct + // by the time `init.lua` - and anything it `srd.load`s - runs, and + // config can branch on them (`if srd.get("platform.backend") == "x11" + // then ... end`). This is the one thing srdwm is cross-platform-first + // about in `main.rs`: Windows/macOS builds compute the same two keys + // from their own compile-time target, so config written against them + // doesn't need a different branching mechanism per OS. + let kind = requested_backend.unwrap_or_else(default_platform_kind); + log::info!("selected platform backend: {}", kind.name()); + let wm = Rc::new(RefCell::new(WindowManager::new())); let dir = config_dir(); let engine = Engine::new(wm.clone(), &dir)?; + engine.set_string("platform.backend", kind.name()); + engine.set_string("platform.os", std::env::consts::OS); match engine.load_init() { Ok(()) => log::info!("loaded config from {}", dir.display()), Err(e) => log::warn!("no usable config at {} ({e}); running with built-in defaults", dir.display()), } + apply_workspace_count(&engine, &wm); + apply_general_settings(&engine, &wm); apply_default_layout(&engine, &wm); let running = engine.running_flag(); - let kind = srdwm_platform::detect(); - log::info!("selected platform backend: {}", kind.name()); - let mut platform: Box<dyn Platform> = match kind { #[cfg(all(unix, not(target_os = "macos")))] PlatformKind::X11 => { @@ -110,9 +430,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { log::info!("detected {} monitor(s)", monitors.len()); wm.borrow_mut().set_monitors(monitors); + // The platform is fully connected now (for Wayland, `WAYLAND_DISPLAY` + // was just set by `srdwm_wayland::connect` - see `udev.rs`/`winit.rs` + // - and for X11, `DISPLAY` was already set by whatever started the X + // server srdwm connected to). Only past this point does a process + // `srd.spawn`ed from a `"ready"` handler have a real display socket to + // inherit and connect to. + if !engine.dispatch_event("ready") { + log::debug!("no 'ready' handler registered"); + } + sync(&wm, platform.as_mut()); while running.get() { + if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) { + // Routed through the same flag `srd.quit()` already sets, + // rather than breaking directly - so a `SIGTERM`/`SIGINT` + // shutdown runs through exactly the same path (and any future + // logic added to it) as a normal quit, not a second one. + running.set(false); + continue; + } let events = match platform.poll_events() { Ok(events) => events, Err(e) => { @@ -126,7 +464,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { match event { Event::KeyPress { key_name, modifiers } => { let combo = format!("{modifiers}{key_name}"); - if !engine.dispatch_keybinding(&combo) { + if combo == srdwm_core::canonicalize_key_combo(RELOAD_COMBO_LITERAL) { + match engine.reload() { + Ok(()) => log::info!("config reloaded"), + Err(e) => log::error!("config reload failed: {e}"), + } + } else if !engine.dispatch_keybinding(&combo) { log::debug!("no binding for '{combo}'"); } dirty = true; @@ -140,6 +483,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { dirty = true; } Event::WindowMoved { .. } | Event::WindowResized { .. } => dirty = true, + Event::WorkspaceChanged => dirty = true, // Laptop lid. The handler is a plain Lua function, so the // config decides what to do (lock, suspend, nothing). Event::LidSwitch { closed } => { diff --git a/crates/wayland/Cargo.toml b/crates/wayland/Cargo.toml index fb86dd8..96d0e50 100644 --- a/crates/wayland/Cargo.toml +++ b/crates/wayland/Cargo.toml @@ -15,6 +15,24 @@ fontdue = "0.9" # implements that protocol directly against these server bindings. Pinned to # the same version smithay itself depends on, so both see one set of types. wayland-protocols-wlr = { version = "0.3.12", features = ["server"] } +# `ext-workspace-v1` (`src/workspace.rs`): staging-tree protocol, not +# wlr-specific - lives in the plain `wayland-protocols` crate instead, and +# (like the wlr protocol above) has no smithay helper to delegate to. +wayland-protocols = { version = "0.32.13", features = ["server", "staging"] } +serde.workspace = true +serde_json.workspace = true +# `gamma_control.rs`: `zwlr_gamma_control_v1.set_gamma` hands over a raw +# gamma table as a memory-mapped fd, not a value on the wire - already a +# transitive dependency (smithay's own shm handling uses it), just not +# previously used directly by this crate. +memmap2 = "0.9" +# `gtk_shell_protocol.rs`: `gtk_shell1`/`gtk_surface1` is a GTK-private +# protocol with no published `wayland-protocols-*` crate (unlike the wlr/ +# staging ones above) - generated at compile time from a vendored copy of +# GTK's own XML instead. Pinned to the version `wayland-server` above +# actually resolves to, so both see the same wire types. +wayland-scanner = "0.31.10" +wayland-backend = { version = "0.3.15", features = ["server_system"] } [dependencies.smithay] version = "0.7" diff --git a/crates/wayland/protocols/gtk-shell.xml b/crates/wayland/protocols/gtk-shell.xml new file mode 100644 index 0000000..f514478 --- /dev/null +++ b/crates/wayland/protocols/gtk-shell.xml @@ -0,0 +1,116 @@ +<protocol name="gtk"> + + <interface name="gtk_shell1" version="7"> + <description summary="gtk specific extensions"> + gtk_shell is a protocol extension providing additional features for + clients implementing it. + </description> + + <enum name="capability"> + <entry name="global_app_menu" value="1"/> + <entry name="global_menu_bar" value="2"/> + <entry name="desktop_icons" value="3"/> + </enum> + + <event name="capabilities"> + <arg name="capabilities" type="uint"/> + </event> + + <request name="get_gtk_surface"> + <arg name="gtk_surface" type="new_id" interface="gtk_surface1"/> + <arg name="surface" type="object" interface="wl_surface"/> + </request> + + <request name="set_startup_id"> + <arg name="startup_id" type="string" allow-null="true"/> + </request> + + <request name="system_bell"> + <arg name="surface" type="object" interface="gtk_surface1" allow-null="true"/> + </request> + + <!-- Version 3 additions --> + <request name="notify_launch" since="3"> + <arg name="startup_id" type="string"/> + </request> + + <!-- Version 6 does not add new API, but asserts that surface offsets are implemented --> + </interface> + + <interface name="gtk_surface1" version="7"> + <request name="set_dbus_properties"> + <arg name="application_id" type="string" allow-null="true"/> + <arg name="app_menu_path" type="string" allow-null="true"/> + <arg name="menubar_path" type="string" allow-null="true"/> + <arg name="window_object_path" type="string" allow-null="true"/> + <arg name="application_object_path" type="string" allow-null="true"/> + <arg name="unique_bus_name" type="string" allow-null="true"/> + </request> + + <request name="set_modal"/> + <request name="unset_modal"/> + + <request name="present"> + <arg name="time" type="uint"/> + </request> + + <!-- Version 2 additions --> + + <enum name="state"> + <entry name="tiled" value="1"/> + + <entry name="tiled_top" value="2" since="2" /> + <entry name="tiled_right" value="3" since="2" /> + <entry name="tiled_bottom" value="4" since="2" /> + <entry name="tiled_left" value="5" since="2" /> + </enum> + + <enum name="edge_constraint" since="2"> + <entry name="resizable_top" value="1"/> + <entry name="resizable_right" value="2"/> + <entry name="resizable_bottom" value="3"/> + <entry name="resizable_left" value="4"/> + </enum> + + <event name="configure"> + <arg name="states" type="array"/> + </event> + + <event name="configure_edges" since="2"> + <arg name="constraints" type="array"/> + </event> + + <!-- Version 3 additions --> + <request name="request_focus" since="3"> + <arg name="startup_id" type="string" allow-null="true"/> + </request> + + <!-- Version 4 additions --> + <request name="release" type="destructor" since="4"/> + + <!-- Version 5 additions --> + <enum name="gesture" since="5"> + <entry name="double_click" value="1"/> + <entry name="right_click" value="2"/> + <entry name="middle_click" value="3"/> + </enum> + + <enum name="error" since="5"> + <entry name="invalid_gesture" value="0"/> + </enum> + + <request name="titlebar_gesture" since="5"> + <arg name="serial" type="uint"/> + <arg name="seat" type="object" interface="wl_seat"/> + <arg name="gesture" type="uint" enum="gesture"/> + </request> + + <!-- Version 6 does not add new API, but asserts that surface offsets are implemented --> + + <request name="set_a11y_properties" since="7"> + <arg name="a11y_dbus_name" type="string" /> + <arg name="toplevel_object_path" type="string" /> + </request> + </interface> + +</protocol> diff --git a/crates/wayland/src/context_menu.rs b/crates/wayland/src/context_menu.rs new file mode 100644 index 0000000..f11e41b --- /dev/null +++ b/crates/wayland/src/context_menu.rs @@ -0,0 +1,133 @@ +//! Right-click titlebar window menu - the one titlebar interaction +//! virtually every desktop WM has always offered that srdwm never did. +//! Right-click on a titlebar previously did nothing at all (the only +//! right-button behaviour anywhere was the SUPER+right-drag resize +//! gesture, which needs the modifier held); this gives plain right-click +//! a real, discoverable action. +//! +//! Deliberately minimal: four fixed actions, no submenus, no live hover +//! highlight (a nice-to-have that would need the render buffer rebuilt on +//! every pointer-motion event over the menu - not worth the extra +//! per-frame cost for a first pass). See `decoration::render_context_menu` +//! for the actual pixels. + +use srdwm_core::{WindowId, WindowManager, TITLEBAR_HEIGHT}; + +#[derive(Clone, Copy)] +pub(crate) enum MenuAction { + Minimize, + ToggleMaximize, + ToggleAlwaysOnTop, + Close, +} + +pub(crate) struct ContextMenu { + pub(crate) window: WindowId, + /// Top-left corner, in global (output-independent) space - same frame + /// `Window.geometry` and every other `custom_elements` position uses. + pub(crate) pos: (i32, i32), + pub(crate) width: u32, + pub(crate) row_height: u32, + pub(crate) items: Vec<(&'static str, MenuAction)>, +} + +const MENU_WIDTH: u32 = 170; + +impl ContextMenu { + /// Builds the menu for `window`, opening with its top-left corner at + /// `pos` (wherever the right-click landed). Labels reflect the + /// window's *current* state - "Maximize" flips to "Restore", "Always + /// on Top" gets a checkmark prefix once pinned - same convention + /// every native window menu uses, rather than a static label that + /// silently means the opposite of what it says half the time. + pub(crate) fn open(wm: &WindowManager, window: WindowId, pos: (i32, i32)) -> Option<Self> { + let w = wm.window(window)?; + let maximize_label = if w.maximized { "Restore" } else { "Maximize" }; + let pin_label = if w.always_on_top { "\u{2713} Always on Top" } else { "Always on Top" }; + let items = vec![ + ("Minimize", MenuAction::Minimize), + (maximize_label, MenuAction::ToggleMaximize), + (pin_label, MenuAction::ToggleAlwaysOnTop), + ("Close", MenuAction::Close), + ]; + Some(Self { window, pos, width: MENU_WIDTH, row_height: TITLEBAR_HEIGHT, items }) + } + + pub(crate) fn height(&self) -> i32 { + self.row_height as i32 * self.items.len() as i32 + } + + /// Which row (if any) global-space point `(x, y)` falls on. + pub(crate) fn row_at(&self, x: i32, y: i32) -> Option<usize> { + if x < self.pos.0 || x >= self.pos.0 + self.width as i32 { + return None; + } + let rel_y = y - self.pos.1; + if rel_y < 0 || rel_y >= self.height() { + return None; + } + Some((rel_y / self.row_height as i32) as usize) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use srdwm_core::Window; + + fn wm_with_window() -> (WindowManager, WindowId) { + let mut wm = WindowManager::new(); + wm.set_monitors(vec![srdwm_core::Monitor::new(0, "primary", srdwm_core::Rect::new(0, 0, 1920, 1080))]); + let id = wm.alloc_window_id(); + wm.add_window(Window::new(id, "a")); + (wm, id) + } + + #[test] + fn open_labels_maximize_action_by_current_state() { + let (mut wm, id) = wm_with_window(); + let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap(); + assert_eq!(menu.items[1].0, "Maximize"); + + wm.toggle_maximize(id); + let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap(); + assert_eq!(menu.items[1].0, "Restore"); + } + + #[test] + fn open_marks_pinned_state_on_the_always_on_top_row() { + let (mut wm, id) = wm_with_window(); + let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap(); + assert_eq!(menu.items[2].0, "Always on Top"); + + wm.toggle_always_on_top(id); + let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap(); + assert!(menu.items[2].0.starts_with('\u{2713}'), "pinned state must be visible on the label itself"); + } + + #[test] + fn open_returns_none_for_a_window_that_no_longer_exists() { + let (wm, id) = wm_with_window(); + assert!(ContextMenu::open(&wm, id + 999, (0, 0)).is_none()); + } + + #[test] + fn row_at_maps_a_point_to_the_right_row() { + let (wm, id) = wm_with_window(); + let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap(); + assert_eq!(menu.row_at(150, 100), Some(0), "top of the first row"); + assert_eq!(menu.row_at(150, 100 + TITLEBAR_HEIGHT as i32 - 1), Some(0), "bottom of the first row"); + assert_eq!(menu.row_at(150, 100 + TITLEBAR_HEIGHT as i32), Some(1), "top of the second row"); + assert_eq!(menu.row_at(150, 100 + menu.height() - 1), Some(3), "last row, last pixel"); + } + + #[test] + fn row_at_is_none_outside_the_menus_bounds() { + let (wm, id) = wm_with_window(); + let menu = ContextMenu::open(&wm, id, (100, 100)).unwrap(); + assert_eq!(menu.row_at(99, 110), None, "just left of the menu"); + assert_eq!(menu.row_at(100 + MENU_WIDTH as i32, 110), None, "just right of the menu"); + assert_eq!(menu.row_at(150, 99), None, "just above the menu"); + assert_eq!(menu.row_at(150, 100 + menu.height()), None, "just below the menu"); + } +} diff --git a/crates/wayland/src/cursor.rs b/crates/wayland/src/cursor.rs index b489c03..d9bc3be 100644 --- a/crates/wayland/src/cursor.rs +++ b/crates/wayland/src/cursor.rs @@ -17,9 +17,10 @@ //! we have no art for. //! //! `CursorImageStatus::Hidden` is honoured, so a client that hides the -//! pointer still gets its way. Named shapes (`CursorIcon::Text` etc.) fall -//! back to the arrow rather than being drawn as the requested shape - most -//! toolkits set a surface rather than a name, so this is rarely visible. +//! pointer still gets its way. Named shapes we have dedicated art for +//! (text entry, the four resize directions, crosshair, move, and the +//! pointing-hand link-hover shape) render as that shape; anything else +//! falls back to the arrow. //! //! The built-in arrow is deliberate rather than loading an XCursor theme: //! theme loading pulls in a dependency, needs a theme to actually be @@ -41,6 +42,17 @@ pub(crate) const CURSOR_SIZE: i32 = 24; /// Encoded as a small bitmap rather than drawn with geometry so the shape is /// obvious and reviewable: `.` transparent, `#` black outline, `*` white /// fill. 24 rows of 24 columns. +/// +/// The tail (below the triangular head, past the horizontal shelf at row +/// 15) used to fork into two separate legs of visibly different widths -- +/// the left one tapering down to a point like the rest of the shape, the +/// right one a constant-width block that never tapered at all, ending in +/// an abrupt flat stop. It rendered fine at a glance in a screenshot but +/// reads as lopsided/broken up close, exactly as reported live ("one side +/// is bigger than the other"). Replaced with a single triangular foot, +/// straight left edge continuing the head's, right edge tapering linearly +/// inward row by row down to a point - the same shape language the head +/// itself already uses, just mirrored. const ARROW: [&str; CURSOR_SIZE as usize] = [ "#.......................", "##......................", @@ -57,13 +69,13 @@ const ARROW: [&str; CURSOR_SIZE as usize] = [ "#***********#...........", "#************#..........", "#*************#.........", - "#******####### .........", - "#***#**#................", - "#**#.#**#...............", - "#*#..#**#...............", - "##....#**#..............", - "#.....#**#..............", - ".......###..............", + "#******#######..........", + "#**********#............", + "#********#..............", + "#******#................", + "#****#..................", + "#**#....................", + "#.......................", "........................", "........................", ]; @@ -90,9 +102,291 @@ pub(crate) fn arrow_bitmap() -> Vec<u8> { buf[i + 3] = a; } } + add_white_halo(&mut buf); buf } +/// Adds a 1px opaque-white ring around every opaque pixel, on whichever +/// neighbouring pixels are still fully transparent. +/// +/// Without this, the black outline drawn by the code above becomes +/// invisible over a dark background: confirmed live from a screenshot of +/// this exact arrow over a black terminal, where the outline had merged +/// completely into the background, leaving only a stark, edgeless white +/// silhouette. The resize/text shapes (drawn as plain opaque black lines, +/// no fill - see `set_px`) have the same problem more severely: solid +/// black on a dark window is close to invisible outright. A halo keeps +/// every shape readable against both light and dark content underneath it, +/// the same trick real cursor themes use - it is the faint white fringe +/// visible around an ordinary system arrow cursor. +/// +/// Two-pass by construction: halo positions are collected against the +/// buffer's original opacity before any of them are written, so the ring +/// stays exactly 1px thick instead of dilating outward on itself. +fn add_white_halo(buf: &mut [u8]) { + let is_opaque = |buf: &[u8], x: i32, y: i32| -> bool { + if x < 0 || y < 0 || x >= CURSOR_SIZE || y >= CURSOR_SIZE { + return false; + } + buf[((y * CURSOR_SIZE + x) * 4 + 3) as usize] != 0 + }; + let mut halo = Vec::new(); + for y in 0..CURSOR_SIZE { + for x in 0..CURSOR_SIZE { + if is_opaque(buf, x, y) { + continue; + } + let touches_shape = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1), (x - 1, y - 1), (x + 1, y - 1), (x - 1, y + 1), (x + 1, y + 1)] + .into_iter() + .any(|(nx, ny)| is_opaque(buf, nx, ny)); + if touches_shape { + halo.push((x, y)); + } + } + } + for (x, y) in halo { + let i = ((y * CURSOR_SIZE + x) * 4) as usize; + buf[i] = 0xff; + buf[i + 1] = 0xff; + buf[i + 2] = 0xff; + buf[i + 3] = 0xff; + } +} + + +/// A small set of built-in cursor bitmaps beyond the default arrow, for the +/// named shapes a client requests most often: text entry, the four resize +/// directions, crosshair, move, and the pointing-hand hyperlink-hover +/// shape. Everything else (grab, wait, help, ...) still falls back to the +/// arrow - one arrow beats zero effort spent on a dozen rarely-seen icons, +/// but "I-beam over a text field", "double arrow at a window edge", and +/// "hand over a link" are common and immediately noticeable when wrong, +/// which is what made the cursor "always look the same regardless of +/// what's under it" worth fixing at all. +/// +/// Built once at startup (`make_buffers`), same as the arrow. +#[derive(Clone)] +pub(crate) struct CursorBuffers { + pub(crate) arrow: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) text: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) ns_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) ew_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) nesw_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) nwse_resize: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) crosshair: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) move_icon: smithay::backend::renderer::element::memory::MemoryRenderBuffer, + pub(crate) pointer: smithay::backend::renderer::element::memory::MemoryRenderBuffer, +} + +/// Sets one pixel to opaque white-on-black-outline isn't needed here (these +/// shapes are drawn solid black, unlike the arrow's outline+fill) -- +/// straight opaque black, since these are thin enough that an outline +/// would just eat the whole shape. +fn set_px(buf: &mut [u8], x: i32, y: i32) { + if x < 0 || y < 0 || x >= CURSOR_SIZE || y >= CURSOR_SIZE { + return; + } + let i = ((y * CURSOR_SIZE + x) * 4) as usize; + buf[i] = 0x00; + buf[i + 1] = 0x00; + buf[i + 2] = 0x00; + buf[i + 3] = 0xff; +} + +/// Bresenham line, thickened by `width` (drawn as `width` parallel lines +/// offset perpendicular to travel) since a single-pixel line is nearly +/// invisible at this size. +fn draw_line(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, width: i32) { + let (dx, dy) = (x1 - x0, y1 - y0); + let len = ((dx * dx + dy * dy) as f32).sqrt().max(1.0); + // Perpendicular unit direction, scaled for the offsets below. + let (px, py) = (-(dy as f32) / len, (dx as f32) / len); + for w in 0..width { + let offset = w - width / 2; + let ox = (px * offset as f32).round() as i32; + let oy = (py * offset as f32).round() as i32; + draw_thin_line(buf, x0 + ox, y0 + oy, x1 + ox, y1 + oy); + } +} + +fn draw_thin_line(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32) { + let (mut x0, mut y0) = (x0, y0); + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + set_px(buf, x0, y0); + if x0 == x1 && y0 == y1 { + break; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + } +} + +/// I-beam: a vertical stem with top/bottom serifs, centered in the bitmap +/// (unlike the arrow, whose hotspot is its tip at (0,0) - an I-beam's +/// hotspot is its center, where the text caret actually is). +fn text_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let mid = CURSOR_SIZE / 2; + draw_line(&mut buf, mid, 3, mid, CURSOR_SIZE - 4, 2); + draw_line(&mut buf, mid - 4, 3, mid + 4, 3, 2); + draw_line(&mut buf, mid - 4, CURSOR_SIZE - 4, mid + 4, CURSOR_SIZE - 4, 2); + add_white_halo(&mut buf); + buf +} + +/// Double-headed arrow along one axis (horizontal if `horizontal`, +/// vertical otherwise), hotspot at center - the standard edge/side resize +/// cursor shape. +fn straight_resize_bitmap(horizontal: bool) -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let mid = CURSOR_SIZE / 2; + let (lo, hi) = (3, CURSOR_SIZE - 4); + if horizontal { + draw_line(&mut buf, lo, mid, hi, mid, 2); + draw_line(&mut buf, lo, mid, lo + 5, mid - 5, 2); + draw_line(&mut buf, lo, mid, lo + 5, mid + 5, 2); + draw_line(&mut buf, hi, mid, hi - 5, mid - 5, 2); + draw_line(&mut buf, hi, mid, hi - 5, mid + 5, 2); + } else { + draw_line(&mut buf, mid, lo, mid, hi, 2); + draw_line(&mut buf, mid, lo, mid - 5, lo + 5, 2); + draw_line(&mut buf, mid, lo, mid + 5, lo + 5, 2); + draw_line(&mut buf, mid, hi, mid - 5, hi - 5, 2); + draw_line(&mut buf, mid, hi, mid + 5, hi - 5, 2); + } + add_white_halo(&mut buf); + buf +} + +/// Double-headed arrow along a diagonal: NW-SE if `nwse`, NE-SW otherwise. +/// Hotspot at center, same as the straight resize shapes. +fn diagonal_resize_bitmap(nwse: bool) -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let (lo, hi) = (3, CURSOR_SIZE - 4); + let (x0, y0, x1, y1) = if nwse { (lo, lo, hi, hi) } else { (lo, hi, hi, lo) }; + draw_line(&mut buf, x0, y0, x1, y1, 2); + // Arrowheads: two short strokes angled off each end, perpendicular-ish + // to the main diagonal so they read as a `<` / `>`-style head. + let head = |buf: &mut [u8], hx: i32, hy: i32, ax1: i32, ay1: i32, ax2: i32, ay2: i32| { + draw_line(buf, hx, hy, ax1, ay1, 2); + draw_line(buf, hx, hy, ax2, ay2, 2); + }; + if nwse { + head(&mut buf, x0, y0, x0 + 7, y0, x0, y0 + 7); + head(&mut buf, x1, y1, x1 - 7, y1, x1, y1 - 7); + } else { + head(&mut buf, x0, y0, x0 + 7, y0, x0, y0 - 7); + head(&mut buf, x1, y1, x1 - 7, y1, x1, y1 + 7); + } + add_white_halo(&mut buf); + buf +} + +/// Solid-fills a rectangle, clamped to the canvas - used by [`pointer_bitmap`] +/// instead of [`draw_line`]'s thin strokes, since a hand cursor reads better +/// as a few blocky filled shapes than as an outline at this resolution. +fn fill_rect(buf: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32) { + for y in y0.max(0)..=y1.min(CURSOR_SIZE - 1) { + for x in x0.max(0)..=x1.min(CURSOR_SIZE - 1) { + set_px(buf, x, y); + } + } +} + +/// A crosshair: full-height vertical line through full-width horizontal +/// line, hotspot dead center where the two cross - `zwp_pointer_constraints` +/// clients (games, precise pixel-editors) and any `cursor: crosshair` CSS +/// both expect this exact shape. +fn crosshair_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let mid = CURSOR_SIZE / 2; + draw_line(&mut buf, mid, 1, mid, CURSOR_SIZE - 2, 1); + draw_line(&mut buf, 1, mid, CURSOR_SIZE - 2, mid, 1); + add_white_halo(&mut buf); + buf +} + +/// Four-way move arrow: one line from center to each edge, with an +/// arrowhead at every tip - `cursor: move` (draggable panels, reordering +/// lists), built the same way [`diagonal_resize_bitmap`] builds its two +/// arrowheads, just aimed at all four cardinal directions instead of one +/// diagonal. +fn move_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + let mid = CURSOR_SIZE / 2; + let (lo, hi) = (2, CURSOR_SIZE - 3); + draw_line(&mut buf, mid, lo, mid, hi, 2); + draw_line(&mut buf, lo, mid, hi, mid, 2); + let head = |buf: &mut [u8], hx: i32, hy: i32, ax1: i32, ay1: i32, ax2: i32, ay2: i32| { + draw_line(buf, hx, hy, ax1, ay1, 2); + draw_line(buf, hx, hy, ax2, ay2, 2); + }; + head(&mut buf, mid, lo, mid - 4, lo + 5, mid + 4, lo + 5); + head(&mut buf, mid, hi, mid - 4, hi - 5, mid + 4, hi - 5); + head(&mut buf, lo, mid, lo + 5, mid - 4, lo + 5, mid + 4); + head(&mut buf, hi, mid, hi - 5, mid - 4, hi - 5, mid + 4); + add_white_halo(&mut buf); + buf +} + +/// A blocky pointing hand: an upright index finger with the hotspot at its +/// tip, above a wider palm block - `CursorIcon::Pointer`, the single most +/// common named shape after the default arrow (every hyperlink, every +/// clickable non-form control). Filled rectangles rather than an outline, +/// since a recognisable hand silhouette needs more coverage than a few thin +/// strokes can give at 24px. +fn pointer_bitmap() -> Vec<u8> { + let mut buf = vec![0u8; (CURSOR_SIZE * CURSOR_SIZE * 4) as usize]; + fill_rect(&mut buf, 8, 1, 11, 11); + fill_rect(&mut buf, 4, 10, 19, 20); + add_white_halo(&mut buf); + buf +} + +fn upload(data: Vec<u8>) -> smithay::backend::renderer::element::memory::MemoryRenderBuffer { + use smithay::backend::allocator::Fourcc; + use smithay::backend::renderer::element::memory::MemoryRenderBuffer; + use smithay::utils::Transform; + MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (CURSOR_SIZE, CURSOR_SIZE), 1, Transform::Normal, None) +} + +/// The centered shapes' hotspot: dead center of the bitmap, unlike the +/// arrow's tip-at-origin. Shared by every shape built here except the +/// arrow itself. +pub(crate) const CENTERED_HOTSPOT: (i32, i32) = (CURSOR_SIZE / 2, CURSOR_SIZE / 2); + +pub(crate) fn make_buffers() -> CursorBuffers { + CursorBuffers { + arrow: make_buffer(), + text: upload(text_bitmap()), + ns_resize: upload(straight_resize_bitmap(false)), + ew_resize: upload(straight_resize_bitmap(true)), + nesw_resize: upload(diagonal_resize_bitmap(false)), + nwse_resize: upload(diagonal_resize_bitmap(true)), + crosshair: upload(crosshair_bitmap()), + move_icon: upload(move_bitmap()), + pointer: upload(pointer_bitmap()), + } +} + +/// [`pointer_bitmap`]'s hotspot: the fingertip, near the top of the canvas +/// - unlike the centered resize/crosshair/move shapes, a pointing hand's +/// "active point" for click purposes is where the finger tip actually is, +/// the same reasoning the built-in arrow's tip-at-origin hotspot already +/// uses. +const POINTER_HOTSPOT: (i32, i32) = (9, 1); /// One cursor render element, whatever the source. /// @@ -107,7 +401,7 @@ pub(crate) fn arrow_bitmap() -> Vec<u8> { /// over this output - otherwise every monitor would draw its own copy. pub(crate) fn render_elements<R>( status: &smithay::input::pointer::CursorImageStatus, - buffer: &smithay::backend::renderer::element::memory::MemoryRenderBuffer, + buffers: &CursorBuffers, renderer: &mut R, pos: Point<f64, Logical>, origin: Point<i32, Logical>, @@ -121,7 +415,7 @@ where { use smithay::backend::renderer::element::surface::render_elements_from_surface_tree; use smithay::backend::renderer::element::Kind; - use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData}; + use smithay::input::pointer::{CursorIcon, CursorImageStatus, CursorImageSurfaceData}; use smithay::wayland::compositor::with_states; if matches!(status, CursorImageStatus::Hidden) { @@ -148,9 +442,36 @@ where return render_elements_from_surface_tree(renderer, surface, at, 1.0, 1.0, Kind::Cursor); } - // No client image (or a named shape we don't have art for): the - // built-in arrow, whose hotspot is its tip, so no offset. - let at = (local.0 as f64, local.1 as f64); + // No client image. A named shape we have dedicated art for gets it + // (centered on the pointer - these are all symmetric shapes, unlike + // the arrow); anything else (Default, or one of the many shapes we + // don't draw, e.g. Grab/Pointer/Crosshair) falls back to the arrow, + // whose hotspot is its tip instead, at the origin. + let (buffer, at) = match status { + CursorImageStatus::Named(icon) => match icon { + CursorIcon::Text | CursorIcon::VerticalText => { + (&buffers.text, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::EResize | CursorIcon::WResize | CursorIcon::EwResize | CursorIcon::ColResize => { + (&buffers.ew_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::NResize | CursorIcon::SResize | CursorIcon::NsResize | CursorIcon::RowResize => { + (&buffers.ns_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::NeResize | CursorIcon::SwResize | CursorIcon::NeswResize => { + (&buffers.nesw_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::NwResize | CursorIcon::SeResize | CursorIcon::NwseResize | CursorIcon::AllResize => { + (&buffers.nwse_resize, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)) + } + CursorIcon::Crosshair => (&buffers.crosshair, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)), + CursorIcon::Move => (&buffers.move_icon, (local.0 - CENTERED_HOTSPOT.0, local.1 - CENTERED_HOTSPOT.1)), + CursorIcon::Pointer => (&buffers.pointer, (local.0 - POINTER_HOTSPOT.0, local.1 - POINTER_HOTSPOT.1)), + _ => (&buffers.arrow, (local.0, local.1)), + }, + _ => (&buffers.arrow, (local.0, local.1)), + }; + let at = (at.0 as f64, at.1 as f64); match MemoryRenderBufferRenderElement::from_buffer(renderer, at, buffer, None, None, None, Kind::Cursor) { Ok(e) => vec![OverlayElement::Memory(e)], Err(e) => { @@ -201,4 +522,126 @@ mod tests { assert!(black > 20, "expected a black outline, got {black} px"); assert!(white > 40, "expected a white fill, got {white} px"); } + + #[test] + fn arrow_tail_is_a_single_tapering_shape_not_a_lopsided_fork() { + // Regression test: the tail below the arrowhead used to split into + // two separate legs of visibly different widths (one tapering to a + // point, the other a constant-width block that never tapered) -- + // reported live as "one side is bigger than the other, not + // conventional at all". Each row of the tail must now be a single + // contiguous opaque run starting at column 0 (no gap splitting it + // into two pieces), and its width must never *grow* from the row + // above - a monotonic taper, not a fork. + let buf = arrow_bitmap(); + let opaque_at = |x: usize, y: usize| buf[(y * CURSOR_SIZE as usize + x) * 4 + 3] != 0; + let mut prev_width: Option<usize> = None; + for y in 15..CURSOR_SIZE as usize { + let width = (0..CURSOR_SIZE as usize).take_while(|&x| opaque_at(x, y)).count(); + if width == 0 { + continue; + } + assert!(opaque_at(0, y), "row {y}: tail must start flush at column 0"); + for x in width..CURSOR_SIZE as usize { + assert!(!opaque_at(x, y), "row {y}: found opaque pixel at x={x} past a gap - tail has forked into two pieces"); + } + if let Some(prev) = prev_width { + assert!(width <= prev, "row {y}: tail width grew from {prev} to {width} - not a monotonic taper"); + } + prev_width = Some(width); + } + } + + fn opaque_px_count(buf: &[u8]) -> usize { + buf.chunks_exact(4).filter(|px| px[3] != 0).count() + } + + #[test] + fn text_bitmap_is_the_expected_size_and_draws_something() { + let buf = text_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + assert!(opaque_px_count(&buf) > 10, "expected a visible I-beam"); + } + + #[test] + fn straight_resize_bitmaps_are_distinguishable_from_each_other() { + let horizontal = straight_resize_bitmap(true); + let vertical = straight_resize_bitmap(false); + assert!(opaque_px_count(&horizontal) > 10); + assert!(opaque_px_count(&vertical) > 10); + // A horizontal double-arrow and a vertical one should not paint the + // exact same pixels - if they did, `render_elements` would be + // silently showing the same shape for both directions. + assert_ne!(horizontal, vertical); + } + + #[test] + fn diagonal_resize_bitmaps_are_distinguishable_from_each_other() { + let nwse = diagonal_resize_bitmap(true); + let nesw = diagonal_resize_bitmap(false); + assert!(opaque_px_count(&nwse) > 10); + assert!(opaque_px_count(&nesw) > 10); + assert_ne!(nwse, nesw); + } + + fn has_white_px(buf: &[u8]) -> bool { + buf.chunks_exact(4).any(|px| px[3] != 0 && px[0] == 0xff && px[1] == 0xff && px[2] == 0xff) + } + + /// The resize/text shapes are drawn as plain opaque black lines (see + /// `set_px`'s doc comment) - with no halo they would be solid black + /// with zero white pixels, i.e. nearly invisible over a dark window. + /// Confirmed live: a screenshot of the resize cursor over a black + /// terminal before this fix showed no visible shape at all. This test + /// would fail against that code. + #[test] + fn resize_and_text_shapes_get_a_visible_halo() { + assert!(has_white_px(&text_bitmap()), "I-beam has no white halo"); + assert!(has_white_px(&straight_resize_bitmap(true)), "ew-resize has no white halo"); + assert!(has_white_px(&straight_resize_bitmap(false)), "ns-resize has no white halo"); + assert!(has_white_px(&diagonal_resize_bitmap(true)), "nwse-resize has no white halo"); + assert!(has_white_px(&diagonal_resize_bitmap(false)), "nesw-resize has no white halo"); + } + + #[test] + fn crosshair_is_the_expected_size_and_draws_something() { + let buf = crosshair_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + assert!(opaque_px_count(&buf) > 10, "expected a visible crosshair"); + assert!(has_white_px(&buf), "crosshair has no white halo"); + } + + #[test] + fn move_icon_is_the_expected_size_and_draws_something() { + let buf = move_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + assert!(opaque_px_count(&buf) > 10, "expected a visible move icon"); + assert!(has_white_px(&buf), "move icon has no white halo"); + } + + #[test] + fn pointer_hand_is_the_expected_size_and_draws_something() { + let buf = pointer_bitmap(); + assert_eq!(buf.len(), (CURSOR_SIZE * CURSOR_SIZE * 4) as usize); + assert!(opaque_px_count(&buf) > 10, "expected a visible pointer hand"); + assert!(has_white_px(&buf), "pointer hand has no white halo"); + // The fingertip (the hotspot) must actually be opaque, same + // requirement the arrow's tip-pixel test already checks - otherwise + // the cursor would appear offset from where clicks land. + let (hx, hy) = POINTER_HOTSPOT; + let i = ((hy * CURSOR_SIZE + hx) * 4 + 3) as usize; + assert_eq!(buf[i], 0xff, "fingertip hotspot pixel must be opaque"); + } + + #[test] + fn crosshair_move_and_pointer_are_distinguishable_from_each_other_and_from_existing_shapes() { + let shapes = [crosshair_bitmap(), move_bitmap(), pointer_bitmap(), text_bitmap(), straight_resize_bitmap(true), diagonal_resize_bitmap(true)]; + for (i, a) in shapes.iter().enumerate() { + for (j, b) in shapes.iter().enumerate() { + if i != j { + assert_ne!(a, b, "shapes {i} and {j} render identically"); + } + } + } + } } diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs index fc722b7..8e897ef 100644 --- a/crates/wayland/src/decoration.rs +++ b/crates/wayland/src/decoration.rs @@ -14,6 +14,14 @@ use std::sync::OnceLock; const FONT_PIXELS: f32 = 13.0; const TEXT_LEFT_PADDING: f32 = 8.0; +/// Titlebar buttons are laid out right-aligned in `height`-wide squares -- +/// matching `ResizeEdge::hit_test` in `crates/core/src/window.rs`, whose +/// `BUTTON` constant is also `TITLEBAR_HEIGHT`. That function only computes +/// *where* a click on close/maximize/minimize lands; nothing painted the +/// buttons themselves, so the whole band was one undifferentiated bar with +/// no visible way to tell where those three clickable regions were. +const BUTTON_MARGIN: f32 = 0.32; + /// Common monospace font file locations on Linux desktops. Not a full /// fontconfig query (no new system dependency for something this small) -- /// if none of these resolve, titlebars fall back to solid-color-only, same @@ -85,12 +93,132 @@ fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] { [rgb.2, rgb.1, rgb.0, alpha] } +/// The titlebar right-click window menu (minimize/maximize/always-on-top/ +/// close) - the one interaction virtually every desktop WM has always +/// offered on a titlebar that srdwm never did (right-click there was only +/// ever the SUPER+right-drag resize gesture, and only with the modifier +/// held). `items` is `(label, highlighted)`; `row_height` matches +/// `TITLEBAR_HEIGHT` by convention at the call site, not enforced here. +/// +/// Deliberately plain: solid rows, left-padded text, a 1px border for +/// definition against whatever's behind it - no submenus, no icons, no +/// separators. A context menu widget with real visual polish is a project +/// of its own; this is the minimum that makes the actions discoverable and +/// clickable at all, which is the actual gap. +pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)], bg: (u8, u8, u8), fg: (u8, u8, u8), highlight_bg: (u8, u8, u8), border: (u8, u8, u8)) -> Vec<u8> { + let (width, row_height) = (width.max(1) as usize, row_height.max(1) as usize); + let height = (row_height * items.len().max(1)).max(1); + let mut buf = vec![0u8; width * height * 4]; + + let font = find_system_font(); + for (i, (label, highlighted)) in items.iter().enumerate() { + let row_bg = if *highlighted { highlight_bg } else { bg }; + let row_top = i * row_height; + for y in row_top..(row_top + row_height).min(height) { + for x in 0..width { + let idx = (y * width + x) * 4; + buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(row_bg, 255)); + } + } + if let Some(font) = &font { + let baseline = row_top as f32 + row_height as f32 * 0.72; + let mut pen_x = TEXT_LEFT_PADDING; + for ch in label.chars() { + if ch.is_control() { + continue; + } + let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS); + if metrics.width > 0 && metrics.height > 0 { + let glyph_x = pen_x + metrics.xmin as f32; + let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32; + blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, row_bg, fg); + } + pen_x += metrics.advance_width; + if pen_x as usize >= width { + break; + } + } + } + } + + // A 1px border around the whole menu, drawn last so it isn't overdrawn + // by any row's background fill. + let border_px = rgb_to_bgra(border, 255); + for x in 0..width { + buf[x * 4..x * 4 + 4].copy_from_slice(&border_px); + let last_row = (height - 1) * width + x; + buf[last_row * 4..last_row * 4 + 4].copy_from_slice(&border_px); + } + for y in 0..height { + let left = y * width; + buf[left * 4..left * 4 + 4].copy_from_slice(&border_px); + let right = y * width + width - 1; + buf[right * 4..right * 4 + 4].copy_from_slice(&border_px); + } + buf +} + +/// The four border strips (top, bottom, left, right) around a window's +/// full rect, `width` thick, drawn *outside* `geometry` - additive to the +/// window's on-screen footprint, the same as a native X11 border, rather +/// than overlapping and clipping into the titlebar or content. This is +/// purely a rendering concern: `geometry` alone stays authoritative for +/// hit-testing and placement, nothing reads the strips back. +/// +/// Without any border at all, a compositor-drawn titlebar and independently +/// client-rendered content have nothing visually tying them together as +/// one window - reported live as the titlebar "not seeming part of the +/// window". `Window.border_color`/`border_width` already existed (and are +/// drawn by the X11 backend via a native X11 border) but were dead fields +/// on the Wayland side - `set_border_color`/`set_border_width` were both +/// no-op stubs. +pub fn border_strips(geometry: srdwm_core::Rect, width: u32) -> [srdwm_core::Rect; 4] { + let w = width as i32; + [ + srdwm_core::Rect::new(geometry.x - w, geometry.y - w, geometry.width + 2 * width, width), + srdwm_core::Rect::new(geometry.x - w, geometry.y + geometry.height as i32, geometry.width + 2 * width, width), + srdwm_core::Rect::new(geometry.x - w, geometry.y, width, geometry.height), + srdwm_core::Rect::new(geometry.x + geometry.width as i32, geometry.y, width, geometry.height), + ] +} + +/// Renders the top border strip (`border_strips`'s first rect) as a BGRA8 +/// bitmap instead of a plain solid fill, with its own outer top corners cut +/// the same way `render_titlebar`'s `round_corners` cuts the titlebar's -- +/// see that parameter's doc comment for why a titlebar rounds but a square +/// border frame around it used to defeat the point. Rounding *this* strip +/// too, at a radius `width` pixels larger than the titlebar's (so the cut +/// continues outward from the titlebar's own, rather than starting over), +/// is what makes a bordered window's corner read as one continuous curve +/// instead of a rounded titlebar sitting inside a square frame. The other +/// three strips (bottom/left/right) don't participate in any visible +/// corner and stay plain solid fills - see their render call sites. +pub fn render_border_top(width: u32, thickness: u32, color: (u8, u8, u8)) -> Vec<u8> { + let (width, thickness) = (width.max(1) as usize, thickness.max(1) as usize); + let bg = rgb_to_bgra(color, 255); + let mut buf = vec![0u8; width * thickness * 4]; + for px in buf.chunks_exact_mut(4) { + px.copy_from_slice(&bg); + } + round_top_corners(&mut buf, width, thickness, CORNER_RADIUS + thickness as u32); + buf +} + /// Renders a `width x height` BGRA8 buffer: filled with `background`, with /// `title` drawn left-aligned in `foreground` (best-effort glyph layout -- /// no text shaping/kerning, adequate for the ASCII-heavy titles window /// managers actually display). Returns `None` (caller keeps the previous /// solid-color-only look) only if no usable font was found on this system. -pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8, u8), foreground: (u8, u8, u8)) -> Vec<u8> { +/// +/// `round_corners` should be `false` only for a window whose border strips +/// are rendered as plain square-cornered fills with no matching rounded +/// treatment of their own. `render_border_top` gives the border's top strip +/// the same rounded-corner cut (see its own doc comment for how the two +/// stay visually continuous), so a normal bordered window should pass +/// `true` here same as a borderless one now - reported live as most +/// windows (anything with the default border) looking inconsistently +/// square next to the few borderless ones that were rounded. +pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8, u8), foreground: (u8, u8, u8), round_corners: bool) -> Vec<u8> { let (width, height) = (width.max(1) as usize, height.max(1) as usize); let bg = rgb_to_bgra(background, 255); let mut buf = vec![0u8; width * height * 4]; @@ -98,26 +226,154 @@ pub fn render_titlebar(width: u32, height: u32, title: &str, background: (u8, u8 px.copy_from_slice(&bg); } - let Some(font) = find_system_font() else { return buf }; + // Reserve the right-hand button squares before laying out text, so a + // long title elides under them the same way it would under real window + // furniture rather than drawing on top of it. + let button_count = if width >= height * 3 { 3 } else { 0 }; + let text_limit = width.saturating_sub(height * button_count); - let baseline = (height as f32 * 0.72).round(); - let mut pen_x = TEXT_LEFT_PADDING; - for ch in title.chars() { - if ch.is_control() { - continue; + if let Some(font) = find_system_font() { + let baseline = (height as f32 * 0.72).round(); + let mut pen_x = TEXT_LEFT_PADDING; + for ch in title.chars() { + if ch.is_control() { + continue; + } + let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS); + if metrics.width > 0 && metrics.height > 0 { + let glyph_x = pen_x + metrics.xmin as f32; + let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32; + blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, background, foreground); + } + pen_x += metrics.advance_width; + if pen_x as usize >= text_limit { + break; + } + } + } + + if button_count == 3 { + draw_minimize_icon(&mut buf, width, height, height * 2, foreground); + draw_maximize_icon(&mut buf, width, height, height, foreground); + draw_close_icon(&mut buf, width, height, 0, foreground); + } + if round_corners { + round_top_corners(&mut buf, width, height, CORNER_RADIUS); + } + buf +} + +/// How many pixels of each top corner are clipped away by +/// `round_top_corners`. Small and fixed rather than configurable: this is a +/// cosmetic nicety, not a feature surface worth a `srd.theme` knob, and a +/// value this small barely reads as "rounded" if it gets any larger at the +/// titlebar heights this compositor actually uses. +const CORNER_RADIUS: u32 = 6; + +/// Clips the top-left and top-right corners of a titlebar buffer to a +/// quarter-circle by making the pixels outside it fully transparent, so +/// whatever's behind (the desktop, on every top-level window) shows through +/// instead of a hard square corner. +/// +/// Only the *top* corners: the titlebar's bottom edge meets the window's +/// content, which this compositor has no way to clip (content is rendered +/// entirely by the client) - rounding that seam too would need a +/// compositor-wide clip mask over arbitrary client buffers, a much larger +/// change than this cosmetic pass. Real desktops mostly round this the same +/// way: only the outermost corners of a window, not every internal seam. +/// +/// Hard cutoff rather than an anti-aliased edge, matching this codebase's +/// existing pixel-art aesthetic elsewhere (the cursor bitmaps) rather than +/// mixing rendering styles for one corner treatment. +fn round_top_corners(buf: &mut [u8], width: usize, height: usize, radius: u32) { + let r = (radius as usize).min(width / 2).min(height); + if r == 0 { + return; + } + // Corner centres: `r` in from each edge, `r` down from the top - the + // standard quarter-circle-in-a-square construction. + let is_outside_corner = |x: usize, y: usize, cx: usize, cy: usize| -> bool { + let (dx, dy) = (x as i64 - cx as i64, y as i64 - cy as i64); + (dx * dx + dy * dy) as u64 > (r * r) as u64 + }; + for y in 0..r { + for x in 0..r { + if is_outside_corner(x, y, r, r) { + buf[(y * width + x) * 4 + 3] = 0; + } } - let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS); - if metrics.width > 0 && metrics.height > 0 { - let glyph_x = pen_x + metrics.xmin as f32; - let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32; - blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, background, foreground); + for x in (width - r)..width { + if is_outside_corner(x, y, width - r - 1, r) { + buf[(y * width + x) * 4 + 3] = 0; + } } - pen_x += metrics.advance_width; - if pen_x as usize >= width { + } +} + +/// Sets one pixel to `color` if it falls inside the buffer - every icon +/// drawn below goes through this so none of them need their own bounds +/// checks. +fn set_px(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, color: (u8, u8, u8)) { + if x < 0 || y < 0 || x as usize >= width || y as usize >= height { + return; + } + let idx = (y as usize * width + x as usize) * 4; + buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(color, 255)); +} + +/// The square `right_offset` pixels in from the right edge of the titlebar, +/// inset by `BUTTON_MARGIN` on each side - the box a button's glyph is +/// drawn inside. +fn button_box(width: usize, height: usize, right_offset: usize) -> (i32, i32, i32, i32) { + let square = height as f32; + let inset = (square * BUTTON_MARGIN).round() as i32; + let right = width as i32 - right_offset as i32; + let left = right - height as i32; + (left + inset, inset, right - inset, height as i32 - inset) +} + +/// Bresenham line, since none of these icons need anything fancier. +fn draw_line(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: (u8, u8, u8)) { + let (mut x0, mut y0) = (x0, y0); + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + set_px(buf, width, height, x0, y0, color); + if x0 == x1 && y0 == y1 { break; } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } } - buf +} + +fn draw_close_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) { + let (x0, y0, x1, y1) = button_box(width, height, right_offset); + draw_line(buf, width, height, x0, y0, x1, y1, color); + draw_line(buf, width, height, x0, y1, x1, y0, color); +} + +fn draw_maximize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) { + let (x0, y0, x1, y1) = button_box(width, height, right_offset); + draw_line(buf, width, height, x0, y0, x1, y0, color); + draw_line(buf, width, height, x0, y1, x1, y1, color); + draw_line(buf, width, height, x0, y0, x0, y1, color); + draw_line(buf, width, height, x1, y0, x1, y1, color); +} + +fn draw_minimize_icon(buf: &mut [u8], width: usize, height: usize, right_offset: usize, color: (u8, u8, u8)) { + let (x0, _, x1, y1) = button_box(width, height, right_offset); + draw_line(buf, width, height, x0, y1, x1, y1, color); } #[allow(clippy::too_many_arguments)] @@ -161,10 +417,56 @@ mod tests { use super::*; #[test] + fn border_strips_surround_geometry_without_overlapping_it() { + let geom = srdwm_core::Rect::new(100, 100, 200, 150); + let [top, bottom, left, right] = border_strips(geom, 3); + // Every strip's own rect must stay entirely outside `geom` - these + // are meant to frame the window, not clip into its own titlebar or + // content. + assert_eq!(top, srdwm_core::Rect::new(97, 97, 206, 3)); + assert_eq!(bottom, srdwm_core::Rect::new(97, 250, 206, 3)); + assert_eq!(left, srdwm_core::Rect::new(97, 100, 3, 150)); + assert_eq!(right, srdwm_core::Rect::new(300, 100, 3, 150)); + } + + #[test] fn fills_background_when_no_text() { - let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4)); + let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true); assert_eq!(buf.len(), 40 * 20 * 4); - assert_eq!(&buf[0..4], &rgb_to_bgra((0x2e, 0x34, 0x40), 255)); + // Center, not (0,0): the top-left pixel is inside the rounded + // corner `round_top_corners` clips away, so it's transparent by + // design - see `corners_are_clipped_but_the_middle_is_not` below. + let mid = ((20 / 2) * 40 + 40 / 2) * 4; + assert_eq!(&buf[mid..mid + 4], &rgb_to_bgra((0x2e, 0x34, 0x40), 255)); + } + + #[test] + fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() { + // Regression test for a bug where every drawn icon was one full + // button-width left of where a click on it actually landed: the + // visible "X" triggered Maximize, the visible square triggered + // Minimize, and the true Close hit-zone (the rightmost + // TITLEBAR_HEIGHT-wide band) was blank. `button_box`'s + // `right_offset` must put each icon in the same square + // `ResizeEdge::hit_test` assigns to it - checked here by picking + // the centre pixel of each drawn icon's square and confirming + // `hit_test` reports the matching button for that same point. + let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT); + let bg = (0x2e, 0x34, 0x40); + let fg = (0xec, 0xef, 0xf4); + let buf = render_titlebar(width, height, "", bg, fg, true); + let frame = srdwm_core::Rect::new(0, 0, width, height); + let (width, height) = (width as usize, height as usize); + + let bg_bytes = rgb_to_bgra(bg, 255); + for (right_offset, expected) in [(0, srdwm_core::TitlebarHit::Close), (height, srdwm_core::TitlebarHit::Maximize), (height * 2, srdwm_core::TitlebarHit::Minimize)] { + let (x0, y0, x1, y1) = button_box(width, height, right_offset); + let drawn = (y0..=y1).any(|y| (x0..=x1).any(|x| buf[(y as usize * width + x as usize) * 4..(y as usize * width + x as usize) * 4 + 4] != bg_bytes)); + assert!(drawn, "expected some drawn icon pixel inside the right_offset={right_offset} square"); + let cx = (x0 + x1) / 2; + let cy = (y0 + y1) / 2; + assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0), Some(expected), "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}"); + } } #[test] @@ -175,17 +477,110 @@ mod tests { } let bg = (0x2e, 0x34, 0x40); let fg = (0xec, 0xef, 0xf4); - let buf = render_titlebar(200, 30, "Terminal", bg, fg); + let buf = render_titlebar(200, 30, "Terminal", bg, fg, true); let bg_bytes = rgb_to_bgra(bg, 255); let changed = buf.chunks_exact(4).any(|px| px != bg_bytes); assert!(changed, "expected at least one pixel to differ from the background once text is drawn"); } #[test] - fn empty_title_leaves_buffer_all_background() { + fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() { let bg = (0x10, 0x20, 0x30); - let buf = render_titlebar(50, 24, "", bg, (0xff, 0xff, 0xff)); + let (width, height) = (50, 24); + let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true); let bg_bytes = rgb_to_bgra(bg, 255); - assert!(buf.chunks_exact(4).all(|px| px == bg_bytes)); + for (i, px) in buf.chunks_exact(4).enumerate() { + let (x, y) = (i % width as usize, i / width as usize); + let in_top_left = x < CORNER_RADIUS as usize && y < CORNER_RADIUS as usize; + let in_top_right = x >= width as usize - CORNER_RADIUS as usize && y < CORNER_RADIUS as usize; + if !in_top_left && !in_top_right { + assert_eq!(px, bg_bytes, "unexpected non-background pixel at ({x}, {y})"); + } + } + } + + #[test] + fn corners_are_clipped_but_the_middle_is_not() { + let bg = (0x10, 0x20, 0x30); + let (width, height) = (50, 24); + let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true); + let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3]; + // The very corner pixel is well outside the quarter-circle at any + // sane radius - fully clipped. + assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be transparent"); + assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be transparent"); + // Bottom corners are deliberately left square (see the function's + // doc comment: the titlebar's bottom edge meets client content, + // which can't be clipped the same way). + assert_eq!(alpha_at(0, height as usize - 1), 255, "bottom-left must stay square"); + assert_eq!(alpha_at(width as usize - 1, height as usize - 1), 255, "bottom-right must stay square"); + // Centre is nowhere near either corner circle - untouched. + assert_eq!(alpha_at(width as usize / 2, height as usize / 2), 255); + } + + #[test] + fn round_corners_false_leaves_the_top_corners_square() { + let bg = (0x10, 0x20, 0x30); + let (width, height) = (50, 24); + let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), false); + let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3]; + assert_eq!(alpha_at(0, 0), 255, "top-left corner should stay square when round_corners is false"); + assert_eq!(alpha_at(width as usize - 1, 0), 255, "top-right corner should stay square when round_corners is false"); + } + + #[test] + fn border_top_rounds_its_own_top_corners_to_match_the_titlebar() { + // Regression coverage for the "not all window borders are rounded" + // report: a bordered window's titlebar used to render with + // `round_corners = false` specifically to avoid clashing with this + // strip's square corners. Now that this strip rounds too, that + // workaround is gone (`render_titlebar` is always called with + // `true`) - this just confirms the strip actually does what that + // change now depends on. + let color = (0x40, 0x50, 0x60); + let (width, thickness) = (60, 2); + let buf = render_border_top(width, thickness, color); + let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3]; + assert_eq!(alpha_at(0, 0), 0, "top-left corner pixel should be clipped"); + assert_eq!(alpha_at(width as usize - 1, 0), 0, "top-right corner pixel should be clipped"); + // A 2px-thick strip is thinner than any sane radius, so the clamp + // in `round_top_corners` bounds the cut to the strip's own height -- + // the bottom row, at least at the strip's horizontal centre, must + // stay opaque or there would be no border left to see at all. + assert_eq!(alpha_at(width as usize / 2, thickness as usize - 1), 255, "centre of the strip must stay opaque"); + } + + #[test] + fn context_menu_is_one_row_tall_per_item() { + let items = [("Minimize", false), ("Maximize", false), ("Always on Top", false), ("Close", false)]; + let buf = render_context_menu(160, 28, &items, (0x2e, 0x34, 0x40), (0xff, 0xff, 0xff), (0x4c, 0x56, 0x6a), (0x10, 0x10, 0x10)); + assert_eq!(buf.len(), 160 * (28 * 4) * 4); + } + + #[test] + fn context_menu_highlighted_row_has_a_different_background_than_the_rest() { + let items = [("Minimize", false), ("Close", true)]; + let bg = (0x2e, 0x34, 0x40); + let highlight = (0x4c, 0x56, 0x6a); + let buf = render_context_menu(160, 28, &items, bg, (0xff, 0xff, 0xff), highlight, (0x10, 0x10, 0x10)); + let width = 160usize; + // Sample a background pixel from each row, away from the text/border. + let px_at = |x: usize, y: usize| -> [u8; 3] { + let i = (y * width + x) * 4; + [buf[i + 2], buf[i + 1], buf[i]] // BGRA -> RGB + }; + assert_eq!(px_at(100, 5), [bg.0, bg.1, bg.2], "row 0 (not highlighted) should use bg"); + assert_eq!(px_at(100, 33), [highlight.0, highlight.1, highlight.2], "row 1 (highlighted) should use highlight_bg"); + } + + #[test] + fn context_menu_border_is_opaque_at_every_edge() { + let items = [("Close", false)]; + let buf = render_context_menu(100, 28, &items, (0, 0, 0), (0xff, 0xff, 0xff), (0, 0, 0), (0x99, 0x99, 0x99)); + let alpha_at = |x: usize, y: usize| buf[(y * 100 + x) * 4 + 3]; + assert_eq!(alpha_at(0, 0), 255); + assert_eq!(alpha_at(99, 0), 255); + assert_eq!(alpha_at(0, 27), 255); + assert_eq!(alpha_at(99, 27), 255); } } diff --git a/crates/wayland/src/elements.rs b/crates/wayland/src/elements.rs index f1fbfee..f9a569e 100644 --- a/crates/wayland/src/elements.rs +++ b/crates/wayland/src/elements.rs @@ -1,14 +1,24 @@ //! The one render-element type for everything srdwm draws *itself*, on top -//! of client windows: its titlebars and the mouse pointer. +//! of client windows: its titlebars, borders, and the mouse pointer. //! //! `render_output` takes a single `custom_elements` slice, so these have to -//! be one type. They come from two different sources - an uploaded bitmap -//! (titlebars, the built-in cursor arrow) and a client's own surface (a -//! client-set cursor image) - hence the two variants. +//! be one type. They come from three different sources - an uploaded +//! bitmap (titlebars, the built-in cursor arrow), a client's own surface (a +//! client-set cursor image), and a native solid-colour fill (borders) -- +//! hence the three variants. use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; -use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement; -use smithay::backend::renderer::{ImportAll, ImportMem}; +use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement}; +use smithay::backend::renderer::element::surface::{render_elements_from_surface_tree, WaylandSurfaceRenderElement}; +use smithay::backend::renderer::element::Kind; +use smithay::backend::renderer::{Color32F, ImportAll, ImportMem, Renderer}; +use smithay::desktop::{layer_map_for_output, PopupManager, Space, Window as DWindow}; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::utils::{Physical, Point, Rectangle, Scale}; + +use srdwm_core::TITLEBAR_HEIGHT; + +use crate::state::CompState; smithay::backend::renderer::element::render_elements! { pub(crate) OverlayElement<R> where @@ -17,4 +27,211 @@ smithay::backend::renderer::element::render_elements! { Surface=WaylandSurfaceRenderElement<R>, /// A bitmap srdwm rasterised: a titlebar, or the built-in cursor arrow. Memory=MemoryRenderBufferRenderElement<R>, + /// A native solid-colour fill - window borders. Deliberately not a + /// stretched 1x1 `Memory` buffer (`border_strips`' previous approach): + /// `PixmanRenderer` (the udev backend's software renderer) hardcodes + /// `Repeat::None` on every imported texture, so sampling a 1x1 texture + /// stretched across a larger destination has no valid neighbouring + /// texels to fall back on and renders fully transparent - the border + /// silently never appeared on real hardware, only on the winit/GLES + /// backend (GPU texture sampling of a 1x1 texture returns that texel + /// regardless of wrap mode, so it happened to work there by accident). + /// `SolidColorRenderElement` goes through `Frame::draw_solid`, a native + /// fill with no texture import or sampling involved at all, so this + /// backend difference cannot affect it. + Solid=SolidColorRenderElement, +} + +/// One border strip as a native solid-colour fill, `origin`-translated to +/// output-local space - the same convention every other `custom_elements` +/// entry (cursor, decorations, popups) already uses. No renderer/texture +/// import needed at all (unlike the `Memory`-buffer approach this replaced). +/// +/// Takes a *persistent* `SolidColorBuffer` (one kept per window per strip in +/// `CompState::border_side_buffers`, updated in place here) rather than +/// building a element with a fresh `Id`/`CommitCounter` every call, which an +/// earlier version of this function did on the reasoning that `Id`'s only +/// role is picking a *tighter* damage rect and a border strip is cheap to +/// redraw in full regardless. That undersold the actual cost: smithay's +/// `OutputDamageTracker::damage_output_internal` looks up each element's +/// previous state by `Id` and falls back to `.unwrap_or(true)` ("damage it") +/// when no match is found - a fresh `Id` every frame means *every* frame +/// finds no match, so every bordered window's border is marked damaged on +/// *every* frame forever, not occasionally with a wider rect. Confirmed by +/// reading `damage/mod.rs`'s `damage_output_internal` directly (0.7.0): +/// `element_last_state.map(|s| !s.instance_matches(...)).unwrap_or(true)`. +/// Downstream, `render_udev_frame`'s `has_damage` gate around the real DRM +/// page flip is then unconditionally true every ~16ms for as long as any +/// window with `border_width > 0` is on screen - effectively every window, +/// since that is the default - so the output never actually goes idle. +/// `SolidColorBuffer::update` only bumps its internal `CommitCounter` when +/// the size or colour actually changed, so reusing the same buffer (and +/// therefore the same stable `Id`) here is what lets the tracker correctly +/// see "nothing changed" and skip real work on a static screen. +pub(crate) fn border_side_render_element(buf: &mut SolidColorBuffer, strip: srdwm_core::Rect, color: (u8, u8, u8), origin: (i32, i32)) -> SolidColorRenderElement { + let c = Color32F::new(color.0 as f32 / 255.0, color.1 as f32 / 255.0, color.2 as f32 / 255.0, 1.0); + buf.update((strip.width as i32, strip.height as i32), c); + let loc = Point::from((strip.x - origin.0, strip.y - origin.1)); + SolidColorRenderElement::from_buffer(buf, loc, 1.0, 1.0, Kind::Unspecified) +} + +/// Splits a border strip into the sub-rectangles still visible after +/// subtracting every window rect stacked in front of it. +/// +/// Border strips render via `custom_elements`, which `smithay::desktop:: +/// space::render_output` always composites above *every* window's own +/// content (drawn separately, via `self.space`) - unconditionally, with +/// no way to interleave the two by real stacking order (confirmed by +/// reading `render_output`'s own source: `custom_elements` are pushed into +/// the final element list before `space_render_elements`, and this +/// backend's damage tracker treats earlier-pushed elements as topmost, the +/// same convention `cursor::render_elements`' own doc comment already +/// relies on). Without this, a background window's border rendered as a +/// solid line straight through a foreground window's content wherever the +/// two visually overlapped - confirmed live: a stack of cascaded +/// terminals showed every earlier window's right border strip as a set of +/// vertical lines cutting across the frontmost window's own content. +/// `occluders` should already be limited to windows stacked above the one +/// this border belongs to. +pub(crate) fn visible_border_fragments(strip: srdwm_core::Rect, occluders: &[srdwm_core::Rect]) -> Vec<srdwm_core::Rect> { + strip.subtract_all(occluders) +} + +/// Gets (growing if needed) the `index`th persistent buffer in a window's +/// border-fragment pool - see `CompState::border_side_buffers`' doc +/// comment for why this is a growable pool rather than a fixed-size array. +pub(crate) fn border_fragment_buffer(pool: &mut Vec<SolidColorBuffer>, index: usize) -> &mut SolidColorBuffer { + if index >= pool.len() { + pool.resize_with(index + 1, SolidColorBuffer::default); + } + &mut pool[index] +} + +/// A mapped toplevel's surface and on-screen (global-space, band-adjusted) +/// position - everything [`popup_render_elements`] needs to find and place +/// that window's popups, pre-extracted from `CompState` by +/// [`popup_targets`] so the actual rendering call can run after +/// `self.udev`/`self.backend` is mutably borrowed for its renderer, the +/// same "gather immutable state first" split `render_udev_frame` already +/// uses for borders and decorations (see its own comment). +pub(crate) struct PopupTarget { + surface: WlSurface, + window_pos: (i32, i32), +} + +/// Only windows `visible_windows()` would show are considered - a popup +/// belonging to a workspace-hidden or minimized parent has no business +/// appearing on screen either. +/// +/// Also gathers every mapped layer-shell surface (bars, launchers) across +/// every output, not just toplevel windows - a popup can be parented to +/// either (`zwlr_layer_surface_v1.get_popup`, used for a bar's own +/// dropdown/context menu, is a completely separate request from +/// `xdg_surface.get_popup`, and this function previously only ever +/// accounted for the latter). `PopupManager` itself already tracked and +/// configured layer-surface-parented popups correctly (that path goes +/// through the same `XdgShellHandler::new_popup`/`commit()` regardless of +/// what the eventual parent turns out to be) - they just never had a +/// `PopupTarget` to render relative to, so they were fully functional and +/// completely invisible: a bar's dropdown menu would open, accept clicks, +/// and never draw a single pixel. +pub(crate) fn popup_targets(state: &CompState) -> Vec<PopupTarget> { + let wm = state.wm.borrow(); + let current = wm.current_workspace(); + let windows = state.id_to_window.iter().filter_map(|(&id, dwindow)| { + let w = wm.window(id)?; + if w.minimized || w.workspace != current { + return None; + } + let toplevel = dwindow.toplevel()?; + let band = if w.decorated { TITLEBAR_HEIGHT as i32 } else { 0 }; + Some(PopupTarget { surface: toplevel.wl_surface().clone(), window_pos: (w.geometry.x, w.geometry.y + band) }) + }); + let layers = state.outputs.iter().flat_map(|entry| { + let origin = entry.location; + let map = layer_map_for_output(&entry.output); + map.layers() + .filter_map(|layer| { + let geo = map.layer_geometry(layer)?; + Some(PopupTarget { surface: layer.wl_surface().clone(), window_pos: (origin.x + geo.loc.x, origin.y + geo.loc.y) }) + }) + .collect::<Vec<_>>() + }); + windows.chain(layers).collect() +} + +/// Every currently-mapped `xdg_popup`'s surface tree, positioned relative to +/// its parent toplevel - tooltips, dropdown menus, right-click menus. Not +/// part of `space` (popups are never `space.map_element`'d, only their +/// parent toplevel is), so `render_output`'s automatic per-space-element +/// rendering never sees them; this is what makes them show up at all now +/// that `protocols.rs`'s `new_popup` actually configures them instead of +/// hanging the client forever. +/// +/// `origin` is the output's location in global space, same as every other +/// `custom_elements` entry (cursor, borders, decorations) already +/// subtracts - positions here have to be output-local too, or a popup on +/// any output but the one at the global origin renders offset by that +/// output's own placement. +pub(crate) fn popup_render_elements<R>(targets: &[PopupTarget], renderer: &mut R, origin: (i32, i32)) -> Vec<OverlayElement<R>> +where + R: Renderer + ImportAll + ImportMem, + R::TextureId: Clone + Send + 'static, +{ + let mut elements = Vec::new(); + for target in targets { + for (popup, offset) in PopupManager::popups_for_surface(&target.surface) { + // Both `Xdg` (tooltips, dropdown/context menus) and + // `InputMethod` (an IME's composition/candidate window -- + // registered via `protocols.rs`'s `InputMethodHandler:: + // new_popup`) render identically here: `PopupManager` already + // tracks both uniformly keyed by parent surface, and `offset` + // is each kind's own on-screen position relative to + // `target.window_pos` regardless of which one it is. + let loc = (target.window_pos.0 - origin.0 + offset.x, target.window_pos.1 - origin.1 + offset.y); + elements.extend(render_elements_from_surface_tree(renderer, popup.wl_surface(), loc, 1.0, 1.0, Kind::Unspecified)); + } + } + elements +} + +/// Which of `space`'s mapped windows actually need a frame callback this +/// pass - the ones whose on-screen bounds overlap `damage`, in physical +/// output-space. +/// +/// Both backends used to call `send_frame` on *every* mapped window +/// whenever the output had any damage at all, including damage from +/// nothing but the cursor moving. `has-damage` is an output-wide gate; it +/// says nothing about *where* the damage was, so a window nowhere near the +/// cursor was told just as urgently to redraw as one it was passing over. +/// Any client using the standard wait-for-frame-callback pattern (most of +/// them, confirmed live: wezterm-gui pinned at 140%+ CPU on a fully idle +/// terminal) had no reason not to redraw every single time, forever. This +/// narrows the callback to windows the damage rectangles actually +/// intersect, which is what makes a stationary cursor's damage stop +/// waking up windows it never touched. +/// +/// Deliberately does *not* also cover the focused/hovered-window starvation +/// case (an idle window's own pending callback never getting answered +/// because nothing else ever damages its bounds) - an earlier version of +/// this function took an `always_notify` list for that, but folding it in +/// here meant it only ever ran on a tick that already had damage from +/// something else, i.e. never on the fully-idle tick it was meant for. See +/// the caller: that case is now handled by a second, always-unconditional +/// pass over the focused/hovered windows specifically, independent of +/// whether this function's damage-gated pass runs at all this tick. +pub(crate) fn windows_touched_by_damage<'a>( + space: &'a Space<DWindow>, + damage: &'a [Rectangle<i32, Physical>], + scale: Scale<f64>, +) -> impl Iterator<Item = &'a DWindow> + 'a { + space.elements().filter(move |w| { + space + .element_geometry(w) + .map(|geo| { + let phys = geo.to_physical_precise_round(scale); + damage.iter().any(|d| d.overlaps(phys)) + }) + .unwrap_or(false) + }) } diff --git a/crates/wayland/src/foreign_toplevel.rs b/crates/wayland/src/foreign_toplevel.rs new file mode 100644 index 0000000..c6913a5 --- /dev/null +++ b/crates/wayland/src/foreign_toplevel.rs @@ -0,0 +1,379 @@ +//! `wlr-foreign-toplevel-management-unstable-v1`: enumerates every managed +//! window (title, app_id, activated/maximized/minimized state) to any +//! client that binds it, and lets that client request activation, close, +//! maximize/minimize - what a dock or taskbar needs to be interactive +//! rather than just a launcher. Requested by AGS's dock (see +//! `docs/PANEL_SUPPORT_TODO.md`'s P1 list): without it there is no running- +//! app indicator, no click-to-focus in the dock, no alt-tab list. +//! +//! Chose this over the newer split `ext_foreign_toplevel_list_v1` (list +//! only) + a separate management protocol: the wlr protocol covers +//! enumeration *and* activate/close/maximize/minimize in one interface, and +//! it is what was actually asked for - smithay 0.7 has no built-in helper +//! for either shape, but has one for the `ext_` list-only half, which would +//! have meant standing up two protocols to get what this one already does +//! alone. Hand-written against the raw `wayland-protocols-wlr` server +//! bindings, same reasoning and the same crate as `screencopy.rs`. +//! +//! One `ZwlrForeignToplevelHandleV1` is created per (bound manager, window) +//! pair - a dock running in one client only ever sees the handles that +//! client's own manager object created, so `CompState` tracks a `Vec` of +//! live handles per `WindowId` to broadcast state changes (activation, +//! maximized, minimized, fullscreen) to every one of them, from whichever +//! call site actually changed that state - this module's own requests, +//! the pointer-driven titlebar handlers, or a client's own +//! `xdg_toplevel`/X11 request. See `update_activated`'s doc comment for +//! the one trigger that still doesn't re-broadcast. +//! +//! Override-redirect X11 windows are deliberately never announced here, +//! matching `_NET_CLIENT_LIST` and ICCCM: a dock has no business listing +//! menus/tooltips/drag images as if they were real applications. + +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource}; +use wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_handle_v1::{self, State, ZwlrForeignToplevelHandleV1}; +use wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}; + +use srdwm_core::WindowId; + +use crate::input::{close_dwindow, focus_window}; +use crate::state::CompState; + +const PROTOCOL_VERSION: u32 = 3; + +pub struct ForeignToplevelState { + _global: smithay::reexports::wayland_server::backend::GlobalId, +} + +impl ForeignToplevelState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<ZwlrForeignToplevelManagerV1, ()> + 'static, + { + Self { _global: dh.create_global::<D, ZwlrForeignToplevelManagerV1, _>(PROTOCOL_VERSION, ()) } + } +} + +/// Per-handle data: which window this specific `ZwlrForeignToplevelHandleV1` +/// (one of possibly several, one per bound manager) speaks for. +pub struct ToplevelHandleData { + window: WindowId, +} + +impl GlobalDispatch<ZwlrForeignToplevelManagerV1, ()> for CompState { + fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New<ZwlrForeignToplevelManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) { + let manager = data_init.init(manager, ()); + // Catch this client's dock/switcher up on every window that already + // exists - without this, only windows opened *after* the client + // bound the global would ever be announced. + let existing: Vec<WindowId> = state.wm.borrow().windows().map(|w| w.id).collect(); + for id in existing { + announce(state, &manager, id, client, dh); + } + state.foreign_toplevel_managers.push(manager); + } +} + +impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for CompState { + fn request(state: &mut Self, _client: &Client, manager: &ZwlrForeignToplevelManagerV1, request: zwlr_foreign_toplevel_manager_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + if let zwlr_foreign_toplevel_manager_v1::Request::Stop = request { + manager.finished(); + state.foreign_toplevel_managers.retain(|m| m != manager); + } + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, manager: &ZwlrForeignToplevelManagerV1, _data: &()) { + state.foreign_toplevel_managers.retain(|m| m != manager); + } +} + +impl Dispatch<ZwlrForeignToplevelHandleV1, ToplevelHandleData> for CompState { + fn request( + state: &mut Self, + _client: &Client, + _handle: &ZwlrForeignToplevelHandleV1, + request: zwlr_foreign_toplevel_handle_v1::Request, + data: &ToplevelHandleData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + let id = data.window; + // Every branch that actually changes state re-announces it + // (`send_state`) rather than waiting for some other path to notice + // - `activate`/`close` are the two the dock actually needs (per + // `docs/PANEL_SUPPORT_TODO.md`), the rest are handled for protocol + // completeness since a dock is free to send them (a maximize + // context-menu entry, say) even if AGS's dock doesn't today. + match request { + zwlr_foreign_toplevel_handle_v1::Request::Activate { seat: _ } => { + // No per-seat distinction - this compositor has exactly + // one seat, same simplification `srd.window.focus()` and + // every other single-window-target API already makes. + focus_window(state, id); + } + zwlr_foreign_toplevel_handle_v1::Request::Close => { + if let Some(w) = state.id_to_window.get(&id) { + close_dwindow(w); + } + } + zwlr_foreign_toplevel_handle_v1::Request::SetMaximized => set_maximized(state, id, true), + zwlr_foreign_toplevel_handle_v1::Request::UnsetMaximized => set_maximized(state, id, false), + zwlr_foreign_toplevel_handle_v1::Request::SetMinimized => set_minimized(state, id, true), + zwlr_foreign_toplevel_handle_v1::Request::UnsetMinimized => set_minimized(state, id, false), + // `output` is a hint only (per the protocol doc: "only a hint to + // the compositor") - ignored, same as `fullscreen_request`'s + // `_output` in `protocols.rs`, and for the same reason: single- + // seat, single-fullscreen-path, matching every other fullscreen + // entry point instead of introducing an output-aware one just + // for this request. + zwlr_foreign_toplevel_handle_v1::Request::SetFullscreen { output: _ } => set_fullscreen(state, id, true), + zwlr_foreign_toplevel_handle_v1::Request::UnsetFullscreen => set_fullscreen(state, id, false), + // The rectangle hint and Destroy: no srdwm-side effect. The + // rectangle is an optional hint we're not obligated to act on; + // `Destroy` just ends this protocol object, handled by + // `destroyed` below. + _ => {} + } + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, handle: &ZwlrForeignToplevelHandleV1, data: &ToplevelHandleData) { + if let Some(handles) = state.foreign_toplevel_handles.get_mut(&data.window) { + handles.retain(|h| h != handle); + } + } +} + +fn set_maximized(state: &mut CompState, id: WindowId, maximized: bool) { + let already = state.wm.borrow().window(id).is_some_and(|w| w.maximized); + if already != maximized { + state.wm.borrow_mut().toggle_maximize(id); + state.sync_geometry(id); + } + send_state(state, id); +} + +fn set_minimized(state: &mut CompState, id: WindowId, minimized: bool) { + let mut wm = state.wm.borrow_mut(); + if minimized { + wm.minimize_window(id); + } else { + wm.restore_window(id); + } + drop(wm); + send_state(state, id); +} + +/// Same `redraw_decoration_buffer`-before-`sync_geometry` ordering as every +/// other fullscreen entry point (`protocols.rs`'s `fullscreen_request`, +/// `xwayland.rs`'s equivalent): `toggle_fullscreen` also flips +/// `Window.decorated`, and the decoration buffer needs to actually be +/// dropped, not just left stale for `sync_geometry`'s resize-only redraw +/// check to skip. +fn set_fullscreen(state: &mut CompState, id: WindowId, fullscreen: bool) { + let already = state.wm.borrow().is_fullscreen(id); + if already != fullscreen { + state.wm.borrow_mut().toggle_fullscreen(id); + state.redraw_decoration_buffer(id); + state.sync_geometry(id); + } + send_state(state, id); +} + +/// Creates and sends a new handle for `id` to `manager`, with its full +/// initial state (title, app_id, activated/maximized/minimized, done) -- +/// the sequence `new_toplevel`'s own doc comment requires: "all initial +/// details... will be sent immediately after this event". +fn announce(state: &mut CompState, manager: &ZwlrForeignToplevelManagerV1, id: WindowId, client: &Client, dh: &DisplayHandle) { + let Ok(handle) = client.create_resource::<ZwlrForeignToplevelHandleV1, ToplevelHandleData, CompState>(dh, manager.version(), ToplevelHandleData { window: id }) else { + return; + }; + manager.toplevel(&handle); + state.foreign_toplevel_handles.entry(id).or_default().push(handle.clone()); + send_state_to(state, id, &[handle]); +} + +/// Re-sends title/app_id/state/done to every handle a window currently has +/// - one per bound manager. Used both right after `announce` creates a +/// fresh handle and whenever state actually changes (`set_maximized`, +/// `update_activated`). +pub(crate) fn send_state(state: &mut CompState, id: WindowId) { + let Some(handles) = state.foreign_toplevel_handles.get(&id).cloned() else { return }; + send_state_to(state, id, &handles); +} + +fn send_state_to(state: &mut CompState, id: WindowId, handles: &[ZwlrForeignToplevelHandleV1]) { + let Some(w) = state.wm.borrow().window(id).cloned() else { return }; + let focused = state.wm.borrow().focused_id() == Some(id); + let fullscreen = state.wm.borrow().is_fullscreen(id); + let bytes = state_flags(w.maximized, w.minimized, focused, fullscreen); + + for handle in handles { + handle.title(w.title.clone()); + handle.app_id(w.app_id.clone()); + handle.state(bytes.clone()); + handle.done(); + } +} + +/// Packs the flags a window currently has into the wire format +/// `zwlr_foreign_toplevel_handle_v1.state` expects: the raw bytes of +/// however many native-endian u32 enum values apply, back to back. Pulled +/// out of `send_state_to` as the one piece of this module that's pure +/// logic rather than protocol I/O, so it's unit-testable the way +/// `decoration.rs`'s rasterizers are. +fn state_flags(maximized: bool, minimized: bool, activated: bool, fullscreen: bool) -> Vec<u8> { + let mut flags = Vec::new(); + if maximized { + flags.push(State::Maximized as u32); + } + if minimized { + flags.push(State::Minimized as u32); + } + if activated { + flags.push(State::Activated as u32); + } + if fullscreen { + flags.push(State::Fullscreen as u32); + } + let mut bytes = Vec::with_capacity(flags.len() * 4); + for f in flags { + bytes.extend_from_slice(&f.to_ne_bytes()); + } + bytes +} + +/// Called from `new_managed_window`/`finish_x11_window_setup`. Announces +/// the new window to every currently-bound manager - `GlobalDispatch::bind` +/// above only covers managers that bind *before* a window exists; this is +/// the other half, for ones that were already bound. +pub(crate) fn window_created(state: &mut CompState, id: WindowId) { + let managers = state.foreign_toplevel_managers.clone(); + for manager in &managers { + let Some(client) = manager.client() else { continue }; + let dh = state.dh.clone(); + announce(state, manager, id, &client, &dh); + } +} + +/// Called from the native/X11 `remove_window` paths. Every real Wayland +/// compositor's foreign-toplevel implementation must send `closed` before +/// a handle becomes unusable - skipping it would leave a dock's entry for +/// this window dangling with no signal to ever remove it. +pub(crate) fn window_closed(state: &mut CompState, id: WindowId) { + if let Some(handles) = state.foreign_toplevel_handles.remove(&id) { + for handle in handles { + handle.closed(); + } + } +} + +/// Called from `set_keyboard_focus`, the single chokepoint every focus +/// change already goes through - re-announces state for whichever window +/// lost `Activated` and whichever gained it, so a dock's focused-app +/// highlight tracks real focus instead of only ever reflecting whatever was +/// focused at creation time. +/// +/// Only `Activated` is kept live this way - maximized/minimized/fullscreen +/// changes are re-broadcast from their own call sites instead (the pointer- +/// driven titlebar handlers in `input.rs`, and the client-request handlers +/// in `protocols.rs`/`xwayland.rs`, all call `send_state` directly). The one +/// trigger that still doesn't re-broadcast is a *compositor keybinding* +/// (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`): `crates/config` +/// only holds a `WindowManager` reference, not `CompState`, so it has no way +/// to reach this module. See `docs/PANEL_SUPPORT_TODO.md`'s P1 section. +pub(crate) fn update_activated(state: &mut CompState, old: Option<WlSurface>, new: Option<&WlSurface>) { + let old_id = old.as_ref().and_then(|s| window_id_for_surface(state, s)); + let new_id = new.and_then(|s| window_id_for_surface(state, s)); + if old_id == new_id { + return; + } + if let Some(id) = old_id { + send_state(state, id); + } + if let Some(id) = new_id { + send_state(state, id); + } +} + +fn window_id_for_surface(state: &CompState, surface: &WlSurface) -> Option<WindowId> { + state.surface_to_id.get(surface).copied() +} + +/// Diff-broadcasts maximized/minimized/fullscreen against what was last +/// sent for each window, called once per frame from `CompState:: +/// tick_dirty_broadcasts` (same poll-loop cadence as `tick_animations`). +/// +/// Every *known* trigger already re-broadcasts immediately at its own call +/// site: this module's own requests (`set_maximized`/`set_minimized`/ +/// `set_fullscreen`), the pointer-driven titlebar handlers in `input.rs`, +/// and a client's own `xdg_toplevel`/X11 request in `protocols.rs`/ +/// `xwayland.rs`. The one trigger documented as a real gap in +/// `docs/PANEL_SUPPORT_TODO.md` is a *compositor keybinding* +/// (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`): `crates/config` +/// is the platform-agnostic scripting engine, shared with the X11/macOS +/// backends, and only ever holds a `WindowManager` reference - it cannot +/// reach this Wayland-protocol-specific module directly, so state it +/// changes went stale in every dock/panel until *something else* +/// coincidentally re-broadcast that window. Rather than thread a callback +/// through `WindowManager` for every future call site that might change +/// this state (and inevitably miss the next one), this diffs against +/// `WindowManager`'s live state once a frame and catches all of them +/// uniformly, including ones that don't exist yet. +/// +/// One accepted redundancy: a brand-new window has no prior entry in +/// `last_broadcast_flags`, so its first tick after creation sends one +/// extra (identical) `send_state` on top of the one `window_created` +/// already sent - harmless per-window, one-time, and far simpler than +/// seeding the cache at creation just to suppress it. +pub(crate) fn broadcast_dirty_state(state: &mut CompState) { + let current: Vec<(WindowId, bool, bool, bool)> = + state.wm.borrow().windows().map(|w| (w.id, w.maximized, w.minimized, w.fullscreen)).collect(); + let mut changed = Vec::new(); + for (id, maximized, minimized, fullscreen) in ¤t { + let key = (*maximized, *minimized, *fullscreen); + if state.last_broadcast_flags.get(id) != Some(&key) { + state.last_broadcast_flags.insert(*id, key); + changed.push(*id); + } + } + // Drops entries for windows that no longer exist - `window_closed` + // already tells clients the handle itself is gone; this just stops the + // map growing forever across the life of a session. + let live: std::collections::HashSet<WindowId> = current.iter().map(|(id, ..)| *id).collect(); + state.last_broadcast_flags.retain(|id, _| live.contains(id)); + for id in changed { + send_state(state, id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn as_u32s(bytes: &[u8]) -> Vec<u32> { + bytes.chunks_exact(4).map(|c| u32::from_ne_bytes(c.try_into().unwrap())).collect() + } + + #[test] + fn no_flags_is_an_empty_array() { + assert_eq!(state_flags(false, false, false, false), Vec::<u8>::new()); + } + + #[test] + fn each_flag_packs_its_own_enum_value() { + assert_eq!(as_u32s(&state_flags(true, false, false, false)), vec![State::Maximized as u32]); + assert_eq!(as_u32s(&state_flags(false, true, false, false)), vec![State::Minimized as u32]); + assert_eq!(as_u32s(&state_flags(false, false, true, false)), vec![State::Activated as u32]); + assert_eq!(as_u32s(&state_flags(false, false, false, true)), vec![State::Fullscreen as u32]); + } + + #[test] + fn all_flags_combine_in_one_array() { + let values = as_u32s(&state_flags(true, true, true, true)); + assert_eq!(values.len(), 4); + assert!(values.contains(&(State::Maximized as u32))); + assert!(values.contains(&(State::Minimized as u32))); + assert!(values.contains(&(State::Activated as u32))); + assert!(values.contains(&(State::Fullscreen as u32))); + } +} diff --git a/crates/wayland/src/gamma_control.rs b/crates/wayland/src/gamma_control.rs new file mode 100644 index 0000000..d126f26 --- /dev/null +++ b/crates/wayland/src/gamma_control.rs @@ -0,0 +1,90 @@ +//! `zwlr_gamma_control_manager_v1`: lets a client (a night-light daemon like +//! `gammastep`/`wlsunset`, or a settings panel) set a per-output gamma +//! ramp - the mechanism behind "reduce blue light in the evening" features. +//! +//! DRM/udev backend only: there is no real CRTC gamma table to adjust when +//! nested under a host compositor, same reasoning as `output_power.rs`. The +//! global is genuinely not created there (`CompState::_gamma_control_state` +//! is `Option`, `None` for `winit.rs`) rather than advertised-and-always- +//! failing. +//! +//! No smithay helper exists for this protocol, so the `GlobalDispatch`/ +//! `Dispatch` plumbing below is hand-written against the raw +//! `wayland-protocols-wlr` server bindings, the same pattern as +//! `screencopy.rs`/`output_power.rs`. + +use smithay::reexports::wayland_server::backend::GlobalId; +use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New}; +use wayland_protocols_wlr::gamma_control::v1::server::zwlr_gamma_control_manager_v1::{self, ZwlrGammaControlManagerV1}; +use wayland_protocols_wlr::gamma_control::v1::server::zwlr_gamma_control_v1::{self, ZwlrGammaControlV1}; + +use crate::state::CompState; + +/// The manager global. Held by `CompState` purely to keep the global alive +/// for the compositor's lifetime. +pub struct GammaControlManagerState { + _global: GlobalId, +} + +impl GammaControlManagerState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<ZwlrGammaControlManagerV1, ()> + 'static, + { + Self { _global: dh.create_global::<D, ZwlrGammaControlManagerV1, _>(1, ()) } + } +} + +/// Which `wl_output` a `zwlr_gamma_control_v1` object controls, resolved +/// once at creation - same reasoning as `output_power::OutputPowerData`. +pub struct GammaControlData { + output: WlOutput, +} + +impl GlobalDispatch<ZwlrGammaControlManagerV1, ()> for CompState { + fn bind(_state: &mut Self, _dh: &DisplayHandle, _client: &Client, manager: New<ZwlrGammaControlManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) { + data_init.init(manager, ()); + } +} + +impl Dispatch<ZwlrGammaControlManagerV1, ()> for CompState { + fn request( + state: &mut Self, + _client: &Client, + _manager: &ZwlrGammaControlManagerV1, + request: zwlr_gamma_control_manager_v1::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + use zwlr_gamma_control_manager_v1::Request; + let Request::GetGammaControl { id, output } = request else { return }; + let control = data_init.init(id, GammaControlData { output: output.clone() }); + // Required "sent immediately when the gamma control object is + // created" - `failed()` covers an output that doesn't resolve or + // has no gamma ramp at all (headless/virtual outputs), rather than + // sending a nonsensical size. + match state.gamma_ramp_size(&output) { + Some(size) => control.gamma_size(size), + None => control.failed(), + } + } +} + +impl Dispatch<ZwlrGammaControlV1, GammaControlData> for CompState { + fn request( + state: &mut Self, + _client: &Client, + resource: &ZwlrGammaControlV1, + request: zwlr_gamma_control_v1::Request, + data: &GammaControlData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + let zwlr_gamma_control_v1::Request::SetGamma { fd } = request else { return }; + if state.set_gamma_ramp(&data.output, fd).is_none() { + resource.failed(); + } + } +} diff --git a/crates/wayland/src/gtk_shell.rs b/crates/wayland/src/gtk_shell.rs new file mode 100644 index 0000000..f4ae030 --- /dev/null +++ b/crates/wayland/src/gtk_shell.rs @@ -0,0 +1,116 @@ +//! `gtk_shell1`/`gtk_surface1`: the Wayland-native half of global-menu +//! support - see `srdwm_core::GlobalMenu`'s doc comment and `xwayland.rs`'s +//! `EwmhState::read_global_menu` for the XWayland half and the full +//! rationale (carry the D-Bus *address*, never the menu content itself). +//! +//! GTK4 only calls `gtk_surface1.set_dbus_properties` - the one request +//! this module actually needs - once it knows the compositor supports the +//! protocol at all, which it establishes by binding `gtk_shell1` and +//! getting a `capabilities` event back. Everything else in the protocol +//! (startup-notification IDs, tiled-state events, modal hints, the +//! titlebar-gesture request GNOME Shell uses for its own double/right/ +//! middle-click-titlebar handling) is real but out of scope for what this +//! pass is actually for - acknowledged with a no-op rather than silently +//! ignored, so a future pass extending this has a clear list of what was +//! deliberately left alone. + +use smithay::reexports::wayland_server::backend::GlobalId; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New}; + +use crate::gtk_shell_protocol::server::gtk_shell1::{self, GtkShell1}; +use crate::gtk_shell_protocol::server::gtk_surface1::{self, GtkSurface1}; +use crate::state::CompState; + +const PROTOCOL_VERSION: u32 = 7; + +/// `global_app_menu | global_menu_bar` - both bits set unconditionally. +/// srdwm has no reason to advertise one without the other: both map onto +/// the same `GlobalMenu`, just the app-menu-only fallback for a client +/// that never got around to exporting a real menu bar. +const CAPABILITIES: u32 = 0b011; + +pub struct GtkShellState { + _global: GlobalId, +} + +impl GtkShellState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<GtkShell1, ()> + 'static, + { + Self { _global: dh.create_global::<D, GtkShell1, _>(PROTOCOL_VERSION, ()) } + } +} + +/// The `wl_surface` a `gtk_surface1` was created for - the only thing +/// `set_dbus_properties` needs to know which `WindowId` to attach its +/// payload to. +pub struct GtkSurfaceData { + surface: WlSurface, +} + +impl GlobalDispatch<GtkShell1, (), CompState> for CompState { + fn bind(_state: &mut CompState, _dh: &DisplayHandle, _client: &Client, resource: New<GtkShell1>, _global_data: &(), data_init: &mut DataInit<'_, CompState>) { + let shell = data_init.init(resource, ()); + // Sent once, right after bind - see this module's doc comment for + // why this has to happen unprompted rather than waiting to be + // asked: GTK only bothers calling `set_dbus_properties` later if it + // saw this first. + shell.capabilities(CAPABILITIES); + } +} + +impl Dispatch<GtkShell1, ()> for CompState { + fn request(state: &mut CompState, _client: &Client, _resource: &GtkShell1, request: gtk_shell1::Request, _data: &(), _dh: &DisplayHandle, data_init: &mut DataInit<'_, CompState>) { + if let gtk_shell1::Request::GetGtkSurface { gtk_surface, surface } = request { + data_init.init(gtk_surface, GtkSurfaceData { surface }); + } + // `set_startup_id`/`system_bell`/`notify_launch`: no startup- + // notification or accessibility-bell feature exists to wire these + // into yet - see this module's doc comment. + let _ = state; + } +} + +impl Dispatch<GtkSurface1, GtkSurfaceData> for CompState { + fn request(state: &mut CompState, _client: &Client, _resource: &GtkSurface1, request: gtk_surface1::Request, data: &GtkSurfaceData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, CompState>) { + let gtk_surface1::Request::SetDbusProperties { menubar_path, app_menu_path, window_object_path, application_object_path, unique_bus_name, .. } = request else { + // `set_modal`/`unset_modal`/`present`/`request_focus`/`release`/ + // `titlebar_gesture`/`set_a11y_properties`: real requests, none + // of which this pass has a feature behind yet. + return; + }; + let Some(&id) = state.surface_to_id.get(&data.surface) else { return }; + // A `None`/empty bus name means the client is *clearing* its menu + // (or never had one) - matches `xwayland.rs`'s `read_global_menu` + // returning `None` for the same case, so a panel sees the same + // shape regardless of which backend a window came from. + // Always `MenuSource::Gtk`: this protocol has no Unity-style + // equivalent to carry, unlike XWayland's `_UNITY_OBJECT_PATH` -- + // see `xwayland.rs`'s `read_global_menu` for the case that needs + // the other variant. + let menu = unique_bus_name.filter(|s| !s.is_empty()).map(|bus_name| srdwm_core::GlobalMenu { + bus_name, + menu_path: menubar_path.filter(|s| !s.is_empty()).or_else(|| app_menu_path.filter(|s| !s.is_empty())), + app_path: application_object_path.filter(|s| !s.is_empty()), + window_path: window_object_path.filter(|s| !s.is_empty()), + source: srdwm_core::MenuSource::Gtk, + }); + if let Some(w) = state.wm.borrow_mut().window_mut(id) { + w.global_menu = menu; + } + } + + fn destroyed(state: &mut CompState, _client: smithay::reexports::wayland_server::backend::ClientId, _resource: &GtkSurface1, data: &GtkSurfaceData) { + // The surface itself may already be gone (this fires on the normal + // window-close teardown path too, not just an explicit `release`) + // - `surface_to_id` simply won't resolve in that case, same as + // any other post-close lookup elsewhere in this codebase. + if let Some(&id) = state.surface_to_id.get(&data.surface) { + if let Some(w) = state.wm.borrow_mut().window_mut(id) { + w.global_menu = None; + } + } + } +} diff --git a/crates/wayland/src/gtk_shell_protocol.rs b/crates/wayland/src/gtk_shell_protocol.rs new file mode 100644 index 0000000..5582680 --- /dev/null +++ b/crates/wayland/src/gtk_shell_protocol.rs @@ -0,0 +1,29 @@ +//! Compile-time-generated wire bindings for `gtk_shell1`/`gtk_surface1` +//! (GTK's own private protocol - `protocols/gtk-shell.xml`, vendored from +//! `gdk/wayland/protocol/gtk-shell.xml` in GTK's own source tree). Unlike +//! the wlr/staging protocols this codebase already uses, there is no +//! published `wayland-protocols-*` crate for this one - it isn't part of +//! any standardised protocol umbrella, just GTK's own extension - so it's +//! generated here the same way `wayland-protocols-wlr` generates its own +//! bindings internally (see that crate's `protocol_macro.rs`), just +//! server-side only, since this compositor never needs the client half. +//! +//! `gtk_shell.rs` is the actual `GlobalDispatch`/`Dispatch` handler logic; +//! this module is only the raw generated types it's built against. + +#![allow(dead_code, non_camel_case_types, unused_unsafe, unused_variables)] +#![allow(non_upper_case_globals, non_snake_case, unused_imports)] +#![allow(missing_docs, clippy::all)] + +pub mod server { + use smithay::reexports::wayland_server; + use smithay::reexports::wayland_server::protocol::*; + + pub mod __interfaces { + use smithay::reexports::wayland_server::protocol::__interfaces::*; + wayland_scanner::generate_interfaces!("protocols/gtk-shell.xml"); + } + use self::__interfaces::*; + + wayland_scanner::generate_server_code!("protocols/gtk-shell.xml"); +} diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs index 17e94d4..264c986 100644 --- a/crates/wayland/src/input.rs +++ b/crates/wayland/src/input.rs @@ -17,6 +17,7 @@ use smithay::input::pointer::{ButtonEvent, MotionEvent}; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::utils::{Logical, Point, SERIAL_COUNTER}; use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, Layer}; +use std::time::{Duration, Instant}; use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, WindowId}; @@ -41,12 +42,28 @@ pub(crate) fn last_pointer_pos(state: &CompState) -> Point<f64, Logical> { /// `pos` is in the global space; layer geometry is relative to its own /// output, so the pointer is translated into output-local coordinates /// before hit-testing and the result translated back out. -pub(crate) fn layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> { +/// Only checked for `Overlay`/`Top` before a window hit-test, and again for +/// `Bottom`/`Background` after one comes up empty - see the two call +/// sites in `handle_pointer_button`/`handle_pointer_position` for why it's +/// split rather than one four-layer loop here. A `Bottom`/`Background` +/// surface (a desktop-icons layer, a wallpaper daemon that wants clicks) is +/// meant to sit *behind* normal windows, so a window covering that point +/// should still get the click; `Overlay`/`Top` (an on-screen keyboard, a +/// bar, a dock) are meant to sit in front of everything, windows included. +/// +/// Was `Overlay`/`Top` only, full stop - a `Bottom`-layer surface was +/// silently unclickable no matter what, since nothing else in +/// `handle_pointer_button` ever checked layers at all. Not the cause of +/// the live "clicking the dock does nothing" report (confirmed: that dock +/// uses `Layer::Top`, which was already checked), but a real, separate gap +/// found while chasing it - worth closing regardless of whether anything +/// currently deployed sits at `Bottom`/`Background` yet. +pub(crate) fn layer_surface_under_layers(state: &CompState, pos: Point<f64, Logical>, layers: [Layer; 2]) -> Option<(WlSurface, Point<i32, Logical>)> { let entry = state.output_at(pos)?; let origin = entry.location; let local = pos - origin.to_f64(); let map = layer_map_for_output(&entry.output); - for layer_kind in [Layer::Overlay, Layer::Top] { + for layer_kind in layers { let Some(layer) = map.layer_under(layer_kind, local) else { continue }; let Some(geo) = map.layer_geometry(layer) else { continue }; if let Some((surface, surface_loc)) = layer.surface_under(local - geo.loc.to_f64(), WindowSurfaceType::ALL) { @@ -56,7 +73,49 @@ pub(crate) fn layer_surface_under(state: &CompState, pos: Point<f64, Logical>) - None } +pub(crate) fn layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> { + layer_surface_under_layers(state, pos, [Layer::Overlay, Layer::Top]) +} + +/// The `Bottom`/`Background` half of the same lookup - see +/// `layer_surface_under_layers`'s doc comment for the ordering rationale. +pub(crate) fn background_layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> { + layer_surface_under_layers(state, pos, [Layer::Bottom, Layer::Background]) +} + +/// `ext_idle_notify_v1`'s whole job is answering "has the user touched an +/// input device recently" - `IdleNotifierState` does the actual timer +/// bookkeeping (see its own doc comment), this just has to be called from +/// every real input path, deliberately including while the session is +/// locked: idle activity is about the seat, not about which surface (if +/// any) an event ends up delivered to, and a lock daemon watching this +/// protocol to decide when to re-dim/re-lock still needs to see real +/// movement even though nothing else happens with it at a locked screen. +/// +/// Throttled to once per 250ms: `notify_activity` removes and re-inserts a +/// calloop timer for every live notification, every call, with no +/// throttling of its own - fine at keypress/click frequency, but pointer +/// motion can fire far more often than that during a drag, and idle +/// timeouts are measured in minutes, not milliseconds, so nothing about +/// idle detection needs - or can even perceive - finer resolution than +/// this. The same class of hot-path-on-every-motion-event cost that made +/// this session's earlier diagnostic logging a real, measured regression +/// (see `docs/IMPLEMENTATION_STATUS.md`), just cheap enough here (in-memory +/// bookkeeping, not synchronous I/O) that throttling rather than removing +/// it outright is the right amount of caution. +fn notify_idle_activity(state: &mut CompState) { + const THROTTLE: Duration = Duration::from_millis(250); + let now = Instant::now(); + if state.last_idle_notify.is_some_and(|last| now.duration_since(last) < THROTTLE) { + return; + } + state.last_idle_notify = Some(now); + let seat = state.seat.clone(); + state.idle_notifier_state.notify_activity(&seat); +} + pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Logical>, time: u32) { + notify_idle_activity(state); // Locked: pointer motion goes to the lock surface only. No hit-testing // against windows/decorations, so no hover, no drag, no resize. if state.lock.locked { @@ -64,13 +123,16 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log if let Some(pointer) = state.seat.get_pointer() { let focus = surface.map(|s| (s, Point::from((0, 0)).to_f64())); pointer.motion(state, focus, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); + pointer.frame(state); } return; } let layer_hit = layer_surface_under(state, pos); + let over_layer_surface = layer_hit.is_some(); let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32); let under = state.space.element_under(pos).map(|(w, loc)| (w.clone(), loc)); + let over_content = under.is_some(); let Some(pointer) = state.seat.get_pointer() else { return }; if let Some((surface, loc)) = layer_hit { @@ -80,13 +142,53 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log // Over our own decoration - no client focus. pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); } else if let Some((window, loc)) = under { - if let Some(surface) = window.toplevel().map(|t| t.wl_surface().clone()) { - let surface_loc = pos - loc.to_f64(); - pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time }); + // `window.toplevel()` is only ever `Some` for a native xdg-shell + // surface - it's `None` for every XWayland window, and even for a + // plain xdg-shell one it's always the *root* surface regardless of + // which subsurface the pointer is actually over (video/GL overlays, + // some GTK/Electron popups). Either way that meant pointer focus + // landed on the wrong surface - or no surface at all, for X11 + // clients - and the click coordinates were relative to the window + // root rather than whatever was actually under the cursor. + // `Window::surface_under` is smithay's own hit-test for this: it + // walks the real surface tree (subsurfaces and popups included) and + // unifies the xdg-shell/X11 cases the way `dwindow_wl_surface` does + // elsewhere in this module. + let win_relative = pos - loc.to_f64(); + if let Some((surface, offset)) = window.surface_under(win_relative, WindowSurfaceType::ALL) { + let surface_loc = win_relative - offset.to_f64(); + let surface_origin = (loc + offset).to_f64(); + pointer.motion(state, Some((surface, surface_origin)), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time }); } + } else if let Some((surface, loc)) = background_layer_surface_under(state, pos) { + // Bare desktop, no window there either - last chance for a + // `Bottom`/`Background` layer surface (see + // `layer_surface_under_layers`'s doc comment) before giving up. + let surface_loc = pos - loc.to_f64(); + pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time }); } else { pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); } + // `PointerHandle::motion`/`button`/`axis` only queue the event with the + // active grab - nothing sends `wl_pointer.frame` on its own (confirmed + // reading smithay's `DefaultGrab`: its `motion`/`button` impls call + // straight through to the handle and never call `frame`). `frame` is + // what tells a client "the events since the last frame are one atomic + // update, process them now" - required by the protocol since + // `wl_pointer` version 5, and this compositor advertises v9. Without + // it, any client that correctly waits for `frame` before acting on + // motion/button state (most modern toolkits, confirmed live: neither + // Firefox nor wezterm registered a click or a drag-selection, in both + // cases with the cursor sitting squarely on the target) never actually + // processes what it was sent, even though every event up to this point + // was individually correct. This is likely the real root cause behind + // this whole session's "clicking/scrolling doesn't work" reports -- + // every fix so far (subsurface routing, decoration geometry, app_id) + // was real and necessary, but none of them could have mattered if the + // client was never told to look at what it received. + pointer.frame(state); + + update_cursor_shape(state, hit, over_layer_surface, over_content); let mut wm = state.wm.borrow_mut(); let dragging_or_resizing = wm.is_dragging() || wm.is_resizing(); @@ -104,6 +206,65 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log } } +/// Sets the pointer to a resize-direction shape while hovering (or +/// actively dragging) one of our own decoration's resize edges, and back +/// to the default arrow when leaving our decoration for anything else. +/// +/// Only ever touches `cursor_status` for our own decoration - never while +/// `layer_hit`/client content has focus, since a client surface drives its +/// own cursor via `wl_pointer.set_cursor` once it starts receiving +/// `pointer.motion()`/`enter` (already sent above, by the time this runs), +/// and stomping on that here would fight the client for control of its own +/// cursor rather than just leaving it alone. +/// +/// Without this, `cursor_status` was only ever set by client requests -- +/// nothing on the compositor's own side ever asked for a resize cursor at +/// all, so hovering or dragging one of our own decoration's edges never +/// looked any different from hovering plain content, regardless of what +/// shapes `cursor.rs` can actually render. +/// +/// `over_content` distinguishes "over a client surface that will drive its +/// own cursor" from "over the bare desktop, where nothing ever will" -- +/// without it, dragging off one of our decoration's resize edges straight +/// onto empty desktop left `cursor_status` stuck on that resize icon +/// forever: there is no client there to ever call `set_cursor` and reset +/// it, and this function's own early-return (for the "let the client drive +/// it" case) doesn't distinguish an *absent* client from a slow one. +fn update_cursor_shape(state: &mut CompState, hit: Option<(WindowId, TitlebarHit)>, over_layer_surface: bool, over_content: bool) { + use smithay::input::pointer::{CursorIcon, CursorImageStatus}; + + if over_layer_surface { + return; + } + let edge = match hit { + Some((_, TitlebarHit::Resize(edge))) => Some(edge), + _ => state.wm.borrow().resize_edge(), + }; + let icon = match edge { + Some(edge) => resize_cursor_icon(edge), + // Hovering our own decoration but not an edge (the drag area, a + // button) and not actively resizing: back to the plain arrow. + None if hit.is_some() => CursorIcon::Default, + // Over a client's own content: leave `cursor_status` alone, per the + // doc comment above - the client drives it. + None if over_content => return, + // Bare desktop: nothing else will ever reset this, so we have to. + None => CursorIcon::Default, + }; + state.cursor_status = CursorImageStatus::Named(icon); +} + +fn resize_cursor_icon(edge: srdwm_core::ResizeEdge) -> smithay::input::pointer::CursorIcon { + use smithay::input::pointer::CursorIcon; + use srdwm_core::ResizeEdge; + match edge { + ResizeEdge::Left | ResizeEdge::Right => CursorIcon::EwResize, + ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::NsResize, + ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize, + ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize, + } +} + /// The underlying `wl_surface` for a mapped window, regardless of whether /// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` -- /// `desktop::Window` exposes these as two separate accessors with no @@ -137,9 +298,33 @@ pub(crate) fn focus_window(state: &mut CompState, id: WindowId) { state.set_keyboard_focus(surface); } +/// Re-syncs real Wayland/X11 keyboard focus to whatever `WindowManager` +/// already considers focused, without changing what that is. +/// +/// For callers where core's own focus already moved on its own -- +/// specifically `WindowManager::remove_window`'s fallback to +/// `self.order.last()` when the just-closed window was the focused one -- +/// and only the Wayland/X11 side needs to catch up to it. Without this, the +/// window core now considers focused (and renders as such) never actually +/// receives a keystroke until it's clicked, since nothing told +/// `set_keyboard_focus` focus had moved. +/// +/// `focus_window` above is for the opposite direction: driving core's +/// focus deliberately (a click, a keybinding) and syncing outward from +/// that. This is "core already decided, catch the rest of the compositor +/// up" - `wm.focus_window` must not be called again here, since the id +/// core picked (or `None`, if nothing is left) is exactly what should win. +pub(crate) fn sync_keyboard_focus(state: &mut CompState) { + let focused = state.wm.borrow().focused_id(); + let surface = focused.and_then(|id| state.id_to_window.get(&id)).and_then(dwindow_wl_surface); + state.set_keyboard_focus(surface); +} + pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logical>, button: u32, pressed: bool, time: u32) { + notify_idle_activity(state); const BTN_LEFT: u32 = 0x110; const BTN_RIGHT: u32 = 0x111; + const BTN_MIDDLE: u32 = 0x112; let serial = SERIAL_COUNTER.next_serial(); // Locked: forward the click to the lock surface (it may have a button or @@ -148,10 +333,31 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic if let Some(pointer) = state.seat.get_pointer() { let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released }; pointer.button(state, &ButtonEvent { serial, time, button, state: button_state }); + pointer.frame(state); } return; } + // The context menu, if open, captures every press: a click inside + // resolves whichever row it landed on, a click anywhere else just + // dismisses it. Neither case falls through to the normal handling + // below - opening the menu and then clicking a window underneath it + // should not *also* focus/raise/drag that window on the same click, + // the same "one click, one action" rule every native window menu + // follows. + if pressed { + if let Some(menu) = state.context_menu.take() { + if let Some(row) = menu.row_at(pos.x as i32, pos.y as i32) { + let (_, action) = menu.items[row]; + state.close_context_menu(); + state.run_context_menu_action(menu.window, action); + } else { + state.close_context_menu(); + } + return; + } + } + // Modifier+drag: with the modifier held, dragging *anywhere* in a window // moves it (left button) or resizes it from the nearest corner (right // button) - the `bindm SUPER, mouse:272/273` gesture. Without this a @@ -213,6 +419,7 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic if state.is_double_click(id, time) { state.wm.borrow_mut().toggle_maximize(id); state.sync_geometry(id); + crate::foreign_toplevel::send_state(state, id); } else { state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32) } @@ -225,8 +432,12 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic TitlebarHit::Maximize => { state.wm.borrow_mut().toggle_maximize(id); state.sync_geometry(id); + crate::foreign_toplevel::send_state(state, id); + } + TitlebarHit::Minimize => { + state.wm.borrow_mut().minimize_window(id); + crate::foreign_toplevel::send_state(state, id); } - TitlebarHit::Minimize => state.wm.borrow_mut().minimize_window(id), TitlebarHit::Resize(edge) => state.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32), } } else if layer_hit.is_none() { @@ -240,18 +451,73 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic } } } + } else if pressed && (button == BTN_RIGHT || button == BTN_MIDDLE) { + // Right-click a titlebar: open the window menu (minimize/maximize/ + // pin/close) - previously nothing at all, since the only + // right-button behaviour anywhere was the SUPER+right-drag resize + // gesture above, which needs the modifier held. Middle-click: + // lower the window instead, the convention several X11 WMs + // (twm, fvwm, IceWM) have always had. Both only fire on the + // titlebar's plain drag area - a resize edge or one of the three + // buttons keeps its own single meaning regardless of which button + // was pressed, so a right-click on the close button, say, doesn't + // do something else entirely. + let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32); + if let Some((id, TitlebarHit::Drag)) = hit { + if button == BTN_RIGHT { + state.open_context_menu(id, (pos.x as i32, pos.y as i32)); + } else { + state.wm.borrow_mut().lower_window(id); + } + } } else if !pressed { let mut wm = state.wm.borrow_mut(); - if wm.is_dragging() { + let was_dragging = wm.is_dragging(); + let was_resizing = wm.is_resizing(); + // `start_drag`/`start_resize` both focus the window they grab, and + // nothing else can change focus while a grab is active (the pointer + // is captured by the drag, not routed elsewhere) - so `focused_id` + // is reliably the window `end_drag`/`end_resize` are about to + // finish, without `WindowManager` needing to hand the id back + // itself. + let id = wm.focused_id(); + if was_dragging { wm.end_drag(); - } else if wm.is_resizing() { + } else if was_resizing { wm.end_resize(); } + drop(wm); + // `end_drag` can snap the geometry one more time (edge/top-of- + // screen snapping, `SmartPlacement::snap_zone`) *after* the last + // `update_drag` already moved the window - without this, that + // final snap only ever reached `Window.geometry`. The border and + // titlebar redraw fresh from live geometry every frame, so they'd + // jump to the snapped rect immediately, while the client's actual + // mapped surface (driven only by `sync_geometry`'s + // `space.map_element`/`xdg_toplevel.configure`) stayed wherever the + // drag physically stopped - decoration visibly detached from its + // own window's content. Click routing desynced the same way: + // `hit_test`/`window_at` read the now-snapped `Window.geometry` + // while `space.element_under` still read the stale pre-snap + // position, so clicks in the visually-snapped zone resolved + // against the wrong rect. The X11 backend already gets this right + // (`crates/x11/src/lib.rs`'s `ButtonRelease` handler); this was the + // one call site in the module doc'd as "shared by both backends" + // that never got the same fix. + if was_dragging || was_resizing { + if let Some(id) = id { + state.sync_geometry(id); + } + } } if let Some(pointer) = state.seat.get_pointer() { let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released }; pointer.button(state, &ButtonEvent { serial, time, button, state: button_state }); + // See the matching comment in `handle_pointer_position`: `button` + // alone never tells the client the event is ready to act on, only + // `frame` does. + pointer.frame(state); } } @@ -260,6 +526,7 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic /// trait, so the precise-keybinding-matching logic (see the module docs) /// only needs to exist once. pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend, E: KeyboardKeyEvent<B>>(state: &mut CompState, event: &E) { + notify_idle_activity(state); let keycode = event.key_code(); let key_state = event.state(); let time = event.time_msec(); @@ -340,6 +607,7 @@ where { use smithay::backend::input::Axis; + notify_idle_activity(state); if state.lock.locked { return false; } @@ -358,5 +626,20 @@ where // Scrolling down (positive) advances, matching `workspace, e+1`. let next = if v > 0.0 { (current + 1) % ids.len() } else { (current + ids.len() - 1) % ids.len() }; wm.switch_workspace(ids[next]); + drop(wm); + // Without this, the switch above is invisible: nothing shows or hides + // a single window for the new workspace until `main.rs`'s `sync()` + // runs, which only happens when a polled event sets `dirty` - see + // `srdwm_core::Event::WorkspaceChanged`'s doc comment. Found live- + // testing the unrelated `ext_workspace_v1` protocol's own `activate` + // request, which has the identical problem; this gesture had the exact + // same bug already, just never one anyone traced back this far. + state.pending.borrow_mut().push(srdwm_core::Event::WorkspaceChanged); + // Same reasoning as `foreign_toplevel::send_state`'s call sites: without + // this, a dock's workspace pill only ever tracked switches driven + // through `ext_workspace_handle_v1.activate` itself, going stale the + // moment this gesture (or any other non-protocol trigger) changed the + // active workspace instead. + crate::workspace::broadcast_active_workspace(state); true } diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs index 1d04d01..e8a50ef 100644 --- a/crates/wayland/src/lib.rs +++ b/crates/wayland/src/lib.rs @@ -36,16 +36,25 @@ //! - xdg-decoration is forced to server-side mode (`Mode::ServerSide`) so //! well-behaved clients don't also draw their own client-side titlebar. +mod context_menu; mod cursor; mod decoration; mod elements; +mod foreign_toplevel; +mod gamma_control; +mod gtk_shell; +mod gtk_shell_protocol; mod input; mod lock; +mod output_management; +mod output_power; mod protocols; mod screencopy; mod state; mod udev; mod winit; +mod workspace; +mod xkb_config; mod xwayland; use std::cell::RefCell; diff --git a/crates/wayland/src/lock.rs b/crates/wayland/src/lock.rs index 51b6158..ccdea98 100644 --- a/crates/wayland/src/lock.rs +++ b/crates/wayland/src/lock.rs @@ -112,6 +112,19 @@ impl SessionLockHandler for CompState { self.lock.surfaces.clear(); self.lock.presented.clear(); self.lock.pending_confirm = None; + // udev backend only: the lock scene was rendered through the same + // per-head damage tracker as the normal desktop (see + // `render_udev_frame`), always with a forced full redraw, so its + // element-state history now reflects the lock surface rather than + // whatever was on screen before locking. Reset each head's buffer + // ages so the next normal-scene render is a full redraw too, + // instead of asking the tracker to diff the desktop against a + // now-irrelevant lock-scene history. + if let Some(udev) = self.udev.as_mut() { + for head in &mut udev.heads { + head.ages = [0, 0]; + } + } // Hand focus back to whatever srdwm considers the focused window. let surface = self .wm diff --git a/crates/wayland/src/output_management.rs b/crates/wayland/src/output_management.rs new file mode 100644 index 0000000..4697a40 --- /dev/null +++ b/crates/wayland/src/output_management.rs @@ -0,0 +1,559 @@ +//! `zwlr_output_management_v1`: lets a settings panel enumerate every +//! output srdwm drives - name, description, physical size, supported +//! modes, and current position/scale/transform/enabled state - and +//! request changes. smithay 0.7 has no built-in helper for this protocol; +//! hand-written against `wayland-protocols-wlr`'s raw server bindings, +//! same pattern as `foreign_toplevel.rs`/`workspace.rs`. Requested by the +//! field-survey comparison against sway/Hyprland/river (`docs/ +//! IMPLEMENTATION_STATUS.md`): every one of them treats this as core, not +//! optional, once external monitors, scaling, or a settings panel are in +//! the picture. +//! +//! **Deliberately conservative on `apply`/`test`**: a configuration can +//! change a head's position, scale and transform - all straightforward +//! via `Output::change_current_state`, which already exists and is +//! already exercised (hotplug, the nested backend's own resize handling). +//! Two things a real configuration payload can also ask for are +//! deliberately *not* attempted, and fail honestly (`failed`, not a +//! silent no-op) rather than accept a request this compositor can't +//! actually carry out: +//! +//! - **Disabling/enabling a head.** srdwm has no concept of a head that +//! exists but isn't mapped into the global compositor space - every +//! output it advertises is always enabled. A request to actually +//! change that state (not just re-`enable_head` an already-enabled one, +//! which every real settings panel does on every apply regardless of +//! what actually changed) fails. +//! - **Switching resolution or refresh rate.** Real DRM mode-setting means +//! finding the matching connector mode and reprogramming the CRTC -- +//! substantial, hardware-dependent work this pass didn't attempt, and +//! this development environment has no multi-mode hardware to verify it +//! against even if it had. A `set_mode`/`set_custom_mode` that matches +//! the head's *already-current* mode (the common case: a panel echoes +//! back existing state alongside a real position/scale change) is +//! accepted as a no-op; a genuinely different one fails. +//! +//! Both are real, scoped-out gaps, not oversights - worth closing in a +//! later pass once there's hardware to verify a mode-setting change +//! against. + + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use smithay::output::{Mode as OutputMode, Output, Scale}; +use smithay::reexports::wayland_server::backend::{ClientId, GlobalId}; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, WEnum}; +use smithay::utils::{Point, Transform}; + +use wayland_protocols_wlr::output_management::v1::server::zwlr_output_configuration_head_v1::{self, ZwlrOutputConfigurationHeadV1}; +use wayland_protocols_wlr::output_management::v1::server::zwlr_output_configuration_v1::{self, ZwlrOutputConfigurationV1}; +use wayland_protocols_wlr::output_management::v1::server::zwlr_output_head_v1::{self, ZwlrOutputHeadV1}; +use wayland_protocols_wlr::output_management::v1::server::zwlr_output_manager_v1::{self, ZwlrOutputManagerV1}; +use wayland_protocols_wlr::output_management::v1::server::zwlr_output_mode_v1::{self, ZwlrOutputModeV1}; + +use crate::state::CompState; + +const PROTOCOL_VERSION: u32 = 4; + +pub struct OutputManagementState { + _global: GlobalId, +} + +impl OutputManagementState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<ZwlrOutputManagerV1, ()> + 'static, + { + Self { _global: dh.create_global::<D, ZwlrOutputManagerV1, _>(PROTOCOL_VERSION, ()) } + } +} + +pub struct HeadData { + name: String, +} + +#[derive(Clone, Copy, PartialEq)] +pub struct ModeData { + mode: OutputMode, +} + +/// What a client has asked for one head within a single in-flight +/// `zwlr_output_configuration_v1` - built up by `enable_head`/ +/// `disable_head` and the requests on the `zwlr_output_configuration_head_v1` +/// object that creates, then read back all at once by `apply`/`test`. +enum HeadRequest { + Disabled, + Enabled { position: Option<(i32, i32)>, scale: Option<f64>, transform: Option<Transform>, mode: Option<ModeRequest> }, +} + +enum ModeRequest { + Existing(OutputMode), + Custom { width: i32, height: i32, refresh: i32 }, +} + +/// Shared between a `ZwlrOutputConfigurationV1` and every +/// `ZwlrOutputConfigurationHeadV1` it creates, keyed by output name (the +/// one stable identity a head has - see `HeadData`'s doc comment on why +/// `Output` itself isn't used as the key). +type ConfigMap = Arc<Mutex<HashMap<String, HeadRequest>>>; + +pub struct ConfigurationData { + serial: u32, + heads: ConfigMap, +} + +pub struct ConfigurationHeadData { + head_name: String, + heads: ConfigMap, +} + +impl GlobalDispatch<ZwlrOutputManagerV1, ()> for CompState { + fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New<ZwlrOutputManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) { + let manager = data_init.init(manager, ()); + let outputs: Vec<Output> = state.outputs().cloned().collect(); + for output in &outputs { + announce_head(state, &manager, output, client, dh); + } + manager.done(state.output_serial); + state.output_managers.push(manager); + } +} + +impl Dispatch<ZwlrOutputManagerV1, ()> for CompState { + fn request( + _state: &mut Self, + _client: &Client, + manager: &ZwlrOutputManagerV1, + request: zwlr_output_manager_v1::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + zwlr_output_manager_v1::Request::CreateConfiguration { id, serial } => { + data_init.init(id, ConfigurationData { serial, heads: Arc::new(Mutex::new(HashMap::new())) }); + } + zwlr_output_manager_v1::Request::Stop => { + manager.finished(); + } + _ => {} + } + } + + fn destroyed(state: &mut Self, _client: ClientId, manager: &ZwlrOutputManagerV1, _data: &()) { + state.output_managers.retain(|m| m != manager); + } +} + +impl Dispatch<ZwlrOutputHeadV1, HeadData> for CompState { + fn request(_state: &mut Self, _client: &Client, _head: &ZwlrOutputHeadV1, _request: zwlr_output_head_v1::Request, _data: &HeadData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + // Only request is `release` (a destructor); `destroyed` below + // handles the cleanup for both that and a client disconnecting + // outright. + } + + fn destroyed(state: &mut Self, _client: ClientId, head: &ZwlrOutputHeadV1, data: &HeadData) { + if let Some(handles) = state.output_heads.get_mut(&data.name) { + handles.retain(|h| h != head); + } + } +} + +impl Dispatch<ZwlrOutputModeV1, ModeData> for CompState { + fn request(_state: &mut Self, _client: &Client, _mode: &ZwlrOutputModeV1, _request: zwlr_output_mode_v1::Request, _data: &ModeData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) {} + + fn destroyed(state: &mut Self, _client: ClientId, mode: &ZwlrOutputModeV1, _data: &ModeData) { + for handles in state.output_modes.values_mut() { + handles.retain(|m| m != mode); + } + } +} + +impl Dispatch<ZwlrOutputConfigurationV1, ConfigurationData> for CompState { + fn request( + state: &mut Self, + client: &Client, + config: &ZwlrOutputConfigurationV1, + request: zwlr_output_configuration_v1::Request, + data: &ConfigurationData, + dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + zwlr_output_configuration_v1::Request::EnableHead { id, head } => { + let Some(name) = head.data::<HeadData>().map(|d| d.name.clone()) else { return }; + data.heads.lock().unwrap().insert(name.clone(), HeadRequest::Enabled { position: None, scale: None, transform: None, mode: None }); + data_init.init(id, ConfigurationHeadData { head_name: name, heads: data.heads.clone() }); + } + zwlr_output_configuration_v1::Request::DisableHead { head } => { + let Some(name) = head.data::<HeadData>().map(|d| d.name.clone()) else { return }; + data.heads.lock().unwrap().insert(name, HeadRequest::Disabled); + } + zwlr_output_configuration_v1::Request::Apply => apply_or_test(state, config, data, client, dh, true), + zwlr_output_configuration_v1::Request::Test => apply_or_test(state, config, data, client, dh, false), + zwlr_output_configuration_v1::Request::Destroy => {} + _ => {} + } + } + + fn destroyed(_state: &mut Self, _client: ClientId, _config: &ZwlrOutputConfigurationV1, _data: &ConfigurationData) {} +} + +impl Dispatch<ZwlrOutputConfigurationHeadV1, ConfigurationHeadData> for CompState { + fn request( + _state: &mut Self, + _client: &Client, + _config_head: &ZwlrOutputConfigurationHeadV1, + request: zwlr_output_configuration_head_v1::Request, + data: &ConfigurationHeadData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + let mut heads = data.heads.lock().unwrap(); + let Some(HeadRequest::Enabled { position, scale, transform, mode }) = heads.get_mut(&data.head_name) else { return }; + match request { + zwlr_output_configuration_head_v1::Request::SetPosition { x, y } => *position = Some((x, y)), + zwlr_output_configuration_head_v1::Request::SetScale { scale: s } => *scale = Some(s), + zwlr_output_configuration_head_v1::Request::SetTransform { transform: t } => { + if let WEnum::Value(t) = t { + *transform = Some(wl_transform_to_smithay(t)); + } + } + zwlr_output_configuration_head_v1::Request::SetMode { mode: mode_obj } => { + if let Some(m) = mode_obj.data::<ModeData>() { + *mode = Some(ModeRequest::Existing(m.mode)); + } + } + zwlr_output_configuration_head_v1::Request::SetCustomMode { width, height, refresh } => { + *mode = Some(ModeRequest::Custom { width, height, refresh }); + } + _ => {} + } + } + + fn destroyed(_state: &mut Self, _client: ClientId, _config_head: &ZwlrOutputConfigurationHeadV1, _data: &ConfigurationHeadData) {} +} + +/// `WEnum<wl_output::Transform>` has no built-in conversion to smithay's +/// own `Transform` (the reverse direction, smithay `Transform` -> wire, is +/// provided by smithay itself and already used elsewhere in this crate -- +/// this is the one direction it doesn't cover, needed here because this is +/// the first place in this codebase that has to *decode* a client-sent +/// transform rather than only ever sending one). +fn wl_transform_to_smithay(t: smithay::reexports::wayland_server::protocol::wl_output::Transform) -> Transform { + use smithay::reexports::wayland_server::protocol::wl_output::Transform as Wl; + match t { + Wl::Normal => Transform::Normal, + Wl::_90 => Transform::_90, + Wl::_180 => Transform::_180, + Wl::_270 => Transform::_270, + Wl::Flipped => Transform::Flipped, + Wl::Flipped90 => Transform::Flipped90, + Wl::Flipped180 => Transform::Flipped180, + Wl::Flipped270 => Transform::Flipped270, + _ => Transform::Normal, + } +} + +/// Whether a client's requested mode is a no-op relative to what's already +/// active - the one `set_mode`/`set_custom_mode` case this module accepts +/// (see its own doc comment on why a genuinely *different* mode doesn't). +/// A custom mode's `refresh` of `0` means "unspecified", so it matches any +/// refresh rate the current mode happens to have. +fn mode_request_matches_current(req: &ModeRequest, current: Option<OutputMode>) -> bool { + match req { + ModeRequest::Existing(m) => current == Some(*m), + ModeRequest::Custom { width, height, refresh } => current.is_some_and(|m| m.size.w == *width && m.size.h == *height && (*refresh == 0 || m.refresh == *refresh)), + } +} + +/// Validates every head in `data.heads` against live state, then - only +/// for `apply`, never `test` - actually changes it. All-or-nothing: if +/// any head's request can't be honoured, nothing is applied and `failed` +/// is sent, matching the protocol's own framing of a configuration as one +/// atomic unit. See the module doc comment for exactly what "can't be +/// honoured" covers. +fn apply_or_test(state: &mut CompState, config: &ZwlrOutputConfigurationV1, data: &ConfigurationData, _client: &Client, _dh: &DisplayHandle, apply: bool) { + if data.serial != state.output_serial { + config.cancelled(); + return; + } + let heads = data.heads.lock().unwrap(); + for (name, req) in heads.iter() { + let Some(output) = state.outputs().find(|o| &o.name() == name).cloned() else { + config.failed(); + return; + }; + match req { + HeadRequest::Disabled => { + config.failed(); + return; + } + HeadRequest::Enabled { mode, .. } => { + let Some(mode_req) = mode else { continue }; + if !mode_request_matches_current(mode_req, output.current_mode()) { + config.failed(); + return; + } + } + } + } + + if apply { + for (name, req) in heads.iter() { + let HeadRequest::Enabled { position, scale, transform, .. } = req else { continue }; + let Some(output) = state.outputs().find(|o| &o.name() == name).cloned() else { continue }; + if let Some(t) = transform { + output.change_current_state(None, Some(*t), None, None); + } + if let Some(s) = scale { + output.change_current_state(None, None, Some(Scale::Fractional(*s)), None); + } + if let Some((x, y)) = position { + apply_output_position(state, &output, Point::from((*x, *y))); + } + } + } + drop(heads); + + config.succeeded(); + if apply { + broadcast_dirty_outputs(state); + } +} + +/// Moves an output and keeps every place its position is separately +/// cached in step - `Output` itself, `CompState::outputs` (used for hit- +/// testing/`output_at`) and, on the udev backend, `UdevHead::location` +/// (used to translate render geometry into head-local space) each keep +/// their own copy for reasons documented on their own fields, and would +/// otherwise silently drift from what `Output` now reports. +fn apply_output_position(state: &mut CompState, output: &Output, new_location: Point<i32, smithay::utils::Logical>) { + output.change_current_state(None, None, None, Some(new_location)); + if let Some(entry) = state.outputs.iter_mut().find(|e| &e.output == output) { + entry.location = new_location; + } + if let Some(udev) = state.udev.as_mut() { + if let Some(head) = udev.heads.iter_mut().find(|h| &h.output == output) { + head.location = new_location; + } + } +} + +fn announce_head(state: &mut CompState, manager: &ZwlrOutputManagerV1, output: &Output, client: &Client, dh: &DisplayHandle) { + let Ok(head) = client.create_resource::<ZwlrOutputHeadV1, HeadData, CompState>(dh, manager.version(), HeadData { name: output.name() }) else { + return; + }; + manager.head(&head); + + head.name(output.name()); + head.description(output.description()); + let phys = output.physical_properties(); + if phys.size.w > 0 && phys.size.h > 0 { + head.physical_size(phys.size.w, phys.size.h); + } + if !phys.make.is_empty() { + head.make(phys.make.clone()); + } + if !phys.model.is_empty() { + head.model(phys.model.clone()); + } + + let modes = output.modes(); + let preferred = output.preferred_mode(); + let current = output.current_mode(); + let mut current_mode_handle = None; + for m in &modes { + let Ok(mode_handle) = client.create_resource::<ZwlrOutputModeV1, ModeData, CompState>(dh, manager.version(), ModeData { mode: *m }) else { + continue; + }; + head.mode(&mode_handle); + mode_handle.size(m.size.w, m.size.h); + if m.refresh > 0 { + mode_handle.refresh(m.refresh); + } + if Some(*m) == preferred { + mode_handle.preferred(); + } + if Some(*m) == current { + current_mode_handle = Some(mode_handle.clone()); + } + state.output_modes.entry(output.name()).or_default().push(mode_handle); + } + + // srdwm has no disabled-output concept - see the module doc comment -- + // so every head it ever advertises is always enabled. + head.enabled(1); + if let Some(mode_handle) = ¤t_mode_handle { + head.current_mode(mode_handle); + } + let loc = output.current_location(); + head.position(loc.x, loc.y); + head.transform(output.current_transform().into()); + head.scale(output.current_scale().fractional_scale()); + + state.output_heads.entry(output.name()).or_default().push(head); +} + +/// One output's state as far as this protocol cares, for the cheap +/// per-frame equality check `broadcast_dirty_outputs` gates on - see +/// `foreign_toplevel::broadcast_dirty_state`/`workspace:: +/// broadcast_dirty_active`'s doc comments for the same "diff once a tick, +/// only do real work on an actual change" shape used throughout this +/// crate's dock/panel-facing protocols. Scale is compared as milli-units +/// (`* 1000.0` rounded) rather than the raw `f64`, so this can derive a +/// plain `PartialEq` instead of needing a fuzzy float comparison. +#[derive(Clone, PartialEq)] +pub(crate) struct OutputSnapshot { + name: String, + x: i32, + y: i32, + scale_millis: i64, + transform: Transform, + mode: Option<(i32, i32, i32)>, +} + +fn snapshot(output: &Output) -> OutputSnapshot { + let loc = output.current_location(); + let mode = output.current_mode().map(|m| (m.size.w, m.size.h, m.refresh)); + OutputSnapshot { + name: output.name(), + x: loc.x, + y: loc.y, + scale_millis: (output.current_scale().fractional_scale() * 1000.0).round() as i64, + transform: output.current_transform(), + mode, + } +} + +/// Called once a frame from `CompState::tick_dirty_broadcasts`. Diffs the +/// live output set/state against what was last broadcast and, only on a +/// real change, creates/destroys head objects for any output that +/// appeared/disappeared and re-sends current state (enabled/current_mode/ +/// position/transform/scale) on every other - covers both real hotplug +/// and an `apply()` that just went through, without either needing its own +/// separate notification path. +pub(crate) fn broadcast_dirty_outputs(state: &mut CompState) { + let current: Vec<OutputSnapshot> = state.outputs().map(snapshot).collect(); + if current == state.last_broadcast_outputs { + return; + } + let current_names: HashSet<String> = current.iter().map(|s| s.name.clone()).collect(); + let previous_names: HashSet<String> = state.last_broadcast_outputs.iter().map(|s| s.name.clone()).collect(); + state.last_broadcast_outputs = current; + + for name in previous_names.difference(¤t_names) { + if let Some(handles) = state.output_heads.remove(name) { + for h in handles { + h.finished(); + } + } + state.output_modes.remove(name); + } + + let added: Vec<String> = current_names.difference(&previous_names).cloned().collect(); + if !added.is_empty() { + let managers = state.output_managers.clone(); + for manager in &managers { + let Some(client) = manager.client() else { continue }; + let dh = state.dh.clone(); + for name in &added { + let found: Option<Output> = state.outputs().find(|o| &o.name() == name).cloned(); + if let Some(output) = found { + announce_head(state, manager, &output, &client, &dh); + } + } + } + } + + let outputs: Vec<Output> = state.outputs().cloned().collect(); + for output in &outputs { + let Some(handles) = state.output_heads.get(&output.name()).cloned() else { continue }; + let mode_handles = state.output_modes.get(&output.name()).cloned().unwrap_or_default(); + let current_mode = output.current_mode(); + let matching_mode_handle = current_mode.and_then(|m| mode_handles.iter().find(|mh| mh.data::<ModeData>().is_some_and(|d| d.mode == m)).cloned()); + let loc = output.current_location(); + for head in &handles { + head.enabled(1); + if let Some(mh) = &matching_mode_handle { + head.current_mode(mh); + } + head.position(loc.x, loc.y); + head.transform(output.current_transform().into()); + head.scale(output.current_scale().fractional_scale()); + } + } + + state.output_serial += 1; + let serial = state.output_serial; + for manager in state.output_managers.clone() { + manager.done(serial); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mode(w: i32, h: i32, refresh: i32) -> OutputMode { + OutputMode { size: (w, h).into(), refresh } + } + + #[test] + fn existing_mode_request_matches_only_the_identical_mode() { + let m = mode(1920, 1080, 60000); + assert!(mode_request_matches_current(&ModeRequest::Existing(m), Some(m))); + assert!(!mode_request_matches_current(&ModeRequest::Existing(m), Some(mode(1280, 720, 60000)))); + assert!(!mode_request_matches_current(&ModeRequest::Existing(m), None)); + } + + #[test] + fn custom_mode_request_matches_same_size_and_refresh() { + let req = ModeRequest::Custom { width: 1920, height: 1080, refresh: 60000 }; + assert!(mode_request_matches_current(&req, Some(mode(1920, 1080, 60000)))); + assert!(!mode_request_matches_current(&req, Some(mode(1920, 1080, 59940)))); + assert!(!mode_request_matches_current(&req, Some(mode(1280, 720, 60000)))); + } + + #[test] + fn custom_mode_request_with_zero_refresh_matches_any_refresh_at_that_size() { + let req = ModeRequest::Custom { width: 1920, height: 1080, refresh: 0 }; + assert!(mode_request_matches_current(&req, Some(mode(1920, 1080, 60000)))); + assert!(mode_request_matches_current(&req, Some(mode(1920, 1080, 59940)))); + assert!(!mode_request_matches_current(&req, Some(mode(1280, 720, 60000)))); + } + + #[test] + fn snapshot_equality_ignores_float_noise_below_a_thousandth() { + let a = OutputSnapshot { name: "HDMI-A-1".into(), x: 0, y: 0, scale_millis: 1500, transform: Transform::Normal, mode: Some((1920, 1080, 60000)) }; + let b = OutputSnapshot { name: "HDMI-A-1".into(), x: 0, y: 0, scale_millis: 1500, transform: Transform::Normal, mode: Some((1920, 1080, 60000)) }; + assert!(a == b); + } + + #[test] + fn snapshot_inequality_catches_a_position_change() { + let a = OutputSnapshot { name: "HDMI-A-1".into(), x: 0, y: 0, scale_millis: 1000, transform: Transform::Normal, mode: None }; + let b = OutputSnapshot { name: "HDMI-A-1".into(), x: 1920, y: 0, scale_millis: 1000, transform: Transform::Normal, mode: None }; + assert!(a != b); + } + + #[test] + fn wl_transform_round_trips_through_smithay_and_back() { + use smithay::reexports::wayland_server::protocol::wl_output::Transform as Wl; + for (wl, expected) in [ + (Wl::Normal, Transform::Normal), + (Wl::_90, Transform::_90), + (Wl::_180, Transform::_180), + (Wl::_270, Transform::_270), + (Wl::Flipped, Transform::Flipped), + (Wl::Flipped90, Transform::Flipped90), + (Wl::Flipped180, Transform::Flipped180), + (Wl::Flipped270, Transform::Flipped270), + ] { + assert_eq!(wl_transform_to_smithay(wl), expected); + let back: Wl = expected.into(); + assert_eq!(back, wl); + } + } +} diff --git a/crates/wayland/src/output_power.rs b/crates/wayland/src/output_power.rs new file mode 100644 index 0000000..e0734f8 --- /dev/null +++ b/crates/wayland/src/output_power.rs @@ -0,0 +1,99 @@ +//! `zwlr_output_power_management_v1`: lets a client (a settings panel, an +//! idle daemon) put a specific output into DPMS off/on. +//! +//! Deliberately separate from `ext_idle_notify_v1`/`zwp_idle_inhibit_manager_v1` +//! (`protocols.rs`): that protocol only *tells* a client the seat has gone +//! idle, it has no way to blank a screen itself. A real "turn the display +//! off after N minutes idle" feature needs both - an idle daemon watches +//! `ext_idle_notify_v1` and calls this protocol's `set_mode` in response. +//! Neither protocol implies the other. +//! +//! DRM/udev backend only: there is no real display to power down when +//! nested under a host compositor (`winit.rs`) - the host owns the actual +//! screen, and turning off the *nested window* makes no sense. The global +//! is simply never created there (`CompState::_output_power_state` is +//! `None`), so a client sees the protocol as genuinely unsupported rather +//! than advertised-but-always-failing. +//! +//! No smithay helper exists for this protocol, so the `GlobalDispatch`/ +//! `Dispatch` plumbing below is hand-written against the raw +//! `wayland-protocols-wlr` server bindings, the same pattern as +//! `screencopy.rs`/`foreign_toplevel.rs`. + +use smithay::reexports::wayland_server::backend::GlobalId; +use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New}; +use wayland_protocols_wlr::output_power_management::v1::server::zwlr_output_power_manager_v1::{self, ZwlrOutputPowerManagerV1}; +use wayland_protocols_wlr::output_power_management::v1::server::zwlr_output_power_v1::{self, Mode, ZwlrOutputPowerV1}; + +use crate::state::CompState; + +/// The manager global. Held by `CompState` purely to keep the global alive +/// for the compositor's lifetime. +pub struct OutputPowerManagerState { + _global: GlobalId, +} + +impl OutputPowerManagerState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<ZwlrOutputPowerManagerV1, ()> + 'static, + { + Self { _global: dh.create_global::<D, ZwlrOutputPowerManagerV1, _>(1, ()) } + } +} + +/// Which `wl_output` a `zwlr_output_power_v1` object controls, resolved +/// once at creation. `set_mode` re-resolves it against the live output list +/// rather than trusting this stays valid - a client is free to hold the +/// object across a monitor unplug. +pub struct OutputPowerData { + output: WlOutput, +} + +impl GlobalDispatch<ZwlrOutputPowerManagerV1, ()> for CompState { + fn bind(_state: &mut Self, _dh: &DisplayHandle, _client: &Client, manager: New<ZwlrOutputPowerManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) { + data_init.init(manager, ()); + } +} + +impl Dispatch<ZwlrOutputPowerManagerV1, ()> for CompState { + fn request( + _state: &mut Self, + _client: &Client, + _manager: &ZwlrOutputPowerManagerV1, + request: zwlr_output_power_manager_v1::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + use zwlr_output_power_manager_v1::Request; + if let Request::GetOutputPower { id, output } = request { + let power = data_init.init(id, OutputPowerData { output }); + // Per protocol: "sent immediately when the object is created + // so the client is informed about the current power management + // mode" - this compositor has no notion of a monitor starting + // powered off, so always On at creation. + power.mode(Mode::On); + } + } +} + +impl Dispatch<ZwlrOutputPowerV1, OutputPowerData> for CompState { + fn request( + state: &mut Self, + _client: &Client, + resource: &ZwlrOutputPowerV1, + request: zwlr_output_power_v1::Request, + data: &OutputPowerData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + let zwlr_output_power_v1::Request::SetMode { mode } = request else { return }; + let Ok(mode) = mode.into_result() else { return }; + match state.set_output_power(&data.output, mode == Mode::On) { + Some(()) => resource.mode(mode), + None => resource.failed(), + } + } +} diff --git a/crates/wayland/src/protocols.rs b/crates/wayland/src/protocols.rs index f51b35d..a0aed3f 100644 --- a/crates/wayland/src/protocols.rs +++ b/crates/wayland/src/protocols.rs @@ -7,34 +7,44 @@ //! session-lock handler is the one exception, living in [`crate::lock`] //! alongside the rest of that feature. -use smithay::desktop::{layer_map_for_output, LayerSurface as DesktopLayerSurface}; -use smithay::input::pointer::CursorImageStatus; +use smithay::desktop::{find_popup_root_surface, layer_map_for_output, LayerSurface as DesktopLayerSurface, PopupKeyboardGrab, PopupKind, PopupPointerGrab}; +use smithay::input::pointer::{CursorImageStatus, Focus}; use smithay::input::{Seat, SeatHandler, SeatState}; use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode; +use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer; use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; use smithay::reexports::wayland_server::protocol::wl_seat; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::Client; +use smithay::reexports::wayland_server::Resource; +use smithay::backend::allocator::dmabuf::Dmabuf; +use smithay::backend::renderer::ImportDma; use smithay::utils::Serial; use smithay::wayland::buffer::BufferHandler; use smithay::wayland::compositor::{CompositorClientState, CompositorHandler, CompositorState}; +use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier}; +use smithay::wayland::xdg_activation::{XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData}; +use smithay::wayland::input_method::PopupSurface as ImePopupSurface; use smithay::wayland::selection::data_device::{ ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, }; use smithay::wayland::selection::primary_selection::{PrimarySelectionHandler, PrimarySelectionState}; use smithay::wayland::selection::wlr_data_control::{DataControlHandler, DataControlState}; +use smithay::wayland::compositor::{add_pre_commit_hook, with_states}; use smithay::wayland::selection::SelectionHandler; use smithay::wayland::shell::wlr_layer::{ - Layer, LayerSurface as WlrLayerSurface, WlrLayerShellHandler, WlrLayerShellState, + Layer, LayerSurface as WlrLayerSurface, LayerSurfaceCachedState, WlrLayerShellHandler, WlrLayerShellState, }; use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler; use smithay::wayland::shell::xdg::{PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState}; use smithay::wayland::shm::{ShmHandler, ShmState}; +use smithay::wayland::tablet_manager::TabletSeatHandler; use smithay::{ - delegate_compositor, delegate_data_control, delegate_data_device, delegate_layer_shell, delegate_output, - delegate_primary_selection, delegate_seat, delegate_session_lock, delegate_shm, delegate_xdg_decoration, - delegate_xdg_shell, + delegate_compositor, delegate_cursor_shape, delegate_data_control, delegate_data_device, delegate_dmabuf, + delegate_layer_shell, delegate_output, delegate_primary_selection, delegate_seat, delegate_session_lock, + delegate_shm, delegate_xdg_activation, delegate_xdg_decoration, delegate_xdg_shell, + delegate_input_method_manager, delegate_text_input_manager, }; use crate::state::{ClientState, CompState}; @@ -55,6 +65,52 @@ impl CompositorHandler for CompState { &client.get_data::<smithay::xwayland::XWaylandClientData>().expect("client is neither ours nor XWayland's").compositor_state } + /// Workaround for a real smithay bug (see docs/PANEL_SUPPORT_TODO.md and + /// `layer_destroyed` below): destroying a `zwlr_layer_surface_v1` role + /// resets the surface's `LayerSurfaceCachedState` to + /// `Default::default()` (size 0x0, no anchor) rather than removing it, + /// but the pre-commit hook smithay itself registers at + /// `get_layer_surface` time keeps validating that state against every + /// future commit regardless of whether the role still exists -- + /// tripping its own `width/height 0 requested without ... anchors` + /// check and posting `invalid_size`, which kills the client's whole + /// connection over what is protocol-legal (committing a now-roleless + /// surface). + /// + /// Fixed by registering our own pre-commit hook here, in `new_surface` + /// - called at `wl_compositor.create_surface`, strictly before any + /// later `get_layer_surface` on the same surface could register + /// smithay's own hook. Hooks run in registration order (`tree.rs`: + /// `pre_commit_hooks` is a plain `Vec`, pushed and iterated in order), + /// so ours always runs first and can neutralize the stale reset state + /// before smithay's hook ever inspects it. This depends on that + /// ordering guarantee holding in future smithay versions - it isn't + /// documented as an API contract, just an implementation detail + /// confirmed against 0.7.0's source - so re-check this file against + /// whatever smithay version replaces it. + /// + /// Cost: one closure registered per `wl_surface` (not just layer + /// surfaces, since we don't know in advance which ones will become + /// one), each a no-op unless that exact surface is in + /// `dead_layer_surfaces`. + fn new_surface(&mut self, surface: &WlSurface) { + add_pre_commit_hook::<CompState, _>(surface, |state, _dh, surface| { + if !state.dead_layer_surfaces.contains(surface) { + return; + } + with_states(surface, |states| { + let mut cached = states.cached_state.get::<LayerSurfaceCachedState>(); + let pending = cached.pending(); + if pending.size.w == 0 && !pending.anchor.anchored_horizontally() { + pending.size.w = 1; + } + if pending.size.h == 0 && !pending.anchor.anchored_vertically() { + pending.size.h = 1; + } + }); + }); + } + fn commit(&mut self, surface: &WlSurface) { smithay::backend::renderer::utils::on_commit_buffer_handler::<CompState>(surface); // XWayland's association of an X11 window with this wl_surface can @@ -67,8 +123,16 @@ impl CompositorHandler for CompState { if let Some(w) = self.id_to_window.get(&id) { w.on_commit(); } + crate::state::sync_toplevel_metadata(self, id, surface); } self.ensure_layer_initial_configure(surface); + // Advances a just-created popup from unmapped to mapped (needed for + // `PopupManager::popups_for_surface`, which `popup_render_elements` + // reads at render time) and prunes dead ones. Cheap and only does + // real work on a popup-role surface, so doing it on every commit + // rather than throttling is not worth the extra bookkeeping. + self.popups.commit(surface); + self.popups.cleanup(); } } @@ -81,11 +145,204 @@ impl XdgShellHandler for CompState { self.new_managed_window(surface); } - fn new_popup(&mut self, _surface: PopupSurface, _positioner: PositionerState) {} + /// `move_request`/`resize_request` were also still smithay's default + /// no-op implementations - a much larger gap than the five below: + /// this is *how a client-side-decorated window gets dragged or resized + /// by its own titlebar/edges at all*. A window we draw our own + /// decoration for never needed this (`TitlebarHit::Drag`/`Resize` in + /// `input.rs` detect the click directly, since we own those pixels), + /// but a window that negotiated client-side decoration and draws its + /// own titlebar - Firefox, and most GTK4 apps by default - handles + /// the click itself and then asks the compositor to actually perform + /// the move/resize via exactly these two requests. Left unimplemented, + /// dragging or resizing any such window by its own chrome did + /// nothing at all - the only way to reposition it was the + /// modifier+drag-anywhere gesture (`bindm`), which most users have no + /// reason to know exists and doesn't work for resize-from-a-specific- + /// edge at all. Reuses the exact same `WindowManager::start_drag`/ + /// `start_resize` the pointer-driven titlebar handlers call -- + /// `handle_pointer_position`/`handle_pointer_button` already drive any + /// in-progress drag/resize to completion on subsequent motion/release + /// regardless of what started it, so no smithay pointer grab is + /// needed here at all, just the same start call from a different + /// trigger. + fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) { + if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) { + let pos = crate::input::last_pointer_pos(self); + self.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32); + } + } + + fn resize_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial, edges: xdg_toplevel::ResizeEdge) { + let Some(edge) = (match edges { + xdg_toplevel::ResizeEdge::Top => Some(srdwm_core::ResizeEdge::Top), + xdg_toplevel::ResizeEdge::Bottom => Some(srdwm_core::ResizeEdge::Bottom), + xdg_toplevel::ResizeEdge::Left => Some(srdwm_core::ResizeEdge::Left), + xdg_toplevel::ResizeEdge::Right => Some(srdwm_core::ResizeEdge::Right), + xdg_toplevel::ResizeEdge::TopLeft => Some(srdwm_core::ResizeEdge::TopLeft), + xdg_toplevel::ResizeEdge::TopRight => Some(srdwm_core::ResizeEdge::TopRight), + xdg_toplevel::ResizeEdge::BottomLeft => Some(srdwm_core::ResizeEdge::BottomLeft), + xdg_toplevel::ResizeEdge::BottomRight => Some(srdwm_core::ResizeEdge::BottomRight), + // `None` is a valid protocol value (the client leaves the edge + // unspecified) but `WindowManager::start_resize` needs one -- + // there's nothing sensible to default it to that wouldn't be a + // guess, so this is a no-op rather than picking one. + _ => None, + }) else { + return; + }; + if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) { + let pos = crate::input::last_pointer_pos(self); + self.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32); + } + } + + /// `maximize_request`/`unmaximize_request`/`fullscreen_request`/ + /// `unfullscreen_request`/`minimize_request` were all still smithay's + /// default no-op (or configure-only) implementations - found + /// investigating the `toggle_fullscreen` decoration bug above, by + /// checking what else routes through the same `WindowManager` calls + /// the titlebar-button click handlers in `input.rs` already use. + /// These five are the *client-initiated* equivalent of those clicks: a + /// client's own window-menu "Maximize", pressing F11, an HTML5 video + /// going fullscreen, or (for a client that negotiated client-side + /// decoration and draws its own titlebar, like Firefox) that titlebar's + /// own maximize button - all ask the compositor to actually perform + /// the state change via these requests rather than the compositor + /// noticing on its own. Left unimplemented, every one of them was a + /// silent no-op: the client's button did nothing, with no error and + /// nothing to suggest why, from any app that relies on this instead of + /// (or in addition to) a compositor-side keybinding. + fn maximize_request(&mut self, surface: ToplevelSurface) { + if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) { + if !self.wm.borrow().window(id).is_some_and(|w| w.maximized) { + self.wm.borrow_mut().toggle_maximize(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + surface.send_configure(); + } + + fn unmaximize_request(&mut self, surface: ToplevelSurface) { + if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) { + if self.wm.borrow().window(id).is_some_and(|w| w.maximized) { + self.wm.borrow_mut().toggle_maximize(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + surface.send_configure(); + } + + /// `_output` (the client's requested target output) is ignored -- + /// single-seat, and every other fullscreen entry point (the titlebar + /// button, `srd.window.fullscreen()`) already fullscreens on whatever + /// monitor the window is already on, so this matches that instead of + /// introducing an output-aware fullscreen path only this one request + /// would use. + fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<WlOutput>) { + if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) { + if !self.wm.borrow().is_fullscreen(id) { + // `redraw_decoration_buffer` first, same reason + // `set_decorated_from_mode` calls it before `sync_geometry`: + // fullscreen also flips `Window.decorated`, and dropping + // the decoration needs the buffer actually removed, not + // just left stale for `sync_geometry`'s own resize-only + // redraw check to skip. + self.wm.borrow_mut().toggle_fullscreen(id); + self.redraw_decoration_buffer(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + surface.send_configure(); + } + + fn unfullscreen_request(&mut self, surface: ToplevelSurface) { + if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) { + if self.wm.borrow().is_fullscreen(id) { + self.wm.borrow_mut().toggle_fullscreen(id); + self.redraw_decoration_buffer(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + surface.send_configure(); + } - fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) {} + /// No `send_configure` here, matching the pointer-driven + /// `TitlebarHit::Minimize` handler in `input.rs`: minimizing doesn't + /// change the window's own size, only whether it's currently shown, so + /// there's nothing new to tell the client about its own geometry. + fn minimize_request(&mut self, surface: ToplevelSurface) { + if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) { + self.wm.borrow_mut().minimize_window(id); + crate::foreign_toplevel::send_state(self, id); + } + } - fn reposition_request(&mut self, _surface: PopupSurface, _positioner: PositionerState, _token: u32) {} + /// Was a bare no-op - no `send_configure` at all. Per xdg-shell, + /// `xdg_surface.configure` is required before a popup's first commit; + /// real toolkits (confirmed live: GTK4's Wayland backend) block that + /// commit in a synchronous roundtrip waiting for it, so every popup + /// hung its client forever. GTK4 implements tooltips *and* + /// `Gtk.Popover` as `xdg_popup`, so this fired on hovering almost any + /// widget with a tooltip - confirmed by a peer session's gdb backtrace + /// (blocked in `wl_display_dispatch_queue` under `gtk_widget_show`) + /// after AGS wedged. + /// + /// Geometry is `positioner.get_geometry()` un-constrained - no + /// on-screen clamping yet (`PositionerState::get_unconstrained_geometry` + /// needs a target rect in the parent's surface-local space, which is a + /// real follow-up, not this fix); an occasional popup placed near a + /// screen edge may render partly off it, which is cosmetic, not a hang. + fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) { + surface.with_pending_state(|state| { + state.geometry = positioner.get_geometry(); + state.positioner = positioner; + }); + if surface.send_configure().is_err() { + return; + } + let _ = self.popups.track_popup(smithay::desktop::PopupKind::Xdg(surface)); + } + + /// Implicit grab + dismiss-on-outside-click. Previously believed + /// blocked on `CompState`'s `SeatHandler` associated types not + /// satisfying `PopupManager::grab_popup`'s `WaylandFocus + + /// From<PopupKind>` bound - rechecked while implementing + /// `move_request`/`resize_request` (same trait, adjacent methods) and + /// it turns out they already do: `KeyboardFocus`/`PointerFocus` are + /// both plain `WlSurface`, smithay provides `impl From<PopupKind> for + /// WlSurface` itself, and `WlSurface: From<WlSurface>` trivially. No + /// blocker ever existed by the time of this pass; the bound just + /// hadn't been rechecked since being noted as unmet. + /// + /// `self.seat.clone()` rather than resolving `_seat` (the client's + /// `wl_seat` resource) via `Seat::from_resource` - this compositor + /// only ever has the one seat, matching how `move_request`/ + /// `resize_request` already ignore the same parameter. + fn grab(&mut self, surface: PopupSurface, _seat: wl_seat::WlSeat, serial: Serial) { + let popup = PopupKind::Xdg(surface); + let Ok(root) = find_popup_root_surface(&popup) else { return }; + let seat = self.seat.clone(); + let Ok(grab) = self.popups.grab_popup(root, popup, &seat, serial) else { return }; + if let Some(keyboard) = seat.get_keyboard() { + keyboard.set_grab(self, PopupKeyboardGrab::new(&grab), serial); + } + if let Some(pointer) = seat.get_pointer() { + pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep); + } + } + + fn reposition_request(&mut self, surface: PopupSurface, positioner: PositionerState, token: u32) { + surface.with_pending_state(|state| { + state.geometry = positioner.get_geometry(); + state.positioner = positioner; + }); + surface.send_repositioned(token); + } fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { self.remove_window(surface.wl_surface()); @@ -99,14 +356,42 @@ impl XdgDecorationHandler for CompState { }); } - fn request_mode(&mut self, toplevel: ToplevelSurface, _mode: DecorationMode) { + /// Honors whichever mode the client actually asked for, rather than + /// always forcing server-side - and mirrors the result into our own + /// `Window.decorated`, so a client drawing its own titlebar doesn't + /// *also* get one drawn on top of it by us. + /// + /// Always forcing `ServerSide` (what this used to do) is why some + /// clients ended up with two sets of window buttons: Firefox requests + /// client-side decoration when its own "use system titlebar" setting + /// is off, and draws its own close/minimize/maximize row regardless of + /// what the compositor grants - so forcing server-side just added + /// srdwm's row on top of the one Firefox was drawing anyway, instead + /// of preventing it. Respecting the request means srdwm steps out of + /// the way for exactly those clients, while everything that accepts + /// (or has no preference and gets offered) server-side still gets our + /// titlebar as before. + fn request_mode(&mut self, toplevel: ToplevelSurface, mode: DecorationMode) { toplevel.with_pending_state(|state| { - state.decoration_mode = Some(DecorationMode::ServerSide); + state.decoration_mode = Some(mode); }); toplevel.send_configure(); + self.set_decorated_from_mode(toplevel.wl_surface(), mode == DecorationMode::ServerSide); } - fn unset_mode(&mut self, _toplevel: ToplevelSurface) {} + /// The client dropped its decoration-mode preference. `new_decoration` + /// already offered `ServerSide` as the default the next configure will + /// carry, so mirror that same default here rather than leaving + /// whatever mode was negotiated before this - otherwise a client that + /// requests `ClientSide`, then later unsets it expecting the default + /// back, would stay undecorated by us forever. + fn unset_mode(&mut self, toplevel: ToplevelSurface) { + toplevel.with_pending_state(|state| { + state.decoration_mode = Some(DecorationMode::ServerSide); + }); + toplevel.send_configure(); + self.set_decorated_from_mode(toplevel.wl_surface(), true); + } } impl ShmHandler for CompState { @@ -119,8 +404,167 @@ impl BufferHandler for CompState { fn buffer_destroyed(&mut self, _buffer: &WlBuffer) {} } +impl DmabufHandler for CompState { + fn dmabuf_state(&mut self) -> &mut DmabufState { + &mut self.dmabuf_state + } + + /// Validates a client's dmabuf by actually importing it wherever a + /// renderer is reachable from here, so a genuinely bad buffer (wrong + /// modifier, format the renderer doesn't support) gets the protocol + /// error instead of silently rendering garbage later. + /// + /// That's only the udev backend: its `PixmanRenderer` lives inside + /// `self.udev` (`UdevState`), a field of this same struct. + /// `PixmanRenderer` supports dmabuf import despite being a pure + /// software renderer - `dmabuf_formats()` only advertises the Linear + /// modifier, which it imports by mmap'ing the buffer and reading it + /// directly as pixels, no GPU involved. This is what actually answers + /// `docs/PANEL_SUPPORT_TODO.md`'s P0.3: GTK4 allocates via its own + /// EGL/gbm path against the real DRM render node (untouched by this + /// compositor either way) and hands the result here as a Linear- + /// modifier dmabuf, which pixman can read straight off. + /// + /// The winit (nested/dev) backend's `GlesRenderer` lives on + /// `WaylandPlatform`, a sibling of `CompState`, not reachable from a + /// method on `CompState` itself. Accepted there without eager + /// validation - the buffer still gets imported the same way every + /// other buffer type already is, lazily, the first time it is actually + /// rendered via `render_elements_from_surface_tree`. Real hardware, + /// where P0.3 actually bites, always goes through the udev path. + fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) { + match self.udev.as_mut() { + Some(udev) => match udev.renderer.import_dmabuf(&dmabuf, None) { + Ok(_) => { + let _ = notifier.successful::<CompState>(); + } + Err(e) => { + log::warn!("udev: rejecting dmabuf import: {e}"); + notifier.failed(); + } + }, + None => { + let _ = notifier.successful::<CompState>(); + } + } + } +} + +impl XdgActivationHandler for CompState { + fn activation_state(&mut self) -> &mut XdgActivationState { + &mut self.xdg_activation_state + } + + /// A launcher spawns an app after first getting a token + /// (`get_activation_token`) and handing it to the new process (usually + /// via `XDG_ACTIVATION_TOKEN`); the app's own first window then + /// presents that same token back here via `activate`, asking to be + /// raised. Without this, that request was silently ignored - the new + /// window opened and just sat there unfocused behind everything, + /// exactly the gap `docs/PANEL_SUPPORT_TODO.md`'s P1 flagged. + /// + /// No token bookkeeping of our own: `token_created`'s default already + /// accepts every token (fine for a single-user session with no + /// cross-client trust boundary to enforce), so all that's left is + /// mapping the activating `surface` to a `WindowId` and reusing the + /// exact same `focus_window` path a dock's "activate" request already + /// goes through (`foreign_toplevel.rs`). If the surface isn't tracked + /// yet - the activation raced ahead of this window's own mapping -- + /// there is nothing to focus yet, so this is a no-op rather than an + /// error; the protocol doesn't require honoring every activation. + fn request_activation(&mut self, _token: XdgActivationToken, _token_data: XdgActivationTokenData, surface: WlSurface) { + if let Some(&id) = self.surface_to_id.get(&surface) { + crate::input::focus_window(self, id); + } + } +} + +/// `zwp_text_input_manager_v3` + `zwp_input_method_manager_v2`: lets a real +/// input method (fcitx5, ibus, any CJK/dead-key/emoji-picker IME) attach to +/// whichever surface has keyboard focus and draw its own candidate/ +/// composition popup. Without these two globals a client that only speaks +/// text-input (most modern toolkits do, GTK4/Qt6 included) has no way to +/// tell the compositor "I have an editable text field, here is its cursor +/// rectangle" - every desktop app's search box, address bar, and chat +/// input silently loses IME support, not just an edge case. +/// +/// Focus tracking needs *no* wiring here at all: `CompState::KeyboardFocus` +/// is a plain `WlSurface`, and smithay's own blanket `impl KeyboardTarget +/// for WlSurface` already calls `seat.text_input().set_focus/.enter()/ +/// .leave()` and `seat.input_method().activate_input_method()/ +/// deactivate_input_method()` from inside `enter`/`leave` - which +/// `set_keyboard_focus`'s existing `keyboard.set_focus(...)` call already +/// triggers on every real focus change. The only things actually missing +/// were the two manager globals and this handler for the popup surface +/// lifecycle. +impl smithay::wayland::input_method::InputMethodHandler for CompState { + /// A candidate/composition window (an emoji picker, a CJK candidate + /// list) just opened. Tracked as a regular [`PopupKind::InputMethod`] + /// in the same [`PopupManager`](smithay::desktop::PopupManager) that + /// already owns every `xdg_popup` - `elements::popup_render_elements` + /// renders both kinds identically, so no separate render path is + /// needed for this to actually become visible. + fn new_popup(&mut self, surface: ImePopupSurface) { + if let Err(e) = self.popups.track_popup(PopupKind::from(surface)) { + log::warn!("input-method: failed to track popup: {e}"); + } + } + + fn dismiss_popup(&mut self, surface: ImePopupSurface) { + if let Some(parent) = surface.get_parent().map(|p| p.surface.clone()) { + let _ = smithay::desktop::PopupManager::dismiss_popup(&parent, &PopupKind::from(surface)); + } + } + + /// The IME moved its own popup (e.g. following the text cursor as the + /// user types) - `PopupSurface::location()` already reflects the new + /// position; nothing else needs updating on this side, matching every + /// other smithay-based compositor's own no-op here. + fn popup_repositioned(&mut self, _surface: ImePopupSurface) {} + + /// Where the IME should anchor its popup, in the parent surface's own + /// output-independent (logical, window-relative-origin) space - same + /// geometry `elements::popup_targets` already computes for xdg popups, + /// reused here rather than duplicated. A window not yet tracked (the + /// activation raced ahead of its own mapping) gets a default/zero rect, + /// same "no-op rather than an error" stance as `request_activation` + /// above. + fn parent_geometry(&self, parent: &WlSurface) -> smithay::utils::Rectangle<i32, smithay::utils::Logical> { + let Some(&id) = self.surface_to_id.get(parent) else { + return smithay::utils::Rectangle::default(); + }; + let wm = self.wm.borrow(); + let Some(w) = wm.window(id) else { + return smithay::utils::Rectangle::default(); + }; + let band = if w.decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 }; + smithay::utils::Rectangle::new((w.geometry.x, w.geometry.y + band).into(), (w.geometry.width as i32, w.geometry.height as i32).into()) + } +} + impl smithay::wayland::output::OutputHandler for CompState {} +/// `wp_cursor_shape_v1`: lets a client ask for a *named* cursor (text, +/// grab, resize edges, ...) instead of rendering and attaching its own +/// surface. Its requests route straight into `SeatHandler::cursor_image` +/// below, same as a client-drawn cursor surface does - no extra state on +/// our side. Without this global at all, a client that only speaks this +/// (increasingly the norm - recent GTK4/Firefox use it for most cursor +/// changes) has no way to tell us the pointer should look like anything +/// but whatever it last was, which reads as the cursor going stale, wrong, +/// or simply disappearing depending on what was showing when the client +/// gave up trying. +/// +/// `TabletSeatHandler` is a supertrait bound of this protocol's `Dispatch` +/// impl (cursor-shape covers tablet tools too); srdwm has no tablet +/// support to speak of, so every method is left at its no-op default. +impl TabletSeatHandler for CompState {} + +/// Fractional scaling. srdwm runs every output at scale 1, so there is +/// nothing to compute - but the global has to exist, because clients that +/// use it (notably wallpaper daemons) treat it as mandatory. +impl smithay::wayland::fractional_scale::FractionalScaleHandler for CompState {} + impl SeatHandler for CompState { type KeyboardFocus = WlSurface; type PointerFocus = WlSurface; @@ -145,6 +589,13 @@ impl WlrLayerShellHandler for CompState { } fn new_layer_surface(&mut self, surface: WlrLayerSurface, wl_output: Option<WlOutput>, _layer: Layer, namespace: String) { + // Logged before anything else can early-return or panic: the + // question this answers (see docs/PANEL_SUPPORT_TODO.md) is + // whether this handler is reached AT ALL for a later + // `get_layer_surface` request in a create -> commit -> destroy -> + // commit-again -> create sequence, or whether the client's + // dispatch is already dead by then and this never runs. + log::debug!("layer-shell: new_layer_surface entered, surface={:?} namespace={namespace:?} output_named={}", surface.wl_surface().id(), wl_output.is_some()); // A client may name the output it wants (a bar on a specific // monitor); if it doesn't, or names one we don't drive, it lands on // the primary output. @@ -157,14 +608,28 @@ impl WlrLayerShellHandler for CompState { log::warn!("wayland: layer surface requested but no output exists yet"); return; }; + // Paired with the debug log in `ensure_layer_initial_configure`'s + // early return - see docs/PANEL_SUPPORT_TODO.md's P0. This is the + // other half of "did map_layer actually succeed, and on which + // output": logged unconditionally (not just on the error paths + // that already existed) so a real reproduction shows both sides of + // the handoff instead of just the failure. + let surface_id = surface.wl_surface().id(); let layer_surface = DesktopLayerSurface::new(surface, namespace); let result = layer_map_for_output(&output).map_layer(&layer_surface); - if let Err(e) = result { - log::warn!("wayland: failed to map layer surface: {e}"); + match &result { + Ok(()) => log::debug!("layer-shell: mapped surface {surface_id:?} onto output {}", output.name()), + Err(e) => log::warn!("wayland: failed to map layer surface {surface_id:?}: {e}"), } } fn layer_destroyed(&mut self, surface: WlrLayerSurface) { + // See the matching top-of-function log in `new_layer_surface`. + log::debug!("layer-shell: layer_destroyed entered, surface={:?}", surface.wl_surface().id()); + // Marks this surface for the pre-commit-hook workaround in + // `new_surface` - see that function's doc comment for the bug + // this exists to route around. + self.dead_layer_surfaces.insert(surface.wl_surface().clone()); // The surface belongs to exactly one output's map, but which one is // the client's choice, so unmap from whichever holds it. for output in self.outputs().cloned().collect::<Vec<_>>() { @@ -229,10 +694,44 @@ impl DataControlHandler for CompState { } } +/// `ext_idle_notify_v1`. All the real logic (per-notification timers, +/// resetting them on activity, honouring inhibition) already lives in +/// smithay's own `IdleNotifierState` - this is just the getter it needs. +/// See `input.rs`'s `notify_idle_activity` for the other half: nothing +/// calls `notify_activity` on its own, that has to happen from every real +/// input path. +impl smithay::wayland::idle_notify::IdleNotifierHandler for CompState { + fn idle_notifier_state(&mut self) -> &mut smithay::wayland::idle_notify::IdleNotifierState<Self> { + &mut self.idle_notifier_state + } +} + +/// `zwp_idle_inhibit_manager_v1`. A video player (or anything else that +/// wants the screen to stay on/unlocked while it runs) creates one of +/// these tied to its own surface; as long as at least one is alive, +/// `IdleNotifierState::set_is_inhibited` stops idle timers from firing at +/// all - see `idle_inhibiting_surfaces`'s doc comment on `CompState` for +/// the one simplification (not workspace-visibility-aware) this takes. +impl smithay::wayland::idle_inhibit::IdleInhibitHandler for CompState { + fn inhibit(&mut self, surface: WlSurface) { + self.idle_inhibiting_surfaces.push(surface); + self.idle_notifier_state.set_is_inhibited(true); + } + + fn uninhibit(&mut self, surface: WlSurface) { + self.idle_inhibiting_surfaces.retain(|s| s != &surface); + self.idle_notifier_state.set_is_inhibited(!self.idle_inhibiting_surfaces.is_empty()); + } +} + delegate_compositor!(CompState); delegate_xdg_shell!(CompState); delegate_xdg_decoration!(CompState); delegate_shm!(CompState); +delegate_dmabuf!(CompState); +delegate_xdg_activation!(CompState); +delegate_text_input_manager!(CompState); +delegate_input_method_manager!(CompState); delegate_seat!(CompState); delegate_output!(CompState); delegate_layer_shell!(CompState); @@ -240,3 +739,8 @@ delegate_data_device!(CompState); delegate_primary_selection!(CompState); delegate_data_control!(CompState); delegate_session_lock!(CompState); +delegate_cursor_shape!(CompState); +smithay::delegate_viewporter!(CompState); +smithay::delegate_fractional_scale!(CompState); +smithay::delegate_idle_notify!(CompState); +smithay::delegate_idle_inhibit!(CompState); diff --git a/crates/wayland/src/screencopy.rs b/crates/wayland/src/screencopy.rs index 324669c..cb632aa 100644 --- a/crates/wayland/src/screencopy.rs +++ b/crates/wayland/src/screencopy.rs @@ -21,6 +21,7 @@ use std::time::UNIX_EPOCH; use smithay::backend::allocator::Fourcc; use smithay::backend::renderer::{ExportMem, Renderer}; +use smithay::output::Output; use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer; use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; use smithay::reexports::wayland_server::protocol::wl_shm; @@ -66,6 +67,11 @@ impl ScreencopyState { pub struct FrameData { /// Region of the output to capture, in physical pixels. pub region: Rectangle<i32, Physical>, + /// The output this frame captures. `None` if the `wl_output` the client + /// named at request time doesn't resolve to a live output (e.g. + /// unplugged between bind and capture) - such a frame is failed + /// immediately and never queued, so this is only read on that path. + pub output: Option<Output>, /// Set once `copy`/`copy_with_damage` has been handled, so a second one /// can be rejected with the protocol's `already_used` error. pub used: bool, @@ -78,6 +84,11 @@ pub struct PendingCapture { pub frame: ZwlrScreencopyFrameV1, pub buffer: WlBuffer, pub region: Rectangle<i32, Physical>, + /// Which head this capture is bound to - the udev backend renders each + /// head into its own framebuffer, so a capture must be serviced against + /// the framebuffer for *this* output, not whichever head happens to + /// render first (see `render_udev_frame`'s per-output split). + pub output: Output, /// `copy_with_damage` clients expect a `damage` event before `ready`. pub with_damage: bool, } @@ -106,9 +117,9 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for CompState { data_init: &mut DataInit<'_, Self>, ) { use zwlr_screencopy_manager_v1::Request; - let (frame, region) = match request { + let (frame, region, output) = match request { Request::CaptureOutput { frame, overlay_cursor: _, output } => { - (frame, state.output_capture_region(&output)) + (frame, state.output_capture_region(&output), state.output_for_wl(&output).map(|e| e.output.clone())) } Request::CaptureOutputRegion { frame, overlay_cursor: _, output, x, y, width, height } => { // Clamp to the output: a client is free to ask for a region @@ -116,15 +127,17 @@ impl Dispatch<ZwlrScreencopyManagerV1, ()> for CompState { // `copy_framebuffer` errors out on out-of-bounds reads. let full = state.output_capture_region(&output); let requested = Rectangle::new((x, y).into(), (width.max(0), height.max(0)).into()); - (frame, full.intersection(requested).unwrap_or_default()) + let region = full.intersection(requested).unwrap_or_default(); + (frame, region, state.output_for_wl(&output).map(|e| e.output.clone())) } Request::Destroy => return, _ => return, }; - let frame = data_init.init(frame, FrameData { region, used: false }); - if region.size.w <= 0 || region.size.h <= 0 { - // Nothing to capture (empty or fully off-screen region). + let frame = data_init.init(frame, FrameData { region, output: output.clone(), used: false }); + if region.size.w <= 0 || region.size.h <= 0 || output.is_none() { + // Nothing to capture: an empty/off-screen region, or a + // `wl_output` that no longer resolves to a live head. frame.failed(); return; } @@ -182,10 +195,20 @@ impl Dispatch<ZwlrScreencopyFrameV1, FrameData> for CompState { return; } + // A frame with no resolved output was already failed at request + // time (see the manager's `request` handler) and should never reach + // `Copy`/`CopyWithDamage` from a well-behaved client; guarded rather + // than unwrapped so a misbehaving one can't panic the compositor. + let Some(output) = data.output.clone() else { + frame.post_error(zwlr_screencopy_frame_v1::Error::InvalidBuffer, "frame has no output to capture"); + return; + }; + state.screencopy_pending.push(PendingCapture { frame: frame.clone(), buffer, region: data.region, + output, with_damage, }); } diff --git a/crates/wayland/src/state.rs b/crates/wayland/src/state.rs index bea3453..1964d99 100644 --- a/crates/wayland/src/state.rs +++ b/crates/wayland/src/state.rs @@ -18,7 +18,8 @@ use std::time::{Duration, Instant}; use smithay::backend::allocator::Fourcc; use smithay::backend::renderer::element::memory::MemoryRenderBuffer; -use smithay::desktop::{layer_map_for_output, Space, Window as DWindow, WindowSurfaceType}; +use smithay::backend::renderer::element::solid::SolidColorBuffer; +use smithay::desktop::{layer_map_for_output, PopupManager, Space, Window as DWindow, WindowSurfaceType}; use smithay::input::{Seat, SeatState}; use smithay::output::Output; use smithay::reexports::wayland_server::backend::{ClientData, ClientId, DisconnectReason}; @@ -34,12 +35,14 @@ use smithay::wayland::session_lock::SessionLockManagerState; use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, LayerSurfaceData, WlrLayerShellState}; use smithay::wayland::shell::xdg::{ToplevelSurface, XdgShellState, XdgToplevelSurfaceData}; use smithay::wayland::shell::xdg::decoration::XdgDecorationState; +use smithay::wayland::dmabuf::DmabufState; use smithay::wayland::shm::ShmState; +use smithay::wayland::xdg_activation::XdgActivationState; use srdwm_core::{Event as CoreEvent, Window as CoreWindow, WindowId, WindowManager, TITLEBAR_HEIGHT}; use crate::lock::SessionLock; -use crate::{decoration, screencopy, udev, xwayland}; +use crate::{decoration, foreign_toplevel, gamma_control, output_management, output_power, screencopy, udev, workspace, xwayland}; #[derive(Default)] pub(crate) struct ClientState { @@ -91,9 +94,33 @@ pub(crate) struct CompState { pub(crate) xdg_shell_state: XdgShellState, pub(crate) _xdg_decoration_state: XdgDecorationState, pub(crate) shm_state: ShmState, + /// `zwp_linux_dmabuf_v1` - see `protocols.rs`'s `DmabufHandler` impl. + /// Without this global, no client can hand the compositor a GPU buffer + /// at all; GTK4 in particular tries to open a DRM render node to + /// allocate one anyway, fails with no global to query, and crashes + /// instead of falling back (see `docs/PANEL_SUPPORT_TODO.md`'s P0.3). + pub(crate) dmabuf_state: DmabufState, + /// `xdg_activation_v1` - see `protocols.rs`'s `XdgActivationHandler`. + /// Without this, a launcher's freshly-spawned app opens unfocused + /// behind everything: nothing raises it once its window actually maps. + pub(crate) xdg_activation_state: XdgActivationState, + /// `zwp_text_input_manager_v3` + `zwp_input_method_manager_v2` - lets a + /// real IME (fcitx5, ibus) attach to the focused text field and draw + /// its own composition/candidate popup. See `protocols.rs`'s + /// `InputMethodHandler` impl for why no extra focus-tracking is needed + /// beyond registering these two globals. + pub(crate) _text_input_manager_state: smithay::wayland::text_input::TextInputManagerState, + pub(crate) _input_method_manager_state: smithay::wayland::input_method::InputMethodManagerState, + /// `gtk_shell1` - the Wayland-native half of global-menu support. See + /// `gtk_shell.rs`'s module doc comment. + pub(crate) _gtk_shell_state: crate::gtk_shell::GtkShellState, pub(crate) seat_state: SeatState<CompState>, pub(crate) seat: Seat<CompState>, pub(crate) space: Space<DWindow>, + /// Tracks every live `xdg_popup` (position, parent, grabs) - see + /// `protocols.rs`'s `new_popup`/`reposition_request`/`commit` and + /// `popup_render_elements` below. + pub(crate) popups: PopupManager, /// Every output srdwm drives, left-to-right in the global coordinate /// space. The winit backend always has exactly one (its nested window); /// the udev backend has one per connected connector. @@ -112,10 +139,60 @@ pub(crate) struct CompState { pub(crate) primary_selection_state: PrimarySelectionState, pub(crate) data_control_state: DataControlState, pub(crate) session_lock_state: SessionLockManagerState, + /// `wp_viewporter` and `wp_fractional_scale_manager_v1`. Held only to + /// keep the globals alive: surface scaling is handled inside smithay, + /// and the compositor needs no logic of its own for either. + /// + /// Not optional in practice - a wallpaper daemon (`awww`/`swww`) hard + /// *requires* both and panics on startup without them, and video + /// players use viewporter for scaled playback. + pub(crate) _viewporter_state: smithay::wayland::viewporter::ViewporterState, + pub(crate) _fractional_scale_state: smithay::wayland::fractional_scale::FractionalScaleManagerState, + pub(crate) _cursor_shape_state: smithay::wayland::cursor_shape::CursorShapeManagerState, pub(crate) _screencopy_state: screencopy::ScreencopyState, /// Captures requested via `wlr-screencopy` but not yet serviced; drained /// inside the render pass (see `screencopy::service_pending`). pub(crate) screencopy_pending: Vec<screencopy::PendingCapture>, + pub(crate) _foreign_toplevel_state: foreign_toplevel::ForeignToplevelState, + /// Every bound `zwlr_foreign_toplevel_manager_v1` (one per dock/switcher + /// client), so a newly-created window can be announced to all of them -- + /// see `foreign_toplevel::window_created`. + pub(crate) foreign_toplevel_managers: Vec<wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1>, + /// The live `zwlr_foreign_toplevel_handle_v1` objects for each window -- + /// one per bound manager, since each manager only ever sees the handles + /// created for *it*. + pub(crate) foreign_toplevel_handles: HashMap<WindowId, Vec<wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1>>, + pub(crate) _workspace_state: workspace::WorkspaceManagerState, + /// `zwlr_output_power_management_v1` - `None` on the winit (nested) + /// backend, which has no real display to power down. See + /// `output_power.rs`'s module doc comment. + pub(crate) _output_power_state: Option<output_power::OutputPowerManagerState>, + /// `zwlr_gamma_control_manager_v1` - `None` on the winit (nested) + /// backend, same reasoning as `_output_power_state`. See + /// `gamma_control.rs`'s module doc comment. + pub(crate) _gamma_control_state: Option<gamma_control::GammaControlManagerState>, + /// `zwlr_output_management_v1` - unlike `_output_power_state`/ + /// `_gamma_control_state` above, not `Option`-gated: enumerating + /// outputs and applying position/scale/transform changes works the + /// same way (`Output::change_current_state`) on both backends, so + /// there's no backend where advertising this global would be + /// dishonest. See `output_management.rs`'s module doc comment. + pub(crate) _output_management_state: output_management::OutputManagementState, + pub(crate) output_managers: Vec<wayland_protocols_wlr::output_management::v1::server::zwlr_output_manager_v1::ZwlrOutputManagerV1>, + pub(crate) output_heads: HashMap<String, Vec<wayland_protocols_wlr::output_management::v1::server::zwlr_output_head_v1::ZwlrOutputHeadV1>>, + pub(crate) output_modes: HashMap<String, Vec<wayland_protocols_wlr::output_management::v1::server::zwlr_output_mode_v1::ZwlrOutputModeV1>>, + /// Bumped on every real output-state change; sent with `zwlr_output_ + /// manager_v1.done` and checked against a `create_configuration` + /// request's own serial so a client configuring against stale + /// (pre-hotplug) state gets `cancelled` rather than silently + /// clobbering whatever changed after it last saw a `done`. + pub(crate) output_serial: u32, + /// What was last broadcast to output-management clients - see + /// `output_management::broadcast_dirty_outputs`'s doc comment. + pub(crate) last_broadcast_outputs: Vec<output_management::OutputSnapshot>, + pub(crate) workspace_managers: Vec<wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::ExtWorkspaceManagerV1>, + pub(crate) workspace_groups: Vec<wayland_protocols::ext::workspace::v1::server::ext_workspace_group_handle_v1::ExtWorkspaceGroupHandleV1>, + pub(crate) workspace_handles: HashMap<srdwm_core::WorkspaceId, Vec<wayland_protocols::ext::workspace::v1::server::ext_workspace_handle_v1::ExtWorkspaceHandleV1>>, /// Session lock (`ext-session-lock-v1`). While `locked` is set, client /// content is never rendered and input never reaches normal clients -- /// see `SessionLockHandler` below. @@ -124,15 +201,63 @@ pub(crate) struct CompState { /// the default when no client has said). See `cursor.rs` for why this /// has to be drawn by us on the DRM backend. pub(crate) cursor_status: smithay::input::pointer::CursorImageStatus, - /// Bitmap for the built-in arrow, built once at startup rather than - /// per frame. - pub(crate) cursor_buffer: MemoryRenderBuffer, + /// Built-in cursor bitmaps (arrow, text, resize directions), built once + /// at startup rather than per frame. + pub(crate) cursor_buffers: crate::cursor::CursorBuffers, /// Last titlebar press, for double-click detection. pub(crate) last_titlebar_click: Option<(WindowId, u32)>, + /// The right-click titlebar window menu, if one is currently open -- + /// see `context_menu.rs`. `None` almost always; a click anywhere while + /// `Some` resolves (selects a row) or dismisses it, never falls + /// through to normal click handling underneath. + pub(crate) context_menu: Option<crate::context_menu::ContextMenu>, + /// Rasterised pixels for the currently-open `context_menu`, rebuilt + /// once when it opens - same cached-until-something-changes pattern + /// `decorations`/`border_top_decorations` already use, not rebuilt + /// per frame. + pub(crate) context_menu_buffer: Option<MemoryRenderBuffer>, pub(crate) wm: Rc<RefCell<WindowManager>>, pub(crate) surface_to_id: HashMap<WlSurface, WindowId>, pub(crate) id_to_window: HashMap<WindowId, DWindow>, + /// Surfaces whose `zwlr_layer_surface_v1` role has been destroyed -- + /// consulted by the pre-commit hook `CompositorHandler::new_surface` + /// registers (see its doc comment) to work around a real smithay bug + /// where a later commit of one of these surfaces gets spuriously + /// rejected. Entries are added in `layer_destroyed`; there's + /// deliberately no removal, since a `WlSurface` here is meaningless + /// after the client destroys it too and Rust never reuses the id while + /// any handle (including this one) still exists. + pub(crate) dead_layer_surfaces: HashSet<WlSurface>, pub(crate) decorations: HashMap<WindowId, MemoryRenderBuffer>, + /// The top border strip's rounded-corner bitmap, cached the same way + /// and at the same trigger points as `decorations` (built in + /// `redraw_decoration_buffer`) - see that method's doc comment for why + /// this has to be rebuilt at the same points a titlebar is, and + /// `elements::border_side_render_element`'s doc comment for the damage- + /// tracking reason a per-frame rebuild was wrong in the first place. + pub(crate) border_top_decorations: HashMap<WindowId, MemoryRenderBuffer>, + /// Persistent solid-colour buffers backing a window's other three + /// border strips (bottom, left, right - `decoration::border_strips`' + /// order past index 0), reused by position every frame rather than + /// rebuilt - see `elements::border_side_render_element`'s doc comment. + /// A flat pool rather than a fixed `[_; 3]`, one buffer per rendered + /// *fragment* rather than per strip: a strip occluded by a window + /// stacked in front of it is split into however many visible pieces + /// remain (see `elements::visible_border_fragments`), so the count + /// needed varies frame to frame as windows move. Never shrunk once + /// grown - a few idle unused buffers cost nothing meaningful, and + /// dropping them would lose the damage-tracking stability the whole + /// scheme exists for the moment fragment counts fluctuate back up. + pub(crate) border_side_buffers: HashMap<WindowId, Vec<SolidColorBuffer>>, + /// Client-visible size (`geometry` minus the titlebar band) last sent to + /// each window via `xdg_toplevel.configure`. `sync_geometry` runs on + /// every pointer-motion tick while a window is being dragged or resized + /// (see `input::handle_pointer_position`); a plain move changes only + /// position, not size, so without this it was re-sending a configure + /// and re-rasterizing the titlebar's text from scratch on every single + /// motion event of every drag, which is what made moving a window + /// stutter. Only a real size change now does either. + pub(crate) last_synced_size: HashMap<WindowId, (i32, i32)>, pub(crate) pending: Rc<RefCell<Vec<CoreEvent>>>, pub(crate) bound_keys: Rc<HashSet<String>>, /// Combos that repeat while held (`srd.bind_repeat`). @@ -151,8 +276,100 @@ pub(crate) struct CompState { /// Mapped X11 windows still waiting for XWayland to associate a /// `wl_surface` - see `xwayland.rs` and `commit()` above. pub(crate) xwayland_pending: Vec<smithay::xwayland::X11Surface>, + /// A second, independent connection to the XWayland X server, used to + /// keep `_NET_ACTIVE_WINDOW`/`_NET_CLIENT_LIST`/`_NET_CLIENT_LIST_STACKING` + /// on the root window up to date - see `xwayland::EwmhState` and its + /// module docs for why this needs its own connection rather than going + /// through `X11Wm`. `None` until XWayland is ready, same as `xwm`. + pub(crate) ewmh: Option<xwayland::EwmhState>, + /// `ext_idle_notify_v1` - lets a client (a lock daemon, a bar's idle + /// indicator) ask to be told after N seconds of no real input. Both + /// this and `_idle_inhibit_manager_state` below use smithay's own + /// complete built-in modules (`wayland::idle_notify`/`idle_inhibit`), + /// unlike every other hand-written protocol in this crate - neither + /// had a raw-XML precedent to follow since smithay already ships full + /// working implementations of both. + pub(crate) idle_notifier_state: smithay::wayland::idle_notify::IdleNotifierState<CompState>, + pub(crate) _idle_inhibit_manager_state: smithay::wayland::idle_inhibit::IdleInhibitManagerState, + /// Surfaces currently holding a live `zwp_idle_inhibitor_v1` (a video + /// player's "keep the screen on while playing" request) - tracked so + /// `uninhibit`/`toplevel_destroyed`/`remove_window` can tell whether any + /// inhibitor is still alive after one goes away. Deliberately not + /// workspace-visibility-aware (an inhibiting window on a workspace + /// you've switched away from still keeps the system awake) - a real, + /// smaller gap, but matching how several other real compositors treat + /// this in practice, and far simpler than threading a re-check through + /// every workspace-switch/minimize call site for a video-player-only + /// protocol most sessions have at most one client using at a time. + pub(crate) idle_inhibiting_surfaces: Vec<WlSurface>, + /// Throttles `input::notify_idle_activity` - see its own doc comment + /// for why pointer motion (a genuinely high-frequency event, and the + /// event this session's earlier per-motion diagnostic-logging + /// regression already proved is worth being careful around) needs one. + pub(crate) last_idle_notify: Option<Instant>, + /// Windows currently mid-tween - see `WindowAnim` and `sync_geometry`'s + /// `anim_from` handling. Driven forward once per frame by + /// `tick_animations`, called from both backends' poll loops. + pub(crate) window_anims: HashMap<WindowId, WindowAnim>, + /// Last (maximized, minimized, fullscreen) broadcast to + /// `zwlr_foreign_toplevel_handle_v1` listeners for each window - see + /// `foreign_toplevel::broadcast_dirty_state`'s doc comment for why this + /// exists alongside that module's own immediate `send_state` calls. + pub(crate) last_broadcast_flags: HashMap<WindowId, (bool, bool, bool)>, + /// Last workspace id broadcast as active to `ext_workspace_v1` + /// listeners - see `workspace::broadcast_dirty_active`'s doc comment. + pub(crate) last_broadcast_workspace: Option<srdwm_core::WorkspaceId>, } +/// An in-flight geometry tween for one window, driven by `tick_animations`. +/// +/// Deliberately geometry-only (no alpha/scale-of-content): content is +/// composited through `self.space` like every other window (see +/// `resync_stacking_order`'s doc comment for why that path was chosen over +/// per-window custom render elements), which has no per-element opacity or +/// scale knob to animate independently of the rest of the output. What +/// *can* animate through `self.space` alone is exactly what interactive +/// drag/resize already proves out every frame: a `Window.geometry` change +/// applied via repeated `map_element`/`xdg_toplevel.configure` calls. This +/// reuses that same, already-live mechanism at a fixed frame rate instead +/// of on pointer motion. +pub(crate) struct WindowAnim { + pub(crate) from: srdwm_core::Rect, + pub(crate) to: srdwm_core::Rect, + pub(crate) start: Instant, + pub(crate) duration: Duration, +} + +impl WindowAnim { + /// Eased (ease-out-cubic) interpolation between `from` and `to`; past + /// `duration` this returns `to` exactly, so a caller that keeps polling + /// after completion never overshoots. + pub(crate) fn current_rect(&self) -> srdwm_core::Rect { + let t = (self.start.elapsed().as_secs_f64() / self.duration.as_secs_f64().max(0.001)).min(1.0); + let eased = 1.0 - (1.0 - t).powi(3); + let lerp = |a: i32, b: i32| a + ((b - a) as f64 * eased).round() as i32; + let lerp_u = |a: u32, b: u32| (a as i64 + ((b as i64 - a as i64) as f64 * eased).round() as i64).max(0) as u32; + srdwm_core::Rect { + x: lerp(self.from.x, self.to.x), + y: lerp(self.from.y, self.to.y), + width: lerp_u(self.from.width, self.to.width), + height: lerp_u(self.from.height, self.to.height), + } + } + + pub(crate) fn is_done(&self) -> bool { + self.start.elapsed() >= self.duration + } +} + +/// How far below its resting position a newly-opened window starts before +/// sliding up into place, in logical pixels. Deliberately a pure position +/// offset with no size change (see `WindowAnim`'s doc comment on why a +/// resize tween is reserved for maximize/fullscreen, where the client is +/// already live and redrawing, not for a window whose first paint may not +/// have arrived yet). +const OPEN_SLIDE_OFFSET: i32 = 24; + /// A held keybinding that is firing repeatedly. /// /// Driven from the poll loop rather than a timer source: the winit backend @@ -167,9 +384,24 @@ pub(crate) struct RepeatState { pub(crate) next_fire: Instant, } -/// Matches the seat's own repeat settings (`add_keyboard(.., 200, 25)`), so +/// Matches the seat's own repeat settings (`add_keyboard(.., 600, 25)`), so /// held bindings feel the same as held keys in a text field. -const REPEAT_DELAY: Duration = Duration::from_millis(200); +/// +/// 600ms, not smithay's own 200ms stock example value this used to copy -- +/// found comparing against Hyprland's default (`repeat_delay = 600`) after +/// a live report that typing felt "too sensitive" compared to other +/// compositors on the same hardware. 200ms is short enough that a key held +/// even slightly past a fifth of a second - well within normal variance in +/// how long a real keystroke's finger-down/finger-up dwell actually is, let +/// alone under any momentary scheduling hiccup delaying when the release +/// gets processed - starts a client-side repeat and inserts an unintended +/// extra character, which reads indistinguishably from "double-typing". +/// This is entirely a client-side effect (repeat_info is sent once and the +/// client manages its own timer from then on, never re-driven by the +/// compositor per keystroke - see `crates/wayland/src/input.rs`'s +/// `handle_keyboard_key_event`), so it was never visible to the diagnostic +/// logging used earlier to rule out server-side event duplication. +const REPEAT_DELAY: Duration = Duration::from_millis(600); const REPEAT_INTERVAL: Duration = Duration::from_millis(1000 / 25); impl CompState { @@ -208,11 +440,27 @@ impl CompState { } } -/// Titlebar background is the same regardless of focus (matching the X11 -/// backend); only the title text color changes. -const TITLEBAR_BG: (u8, u8, u8) = (0x2e, 0x34, 0x40); -const TITLEBAR_FG_FOCUSED: (u8, u8, u8) = (0x88, 0xc0, 0xd0); -const TITLEBAR_FG_UNFOCUSED: (u8, u8, u8) = (0x4c, 0x56, 0x6a); + +/// The border (added this session, see `decoration::border_strips`) is a +/// much bigger, more obvious visual element than the titlebar's text +/// color, so this is what actually answers "which window is focused" -- +/// reported live as genuinely unanswerable, since `Window.border_color` is +/// a single fixed color with no focus distinction at all, applied +/// identically to every window regardless of focus. +/// +/// Dims the window's own configured colour toward gray rather than +/// replacing it outright with one fixed "unfocused" colour: per-window +/// `border_color` is a real, used feature (rules set distinct colours per +/// app), and dimming keeps that distinction visible at a glance while +/// still making focus unambiguous. +pub(crate) fn effective_border_color(configured: (u8, u8, u8), focused: bool) -> (u8, u8, u8) { + if focused { + return configured; + } + const DIM: f32 = 0.35; + let dim = |c: u8| (c as f32 * DIM) as u8; + (dim(configured.0), dim(configured.1), dim(configured.2)) +} /// Output lookup. Everything that used to reach for a single /// `CompState::output` goes through one of these, so adding outputs did not @@ -255,23 +503,34 @@ impl CompState { let id = { let mut wm = self.wm.borrow_mut(); let id = wm.alloc_window_id(); - let title = with_toplevel_title(&toplevel).unwrap_or_default(); + let title = with_toplevel_title(toplevel.wl_surface()).unwrap_or_default(); let mut w = CoreWindow::new(id, title); + w.app_id = with_toplevel_app_id(toplevel.wl_surface()).unwrap_or_default(); w.geometry = srdwm_core::Rect::new(0, 0, 800, 600 + TITLEBAR_HEIGHT as i32 as u32); wm.add_window(w); + // Starts the open-slide tween (see `WindowAnim`'s doc comment): + // the window's first `sync_geometry` call below will see this, + // register the tween, and place it here - a few pixels below + // its resting position - rather than jumping straight to + // `geometry`. Same size throughout, so no extra client configure + // is needed for the tween itself. + if wm.animations_enabled { + if let Some(win) = wm.window_mut(id) { + let g = win.geometry; + win.anim_from = Some(srdwm_core::Rect { y: g.y + OPEN_SLIDE_OFFSET, ..g }); + } + } id }; - let geom = self.wm.borrow().window(id).map(|w| w.geometry).unwrap_or_default(); let dwindow = DWindow::new_wayland_window(toplevel.clone()); - toplevel.with_pending_state(|state| { - state.size = Some((geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into()); - }); - toplevel.send_configure(); - - self.space.map_element(dwindow.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), true); self.surface_to_id.insert(surface.clone(), id); self.id_to_window.insert(id, dwindow); + // `sync_geometry` handles the initial placement itself (map_element + // + the first configure, since `last_synced_size` has no entry yet + // for this id) as well as starting the open-slide tween registered + // above - see its own doc comment. + self.sync_geometry(id); self.redraw_decoration_buffer(id); // `WindowManager::add_window` already made this the focused window in // srdwm's own state, but that alone is purely internal bookkeeping -- @@ -283,6 +542,26 @@ impl CompState { // A newly-mapped window goes on top, but not over a pinned one. self.raise_pinned(); self.pending.borrow_mut().push(CoreEvent::WindowCreated(id)); + foreign_toplevel::window_created(self, id); + } + + /// Applies a negotiated `zxdg_toplevel_decoration_v1` mode to our own + /// `Window.decorated` flag and refreshes (or drops) its titlebar buffer + /// to match - see `XdgDecorationHandler::request_mode`'s doc comment + /// for why. A no-op if the surface has no window yet (decoration + /// negotiation racing ahead of `new_toplevel`, which shouldn't happen + /// in practice but costs nothing to guard against). + pub(crate) fn set_decorated_from_mode(&mut self, surface: &WlSurface, decorated: bool) { + let Some(&id) = self.surface_to_id.get(surface) else { return }; + if let Some(w) = self.wm.borrow_mut().window_mut(id) { + w.decorated = decorated; + } + self.redraw_decoration_buffer(id); + // Re-applies content size/position for the now-changed titlebar + // reservation - see `sync_geometry`'s own doc comment on why this + // can't be skipped: redrawing the titlebar buffer alone doesn't + // touch the content area's size or offset at all. + self.sync_geometry(id); } /// (Re)renders the titlebar band for `id` - background plus title text @@ -291,16 +570,46 @@ impl CompState { /// affects layout), and focus change (text color). pub(crate) fn redraw_decoration_buffer(&mut self, id: WindowId) { let Some(w) = self.wm.borrow().window(id).cloned() else { return }; - if !w.decorated { + let focused = self.wm.borrow().focused_id() == Some(id); + let theme = self.wm.borrow().theme; + if w.decorated { + let fg = if focused { theme.titlebar_fg_focused } else { theme.titlebar_fg_unfocused }; + let width = w.geometry.width.max(1); + // Always rounded now, bordered or not - `render_border_top` + // gives a bordered window's border strip the matching rounded + // cut, so there's no more square-frame-around-a-round-titlebar + // clash to avoid. See `render_titlebar`'s `round_corners` doc + // comment. + let data = decoration::render_titlebar(width, TITLEBAR_HEIGHT, &w.title, theme.titlebar_bg, fg, true); + let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (width as i32, TITLEBAR_HEIGHT as i32), 1, Transform::Normal, None); + self.decorations.insert(id, buffer); + } else { self.decorations.remove(&id); - return; } - let focused = self.wm.borrow().focused_id() == Some(id); - let fg = if focused { TITLEBAR_FG_FOCUSED } else { TITLEBAR_FG_UNFOCUSED }; - let width = w.geometry.width.max(1); - let data = decoration::render_titlebar(width, TITLEBAR_HEIGHT, &w.title, TITLEBAR_BG, fg); - let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (width as i32, TITLEBAR_HEIGHT as i32), 1, Transform::Normal, None); - self.decorations.insert(id, buffer); + // The border-top bitmap is independent of `decorated` - an + // undecorated (CSD) window can still have `border_width > 0` - so + // it's rebuilt here unconditionally rather than falling under the + // early return above. Cached the same way `decorations` is, at the + // same trigger points (creation, a size change, a rule re-applying, + // and - since this call is now also reached from focus changes -- + // `w.border_color`'s focused/unfocused dimming): see `elements:: + // border_side_render_element`'s doc comment for why re-rasterizing + // this every render frame (an earlier version of this method did) + // was a real, continuous cost, not just a redundant one. + if w.border_width > 0 { + let color = effective_border_color(w.border_color, focused); + let strips = decoration::border_strips(w.geometry, w.border_width); + if strips[0].width > 0 && strips[0].height > 0 { + let data = decoration::render_border_top(strips[0].width, w.border_width, color); + let buffer = + MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (strips[0].width as i32, w.border_width as i32), 1, Transform::Normal, None); + self.border_top_decorations.insert(id, buffer); + } else { + self.border_top_decorations.remove(&id); + } + } else { + self.border_top_decorations.remove(&id); + } } pub(crate) fn remove_window(&mut self, surface: &WlSurface) { @@ -309,8 +618,37 @@ impl CompState { self.space.unmap_elem(&w); } self.decorations.remove(&id); + self.border_top_decorations.remove(&id); + self.border_side_buffers.remove(&id); + self.last_synced_size.remove(&id); + // A window closing (crash, kill, or its own menu's "Close" action + // racing ahead of this) while its context menu is still open would + // otherwise leave the menu pointing at a dead id - selecting any + // row on it would then silently no-op against a window that no + // longer exists, with no indication anything went wrong. + if self.context_menu.as_ref().is_some_and(|m| m.window == id) { + self.close_context_menu(); + } self.wm.borrow_mut().remove_window(id); self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id)); + foreign_toplevel::window_closed(self, id); + // `remove_window` may have picked a new focused window on its own + // (falls back to whatever's now on top) - see `sync_keyboard_focus`'s + // doc comment for why the Wayland/X11 side needs a separate nudge to + // actually catch up to that. + crate::input::sync_keyboard_focus(self); + // Safety net for `zwp_idle_inhibit_manager_v1`: smithay's own + // `IdleInhibitorState` only calls `uninhibit` on an explicit + // `destroy` request, never on `Dispatch::destroyed` - so a video + // player that crashes or gets killed instead of exiting cleanly + // would leave its inhibitor permanently stuck, holding the whole + // system awake forever with no client left to ever release it. + // Its window closing is the one thing guaranteed to happen either + // way, so this is what actually catches that case. + if self.idle_inhibiting_surfaces.contains(surface) { + self.idle_inhibiting_surfaces.retain(|s| s != surface); + self.idle_notifier_state.set_is_inhibited(!self.idle_inhibiting_surfaces.is_empty()); + } } /// Layer surfaces need a configure sent in direct response to their @@ -322,6 +660,21 @@ impl CompState { /// its `keyboard_interactivity` isn't reliably known until the client's /// state has actually committed. pub(crate) fn ensure_layer_initial_configure(&mut self, surface: &WlSurface) { + // Called unconditionally from `commit()` for every surface in the + // whole desktop, on every single commit - so before doing anything + // that scales with output/layer count, a cheap O(1) check: has this + // surface ever gone through `get_layer_surface` at all? Only that + // request ever inserts `LayerSurfaceData` into a surface's + // `data_map` (smithay's own `handlers.rs`), so this is `None` for + // every ordinary xdg-toplevel/subsurface commit - the overwhelming + // majority of commits on any real desktop. Skipping straight past + // the per-output `layer_for_surface` surface-tree walk for all of + // those is the difference between this function costing something + // on every single frame any window renders versus only on commits + // from the handful of surfaces that were ever layer surfaces. + if with_states(surface, |states| states.data_map.get::<LayerSurfaceData>().is_none()) { + return; + } // A layer surface lives in exactly one output's `LayerMap` (whichever // one `new_layer_surface` mapped it into), so find that output rather // than assuming a single global one. @@ -329,7 +682,18 @@ impl CompState { let layer = layer_map_for_output(output).layer_for_surface(surface, WindowSurfaceType::TOPLEVEL).cloned(); layer.map(|l| (output.clone(), l)) }); - let Some((output, layer)) = found else { return }; + // Not a layer surface (or a destroyed one - see `new_surface`'s + // pre-commit-hook workaround, which is what stops this from being + // a protocol error). Every ordinary commit from every window in + // the desktop passes through here and takes this branch, so this + // used to log unconditionally during the P0 investigation + // (docs/PANEL_SUPPORT_TODO.md) - diagnostic purpose long since + // served, and left running it logged upwards of 15k lines in a few + // minutes of normal use (every Firefox frame, every terminal + // redraw, ...), which is real wasted I/O, not just noise. + let Some((output, layer)) = found else { + return; + }; // Recompute geometry from whatever the client just committed // (`set_size`/`set_anchor`/`set_margin`/`set_exclusive_zone` are all @@ -343,7 +707,27 @@ impl CompState { // changes; `arrange()` only actually sends a configure when // something changed, so this is a no-op on a commit that didn't // touch layer-shell state. + let zone_before = layer_map_for_output(&output).non_exclusive_zone(); layer_map_for_output(&output).arrange(); + let zone_after = layer_map_for_output(&output).non_exclusive_zone(); + if zone_after != zone_before { + // A bar/dock claiming (or releasing) an exclusive zone changes + // the area core's placement/tiling should actually use -- + // without this, `WindowManager`'s notion of the monitor rect + // is whatever `Platform::monitors()` returned once at startup + // (before any layer-shell client had connected and set a real + // exclusive zone), so every window keeps being placed across + // the *whole* output including the strip a bar now occupies: + // new windows spawn with their titlebar directly under the bar, + // rendered beneath it and unreachable to drag. Reusing + // `MonitorAdded` here rather than a new event type: main.rs's + // handler for it already re-queries the full monitor list from + // the platform rather than trusting the event's payload (see + // its own comment on why), which is exactly "go recompute the + // usable area" - the placeholder `Monitor` below is discarded + // unread on that path. + self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0)))); + } let initial_configure_sent = with_states(surface, |states| { states @@ -354,6 +738,7 @@ impl CompState { }); if !initial_configure_sent { layer.layer_surface().send_configure(); + log::debug!("layer-shell: sent initial configure for surface {:?}", surface.id()); } // Checked on every commit, not just the first: a client can flip @@ -392,16 +777,121 @@ impl CompState { } } let Some(keyboard) = self.seat.get_keyboard() else { return }; - if keyboard.current_focus() == surface { + let old_focus = keyboard.current_focus(); + if old_focus == surface { return; } let client = surface.as_ref().and_then(|s| self.dh.get_client(s.id()).ok()); set_data_device_focus(&self.dh.clone(), &self.seat.clone(), client.clone()); set_primary_focus(&self.dh.clone(), &self.seat.clone(), client); + self.update_net_active_window(surface.as_ref()); + foreign_toplevel::update_activated(self, old_focus.clone(), surface.as_ref()); + // `xdg_toplevel`'s own `Activated` state - never sent anywhere + // before this. `keyboard.set_focus` below only delivers + // `wl_keyboard.enter`/`leave`; it says nothing about `xdg_toplevel` + // state, which is the signal GTK4/libadwaita's `:backdrop` CSS + // pseudo-class (and equivalents elsewhere) actually key off to + // decide whether to paint their *own* titlebar as focused. Without + // this, no window - including the only one open, with nothing else + // it could be losing focus to - ever received it, so any client + // that draws its own focus indicator this way looked permanently + // unfocused no matter how many other windows existed, even though + // real keyboard input (`wl_keyboard.enter`/`keyboard.set_focus` + // below) was unaffected and reached the right window regardless. + self.set_window_activated(old_focus.as_ref(), false); + self.set_window_activated(surface.as_ref(), true); let serial = SERIAL_COUNTER.next_serial(); keyboard.set_focus(self, surface, serial); } + /// Sets `xdg_toplevel`'s `Activated` state (or the X11 equivalent) for + /// whichever window owns `surface`, flushing a configure for the native + /// case - `DWindow::set_activated` alone only queues the pending state + /// for an xdg-shell window; nothing sends it to the client without an + /// explicit `send_configure` (the X11 case has no such split, its own + /// `set_activated` talks to the X connection directly). A no-op if + /// `surface` has no window (e.g. `None`, or a layer surface/popup, + /// neither of which are `xdg_toplevel`s) or the state didn't actually + /// change. + fn set_window_activated(&mut self, surface: Option<&WlSurface>, active: bool) { + let Some(id) = surface.and_then(|s| self.surface_to_id.get(s)).copied() else { return }; + let changed = match self.id_to_window.get(&id) { + Some(w) if w.set_activated(active) => { + if let Some(toplevel) = w.toplevel() { + toplevel.send_configure(); + } + true + } + _ => false, + }; + // Titlebar text and border colour both dim/brighten on focus (see + // `redraw_decoration_buffer`'s `fg`/`effective_border_color`), but + // neither was ever actually re-rasterized here before - this call + // only ever ran for other reasons (creation, a resize, a rule + // re-applying) that happened to also be roughly when focus + // changed, in practice, close enough that the gap went unnoticed. + // Gated on `changed` so a redundant `set_window_activated(_, false)` + // for a surface that was never activated (the common `old_focus == + // None` case) doesn't force a pointless redraw. + if changed { + self.redraw_decoration_buffer(id); + } + } + + /// Opens the right-click titlebar window menu for `window`, top-left + /// corner at `pos` (global space, wherever the click landed). Rebuilds + /// and caches the rasterised buffer once here rather than per frame -- + /// same reasoning as `redraw_decoration_buffer`. + pub(crate) fn open_context_menu(&mut self, window: WindowId, pos: (i32, i32)) { + let Some(menu) = ({ + let wm = self.wm.borrow(); + crate::context_menu::ContextMenu::open(&wm, window, pos) + }) else { + return; + }; + let theme = self.wm.borrow().theme; + let items: Vec<(&str, bool)> = menu.items.iter().map(|&(label, _)| (label, false)).collect(); + let data = decoration::render_context_menu(menu.width, menu.row_height, &items, theme.titlebar_bg, theme.titlebar_fg_focused, theme.titlebar_fg_unfocused, theme.default_border_color); + let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (menu.width as i32, menu.height()), 1, Transform::Normal, None); + self.context_menu_buffer = Some(buffer); + self.context_menu = Some(menu); + } + + pub(crate) fn close_context_menu(&mut self) { + self.context_menu = None; + self.context_menu_buffer = None; + } + + /// Runs whichever action a click on `row` of the currently-open context + /// menu selected. Takes the menu's `window`/action rather than reading + /// `self.context_menu` itself so the caller can close the menu first + /// (clearing the borrow) before this runs - several of these actions + /// (`sync_geometry`, `redraw_decoration_buffer`) need `&mut self` in + /// ways that would otherwise conflict with an active `self.context_menu` + /// borrow. + pub(crate) fn run_context_menu_action(&mut self, window: WindowId, action: crate::context_menu::MenuAction) { + use crate::context_menu::MenuAction; + match action { + MenuAction::Minimize => { + self.wm.borrow_mut().minimize_window(window); + foreign_toplevel::send_state(self, window); + } + MenuAction::ToggleMaximize => { + self.wm.borrow_mut().toggle_maximize(window); + self.sync_geometry(window); + foreign_toplevel::send_state(self, window); + } + MenuAction::ToggleAlwaysOnTop => { + self.wm.borrow_mut().toggle_always_on_top(window); + } + MenuAction::Close => { + if let Some(w) = self.id_to_window.get(&window) { + crate::input::close_dwindow(w); + } + } + } + } + /// True when this titlebar press is the second of a double-click on the /// same window. Threshold is the usual 400ms. pub(crate) fn is_double_click(&mut self, id: WindowId, time: u32) -> bool { @@ -432,24 +922,296 @@ impl CompState { } pub(crate) fn sync_geometry(&mut self, id: WindowId) { - let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { return }; + // A pending `anim_from` (set by `toggle_maximize`/`toggle_fullscreen`, + // or by `new_managed_window` for the open-slide) means the target + // geometry below is where this window is *headed*, not where it + // should appear right now - register (or replace) a tween and use + // `WindowAnim::current_rect` in its place for this call and every + // `tick_animations` call afterward, until it completes. `take()` + // both reads and clears it, so a later, non-animated `sync_geometry` + // call for the same window (an ordinary drag/resize frame) goes + // straight back to applying `geometry` immediately, as before. + let anim_from = self.wm.borrow_mut().window_mut(id).and_then(|w| w.anim_from.take()); + let Some((target, decorated)) = self.wm.borrow().window(id).map(|w| (w.geometry, w.decorated)) else { return }; + if let Some(from) = anim_from { + let duration_ms = self.wm.borrow().animation_duration_ms; + if from != target && duration_ms > 0 { + self.window_anims + .insert(id, WindowAnim { from, to: target, start: Instant::now(), duration: Duration::from_millis(duration_ms as u64) }); + } + } + let geom = self.window_anims.get(&id).map(WindowAnim::current_rect).unwrap_or(target); + // The titlebar band is only actually reserved when there is one -- + // an undecorated window (client-side decoration, see + // `set_decorated_from_mode`) gets the whole of `geom` as content, + // not `geom` minus a band that's no longer being drawn. Without + // this, a window that negotiated client-side decoration kept the + // same 30px gap at its top anyway: our titlebar wasn't drawn there + // (correctly), but the content was still offset down and told it + // was 30px shorter than the window actually is, leaving a blank + // strip and the frame sitting visibly wrong relative to what's + // inside it. + let band = if decorated { TITLEBAR_HEIGHT as i32 } else { 0 }; + // Position always moves with the pointer; only a size change needs a + // client configure or a titlebar re-render (see `last_synced_size`'s + // doc comment). + let size = (geom.width as i32, geom.height as i32 - band); + let size_changed = self.last_synced_size.insert(id, size) != Some(size); + let mut moved = false; if let Some(w) = self.id_to_window.get(&id) { - self.space.map_element(w.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), false); + self.space.map_element(w.clone(), (geom.x, geom.y + band), false); + moved = true; if let Some(top) = w.toplevel() { - top.with_pending_state(|state| { - state.size = Some((geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into()); - }); - top.send_configure(); + // xdg-shell position is a purely compositor-side concept -- + // the client is never told it - so only a size change + // needs a configure here. + if size_changed { + top.with_pending_state(|state| { + state.size = Some(size.into()); + }); + top.send_configure(); + } + } else if let Some(x11) = w.x11_surface() { + // Unlike xdg-shell, an X11 client's real on-screen position + // is part of its own window state - it has to be told on + // every move, not just every resize, the same way a real + // X11 window manager sends continuous `ConfigureNotify` + // during an interactive drag. Without this branch at all, + // `sync_geometry` never reconfigured an XWayland window a + // second time past its initial map: `space.map_element` + // above still moved smithay's own tracked position (see + // `resync_stacking_order`'s doc comment for the real + // z-order side effect that has, since fixed below) and the + // border/titlebar still redrew at the new `Window.geometry` + // (both read it fresh every frame), but the real X11 + // client window was never told to move or resize - any + // drag, resize, maximize, edge-snap, or tiling re-layout of + // an XWayland-backed app left its actual content frozen at + // its original position/size forever while srdwm's own + // decoration moved freely around it. + let _ = x11.configure(Rectangle::new((geom.x, geom.y + band).into(), size.into())); } } - if self.decorations.contains_key(&id) { + if size_changed && self.decorations.contains_key(&id) { self.redraw_decoration_buffer(id); } + // See `resync_stacking_order`'s doc comment: `map_element` above + // always re-stacks its target to the top of `Space`'s own order as + // a side effect of updating position, `activate` or not - and + // `sync_geometry` runs for reasons with nothing to do with raising + // a window (a title changing, an ordinary resize frame), so left + // uncorrected this silently, non-deterministically desynced + // `Space`'s notion of "on top" from `WindowManager`'s. + if moved { + self.resync_stacking_order(); + } + } + + /// Re-broadcasts any dock/panel-facing protocol state that changed by a + /// path with no direct hook back into this module - specifically a + /// compositor keybinding via `crates/config`'s `WindowAction`/ + /// `srd.workspace.*` API, which only ever touches `WindowManager` and + /// has no way to reach `CompState`. See `foreign_toplevel:: + /// broadcast_dirty_state` and `workspace::broadcast_dirty_active`'s own + /// doc comments for the full story; called once per frame alongside + /// `tick_animations`, from both backends' poll loops. + pub(crate) fn tick_dirty_broadcasts(&mut self) { + foreign_toplevel::broadcast_dirty_state(self); + workspace::broadcast_dirty_active(self); + output_management::broadcast_dirty_outputs(self); + } + + /// Advances every in-flight `WindowAnim` by one frame; called once per + /// redraw from both backends' poll loops. A finished tween is dropped + /// *before* its final `sync_geometry` call, so that call lands exactly + /// on `Window.geometry` (the authoritative target) rather than on + /// whatever the eased curve's last sub-pixel step happened to be. + pub(crate) fn tick_animations(&mut self) { + if self.window_anims.is_empty() { + return; + } + let ids: Vec<WindowId> = self.window_anims.keys().copied().collect(); + for id in ids { + if self.window_anims.get(&id).is_some_and(WindowAnim::is_done) { + self.window_anims.remove(&id); + } + self.sync_geometry(id); + } + } + + /// Re-applies `WindowManager`'s own stacking order to `Space`, bottom + /// to top. + /// + /// `Space::map_element` (smithay 0.7.0) always re-stacks its target to + /// the top of `Space`'s internal order as an unconditional side effect + /// of updating its tracked position - true regardless of the + /// `activate` argument, and there is no "move without restacking" in + /// this smithay version. `sync_geometry` calls `map_element` for + /// reasons that have nothing to do with raising a window at all (a + /// title/app_id changing, an ordinary resize frame, a workspace + /// switch), so every one of those silently raised the window it + /// touched to the top of `Space`'s order regardless of which window + /// `WindowManager`/the user actually considered focused or on top. + /// Real-world effect, confirmed live: two windows created moments + /// apart, each independently going through their own startup + /// title/app_id negotiation, would each trigger a handful of + /// `sync_geometry` calls purely from that startup sequence - so + /// whichever one happened to settle *last* silently won `Space`'s + /// notion of "on top", a race with no relationship to which window was + /// actually focused. Reported live as a background window's content + /// and decoration randomly painting in front of the actually-focused + /// window on top of it. Calling this right after any `map_element` + /// restores `Space`'s order to exactly match `WindowManager.order` + /// (which `restack_pinned` already keeps pinned windows at the tail + /// of), so the two can never drift apart again. + fn resync_stacking_order(&mut self) { + let order: Vec<WindowId> = self.wm.borrow().stacking_order().map(|w| w.id).collect(); + for id in order { + if let Some(w) = self.id_to_window.get(&id).cloned() { + self.space.raise_element(&w, false); + } + } } } -pub(crate) fn with_toplevel_title(toplevel: &ToplevelSurface) -> Option<String> { - smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| { +pub(crate) fn with_toplevel_title(surface: &WlSurface) -> Option<String> { + smithay::wayland::compositor::with_states(surface, |states| { states.data_map.get::<XdgToplevelSurfaceData>().map(|d| d.lock().unwrap().title.clone().unwrap_or_default()) }) } + +/// Same pattern as `with_toplevel_title`, for `app_id` - the xdg-shell +/// equivalent of `WM_CLASS`, and what `srd.rule({ class = ... })` matches +/// against (`crates/core/src/rules.rs`). +/// +/// Nothing read this before: `new_managed_window` populated `Window.title` +/// from `with_toplevel_title` but never touched `Window.app_id` at all, so +/// every native Wayland window had an *empty* app_id the entire time rules +/// are evaluated (`WindowManager::add_window` matches rules once, at +/// creation). Every `class`-based rule - including the Firefox +/// `decorated = false` one meant to stop srdwm drawing a second titlebar +/// on top of Firefox's own - could therefore never match a native Wayland +/// client, only an XWayland one (`map_window_request` in xwayland.rs does +/// set `app_id` correctly, from `X11Surface::class()`). Reported live as +/// Firefox showing two titlebars, "same for other applications" - exactly +/// what this predicts, since it silently breaks every class-matched rule +/// for every native Wayland app, not just Firefox's. +pub(crate) fn with_toplevel_app_id(surface: &WlSurface) -> Option<String> { + smithay::wayland::compositor::with_states(surface, |states| { + states.data_map.get::<XdgToplevelSurfaceData>().map(|d| d.lock().unwrap().app_id.clone().unwrap_or_default()) + }) +} + +/// Re-reads title/app_id from the surface's own xdg-shell state and updates +/// `Window`/foreign-toplevel listeners if either changed since last read. +/// +/// Needed because a fresh toplevel's `new_toplevel` fires at +/// `xdg_surface.get_toplevel()` - role assignment - which for essentially +/// every real client happens *before* the `set_title`/`set_app_id`/first +/// `commit()` sequence that actually supplies them. `new_managed_window` +/// reading those fields at that moment (see its own comment) reliably got +/// nothing: not a race, a fixed ordering every client hits, confirmed live +/// by a peer session capturing the raw `zwlr_foreign_toplevel_handle_v1` +/// wire output and finding `app_id`/`title` empty on every window. Unlike +/// `Window.geometry`/state (double-buffered per xdg-shell semantics), +/// title and app_id are plain immediate-apply requests in smithay's own +/// `XdgToplevelSurfaceData`, so re-reading them on every commit - cheap, +/// and this is already a per-commit hook - keeps `Window.title`/`app_id` +/// (and anything, like `srd.rule`, that reads them) correct from the first +/// real commit onward instead of frozen at an empty initial snapshot. +pub(crate) fn sync_toplevel_metadata(state: &mut CompState, id: WindowId, surface: &WlSurface) { + let title = with_toplevel_title(surface).unwrap_or_default(); + let app_id = with_toplevel_app_id(surface).unwrap_or_default(); + let changed = { + let mut wm = state.wm.borrow_mut(); + let Some(w) = wm.window_mut(id) else { return }; + let changed = w.title != title || w.app_id != app_id; + w.title = title; + w.app_id = app_id; + changed + }; + if changed { + // Now that `title`/`app_id` are real, give `add_window`'s rule + // match (which ran before either was set - see `Window:: + // rules_applied`'s doc comment) a real chance. Only the *first* + // successful evaluation (rule actually matched, `Some` returned) + // warrants `redraw_decoration_buffer`/`sync_geometry` - unlike + // `set_decorated_from_mode`, this fires on every subsequent title + // change too (a page finishing loading, long after the window's + // own creation), and `sync_geometry` re-stacks the window to the + // top of smithay's `Space` as a side effect of `map_element` + // (true regardless of its `activate` argument - there's no + // "move without restacking" in this smithay version). Calling it + // unconditionally here silently yanked an unrelated, unfocused + // window back to the front any time its title happened to + // update - reported live as an older window jumping in front of + // a newer, focused one with no user action to explain it. + if state.wm.borrow_mut().reapply_rules_if_pending(id) { + state.redraw_decoration_buffer(id); + state.sync_geometry(id); + } + crate::foreign_toplevel::send_state(state, id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focused_window_keeps_its_configured_colour() { + assert_eq!(effective_border_color((136, 192, 208), true), (136, 192, 208)); + } + + #[test] + fn unfocused_window_is_dimmed_but_still_recognisably_that_colour() { + let dimmed = effective_border_color((136, 192, 208), false); + // Dimmer in every channel... + assert!(dimmed.0 < 136 && dimmed.1 < 192 && dimmed.2 < 208); + // ...but not black, and the channels' relative order is preserved + // (still "bluish", not just "gray") so a per-window colour set via + // a rule stays distinguishable from another window's even while + // unfocused. + assert!(dimmed.0 > 0 || dimmed.1 > 0 || dimmed.2 > 0); + assert!(dimmed.2 >= dimmed.1 && dimmed.1 >= dimmed.0); + } + + #[test] + fn window_anim_starts_at_from_and_ends_at_to() { + let anim = WindowAnim { + from: srdwm_core::Rect::new(0, 100, 300, 200), + to: srdwm_core::Rect::new(0, 0, 300, 200), + start: Instant::now(), + duration: Duration::from_millis(200), + }; + assert_eq!(anim.current_rect(), anim.from); + assert!(!anim.is_done()); + } + + #[test] + fn window_anim_is_done_and_settles_exactly_on_to_once_duration_elapses() { + let anim = WindowAnim { + from: srdwm_core::Rect::new(0, 100, 300, 200), + to: srdwm_core::Rect::new(50, 0, 600, 400), + start: Instant::now() - Duration::from_millis(500), + duration: Duration::from_millis(200), + }; + assert!(anim.is_done()); + assert_eq!(anim.current_rect(), anim.to); + } + + #[test] + fn window_anim_midway_is_strictly_between_from_and_to_on_every_axis() { + let anim = WindowAnim { + from: srdwm_core::Rect::new(0, 200, 200, 100), + to: srdwm_core::Rect::new(100, 0, 800, 600), + start: Instant::now() - Duration::from_millis(100), + duration: Duration::from_millis(200), + }; + let r = anim.current_rect(); + assert!(r.x > 0 && r.x < 100); + assert!(r.y > 0 && r.y < 200); + assert!(r.width > 200 && r.width < 800); + assert!(r.height > 100 && r.height < 600); + } +} diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs index 95ef571..96d57d5 100644 --- a/crates/wayland/src/udev.rs +++ b/crates/wayland/src/udev.rs @@ -38,20 +38,21 @@ use smithay::backend::input::{ }; use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; use smithay::backend::renderer::damage::OutputDamageTracker; -use smithay::backend::renderer::element::memory::{MemoryRenderBuffer, MemoryRenderBufferRenderElement}; +use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::pixman::PixmanRenderer; -use smithay::backend::renderer::Bind; +use smithay::backend::renderer::{Bind, ImportDma}; use smithay::backend::session::{libseat::LibSeatSession, libseat::LibSeatSessionNotifier, Event as SessionEvent, Session}; use smithay::backend::udev::{self, UdevBackend, UdevEvent}; use smithay::desktop::space::render_output; -use smithay::desktop::{layer_map_for_output, Space}; +use smithay::desktop::{layer_map_for_output, PopupManager, Space}; +use smithay::backend::input::AxisSource; use smithay::input::pointer::AxisFrame; use smithay::input::SeatState; use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Subpixel}; use smithay::reexports::calloop::generic::{FdWrapper, Generic}; use smithay::reexports::calloop::{EventLoop, Interest, LoopHandle, Mode as CalloopMode, PostAction}; -use smithay::reexports::drm::buffer::DrmFourcc; +use smithay::reexports::drm::buffer::{Buffer as DrmBufferTrait, DrmFourcc}; use smithay::reexports::drm::control::{ connector, crtc, dumbbuffer::DumbBuffer, framebuffer, Device as ControlDevice, Event as DrmEvent, Mode as DrmMode, ModeTypeFlags, PageFlipFlags, @@ -62,18 +63,21 @@ use smithay::reexports::pixman::{FormatCode, Image}; use smithay::reexports::rustix; use smithay::reexports::wayland_server::backend::GlobalId; use smithay::reexports::wayland_server::{Client, Display, DisplayHandle, ListeningSocket}; -use smithay::utils::{Logical, Point, Transform}; +use smithay::utils::{Logical, Physical, Point, Rectangle, Scale, Size, Transform}; use smithay::wayland::compositor::CompositorState; +use smithay::wayland::dmabuf::DmabufState; use smithay::wayland::selection::data_device::DataDeviceState; use smithay::wayland::selection::primary_selection::PrimarySelectionState; use smithay::wayland::selection::wlr_data_control::DataControlState; use smithay::wayland::shell::xdg::decoration::XdgDecorationState; use smithay::wayland::shell::xdg::XdgShellState; use smithay::wayland::shm::ShmState; +use smithay::wayland::xdg_activation::XdgActivationState; use srdwm_core::{Event as CoreEvent, WindowManager}; use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult}; +use crate::decoration; use crate::err; use crate::input::{handle_keyboard_key_event, handle_pointer_button, handle_pointer_position}; use crate::state::{ClientState, CompState}; @@ -117,6 +121,24 @@ pub(crate) struct UdevHead { /// A flip is in flight; the next frame for this head waits for the DRM /// page-flip event (matched by `crtc`) before starting. pub(crate) flip_pending: bool, + /// Per-buffer-slot age passed to `damage_tracker.render_output`: how + /// many *damage-producing* renders ago that exact buffer was last + /// brought fully up to date. 0 means "never rendered, contents + /// undefined" and forces a full redraw. This used to be hardcoded to 0 + /// on every single call regardless - which, per + /// `OutputDamageTracker::damage_output_internal`, forces the entire + /// output geometry to be treated as damaged every time, so every frame + /// was a full-screen software (pixman) recomposite plus a page-flip, + /// nonstop, at whatever rate the event loop's 16ms dispatch timeout + /// allowed - continuously, even with a fully idle desktop. That + /// competes for the same single thread's CPU time as libinput event + /// processing and is exactly what `client bug: event processing + /// lagging behind` (logged for both the keyboard and the mouse) was + /// reporting. With correct ages, a call that finds no real damage + /// returns near-free (`damage_output_internal`'s own element/geometry + /// comparison, no pixel work) and skips the flip entirely instead of + /// always finding "damage". + pub(crate) ages: [usize; 2], /// Origin of this head in the global coordinate space. pub(crate) location: Point<i32, Logical>, pub(crate) size: (i32, i32), @@ -154,6 +176,8 @@ impl CompState { /// monitors on different refresh rates each run at their own pace /// instead of the slowest one gating the rest. pub(crate) fn render_udev_frame(&mut self) { + self.tick_animations(); + self.tick_dirty_broadcasts(); let locked = self.lock.locked; let elapsed = self.start_time.elapsed(); // Drained before the `&mut self.udev` borrow below, so screencopy can @@ -161,12 +185,41 @@ impl CompState { let mut captures = std::mem::take(&mut self.screencopy_pending); // Same reason: the cursor needs the renderer that borrow owns. let cursor_status = self.cursor_status.clone(); - let cursor_buffer = self.cursor_buffer.clone(); + let cursor_buffers = self.cursor_buffers.clone(); + + // Border geometry is in global space, independent of which head + // renders it, so it's gathered once here rather than per head. + // Buffers are pre-built for the same reason as `cursor_buffers`: + // Rendered per window, front-to-back (topmost first), each window's + // content immediately followed by its decoration and border -- + // fixes the same cross-window ordering bug documented in + // `winit.rs`'s render loop: a background window's titlebar could + // otherwise show through in front of the actually-focused window on + // top of it, since decorations/borders used to be a single flat + // layer drawn unconditionally above *every* window's content + // regardless of real stacking order. `visible_windows_front_to_back` + // is `WindowManager.order` reversed - not `visible_windows`, which + // iterates the `windows` HashMap with no ordering guarantee - the + // same "topmost first" convention `hit_test`/`window_at` use. + // Fetched once here (`&mut self` fields, id_to_window/space lookups + // happen per head below without needing `self` itself mutably) -- + // see the per-head loop for why decoration/border buffers still get + // looked up fresh per head (head-local `origin` translation). + let ids: Vec<srdwm_core::WindowId> = if locked { Vec::new() } else { self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect() }; + let focused = self.wm.borrow().focused_id(); + let popup_targets = if locked { Vec::new() } else { crate::elements::popup_targets(self) }; // Which heads are eligible, and what each needs, gathered before the - // mutable borrow of `self.udev`. - let Some(udev) = self.udev.as_ref() else { return }; + // mutable borrow of `self.udev`. Both early-outs below give the + // `captures` taken above nowhere to go this pass - put them back + // rather than silently dropping a client's pending screenshot + // because a VT switch happened to be in progress at that instant. + let Some(udev) = self.udev.as_ref() else { + self.screencopy_pending.extend(captures); + return; + }; if !udev.active { + self.screencopy_pending.extend(captures); return; } let ready: Vec<(usize, Output)> = udev @@ -176,22 +229,23 @@ impl CompState { .filter(|(_, h)| !h.flip_pending) .map(|(i, h)| (i, h.output.clone())) .collect(); - - let mut presented: Vec<Output> = Vec::new(); + // Kept separately from `presented` below: layer-shell surfaces + // (bars, docks) get their frame callback every pass regardless of + // `has_damage`, unlike toplevel windows - see the callback loop at + // the end of this function for why the two can't share one gate. + let ready_outputs: Vec<Output> = ready.iter().map(|(_, o)| o.clone()).collect(); + + // Damage rects travel alongside each presented output so the + // frame-callback loop below (after `udev` is no longer borrowed) + // can notify only the windows that damage actually overlapped -- + // see `windows_touched_by_damage`'s doc comment in elements.rs. + let mut presented: Vec<(Output, Vec<Rectangle<i32, Physical>>)> = Vec::new(); for (index, output) in ready { let lock_surface = self.lock_surface_for(&output).cloned(); - // Decoration elements are built per head: `from_buffer` needs the + // Content/decoration elements are built per head: both need the // renderer, and geometry is translated into head-local space. let origin = self.udev.as_ref().map(|u| u.heads[index].location).unwrap_or_default(); - let decorations: Vec<(srdwm_core::Rect, MemoryRenderBuffer)> = if locked { - Vec::new() - } else { - self.decorations - .iter() - .filter_map(|(&id, deco)| self.wm.borrow().window(id).map(|w| (w.geometry, deco.clone()))) - .collect() - }; let Some(udev) = self.udev.as_mut() else { return }; let head = &mut udev.heads[index]; @@ -206,19 +260,143 @@ impl CompState { let hsize = udev.heads[index].size; custom_elements.extend(crate::cursor::render_elements( &cursor_status, - &cursor_buffer, + &cursor_buffers, &mut udev.renderer, pointer_pos, origin, hsize, )); - for (geom, deco) in &decorations { - let pos = ((geom.x - origin.x) as f64, (geom.y - origin.y) as f64); - match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, deco, None, None, None, Kind::Unspecified) { + // The right-click titlebar menu, if open - pushed right + // after the cursor so it's still topmost over every window + // but never hides the pointer itself (you need to see what + // you're about to click). + if let (Some(menu), Some(buffer)) = (self.context_menu.as_ref(), self.context_menu_buffer.as_ref()) { + let pos = ((menu.pos.0 - origin.x) as f64, (menu.pos.1 - origin.y) as f64); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) { Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), - Err(e) => log::warn!("udev: failed to import titlebar buffer: {e}"), + Err(e) => log::warn!("udev: failed to import context menu buffer: {e}"), } } + // Content still comes from `render_output`'s own `spaces` + // argument (`self.space`, passed below), not from a + // per-window `render_elements` call here - see the long + // comment on the matching loop in `winit.rs`, and `state.rs`'s + // `resync_stacking_order` doc comment, for the real + // stacking-order bug this was chasing and its actual fix. + // Windows stacked in front of whichever one border/ + // decoration is being built right now - `ids` is already + // front-to-back, so this only ever needs appending to, not + // recomputing. Only a window's own *content* occludes + // correctly on its own path (via `space`, real stacking + // order respected); everything drawn here goes through + // `custom_elements`, which composites above *all* content + // unconditionally, so both the border strips and the + // titlebar bitmap below need this explicit occlusion test + // against it. + let mut occluders: Vec<srdwm_core::Rect> = Vec::with_capacity(ids.len()); + for &id in &ids { + let Some(w) = self.wm.borrow().window(id).cloned() else { continue }; + // `w.geometry` is the animation's *target*, not + // necessarily where the window is actually drawn this + // frame - during a maximize/fullscreen/open-slide tween, + // `sync_geometry` already renders the window's own + // content at `window_anims`' interpolated rect (see its + // doc comment), but this loop used to read `w.geometry` + // straight from the model regardless, so the border and + // titlebar sat at the final rect while the content they + // were supposed to outline slid past underneath them -- + // reported live as the border "not flush" with the + // window during any animated transition. Every use of + // this window's geometry below (titlebar/border + // placement *and* the occlusion test against later + // windows) has to agree with what `sync_geometry` mapped + // the content to, or they drift apart again. + let geom = self.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry); + if let Some(deco) = self.decorations.get(&id) { + // Fragment-clipped, same as the three solid border + // strips below - an *all-or-nothing* version of + // this (skip only once fully covered) was tried + // first and reported live as still showing "the + // behind window's bar": a titlebar only *partially* + // covered - the common case for cascaded/ + // overlapping windows - drew in full regardless, + // bleeding through the covered part. `from_buffer`'s + // `src` parameter crops the bitmap itself, so each + // visible fragment can come from the matching + // sub-rect of the source image rather than the + // whole thing. + let titlebar_rect = srdwm_core::Rect::new(geom.x, geom.y, geom.width, srdwm_core::TITLEBAR_HEIGHT); + for fragment in crate::elements::visible_border_fragments(titlebar_rect, &occluders) { + let pos = ((fragment.x - origin.x) as f64, (fragment.y - origin.y) as f64); + let src = Rectangle::new( + Point::from(((fragment.x - titlebar_rect.x) as f64, (fragment.y - titlebar_rect.y) as f64)), + Size::from((fragment.width as f64, fragment.height as f64)), + ); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, deco, None, Some(src), None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("udev: failed to import titlebar buffer: {e}"), + } + } + } + // Border strips sit entirely outside this window's own + // `geometry` (see `decoration::border_strips`), so they + // never overlap its own decoration/content - draw + // order against those doesn't matter here, only against + // other windows', which iterating `ids` in stacking + // order already gets right *for windows also drawn via + // this same custom_elements loop* - but not against + // any window's own *content*, which is why `occluders` + // below is still needed even with that ordering. + if w.border_width > 0 { + let color = crate::state::effective_border_color(w.border_color, focused == Some(id)); + let strips = decoration::border_strips(geom, w.border_width); + // Strip 0 (top) rounded to match the titlebar under + // it - see `render_border_top`'s doc comment - so + // it's a cached bitmap (rebuilt only in + // `redraw_decoration_buffer`, same as the titlebar + // itself), not rasterized fresh here every frame. + // Not fragment-clipped like the other three strips + // below - cropping a bitmap's source rect per + // fragment is real extra work for a strip that's + // only `border_width` pixels tall to begin with, so + // this only handles the all-or-nothing case: skip + // entirely once *fully* covered, accept a small + // residual bleed while only partially covered. + if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() { + if let Some(buffer) = self.border_top_decorations.get(&id) { + let pos = ((strips[0].x - origin.x) as f64, (strips[0].y - origin.y) as f64); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("udev: failed to import top border buffer: {e}"), + } + } + } + // The other three strips are persistent + // `SolidColorBuffer`s updated in place, not rebuilt + // with a fresh `Id` every frame - see + // `elements::border_side_render_element`'s doc + // comment for why that distinction is load-bearing + // for damage tracking, not cosmetic. Each strip is + // further split into whatever fragments remain + // visible after subtracting `occluders`, since a + // whole unclipped strip is exactly the bug fixed + // here. + let pool = self.border_side_buffers.entry(id).or_default(); + let mut buf_index = 0; + for strip in &strips[1..] { + if strip.width == 0 || strip.height == 0 { + continue; + } + for fragment in crate::elements::visible_border_fragments(*strip, &occluders) { + let buf = crate::elements::border_fragment_buffer(pool, buf_index); + buf_index += 1; + custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element(buf, fragment, color, (origin.x, origin.y)))); + } + } + } + occluders.push(geom); + } + custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, &mut udev.renderer, (origin.x, origin.y))); } let lock_elements = if locked { crate::lock::lock_render_elements(lock_surface.as_ref(), &mut udev.renderer) @@ -240,7 +418,7 @@ impl CompState { let result = if locked { head.damage_tracker .render_output(&mut udev.renderer, &mut framebuffer, 0, &lock_elements, [0.0, 0.0, 0.0, 1.0]) - .map(|r| r.damage.is_some()) + .map(|r| (r.damage.is_some(), Vec::new())) .map_err(|e| e.to_string()) } else { render_output( @@ -248,24 +426,34 @@ impl CompState { &mut udev.renderer, &mut framebuffer, 1.0, - 0, // always a full redraw: buffer "age" tracking isn't worth the complexity for a software-only backend + head.ages[back], [&self.space], &custom_elements, &mut head.damage_tracker, [0.05, 0.05, 0.08, 1.0], ) - .map(|r| r.damage.is_some()) - // Both arms reduce to "was there damage"; the two error types - // differ, so they are flattened to a message here. + .map(|r| (r.damage.is_some(), r.damage.cloned().unwrap_or_default())) + // Both arms reduce to "was there damage" plus the damage + // rects themselves; the two error types differ, so they are + // flattened to a message here. .map_err(|e| e.to_string()) }; if !locked { - crate::screencopy::service_pending(std::mem::take(&mut captures), &mut udev.renderer, &framebuffer); + // Only this head's own captures: `captures` holds requests + // for every output, and each must be read back from the + // framebuffer it was actually requested against, not + // whichever head happens to render first in this loop (a + // multi-monitor capture would otherwise silently read the + // wrong screen). Whatever doesn't match `output` stays in + // `captures` for a later head this same pass. + let (mine, rest): (Vec<_>, Vec<_>) = captures.into_iter().partition(|c| c.output == output); + captures = rest; + crate::screencopy::service_pending(mine, &mut udev.renderer, &framebuffer); } drop(framebuffer); - let has_damage = match result { - Ok(d) => d, + let (has_damage, damage_rects) = match result { + Ok(v) => v, Err(e) => { log::error!("udev: render_output failed: {e}"); continue; @@ -277,24 +465,197 @@ impl CompState { log::error!("udev: page flip failed: {e}"); continue; } + // This buffer is now fully up to date. It won't be rendered + // into again until the *other* slot has also been presented + // once (strict two-buffer alternation), so by then it will + // be exactly 2 damage-producing renders stale - matching + // `damage_tracker`'s own history, which only advances on + // calls that actually found damage (see `ages`' doc + // comment). + head.ages[back] = 2; + // Only a head that actually presented a new frame should + // tell its windows they may render their next one - this + // used to run unconditionally for every "ready" head (i.e. + // every head not already mid-flip) on every single call to + // this function, which is every ~16ms regardless of + // activity. A client that renders on the standard + // wait-for-frame-callback pattern (which is most of + // them - confirmed live: wezterm-gui pinned at 140%+ CPU + // sitting on a fully idle, unchanged terminal) had no + // reason not to redraw at whatever rate this loop cycled, + // forever, since it kept getting told a new frame was + // wanted whether or not the screen had changed at all. + presented.push((output, damage_rects)); } - presented.push(output); } // Frame callbacks + lock confirmation, once the `udev` borrow is done. - for output in presented { + for (output, damage_rects) in presented { if locked { let surface = self.lock_surface_for(&output).cloned(); crate::lock::send_lock_frame(surface.as_ref(), &output, elapsed); self.confirm_lock_if_presented(&output); } else { let out = output.clone(); - self.space.elements().for_each(|w| w.send_frame(&out, elapsed, None, |_, _| Some(out.clone()))); + let scale = Scale::from(out.current_scale().fractional_scale()); + for w in crate::elements::windows_touched_by_damage(&self.space, &damage_rects, scale) { + w.send_frame(&out, elapsed, None, |_, _| Some(out.clone())); + } + } + } + // Deliberately unconditional - not folded into the `presented` + // loop above, and not gated on any head having had damage this + // tick at all. The whole point of `always_notify` is covering the + // case where the output has *no* damage whatsoever (a fully idle + // desktop, cursor not moving) but the focused/hovered window still + // has a pending callback it needs answered to unblock an input- + // driven redraw - GTK's frame-clock model (Firefox's Wayland + // vsync source included) paces every repaint through that + // callback, even the first one after being idle, with no "just + // commit immediately" fallback. Nesting this inside the `presented` + // loop (the first version of this fix) meant it only ever ran on a + // tick that already had damage from something else happening -- + // i.e. never in the exact scenario it exists for. Reported live as + // clicks in Firefox still doing nothing at all, not just + // intermittently, after the first version of this fix. + if !locked { + let pointer_pos = self.udev.as_ref().map(|u| u.pointer_pos).unwrap_or_default(); + let wm = self.wm.borrow(); + let always_notify = [wm.focused_id(), wm.window_at(pointer_pos.x as i32, pointer_pos.y as i32)]; + drop(wm); + let outputs: Vec<Output> = self.outputs.iter().map(|e| e.output.clone()).collect(); + for w in always_notify.into_iter().flatten().filter_map(|id| self.id_to_window.get(&id)) { + for out in &outputs { + w.send_frame(out, elapsed, None, |_, _| Some(out.clone())); + } + } + } + // Layer-shell surfaces (bars, docks, launchers) get their frame + // callback on every pass, unconditionally - NOT folded into the + // `presented`/`has_damage` gate above. + // + // That gate exists because most toplevel clients redraw on the + // standard wait-for-callback loop regardless of whether their own + // content changed (confirmed live: wezterm-gui pinned at 140%+ CPU + // on a fully idle terminal when it got a callback every ~16ms + // whether or not the screen had changed). Gating toplevel callbacks + // on real output damage fixed that. + // + // Applying the same gate to layer surfaces creates a real deadlock + // instead: many (GTK4/AGS among them) drive their *entire* repaint + // loop off frame callbacks with no independent timer fallback -- + // paint once, request a callback, wait. If nothing ELSE on the + // desktop ever produces damage again (a static terminal, no other + // animation), that callback never arrives, so the surface can never + // draw its next frame, which means it can never produce damage, + // which means it never gets a callback - permanently frozen after + // exactly one frame. Confirmed live: AGS and waybar both hung this + // way, one frame in, with `wl_surface.frame` requests that were + // never answered (see docs/PANEL_SUPPORT_TODO.md). + // + // Splitting the gate is safe rather than reintroducing the wezterm + // bug: there are at most a handful of layer surfaces on a real + // desktop (a bar, maybe a dock/launcher), their content is cheap to + // redraw even when done needlessly, and periodic UI chrome (a + // clock, a resource graph) is exactly the case frame callbacks + // exist to pace - unlike a full toplevel window, whose redraw cost + // is what made the unconditional case expensive in the first place. + if !locked { + for output in &ready_outputs { + for layer in layer_map_for_output(output).layers() { + layer.send_frame(output, elapsed, None, |_, _| Some(output.clone())); + } } } if locked { crate::screencopy::fail_pending(captures); + } else if !captures.is_empty() { + // Left over because their target head wasn't in `ready` this + // pass (e.g. mid-page-flip). Put back rather than dropped: this + // function runs again on the next poll tick (or the page-flip + // completion that made the head ready), so the capture gets + // another chance instead of silently vanishing - which is what + // made `grim` hang waiting on a `ready`/`failed` that would + // otherwise never come (see docs/PANEL_SUPPORT_TODO.md, P1). + self.screencopy_pending.extend(captures); + } + } + + /// Sets a connector's DPMS mode via the generic KMS "DPMS" property -- + /// there is no dedicated legacy-API call for this in `drm-rs`, only the + /// same `get_properties`/`set_property` pair every other connector + /// property goes through, so the property has to be found by name each + /// time rather than through some `Dpms` -specific method. `None` if the + /// `wl_output` doesn't resolve to a live head, or the connector has no + /// "DPMS" property at all (rare on real hardware, but virtual/headless + /// outputs may not expose one) - either way maps to `zwlr_output_power_v1`'s + /// `failed` event, matching what the protocol asks for when the mode + /// can't be honoured. + pub(crate) fn set_output_power(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput, on: bool) -> Option<()> { + // Raw KMS UAPI values for the "DPMS" connector property + // (`DRM_MODE_DPMS_ON`/`_OFF` in `drm_sys`/the kernel's + // `drm_mode.h`) - not worth a whole extra dependency on `drm-sys` + // just for two constants that have been stable since DPMS was + // added to the DRM UAPI. + const DRM_MODE_DPMS_ON: u64 = 0; + const DRM_MODE_DPMS_OFF: u64 = 3; + + let target = self.output_for_wl(wl_output)?.output.clone(); + let udev = self.udev.as_ref()?; + let head = udev.heads.iter().find(|h| h.output == target)?; + let props = udev.card.get_properties(head.connector).ok()?; + let dpms_prop = props.as_props_and_values().0.iter().copied().find(|&handle| udev.card.get_property(handle).is_ok_and(|info| info.name().to_str() == Ok("DPMS")))?; + let mode = if on { DRM_MODE_DPMS_ON } else { DRM_MODE_DPMS_OFF }; + udev.card.set_property(head.connector, dpms_prop, mode).ok() + } + + /// The CRTC's gamma ramp length, in elements per channel - what + /// `zwlr_gamma_control_v1.gamma_size` reports so a client knows how + /// large a table `set_gamma` expects. `None` if the output doesn't + /// resolve to a live head, or the CRTC reports a zero-length ramp + /// (no gamma hardware, common on virtual/headless outputs). + pub(crate) fn gamma_ramp_size(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput) -> Option<u32> { + let target = self.output_for_wl(wl_output)?.output.clone(); + let udev = self.udev.as_ref()?; + let head = udev.heads.iter().find(|h| h.output == target)?; + let len = udev.card.get_crtc(head.crtc).ok()?.gamma_length(); + (len > 0).then_some(len) + } + + /// Reads a client-supplied gamma table (`zwlr_gamma_control_v1. + /// set_gamma`'s `fd`: a memory-mapped blob of `gamma_size` `u16`s per + /// channel, red then green then blue, per the protocol) and applies it + /// to the CRTC. `None` on any failure - output/head not found, the + /// blob is the wrong size, or the DRM `set_gamma` call itself fails -- + /// which the caller maps to `zwlr_gamma_control_v1.failed`, exactly + /// what the protocol specifies for "setting the gamma tables failed". + pub(crate) fn set_gamma_ramp(&self, wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput, fd: std::os::fd::OwnedFd) -> Option<()> { + let target = self.output_for_wl(wl_output)?.output.clone(); + let udev = self.udev.as_ref()?; + let head = udev.heads.iter().find(|h| h.output == target)?; + let size = udev.card.get_crtc(head.crtc).ok()?.gamma_length() as usize; + if size == 0 { + return None; } + // Three channels, two bytes (one native-endian u16) per element -- + // client and compositor are always the same machine, so there is + // no cross-endianness concern to handle here, unlike an over-the- + // wire protocol value. + let expected_bytes = size * 3 * 2; + // SAFETY: the fd is a client-supplied shared-memory blob, mapped + // read-only for the duration of this call and never touched again + // afterwards - the same trust boundary `wl_shm` buffers already + // cross for every window's actual pixel content elsewhere in this + // codebase. + let map = unsafe { memmap2::MmapOptions::new().map(&fd) }.ok()?; + if map.len() < expected_bytes { + return None; + } + let read_channel = |offset: usize| -> Vec<u16> { map[offset..offset + size * 2].chunks_exact(2).map(|b| u16::from_ne_bytes([b[0], b[1]])).collect() }; + let red = read_channel(0); + let green = read_channel(size * 2); + let blue = read_channel(size * 4); + udev.card.set_gamma(head.crtc, &red, &green, &blue).ok() } } @@ -423,17 +784,35 @@ impl UdevHead { /// scanout memory directly, to avoid tying that image's lifetime to an /// mmap - see this module's docs) and flips to it. fn copy_and_flip(&mut self, card: &Card, back: usize) -> std::io::Result<()> { - let (stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height()); - let byte_len = stride * height; + let (src_stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height()); + let byte_len = src_stride * height; // SAFETY: `image` owns this memory and outlives the byte slice we // construct from it here; we only read, and only for the duration // of this call. let src: &[u8] = unsafe { std::slice::from_raw_parts(self.buffers[back].image.data() as *const u8, byte_len) }; + // The dumb buffer's pitch is whatever the kernel driver actually + // allocated, which the DRM API does not guarantee equals pixman's + // own `src_stride` (drivers are free to pad each row for + // alignment). This used to be a single flat `copy_from_slice` sized + // off `src_stride` alone; on any driver that pads, that copies each + // source row into the wrong offset in the destination, shearing the + // image diagonally by one row per `dst_stride - src_stride` bytes of + // padding. Copying row by row, each clamped to the narrower of the + // two strides, is correct regardless of whether the strides happen + // to match. + let dst_stride = self.buffers[back].dumb.pitch() as usize; { let mut mapping = card.map_dumb_buffer(&mut self.buffers[back].dumb)?; let dst = mapping.as_mut(); - let len = byte_len.min(dst.len()); - dst[..len].copy_from_slice(&src[..len]); + let row_len = src_stride.min(dst_stride); + for row in 0..height { + let s = row * src_stride; + let d = row * dst_stride; + if s + row_len > src.len() || d + row_len > dst.len() { + break; + } + dst[d..d + row_len].copy_from_slice(&src[s..s + row_len]); + } } card.page_flip(self.crtc, self.buffers[back].fb, PageFlipFlags::EVENT, None)?; self.flip_pending = true; @@ -448,6 +827,7 @@ pub struct UdevPlatform { listener: ListeningSocket, clients: Vec<Client>, pending: Rc<RefCell<Vec<CoreEvent>>>, + ipc: Option<srdwm_platform::IpcServer>, } impl UdevPlatform { @@ -476,6 +856,16 @@ impl UdevPlatform { let renderer = PixmanRenderer::new().map_err(err)?; let dh = Display::<CompState>::new().map_err(err)?; let display_handle = dh.handle(); + // `zwp_linux_dmabuf_v1` - see `protocols.rs`'s `DmabufHandler` for + // why `PixmanRenderer`, a pure software renderer, can still import + // these (mmap, not GPU). `create_global` (v3) rather than the v4 + // `..._with_default_feedback` variant: the latter needs a + // `main_device` `dev_t` to steer multi-GPU clients toward the + // right render node, which is a real gap worth closing later but + // not required for a single-GPU client to allocate and hand over a + // Linear-modifier buffer, which is all this backend can use anyway. + let mut dmabuf_state = DmabufState::new(); + dmabuf_state.create_global::<CompState>(&display_handle, renderer.dmabuf_formats()); let mut heads: Vec<UdevHead> = Vec::new(); let mut output_entries: Vec<crate::state::OutputEntry> = Vec::new(); @@ -514,7 +904,17 @@ impl UdevPlatform { DataControlState::new::<CompState, _>(&display_handle, Some(&primary_selection_state), |_| true); let mut seat_state = SeatState::new(); let mut seat = seat_state.new_wl_seat(&display_handle, "seat0"); - seat.add_keyboard(Default::default(), 200, 25).map_err(err)?; + let system_xkb = crate::xkb_config::read(); + let xkb_config = smithay::input::keyboard::XkbConfig { + rules: "", + model: system_xkb.model.as_deref().unwrap_or(""), + layout: system_xkb.layout.as_deref().unwrap_or(""), + variant: system_xkb.variant.as_deref().unwrap_or(""), + options: system_xkb.options.clone(), + }; + // 600ms delay, not 200 - see `state.rs`'s `REPEAT_DELAY` doc + // comment for why. + seat.add_keyboard(xkb_config, 600, 25).map_err(err)?; seat.add_pointer(); // Each output occupies its own slice of the global space, so a @@ -538,9 +938,15 @@ impl UdevPlatform { xdg_shell_state, _xdg_decoration_state: xdg_decoration_state, shm_state, + dmabuf_state, + xdg_activation_state: XdgActivationState::new::<CompState>(&display_handle), + _text_input_manager_state: smithay::wayland::text_input::TextInputManagerState::new::<CompState>(&display_handle), + _input_method_manager_state: smithay::wayland::input_method::InputMethodManagerState::new::<CompState, _>(&display_handle, |_client| true), + _gtk_shell_state: crate::gtk_shell::GtkShellState::new::<CompState>(&display_handle), seat_state, seat, space, + popups: PopupManager::default(), outputs: output_entries, layer_shell_state: smithay::wayland::shell::wlr_layer::WlrLayerShellState::new::<CompState>(&display_handle), dh: display_handle.clone(), @@ -553,14 +959,45 @@ impl UdevPlatform { ), _screencopy_state: crate::screencopy::ScreencopyState::new::<CompState>(&display_handle), screencopy_pending: Vec::new(), + _foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&display_handle), + foreign_toplevel_managers: Vec::new(), + foreign_toplevel_handles: HashMap::new(), + _workspace_state: crate::workspace::WorkspaceManagerState::new::<CompState>(&display_handle), + _output_power_state: Some(crate::output_power::OutputPowerManagerState::new::<CompState>(&display_handle)), + _gamma_control_state: Some(crate::gamma_control::GammaControlManagerState::new::<CompState>(&display_handle)), + _output_management_state: crate::output_management::OutputManagementState::new::<CompState>(&display_handle), + output_managers: Vec::new(), + output_heads: HashMap::new(), + output_modes: HashMap::new(), + output_serial: 0, + last_broadcast_outputs: Vec::new(), + workspace_managers: Vec::new(), + workspace_groups: Vec::new(), + workspace_handles: HashMap::new(), + _viewporter_state: smithay::wayland::viewporter::ViewporterState::new::<CompState>(&display_handle), + _fractional_scale_state: smithay::wayland::fractional_scale::FractionalScaleManagerState::new::<CompState>(&display_handle), + _cursor_shape_state: smithay::wayland::cursor_shape::CursorShapeManagerState::new::<CompState>(&display_handle), + idle_notifier_state: smithay::wayland::idle_notify::IdleNotifierState::new(&display_handle, event_loop.handle()), + _idle_inhibit_manager_state: smithay::wayland::idle_inhibit::IdleInhibitManagerState::new::<CompState>(&display_handle), + idle_inhibiting_surfaces: Vec::new(), + last_idle_notify: None, + window_anims: HashMap::new(), + last_broadcast_flags: HashMap::new(), + last_broadcast_workspace: None, lock: Default::default(), cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), - cursor_buffer: crate::cursor::make_buffer(), + cursor_buffers: crate::cursor::make_buffers(), last_titlebar_click: None, + context_menu: None, + context_menu_buffer: None, wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), + dead_layer_surfaces: HashSet::new(), decorations: HashMap::new(), + border_top_decorations: HashMap::new(), + border_side_buffers: HashMap::new(), + last_synced_size: HashMap::new(), pending: pending.clone(), bound_keys: Rc::new(bound_keys.iter().cloned().collect::<HashSet<_>>()), repeat_keys: Rc::new(repeat_keys.iter().cloned().collect::<HashSet<_>>()), @@ -571,6 +1008,7 @@ impl UdevPlatform { xwm: None, xwayland_windows: HashMap::new(), xwayland_pending: Vec::new(), + ewmh: None, }; let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?; @@ -578,6 +1016,28 @@ impl UdevPlatform { std::env::set_var("WAYLAND_DISPLAY", name); log::info!("wayland socket: {}", name.to_string_lossy()); } + // Otherwise this is whatever the session inherited - typically + // stale from a *previous* login's compositor (a shell's exported + // `XDG_CURRENT_DESKTOP=Hyprland` surviving into this one), since + // nothing else ever sets it. `xdg-desktop-portal` and any client + // that sniffs this value to pick a desktop-specific integration + // (screenshot/file-picker backends, etc.) get actively misrouted by + // the stale value rather than just seeing "unknown". Only affects + // processes spawned from here on (autostart, `srd.spawn`) - an + // env var set mid-process doesn't retroactively reach anything + // already running. + std::env::set_var("XDG_CURRENT_DESKTOP", "srdwm"); + + let ipc = match listener.socket_name().map(|n| n.to_string_lossy().into_owned()) { + Some(name) => match srdwm_platform::IpcServer::bind(&name) { + Ok(ipc) => Some(ipc), + Err(e) => { + log::warn!("control socket unavailable ({e}); srd and scripts that use it won't work"); + None + } + }, + None => None, + }; let handle = event_loop.handle(); register_drm_fd(&handle, &card)?; @@ -590,7 +1050,7 @@ impl UdevPlatform { log::warn!("XWayland unavailable ({e}); X11-only clients will not run"); } - Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending }) + Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending, ipc }) } fn accept_clients(&mut self) -> PlatformResult<()> { @@ -630,9 +1090,18 @@ fn bring_up_head( // Named after the real connector (eDP-1, HDMI-A-1, ...) so clients and // the user can tell monitors apart; `wl_output.name` is what a bar's // per-monitor config keys off. + // + // Physical size in millimeters comes straight from EDID via the + // connector, not the hardcoded (0, 0) this used to be - some clients + // compute their own effective DPI from it (independently of the + // compositor's own scale factor, which srdwm always reports as 1), so + // reporting "no physical size at all" was live, wrong data reaching + // every client, not just an unfilled-in placeholder. + let (phys_w, phys_h) = probe.info.size().unwrap_or((0, 0)); + let physical_mm = (phys_w as i32, phys_h as i32); let output = Output::new( probe.name.clone(), - PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() }, + PhysicalProperties { size: physical_mm.into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() }, ); let mode = OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) }; output.change_current_state(Some(mode), Some(Transform::Normal), None, Some((x_offset, 0).into())); @@ -649,6 +1118,7 @@ fn bring_up_head( buffers, front: 0, flip_pending: false, + ages: [0, 0], location, size: (width, height), }; @@ -798,8 +1268,10 @@ fn register_session_notifier(handle: &LoopHandle<'static, CompState>, notifier: log::warn!("udev: failed to reassert crtc on resume: {e}"); } // Force a full repaint: contents are undefined after - // the VT switch. + // the VT switch (another VT's session may have + // scanned out something else entirely in between). head.flip_pending = false; + head.ages = [0, 0]; } data.render_udev_frame(); } @@ -881,8 +1353,35 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB let source = event.source(); let mut frame = AxisFrame::new(event.time_msec()).source(source); for axis in [Axis::Horizontal, Axis::Vertical] { - if let Some(value) = event.amount(axis) { - frame = frame.value(axis, value); + match event.amount(axis) { + Some(value) => frame = frame.value(axis, value), + // `AxisSource::Finger` (a touchpad) *requires* a stop + // event on the frame where the finger lifts and the + // axis genuinely has no more motion - see `AxisFrame:: + // source`'s own doc comment ("Using AxisSource::Finger + // requires a stop event to be sent, when the user lifts + // off the finger"). Never sending it left every + // two-finger scroll gesture with no way to tell Firefox/ + // GTK it had actually ended, which is exactly the kind + // of thing that reads as "scrolling doesn't work" -- + // not "no events arrive" (discrete wheel scrolling, + // which needs no stop event, was never affected) but + // kinetic/momentum scrolling and starting a fresh + // gesture right after a previous one never settling. + None if source == AxisSource::Finger => frame = frame.stop(axis), + None => {} + } + // Discrete wheel steps, additional to the pixel `value` + // above - optional (`value` is the only event a client + // strictly needs), but some clients use it to distinguish + // "one physical click" from a smooth/high-resolution + // scroll, so provide it whenever the device actually + // reports one (real scroll wheels; never touchpads, which + // have no discrete steps to report - `amount_v120` is + // `None` for those, same guarantee `amount` gives the + // other way around). + if let Some(v120) = event.amount_v120(axis) { + frame = frame.v120(axis, v120 as i32); } } pointer.axis(state, frame); @@ -904,6 +1403,11 @@ impl Platform for UdevPlatform { self.state.tick_repeat(); self.display.dispatch_clients(&mut self.state).map_err(err)?; self.display.flush_clients().map_err(err)?; + if let Some(ipc) = self.ipc.as_mut() { + if ipc.poll(&self.state.wm) { + self.pending.borrow_mut().push(CoreEvent::WorkspaceChanged); + } + } self.state.render_udev_frame(); Ok(self.pending.borrow_mut().drain(..).collect()) } @@ -919,9 +1423,37 @@ impl Platform for UdevPlatform { .iter() .enumerate() .map(|(i, head)| { - let (w, h) = head.size; - let rect = srdwm_core::Rect::new(head.location.x, head.location.y, w as u32, h as u32); + // Shrunk by whatever a layer-shell surface (bar, dock) has + // reserved via `set_exclusive_zone` - reporting the full + // head size here otherwise means core's placement/tiling + // treats that strip as ordinary free space, so a new + // window's titlebar lands right where the bar renders on + // top of it, unreachable to drag. `non_exclusive_zone()` is + // output-local, so it's translated into this head's + // position in the shared global space the same way + // `head.location` already is. + let zone = layer_map_for_output(&head.output).non_exclusive_zone(); + let rect = srdwm_core::Rect::new( + head.location.x + zone.loc.x, + head.location.y + zone.loc.y, + zone.size.w as u32, + zone.size.h as u32, + ); let mut m = srdwm_core::Monitor::new(i as u32, head.output.name(), rect); + // `Monitor::new` defaults `full_geometry` to whatever + // `geometry` was constructed with - correct for a monitor + // with no layer-shell client at all, wrong the moment one + // exists, since `rect` above is already zone-shrunk. Without + // this, `full_geometry` was silently identical to `geometry` + // for every real monitor this backend ever reported, which + // made `toggle_fullscreen`'s whole "ignore the reserved + // zone" design a no-op in practice: fullscreen still + // stopped at the bar/dock exactly like maximize does. + // Reported live as "fullscreen isn't actually going + // fullscreen" - confirmed by triggering it and reading + // the resulting geometry back over IPC, not just from + // reading this code. + m.full_geometry = srdwm_core::Rect::new(head.location.x, head.location.y, head.size.0 as u32, head.size.1 as u32); m.primary = i == 0; m }) @@ -937,8 +1469,18 @@ impl Platform for UdevPlatform { Ok(()) } + /// Was `wm.focus_window(window)` alone - core-only, so a caller that + /// only has `Platform` to go through (`crates/platform`'s `IpcServer`, + /// which can't reach `CompState`/real Wayland focus at all) could make + /// a window *look* focused (rendering already reads live core state + /// for the highlighted-border/titlebar-text colour) without it ever + /// actually receiving a keystroke - confirmed live: `srd dispatch + /// focus <xwayland-window-id>` changed core's own focused-window + /// bookkeeping but left `_NET_ACTIVE_WINDOW` at `0x0` and real + /// keyboard input going nowhere. `crate::input::focus_window` is the + /// same full path a real mouse click already goes through. fn focus(&mut self, window: srdwm_core::WindowId) -> PlatformResult<()> { - self.state.wm.borrow_mut().focus_window(window); + crate::input::focus_window(&mut self.state, window); Ok(()) } diff --git a/crates/wayland/src/winit.rs b/crates/wayland/src/winit.rs index a11e263..733f4b0 100644 --- a/crates/wayland/src/winit.rs +++ b/crates/wayland/src/winit.rs @@ -9,25 +9,32 @@ //! what differs is only how a frame reaches a screen. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use std::time::Instant; +use std::time::{Duration, Instant}; use smithay::backend::allocator::Fourcc; -use smithay::backend::input::{AbsolutePositionEvent, ButtonState as BackendButtonState, Event as InputEventTrait, InputEvent, PointerButtonEvent}; +use smithay::backend::input::{ + AbsolutePositionEvent, Axis, AxisSource, ButtonState as BackendButtonState, Event as InputEventTrait, InputEvent, PointerAxisEvent, PointerButtonEvent, +}; use smithay::backend::renderer::damage::OutputDamageTracker; use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::gles::GlesRenderer; +use smithay::backend::renderer::ImportDma; use smithay::backend::winit::{self, WinitEvent, WinitEventLoop, WinitGraphicsBackend}; +use smithay::reexports::winit::dpi::LogicalSize as WinitLogicalSize; +use smithay::reexports::winit::window::Window as WinitWindow; use smithay::desktop::space::render_output; -use smithay::desktop::{layer_map_for_output, Space}; +use smithay::desktop::{layer_map_for_output, PopupManager, Space}; use smithay::input::SeatState; use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Subpixel}; +use smithay::reexports::calloop::EventLoop as CalloopEventLoop; use smithay::reexports::wayland_server::{Client, Display, ListeningSocket}; use smithay::reexports::winit::platform::pump_events::PumpStatus; -use smithay::utils::Transform; +use smithay::utils::{Physical, Point, Rectangle, Scale, Size, Transform}; use smithay::wayland::compositor::CompositorState; +use smithay::wayland::dmabuf::DmabufState; use smithay::wayland::selection::data_device::DataDeviceState; use smithay::wayland::selection::primary_selection::PrimarySelectionState; use smithay::wayland::selection::wlr_data_control::DataControlState; @@ -36,6 +43,7 @@ use smithay::wayland::shell::wlr_layer::WlrLayerShellState; use smithay::wayland::shell::xdg::decoration::XdgDecorationState; use smithay::wayland::shell::xdg::XdgShellState; use smithay::wayland::shm::ShmState; +use smithay::wayland::xdg_activation::XdgActivationState; use srdwm_core::{Event as CoreEvent, Window as CoreWindow, WindowId, WindowManager}; use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult}; @@ -43,8 +51,9 @@ use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformRe use crate::input::{handle_keyboard_key_event, handle_pointer_button, handle_pointer_position, last_pointer_pos}; use crate::lock::{lock_render_elements, send_lock_frame}; use crate::lock::SessionLock; +use srdwm_platform::IpcServer; use crate::state::{ClientState, CompState, OutputEntry}; -use crate::{err, screencopy}; +use crate::{decoration, err, screencopy}; pub struct WaylandPlatform { display: Display<CompState>, @@ -57,8 +66,30 @@ pub struct WaylandPlatform { clients: Vec<Client>, pending: Rc<RefCell<Vec<CoreEvent>>>, wm: Rc<RefCell<WindowManager>>, + ipc: Option<IpcServer>, + /// Exists solely to host `IdleNotifierState`'s internal per-notification + /// timers - this backend otherwise has no `calloop` loop of its own at + /// all (see `ipc.rs`'s module doc comment), drawing everything instead + /// from `winit_events`'s manual poll and this struct's own per-tick + /// work. `ext_idle_notify_v1` needs a real `LoopHandle` to construct + /// (`smithay::wayland::idle_notify::IdleNotifierState::new`), and the + /// alternative - constructing the global without ever dispatching the + /// loop backing it - would advertise a protocol whose `idled`/`resumed` + /// events then simply never fire, a worse trap than the small addition + /// of a second, narrowly-scoped loop dispatched non-blocking once per + /// tick in `poll_events`. + idle_event_loop: CalloopEventLoop<'static, CompState>, + /// When the last frame was rendered - see `poll_events`' doc comment + /// on why this backend has to pace itself. + last_frame: Instant, } +/// Target frame budget for the winit (nested) backend's self-imposed pacing +/// - see `poll_events`' doc comment. 60fps to match `OutputMode`'s own +/// `refresh: 60_000` a few lines below, not because either number is +/// special. +const TARGET_FRAME_TIME: Duration = Duration::from_micros(1_000_000 / 60); + impl WaylandPlatform { /// `bound_keys` are the config's `"Mod4+Shift+Return"`-style combo /// strings (see `srdwm_core::key_combo_string`) - the same set the X11 @@ -67,8 +98,16 @@ impl WaylandPlatform { pub fn connect(wm: Rc<RefCell<WindowManager>>, bound_keys: &[String], repeat_keys: &[String]) -> PlatformResult<Self> { let display: Display<CompState> = Display::new().map_err(err)?; let dh = display.handle(); - - let (backend, winit_events) = winit::init::<GlesRenderer>().map_err(err)?; + // See `idle_event_loop`'s own doc comment on `WaylandPlatform`. + let idle_event_loop: CalloopEventLoop<'static, CompState> = CalloopEventLoop::try_new().map_err(err)?; + + let (mut backend, winit_events) = winit::init_from_attributes::<GlesRenderer>( + WinitWindow::default_attributes() + .with_inner_size(WinitLogicalSize::new(1280.0, 800.0)) + .with_title("srdwm") + .with_visible(true), + ) + .map_err(err)?; let size = backend.window_size(); let output = Output::new( @@ -103,21 +142,44 @@ impl WaylandPlatform { let data_control_state = DataControlState::new::<CompState, _>(&dh, Some(&primary_selection_state), |_| true); let mut seat_state = SeatState::new(); let mut seat = seat_state.new_wl_seat(&dh, "seat0"); - seat.add_keyboard(Default::default(), 200, 25).map_err(err)?; + let system_xkb = crate::xkb_config::read(); + let xkb_config = smithay::input::keyboard::XkbConfig { + rules: "", + model: system_xkb.model.as_deref().unwrap_or(""), + layout: system_xkb.layout.as_deref().unwrap_or(""), + variant: system_xkb.variant.as_deref().unwrap_or(""), + options: system_xkb.options.clone(), + }; + // 600ms delay, not 200 - see `state.rs`'s `REPEAT_DELAY` doc + // comment for why. + seat.add_keyboard(xkb_config, 600, 25).map_err(err)?; seat.add_pointer(); let mut space = Space::default(); space.map_output(&output, (0, 0)); + // `zwp_linux_dmabuf_v1` - see `protocols.rs`'s `DmabufHandler` for + // the udev-vs-winit split on eager import validation, and udev.rs's + // matching global for why v3 (`create_global`) rather than v4's + // feedback variant. + let mut dmabuf_state = DmabufState::new(); + dmabuf_state.create_global::<CompState>(&dh, backend.renderer().dmabuf_formats()); + let pending = Rc::new(RefCell::new(Vec::new())); let state = CompState { compositor_state, xdg_shell_state, _xdg_decoration_state: xdg_decoration_state, shm_state, + dmabuf_state, + xdg_activation_state: XdgActivationState::new::<CompState>(&dh), + _text_input_manager_state: smithay::wayland::text_input::TextInputManagerState::new::<CompState>(&dh), + _input_method_manager_state: smithay::wayland::input_method::InputMethodManagerState::new::<CompState, _>(&dh, |_client| true), + _gtk_shell_state: crate::gtk_shell::GtkShellState::new::<CompState>(&dh), seat_state, seat, space, + popups: PopupManager::default(), // The nested backend is inherently one output: a single window on // the host. Multi-output is a udev/DRM concern. outputs: vec![OutputEntry { output: output.clone(), location: (0, 0).into() }], @@ -129,14 +191,45 @@ impl WaylandPlatform { session_lock_state: SessionLockManagerState::new::<CompState, _>(&dh, |_| true), _screencopy_state: screencopy::ScreencopyState::new::<CompState>(&dh), screencopy_pending: Vec::new(), + _foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&dh), + foreign_toplevel_managers: Vec::new(), + foreign_toplevel_handles: HashMap::new(), + _workspace_state: crate::workspace::WorkspaceManagerState::new::<CompState>(&dh), + _output_power_state: None, + _gamma_control_state: None, + _output_management_state: crate::output_management::OutputManagementState::new::<CompState>(&dh), + output_managers: Vec::new(), + output_heads: HashMap::new(), + output_modes: HashMap::new(), + output_serial: 0, + last_broadcast_outputs: Vec::new(), + workspace_managers: Vec::new(), + workspace_groups: Vec::new(), + workspace_handles: HashMap::new(), + _viewporter_state: smithay::wayland::viewporter::ViewporterState::new::<CompState>(&dh), + _fractional_scale_state: smithay::wayland::fractional_scale::FractionalScaleManagerState::new::<CompState>(&dh), + _cursor_shape_state: smithay::wayland::cursor_shape::CursorShapeManagerState::new::<CompState>(&dh), + idle_notifier_state: smithay::wayland::idle_notify::IdleNotifierState::new(&dh, idle_event_loop.handle()), + _idle_inhibit_manager_state: smithay::wayland::idle_inhibit::IdleInhibitManagerState::new::<CompState>(&dh), + idle_inhibiting_surfaces: Vec::new(), + last_idle_notify: None, + window_anims: HashMap::new(), + last_broadcast_flags: HashMap::new(), + last_broadcast_workspace: None, lock: SessionLock::default(), cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), - cursor_buffer: crate::cursor::make_buffer(), + cursor_buffers: crate::cursor::make_buffers(), last_titlebar_click: None, + context_menu: None, + context_menu_buffer: None, wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), + dead_layer_surfaces: HashSet::new(), decorations: HashMap::new(), + border_top_decorations: HashMap::new(), + border_side_buffers: HashMap::new(), + last_synced_size: HashMap::new(), pending: pending.clone(), bound_keys: Rc::new(bound_keys.iter().cloned().collect()), repeat_keys: Rc::new(repeat_keys.iter().cloned().collect()), @@ -147,6 +240,7 @@ impl WaylandPlatform { xwm: None, xwayland_windows: HashMap::new(), xwayland_pending: Vec::new(), + ewmh: None, }; let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?; @@ -157,7 +251,32 @@ impl WaylandPlatform { let damage_tracker = OutputDamageTracker::from_output(&output); - Ok(Self { display, state, backend, winit_events, damage_tracker, output, listener, clients: Vec::new(), pending, wm }) + let ipc = match listener.socket_name().map(|n| n.to_string_lossy().into_owned()) { + Some(name) => match IpcServer::bind(&name) { + Ok(ipc) => Some(ipc), + Err(e) => { + log::warn!("control socket unavailable ({e}); srd and scripts that use it won't work"); + None + } + }, + None => None, + }; + + Ok(Self { + display, + state, + backend, + winit_events, + damage_tracker, + output, + listener, + clients: Vec::new(), + pending, + wm, + ipc, + idle_event_loop, + last_frame: Instant::now(), + }) } fn accept_clients(&mut self) -> PlatformResult<()> { @@ -182,6 +301,8 @@ impl WaylandPlatform { } fn render_frame(&mut self) -> PlatformResult<()> { + self.state.tick_animations(); + self.state.tick_dirty_broadcasts(); let size = self.backend.window_size(); let resized = self.output.current_mode().map(|m| m.size) != Some(size); if resized { @@ -216,15 +337,133 @@ impl WaylandPlatform { } let mut custom_elements: Vec<crate::elements::OverlayElement<GlesRenderer>> = Vec::new(); - for (&id, deco) in self.state.decorations.iter() { - let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue }; - match MemoryRenderBufferRenderElement::from_buffer(renderer, (geom.x as f64, geom.y as f64), deco, None, None, None, Kind::Unspecified) { + // The right-click titlebar menu, if open - pushed first so it's + // topmost over every window (this backend draws no cursor of its + // own, see this module's doc comment, so there's no "stay under + // the pointer" ordering concern like udev.rs's matching push has). + if let (Some(menu), Some(buffer)) = (self.state.context_menu.as_ref(), self.state.context_menu_buffer.as_ref()) { + let pos = (menu.pos.0 as f64, menu.pos.1 as f64); + match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, None, None, Kind::Unspecified) { Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), - Err(e) => log::warn!("failed to import titlebar buffer for window {id}: {e}"), + Err(e) => log::warn!("failed to import context menu buffer: {e}"), } } + // Decoration/border built per window, front-to-back (topmost + // first), so a window's own titlebar/border at least stay ordered + // consistently relative to *other* windows' decoration/border. + // + // Content deliberately still goes through `render_output`'s own + // `spaces` argument below (`self.state.space`), not through this + // loop: an earlier version of this fix pushed each window's own + // `Window::render_elements` output into `custom_elements` here too, + // in per-window stacking order, specifically to let decoration/ + // border interleave correctly with *other* windows' content + // (custom_elements otherwise draws entirely above every window's + // content, `spaces` or not - seeing `render_output`'s source is + // what motivated that attempt). It was reverted: with two or more + // native Wayland toplevels on screen, whichever was created + // *first* always painted in front of later ones regardless of + // real focus/stacking order, reproduced consistently across three + // separate test windows, independent of push order, forced full + // redraws (`age = 0`), and buffer age - i.e. a real ordering bug + // in mixing multiple windows' own `render_elements` output this + // way, not a damage-tracking artifact. The real root cause (found + // later, by instrumenting a locally vendored smithay copy + // directly) turned out to be unrelated to content-vs-decoration + // mixing at all: `sync_geometry`'s `Space::map_element` call + // silently re-stacked windows to the top of `Space`'s own order + // any time position/size synced for *any* reason, independent of + // which rendering path was used - see `state.rs`'s + // `resync_stacking_order` doc comment for the full story and the + // actual fix. `self.state.space` stayed the content path here + // since reverting it was never itself wrong, just insufficient on + // its own. + let ids: Vec<WindowId> = self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect(); + let focused = self.wm.borrow().focused_id(); + // Windows stacked in front of whichever one border/decoration is + // being built right now - `ids` is already front-to-back, so this + // only ever needs appending to, not recomputing. Only a window's + // own *content* occludes correctly on its own path (via `space`, + // real stacking order respected); everything drawn here goes + // through `custom_elements`, which composites above *all* content + // unconditionally, so both the border strips and the titlebar + // bitmap below need this explicit occlusion test against it. + let mut occluders: Vec<srdwm_core::Rect> = Vec::with_capacity(ids.len()); + for id in ids { + let Some(w) = self.wm.borrow().window(id).cloned() else { continue }; + // `w.geometry` is the animation's target, not necessarily where + // the window is actually drawn this frame - see the matching + // comment in `udev.rs`'s render loop for the full story + // (reported live as the border "not flush" with the window + // during an animated maximize/fullscreen/open-slide transition). + let geom = self.state.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry); + if let Some(deco) = self.state.decorations.get(&id) { + // Fragment-clipped, same as udev.rs's matching titlebar + // push - see that comment for why all-or-nothing (skip + // only once *fully* covered) wasn't enough: a titlebar + // only partially covered, the common case for cascaded + // windows, still bled through the covered part. + let titlebar_rect = srdwm_core::Rect::new(geom.x, geom.y, geom.width, srdwm_core::TITLEBAR_HEIGHT); + for fragment in crate::elements::visible_border_fragments(titlebar_rect, &occluders) { + let pos = (fragment.x as f64, fragment.y as f64); + let src = Rectangle::new( + Point::from(((fragment.x - titlebar_rect.x) as f64, (fragment.y - titlebar_rect.y) as f64)), + Size::from((fragment.width as f64, fragment.height as f64)), + ); + match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, deco, None, Some(src), None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("failed to import titlebar buffer for window {id}: {e}"), + } + } + } + // Border strips sit entirely outside `geometry` (see + // `decoration::border_strips`), so they never overlap this same + // window's own decoration/content pixels - draw order relative + // to those doesn't matter, only relative to other windows'. + if w.border_width > 0 { + let color = crate::state::effective_border_color(w.border_color, focused == Some(id)); + let strips = decoration::border_strips(geom, w.border_width); + // Strip 0 (top) is rounded to match the titlebar underneath + // it - see `render_border_top`'s doc comment - so it's a + // cached bitmap (rebuilt only in `redraw_decoration_buffer`, + // same as the titlebar itself), not rasterized fresh here + // every frame; the other three don't touch a rounded corner + // and stay persistent solid-colour buffers instead - see + // `elements::border_side_render_element`'s doc comment for + // why a per-frame rebuild of either was a real, continuous + // cost, not a cosmetic one. Not fragment-clipped like the + // other three below - see the matching comment in + // `udev.rs` for why the top strip only gets the cheaper + // all-or-nothing occlusion check. + if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() { + if let Some(buffer) = self.state.border_top_decorations.get(&id) { + match MemoryRenderBufferRenderElement::from_buffer(renderer, (strips[0].x as f64, strips[0].y as f64), buffer, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("failed to import top border buffer for window {id}: {e}"), + } + } + } + let pool = self.state.border_side_buffers.entry(id).or_default(); + let mut buf_index = 0; + for strip in &strips[1..] { + if strip.width == 0 || strip.height == 0 { + continue; + } + for fragment in crate::elements::visible_border_fragments(*strip, &occluders) { + let buf = crate::elements::border_fragment_buffer(pool, buf_index); + buf_index += 1; + custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element(buf, fragment, color, (0, 0)))); + } + } + } + occluders.push(geom); + } + // Single output at the global origin - no offset to subtract, see + // `elements.rs`'s doc comment on why udev.rs's per-head call does. + let popup_targets = crate::elements::popup_targets(&self.state); + custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, renderer, (0, 0))); - render_output( + let result = render_output( &self.output, renderer, &mut framebuffer, @@ -236,9 +475,69 @@ impl WaylandPlatform { [0.05, 0.05, 0.08, 1.0], ) .map_err(err)?; + let damage_rects: Vec<Rectangle<i32, Physical>> = result.damage.cloned().unwrap_or_default(); + let has_damage = !damage_rects.is_empty(); drop(framebuffer); - self.backend.submit(None).map_err(err)?; - self.state.space.elements().for_each(|w| w.send_frame(&self.output, self.state.start_time.elapsed(), None, |_, _| Some(self.output.clone()))); + // Both the buffer swap and the frame-callback notification are + // conditional on real damage now - this used to run + // unconditionally on every call to this function (every ~16ms + // regardless of activity), which told every window it could render + // its next frame whether or not the screen had actually changed. + // Any client using the standard wait-for-frame-callback render + // pattern (most of them) had no reason not to redraw at whatever + // rate this loop cycled, forever - confirmed live on the udev + // backend: wezterm-gui pinned at 140%+ CPU sitting on a fully idle, + // unchanged terminal, from the identical bug there. + // + // That output-wide gate wasn't enough on its own: cursor motion + // alone damages the small region around the pointer, which still + // marked the *whole output* damaged and sent every mapped window a + // callback regardless of whether the cursor was anywhere near it. + // `windows_touched_by_damage` narrows this to windows the actual + // damage rectangles overlap - see its doc comment in elements.rs. + if has_damage { + self.backend.submit(None).map_err(err)?; + let scale = Scale::from(self.output.current_scale().fractional_scale()); + let now = self.state.start_time.elapsed(); + for w in crate::elements::windows_touched_by_damage(&self.state.space, &damage_rects, scale) { + w.send_frame(&self.output, now, None, |_, _| Some(self.output.clone())); + } + } + // Deliberately outside `if has_damage`: the whole point of + // `always_notify` is covering the case where the output has *no* + // damage at all (a fully idle desktop, cursor not moving) but the + // focused/hovered window still has a pending callback it needs + // answered to unblock an input-driven redraw. Nesting this inside + // `if has_damage` (the first version of this fix) meant it only + // ever ran on a tick that already had damage from something else + // happening - i.e. never in the exact scenario it exists for. + // Reported live as clicks in Firefox still doing nothing at all, + // not just intermittently, after the first version of this fix. + { + let pointer_pos = last_pointer_pos(&self.state); + let now = self.state.start_time.elapsed(); + let wm = self.wm.borrow(); + let always_notify = [wm.focused_id(), wm.window_at(pointer_pos.x as i32, pointer_pos.y as i32)]; + drop(wm); + for w in always_notify.into_iter().flatten().filter_map(|id| self.state.id_to_window.get(&id)) { + w.send_frame(&self.output, now, None, |_, _| Some(self.output.clone())); + } + } + // Layer-shell surfaces get their callback every pass, unconditionally + // - NOT folded into the `has_damage` gate above. See the matching + // (much longer) comment in udev.rs's `render_udev_frame`: many + // layer-shell clients (GTK4/AGS among them) drive their entire + // repaint loop off frame callbacks with no independent timer + // fallback, so withholding the callback until *something* on the + // desktop happens to produce damage deadlocks them permanently + // after their first frame - confirmed live, AGS and waybar both + // froze exactly this way. Toplevel windows keep the damage gate + // (that's what fixed the wezterm-gui CPU-burn bug); layer surfaces + // are few, cheap to redraw, and are exactly the periodic-UI-chrome + // case frame callbacks exist to pace. + for layer in layer_map_for_output(&self.output).layers() { + layer.send_frame(&self.output, self.state.start_time.elapsed(), None, |_, _| Some(self.output.clone())); + } // Screencopy is serviced *after* the on-screen frame is submitted, // into its own offscreen buffer - never by reading back the window @@ -279,7 +578,7 @@ impl WaylandPlatform { let mut custom_elements: Vec<crate::elements::OverlayElement<GlesRenderer>> = Vec::new(); for (&id, deco) in self.state.decorations.iter() { - let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue }; + let Some(geom) = self.wm.borrow().visible_windows().find(|w| w.id == id).map(|w| w.geometry) else { continue }; if let Ok(elem) = MemoryRenderBufferRenderElement::from_buffer(renderer, (geom.x as f64, geom.y as f64), deco, None, None, None, Kind::Unspecified) { custom_elements.push(crate::elements::OverlayElement::Memory(elem)); } @@ -321,6 +620,33 @@ fn handle_winit_event(state: &mut CompState, output: &Output, event: WinitEvent, let pressed = event.state() == BackendButtonState::Pressed; handle_pointer_button(state, pos, button, pressed, event.time_msec()); } + // This backend had no scroll handling at all - `InputEvent:: + // PointerAxis` fell into the catch-all below and was silently + // dropped, unconditionally, on every device. Same forwarding as + // `udev.rs`'s equivalent (see its own comment for the `stop()`/ + // `v120()` reasoning); duplicated rather than shared since the two + // backends' `InputEvent` generic parameters differ and there's no + // shared event type to write one function against. + WinitEvent::Input(InputEvent::PointerAxis { event }) => { + if crate::input::handle_workspace_scroll(state, &event) { + return; + } + let Some(pointer) = state.seat.get_pointer() else { return }; + let source = event.source(); + let mut frame = smithay::input::pointer::AxisFrame::new(event.time_msec()).source(source); + for axis in [Axis::Horizontal, Axis::Vertical] { + match event.amount(axis) { + Some(value) => frame = frame.value(axis, value), + None if source == AxisSource::Finger => frame = frame.stop(axis), + None => {} + } + if let Some(v120) = event.amount_v120(axis) { + frame = frame.v120(axis, v120 as i32); + } + } + pointer.axis(state, frame); + pointer.frame(state); + } WinitEvent::Resized { .. } => {} _ => {} } @@ -331,6 +657,31 @@ impl Platform for WaylandPlatform { PlatformKind::Wayland } + /// **Self-paced, deliberately**: nothing else in this backend ever + /// blocks. `pump_winit`'s underlying `dispatch_new_events` polls + /// (returns immediately either way), and smithay's winit backend + /// hardcodes `vsync: false` on the EGL surface it creates + /// (`init_from_attributes_with_gl_attr` in smithay 0.7.0's own + /// `backend/winit/mod.rs` - true of *every* entry point into that + /// module, including the one this backend used before it needed custom + /// `WindowAttributes`, so this was never introduced by that switch). + /// `swap_buffers` therefore returns as soon as the GPU accepts the + /// frame, with no wait for the next display refresh at all. Before this + /// fix, that meant `poll_events` -> `render_frame` -> full render + + /// `swap_buffers` ran back-to-back with nothing pacing the `while + /// running.get()` loop in `main.rs` between iterations - confirmed + /// live: an idle nested instance, zero windows, sat at a sustained + /// ~52% of one core (`ps -o %cpu`), because it was rendering and + /// presenting a full frame as fast as the CPU/GPU could physically + /// cycle, forever, whether or not anything on screen had changed. + /// Fixed by giving `idle_event_loop.dispatch` (already called every + /// tick to service `ext_idle_notify_v1`'s timers, see its field doc + /// comment) a real timeout instead of always `Duration::ZERO`: the + /// remaining budget until `TARGET_FRAME_TIME` has elapsed since the + /// last frame, clamped to zero once that budget is already spent. This + /// reuses the one blocking wait this backend already has rather than + /// adding a second, separate `thread::sleep`, and still services any + /// idle-notify timer that comes due sooner than a full frame away. fn poll_events(&mut self) -> PlatformResult<Vec<CoreEvent>> { self.accept_clients()?; let closed = self.pump_winit()?; @@ -341,14 +692,35 @@ impl Platform for WaylandPlatform { self.state.tick_repeat(); self.display.dispatch_clients(&mut self.state).map_err(err)?; self.display.flush_clients().map_err(err)?; + if let Some(ipc) = self.ipc.as_mut() { + if ipc.poll(&self.wm) { + self.pending.borrow_mut().push(CoreEvent::WorkspaceChanged); + } + } + let wait = TARGET_FRAME_TIME.saturating_sub(self.last_frame.elapsed()); + let _ = self.idle_event_loop.dispatch(Some(wait), &mut self.state); + self.last_frame = Instant::now(); self.render_frame()?; Ok(self.pending.borrow_mut().drain(..).collect()) } fn monitors(&mut self) -> PlatformResult<Vec<srdwm_core::Monitor>> { - let size = self.backend.window_size(); + // Shrunk by any layer-shell exclusive zone - see the matching + // comment in `udev.rs`'s `monitors()`. This backend is always a + // single output at the global origin, so the output-local zone + // rectangle already is the usable global-space rect. + let zone = layer_map_for_output(&self.output).non_exclusive_zone(); Ok(vec![{ - let mut m = srdwm_core::Monitor::new(0, "winit", srdwm_core::Rect::new(0, 0, size.w as u32, size.h as u32)); + let rect = srdwm_core::Rect::new(zone.loc.x, zone.loc.y, zone.size.w as u32, zone.size.h as u32); + let mut m = srdwm_core::Monitor::new(0, "winit", rect); + // Same fix as `udev.rs`'s matching function: `Monitor::new` + // defaults `full_geometry` to `geometry`, which is already + // zone-shrunk here - without this, `toggle_fullscreen` had no + // way to actually cover a bar/dock's reserved strip, since the + // "true full rect" it targets was silently identical to the + // "usable, shrunk rect" `toggle_maximize` targets. + let full = self.backend.window_size(); + m.full_geometry = srdwm_core::Rect::new(0, 0, full.w as u32, full.h as u32); m.primary = true; m }]) @@ -364,8 +736,11 @@ impl Platform for WaylandPlatform { Ok(()) } + /// See `udev.rs`'s matching impl for why this has to go through + /// `crate::input::focus_window` (the same path a real mouse click + /// already uses) rather than only touching core state. fn focus(&mut self, window: WindowId) -> PlatformResult<()> { - self.state.wm.borrow_mut().focus_window(window); + crate::input::focus_window(&mut self.state, window); Ok(()) } diff --git a/crates/wayland/src/workspace.rs b/crates/wayland/src/workspace.rs new file mode 100644 index 0000000..ce9cd04 --- /dev/null +++ b/crates/wayland/src/workspace.rs @@ -0,0 +1,251 @@ +//! `ext-workspace-v1`: enumerates srdwm's workspaces (name, active/urgent/ +//! hidden state) to any client that binds it, and lets that client request +//! activation - the workspace pill/switcher half of a dock, requested +//! alongside `foreign_toplevel.rs` (see `docs/PANEL_SUPPORT_TODO.md`'s P1 +//! list). smithay 0.7 has no built-in helper for this protocol either; +//! hand-written against the raw `wayland-protocols` server bindings, same +//! pattern as `screencopy.rs`/`foreign_toplevel.rs`. +//! +//! srdwm has exactly one flat, global list of workspaces shared by every +//! output (`WindowManager::current_workspace`/`workspaces()`), not a +//! separate set per monitor - so this always advertises exactly one +//! `ext_workspace_group_handle_v1`, entered by every output, containing +//! every workspace. A compositor with per-output workspaces would need one +//! group per output instead; that's not this one. +//! +//! Only `activate` is implemented as a request: srdwm's workspaces are a +//! fixed, config-defined set (created once at startup, not created/removed/ +//! reassigned at runtime), so `create_workspace`/`remove`/`assign` have +//! nothing meaningful to do and their capability bits are simply not +//! advertised - a client is expected to hide the UI for a request whose +//! capability bit is unset, per the protocol's own `capabilities` event +//! doc comment, rather than send it and be ignored. +//! +//! `deactivate` is also not advertised: srdwm always has exactly one +//! current workspace, there is no "no workspace active" state to request. + +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource}; +use wayland_protocols::ext::workspace::v1::server::ext_workspace_group_handle_v1::{self, ExtWorkspaceGroupHandleV1, GroupCapabilities}; +use wayland_protocols::ext::workspace::v1::server::ext_workspace_handle_v1::{self, ExtWorkspaceHandleV1, State, WorkspaceCapabilities}; +use wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::{self, ExtWorkspaceManagerV1}; + +use srdwm_core::WorkspaceId; + +use crate::state::CompState; + +const PROTOCOL_VERSION: u32 = 1; + +pub struct WorkspaceManagerState { + _global: smithay::reexports::wayland_server::backend::GlobalId, +} + +impl WorkspaceManagerState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<ExtWorkspaceManagerV1, ()> + 'static, + { + Self { _global: dh.create_global::<D, ExtWorkspaceManagerV1, _>(PROTOCOL_VERSION, ()) } + } +} + +pub struct WorkspaceHandleData { + workspace: WorkspaceId, +} + +impl GlobalDispatch<ExtWorkspaceManagerV1, ()> for CompState { + fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New<ExtWorkspaceManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) { + let manager = data_init.init(manager, ()); + + let Ok(group) = client.create_resource::<ExtWorkspaceGroupHandleV1, (), CompState>(dh, manager.version(), ()) else { + return; + }; + manager.workspace_group(&group); + // Nothing here is dynamically created/removed/reassigned - see the + // module doc comment on why this is always empty. + group.capabilities(GroupCapabilities::empty()); + // Every output enters the one group, since workspaces span all of + // them. Only reaches outputs this client has *already* bound + // `wl_output` for - a client binding `ext_workspace_manager_v1` + // before any `wl_output` global would see no `output_enter` here, + // and none later either, since nothing currently re-checks this on + // a subsequent `wl_output` bind. Real clients bind their globals up + // front, so not fixing that ordering-dependency is an accepted gap + // rather than a deliberate design choice. + for output in state.outputs() { + for wl_output in output.client_outputs(client) { + group.output_enter(&wl_output); + } + } + + let ids: Vec<WorkspaceId> = state.wm.borrow().workspaces().iter().map(|w| w.id).collect(); + for id in ids { + announce_workspace(state, &manager, &group, id, client, dh); + } + manager.done(); + + state.workspace_managers.push(manager); + state.workspace_groups.push(group); + } +} + +impl Dispatch<ExtWorkspaceManagerV1, ()> for CompState { + fn request(state: &mut Self, _client: &Client, manager: &ExtWorkspaceManagerV1, request: ext_workspace_manager_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + match request { + // Every request below is handled the moment it arrives rather + // than batched, so there is nothing left for `commit` itself to + // flush - a plain acknowledgement. + ext_workspace_manager_v1::Request::Commit => {} + ext_workspace_manager_v1::Request::Stop => { + manager.finished(); + state.workspace_managers.retain(|m| m != manager); + } + _ => {} + } + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, manager: &ExtWorkspaceManagerV1, _data: &()) { + state.workspace_managers.retain(|m| m != manager); + } +} + +impl Dispatch<ExtWorkspaceGroupHandleV1, ()> for CompState { + fn request(state: &mut Self, _client: &Client, group: &ExtWorkspaceGroupHandleV1, request: ext_workspace_group_handle_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + // `create_workspace` is deliberately unimplemented - its + // capability bit is never advertised (see the module doc comment), + // so a well-behaved client never sends it; ignored either way + // rather than erroring a client that sends it anyway. + if let ext_workspace_group_handle_v1::Request::Destroy = request { + state.workspace_groups.retain(|g| g != group); + } + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, group: &ExtWorkspaceGroupHandleV1, _data: &()) { + state.workspace_groups.retain(|g| g != group); + } +} + +impl Dispatch<ExtWorkspaceHandleV1, WorkspaceHandleData> for CompState { + fn request(state: &mut Self, _client: &Client, _handle: &ExtWorkspaceHandleV1, request: ext_workspace_handle_v1::Request, data: &WorkspaceHandleData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + if let ext_workspace_handle_v1::Request::Activate = request { + let mut wm = state.wm.borrow_mut(); + if wm.current_workspace() != data.workspace { + wm.switch_workspace(data.workspace); + drop(wm); + // `switch_workspace` alone only updates core's own state -- + // nothing re-renders or shows/hides windows for the new + // workspace without `main.rs`'s `sync()` running, which + // only happens when a polled event sets `dirty`. Pushing + // `WorkspaceChanged` is what makes that happen; see its + // definition in `srdwm_core::event` for the fuller story + // (this request path has the exact same problem the + // pre-existing `SUPER+scroll` workspace gesture already + // had, found while wiring this up). + state.pending.borrow_mut().push(srdwm_core::Event::WorkspaceChanged); + broadcast_active_workspace(state); + } + } + // Deactivate/Assign/Remove: not advertised as available (see the + // module doc comment), so real clients don't send them. Destroy + // ends this protocol object, handled by `destroyed` below. + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, handle: &ExtWorkspaceHandleV1, data: &WorkspaceHandleData) { + if let Some(handles) = state.workspace_handles.get_mut(&data.workspace) { + handles.retain(|h| h != handle); + } + } +} + +/// Packs a workspace's current flags into `ext_workspace_handle_v1.state`'s +/// wire format. srdwm's workspaces are never `Urgent` (nothing in this +/// codebase has an "urgent"/attention-request concept for a workspace) or +/// `Hidden` (all of them are always real, switchable workspaces, never an +/// internal/scratch one that should stay out of a switcher UI) - only +/// `Active` ever varies. +fn workspace_state(active: bool) -> State { + if active { + State::Active + } else { + State::empty() + } +} + +fn announce_workspace(state: &mut CompState, manager: &ExtWorkspaceManagerV1, group: &ExtWorkspaceGroupHandleV1, id: WorkspaceId, client: &Client, dh: &DisplayHandle) { + let Ok(handle) = client.create_resource::<ExtWorkspaceHandleV1, WorkspaceHandleData, CompState>(dh, manager.version(), WorkspaceHandleData { workspace: id }) else { + return; + }; + manager.workspace(&handle); + group.workspace_enter(&handle); + + let Some(w) = state.wm.borrow().workspaces().iter().find(|w| w.id == id).cloned() else { return }; + let active = state.wm.borrow().current_workspace() == id; + handle.name(w.name); + // 1D: srdwm numbers workspaces without any geometric/grid arrangement + // - see this event's own doc comment on why a flat index is the + // correct thing to send here, not a guess at one. + handle.coordinates((id as u32).to_ne_bytes().to_vec()); + handle.state(workspace_state(active)); + handle.capabilities(WorkspaceCapabilities::Activate); + + state.workspace_handles.entry(id).or_default().push(handle); +} + +/// Called after `switch_workspace` actually changes which workspace is +/// current - re-sends `state` (and `done`) for every workspace handle +/// across every bound client, so a switcher's active-workspace highlight +/// follows real changes instead of only ever reflecting whatever was +/// active when each handle was created. Broadcasts to *all* workspaces +/// (not just the old/new pair) since, unlike `foreign_toplevel`'s +/// per-window handle lookup, resolving "which workspace lost `Active`" has +/// no direct handle to key off here - this only fires on a real +/// workspace switch, not on every input event, so the cost is one small +/// loop per switch, not per frame. +pub(crate) fn broadcast_active_workspace(state: &mut CompState) { + let current = state.wm.borrow().current_workspace(); + let ids: Vec<WorkspaceId> = state.workspace_handles.keys().copied().collect(); + for id in ids { + let Some(handles) = state.workspace_handles.get(&id).cloned() else { continue }; + for handle in &handles { + handle.state(workspace_state(id == current)); + } + } + for manager in state.workspace_managers.clone() { + manager.done(); + } +} + +/// Calls `broadcast_active_workspace` only when the current workspace has +/// actually changed since the last broadcast (by *any* means), called once +/// per frame from `CompState::tick_dirty_broadcasts`. +/// +/// `input.rs`'s `SUPER+scroll` cycle gesture and this protocol's own +/// `activate` request both already call `broadcast_active_workspace` +/// directly. The gap, same shape and same root cause as `foreign_toplevel:: +/// broadcast_dirty_state`'s (see its doc comment): the Lua `srd.workspace. +/// next()`/`.prev()`/`.switch()` API changes `WindowManager` state through +/// `crates/config`, which has no reachable path to this Wayland-specific +/// module, so a switch driven from a keybinding went stale in a dock's +/// workspace pill until some unrelated scroll or `activate` request +/// happened to resync it. `broadcast_active_workspace` itself is not safe +/// to call unconditionally every frame - it always re-sends `state` to +/// every handle and `done` to every manager, real protocol traffic, not +/// just a cheap comparison - so this gates it on an actual change first. +pub(crate) fn broadcast_dirty_active(state: &mut CompState) { + let current = state.wm.borrow().current_workspace(); + if state.last_broadcast_workspace == Some(current) { + return; + } + state.last_broadcast_workspace = Some(current); + broadcast_active_workspace(state); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_active_workspace_gets_the_active_bit() { + assert_eq!(workspace_state(true), State::Active); + assert_eq!(workspace_state(false), State::empty()); + } +} diff --git a/crates/wayland/src/xkb_config.rs b/crates/wayland/src/xkb_config.rs new file mode 100644 index 0000000..5b16904 --- /dev/null +++ b/crates/wayland/src/xkb_config.rs @@ -0,0 +1,102 @@ +//! Reads the system's real keyboard layout/model/options so both backends' +//! `seat.add_keyboard()` calls actually use them. +//! +//! `smithay::input::keyboard::XkbConfig::default()` - what both backends +//! passed unconditionally before this - resolves any field left as `""`/ +//! `None` via the `XKB_DEFAULT_*` environment variables, per xkbcommon's +//! own documented behavior (see its doc comment). That covers a session +//! that actually sets those variables, but this machine doesn't: the real +//! configuration lives in `/etc/X11/xorg.conf.d/00-keyboard.conf`, written +//! by `systemd-localed` from `localectl`, which nothing was ever reading. +//! Concretely: `Option "XkbOptions" "terminate:ctrl_alt_bksp"` was silently +//! dropped, along with the model (`pc105+inet`), regardless of what +//! `localectl status` actually reports. +//! +//! Deliberately fails soft, field by field: a missing file, an unreadable +//! one, or a field just not present in it all fall through to the same +//! `""`/`None` `Default::default()` already used, not an error - this is +//! strictly additive over today's behavior, never worse. + +use std::collections::HashMap; + +/// Parsed fields from the standard `XkbLayout`/`XkbModel`/`XkbVariant`/ +/// `XkbOptions` `Option "..." "..."` lines `systemd-localed` writes. +/// Anything not found is `None`, which is exactly what an empty-string +/// `XkbConfig` field already meant. +#[derive(Default)] +pub(crate) struct SystemXkbConfig { + pub(crate) model: Option<String>, + pub(crate) layout: Option<String>, + pub(crate) variant: Option<String>, + pub(crate) options: Option<String>, +} + +const KEYBOARD_CONF_PATH: &str = "/etc/X11/xorg.conf.d/00-keyboard.conf"; + +pub(crate) fn read() -> SystemXkbConfig { + let Ok(content) = std::fs::read_to_string(KEYBOARD_CONF_PATH) else { + return SystemXkbConfig::default(); + }; + let fields = parse(&content); + SystemXkbConfig { + model: fields.get("XkbModel").cloned(), + layout: fields.get("XkbLayout").cloned(), + variant: fields.get("XkbVariant").cloned(), + options: fields.get("XkbOptions").cloned(), + } +} + +/// Extracts `Option "Name" "value"` lines into a name -> value map. Not a +/// general X11 config parser - this file has exactly one `InputClass` +/// section with a handful of `Option` lines in that fixed form, always +/// machine-written, so a line-by-line scan for that one pattern is all +/// this needs. +fn parse(content: &str) -> HashMap<String, String> { + let mut fields = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("Option") else { continue }; + let quoted: Vec<&str> = rest.split('"').filter(|s| !s.trim().is_empty()).collect(); + if let [name, value] = quoted[..] { + fields.insert(name.to_string(), value.to_string()); + } + } + fields +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_real_systemd_localed_file() { + let content = r#" +Section "InputClass" + Identifier "system-keyboard" + MatchIsKeyboard "on" + Option "XkbLayout" "us" + Option "XkbModel" "pc105+inet" + Option "XkbOptions" "terminate:ctrl_alt_bksp" +EndSection +"#; + let fields = parse(content); + assert_eq!(fields.get("XkbLayout").map(String::as_str), Some("us")); + assert_eq!(fields.get("XkbModel").map(String::as_str), Some("pc105+inet")); + assert_eq!(fields.get("XkbOptions").map(String::as_str), Some("terminate:ctrl_alt_bksp")); + } + + #[test] + fn ignores_unrelated_lines_without_panicking() { + let content = "Section \"InputClass\"\nIdentifier \"system-keyboard\"\nMatchIsKeyboard \"on\"\nEndSection\n"; + assert!(parse(content).is_empty()); + } + + #[test] + fn missing_file_yields_all_none_not_an_error() { + // read() itself isn't unit-testable without touching the real + // filesystem path, but the fallback behavior it guarantees is + // exactly SystemXkbConfig::default() - covered by construction. + let cfg = SystemXkbConfig::default(); + assert!(cfg.model.is_none() && cfg.layout.is_none() && cfg.variant.is_none() && cfg.options.is_none()); + } +} diff --git a/crates/wayland/src/xwayland.rs b/crates/wayland/src/xwayland.rs index ac71492..f18a485 100644 --- a/crates/wayland/src/xwayland.rs +++ b/crates/wayland/src/xwayland.rs @@ -75,7 +75,11 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm XWaylandEvent::Ready { x11_socket, display_number } => { log::info!("XWayland ready on display :{display_number}"); match X11Wm::start_wm(handle_for_ready.clone(), x11_socket, client.clone()) { - Ok(wm) => data.xwm = Some(wm), + Ok(wm) => { + data.xwm = Some(wm); + fix_wm_name(display_number); + data.ewmh = EwmhState::connect(display_number); + } Err(e) => log::error!("failed to start X11 window manager for XWayland: {e}"), } } @@ -85,6 +89,289 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm Ok(()) } +/// Overwrites `_NET_WM_NAME` on XWayland's WM-check window from "Smithay X +/// WM" to "srdwm". +/// +/// `X11Wm::start_wm` hardcodes that string with no override hook exposed -- +/// no public method on `X11Wm`, and `wm_window`/its connection are private +/// fields, so it can't be reached through smithay's API at all. Every X11 +/// client that asks "who is the window manager" (`xprop`, `wmctrl`, +/// `xdotool`, fetch tools, app-compat shims that branch on WM identity) +/// gets told the name of the *library*, not the compositor - which is +/// actively misleading, not just cosmetic: it broke a shell function that +/// resolved the WM's process name from this exact property to kill it on +/// logout, `pkill`ing "Smithay" and matching nothing. +/// +/// Worked around by opening a second, independent X11 connection of our +/// own to the same XWayland display - exactly what `xprop` itself would +/// do - and rewriting the property directly. `_NET_SUPPORTING_WM_CHECK` +/// (which `start_wm` does set correctly) is how a plain client is meant to +/// find the WM-check window in the first place, so reading it back off the +/// root window rather than assuming a window ID keeps this from silently +/// going stale if smithay ever changes how it allocates that window. +fn fix_wm_name(display_number: u32) { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _, PropMode}; + use smithay::reexports::x11rb::rust_connection::RustConnection; + use smithay::reexports::x11rb::wrapper::ConnectionExt as _; + + let display = format!(":{display_number}"); + let (conn, screen_num) = match RustConnection::connect(Some(&display)) { + Ok(c) => c, + Err(e) => { + log::warn!("xwayland: couldn't open a second connection to fix _NET_WM_NAME: {e}"); + return; + } + }; + let root = conn.setup().roots[screen_num].root; + + let intern = |name: &str| -> Option<u32> { conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom) }; + let (Some(supporting_wm_check), Some(net_wm_name), Some(utf8_string)) = + (intern("_NET_SUPPORTING_WM_CHECK"), intern("_NET_WM_NAME"), intern("UTF8_STRING")) + else { + log::warn!("xwayland: couldn't intern EWMH atoms to fix _NET_WM_NAME"); + return; + }; + + let reply = conn.get_property(false, root, supporting_wm_check, AtomEnum::WINDOW, 0, 1).ok().and_then(|c| c.reply().ok()); + let wm_window = reply.as_ref().and_then(|r| r.value32()).and_then(|mut it| it.next()); + let Some(wm_window) = wm_window else { + log::warn!("xwayland: _NET_SUPPORTING_WM_CHECK unset on the XWayland root; can't fix _NET_WM_NAME"); + return; + }; + + if let Err(e) = conn.change_property8(PropMode::REPLACE, wm_window, net_wm_name, utf8_string, b"srdwm") { + log::warn!("xwayland: failed to set _NET_WM_NAME: {e}"); + return; + } + let _ = conn.flush(); +} + +/// Keeps `_NET_ACTIVE_WINDOW`/`_NET_CLIENT_LIST`/`_NET_CLIENT_LIST_STACKING` +/// on the XWayland root window up to date. +/// +/// srdwm declares all three in `_NET_SUPPORTED` (smithay's `X11Wm` sets that +/// part up on its own), but never actually wrote them: `_NET_ACTIVE_WINDOW` +/// stayed `0x0` and `_NET_CLIENT_LIST` stayed empty regardless of what was +/// focused or mapped. Confirmed this is not something `X11Wm` does for us +/// automatically - it only updates `_NET_ACTIVE_WINDOW` in response to a +/// real X11 `FocusIn`/`FocusOut` event on the window, which requires an +/// actual `SetInputFocus` request to have been issued in the first place, +/// and nothing in this codebase ever issues one (Wayland keyboard focus and +/// X11 input focus are separate things; only the former was ever set). The +/// practical effect: any X11-aware client trying to answer "what's the +/// focused window" or "what windows exist" - `xdotool`, `wmctrl`, and +/// (per a downstream report) an AGS global-menu widget resolving the +/// focused window to query its DBusMenu registrar - got nothing. +/// +/// Rather than depend on `X11Wm`'s `FocusIn`-triggered path (which would +/// also need us to issue real `SetInputFocus` requests, itself a bigger +/// change with its own risk of fighting Wayland focus), this writes both +/// properties directly, from srdwm's own already-authoritative focus and +/// window-list state - exactly the "no new protocol needed" shape a +/// real EWMH-maintaining WM uses. Same reasoning as `fix_wm_name` for using +/// a second, independent connection rather than reaching into `X11Wm`'s +/// private one: there is no public accessor for it. +pub(crate) struct EwmhState { + conn: smithay::reexports::x11rb::rust_connection::RustConnection, + root: u32, + net_active_window: u32, + net_client_list: u32, + net_client_list_stacking: u32, + /// Global-menu atoms - see `read_global_menu`. Individually optional + /// (unlike the EWMH atoms above): a server old enough, or configured + /// oddly enough, to not know these names is still a fully functional + /// X server for everything else this module does, so a failure to + /// intern any one of them just means that field never resolves rather + /// than aborting `connect` entirely. + gtk_unique_bus_name: Option<u32>, + gtk_application_object_path: Option<u32>, + gtk_window_object_path: Option<u32>, + gtk_menubar_object_path: Option<u32>, + gtk_app_menu_object_path: Option<u32>, + unity_object_path: Option<u32>, +} + +impl EwmhState { + fn connect(display_number: u32) -> Option<Self> { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::ConnectionExt as _; + use smithay::reexports::x11rb::rust_connection::RustConnection; + + let display = format!(":{display_number}"); + let (conn, screen_num) = match RustConnection::connect(Some(&display)) { + Ok(c) => c, + Err(e) => { + log::warn!("xwayland: couldn't open a connection for EWMH property updates: {e}"); + return None; + } + }; + let root = conn.setup().roots[screen_num].root; + let intern = |name: &str| -> Option<u32> { conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom) }; + let (Some(net_active_window), Some(net_client_list), Some(net_client_list_stacking)) = + (intern("_NET_ACTIVE_WINDOW"), intern("_NET_CLIENT_LIST"), intern("_NET_CLIENT_LIST_STACKING")) + else { + log::warn!("xwayland: couldn't intern EWMH atoms; _NET_ACTIVE_WINDOW/_NET_CLIENT_LIST won't be maintained"); + return None; + }; + let gtk_unique_bus_name = intern("_GTK_UNIQUE_BUS_NAME"); + let gtk_application_object_path = intern("_GTK_APPLICATION_OBJECT_PATH"); + let gtk_window_object_path = intern("_GTK_WINDOW_OBJECT_PATH"); + let gtk_menubar_object_path = intern("_GTK_MENUBAR_OBJECT_PATH"); + let gtk_app_menu_object_path = intern("_GTK_APP_MENU_OBJECT_PATH"); + let unity_object_path = intern("_UNITY_OBJECT_PATH"); + let state = Self { + conn, + root, + net_active_window, + net_client_list, + net_client_list_stacking, + gtk_unique_bus_name, + gtk_application_object_path, + gtk_window_object_path, + gtk_menubar_object_path, + gtk_app_menu_object_path, + unity_object_path, + }; + // `_NET_CLIENT_LIST`/`_STACKING` are properties on the X root window, + // which XWayland recreates fresh on every launch - but nothing + // guarantees a *client* reading them does so only after this + // compositor's own first `update_net_client_list()` call, and until + // that first real add/remove there is no guarantee the property even + // has a defined initial value. Clearing it here, before any window + // has ever mapped, means a freshly connected client can never read a + // leftover or undefined list - it always starts empty and correct. + state.set_client_list(&[]); + Some(state) + } + + /// Reads `xid`'s global-menu D-Bus address straight off its own X11 + /// properties - `_GTK_UNIQUE_BUS_NAME` plus whichever menu-path atom + /// the client actually set. `_GTK_MENUBAR_OBJECT_PATH` (a real menu + /// bar) wins over `_GTK_APP_MENU_OBJECT_PATH` (the single-item + /// fallback simpler/older clients export) if a client somehow sets + /// both; `_UNITY_OBJECT_PATH` is the pre-`_GTK_*` name some + /// still-relevant toolkits (older Qt builds with the appmenu-qt5 + /// platform theme) use instead, tried last. No bus name means no menu + /// at all - the paths are meaningless without it - so this returns + /// `None` rather than a `GlobalMenu` with an empty `bus_name`. + /// + /// Which atom actually won is recorded as `source` - a consumer needs + /// it to pick the right D-Bus action-group prefix (`app`/`win` for a + /// real `GMenuModel`, `unity` for the older export), and + /// `appmenu-gtk-module` is known to set the `_GTK_*` atoms *and* + /// `_UNITY_OBJECT_PATH` simultaneously in some configurations - a + /// consumer with only the resolved path string, and no record of + /// which one it came from, can't tell the two cases apart even though + /// picking the wrong prefix means every menu item renders permanently + /// insensitive (a silent failure that reads exactly like a broken + /// app, not a wiring bug). Reported by the AGS peer session building + /// the consumer, from hitting exactly this live. + fn read_global_menu(&self, xid: u32) -> Option<srdwm_core::GlobalMenu> { + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _}; + + let read_string = |atom: Option<u32>| -> Option<String> { + let atom = atom?; + let reply = self.conn.get_property(false, xid, atom, AtomEnum::ANY, 0, u32::MAX).ok()?.reply().ok()?; + if reply.value.is_empty() { + return None; + } + String::from_utf8(reply.value).ok().filter(|s| !s.is_empty()) + }; + + let bus_name = read_string(self.gtk_unique_bus_name)?; + let gtk_menu_path = read_string(self.gtk_menubar_object_path).or_else(|| read_string(self.gtk_app_menu_object_path)); + let (menu_path, source) = match gtk_menu_path { + Some(path) => (Some(path), srdwm_core::MenuSource::Gtk), + None => match read_string(self.unity_object_path) { + Some(path) => (Some(path), srdwm_core::MenuSource::Unity), + None => (None, srdwm_core::MenuSource::Gtk), + }, + }; + let app_path = read_string(self.gtk_application_object_path); + let window_path = read_string(self.gtk_window_object_path); + Some(srdwm_core::GlobalMenu { bus_name, menu_path, app_path, window_path, source }) + } + + /// `xid` is `None` when focus is on a native Wayland window (or + /// nothing) rather than an X11 one - `_NET_ACTIVE_WINDOW`'s value is + /// only meaningful for X11 clients, so this writes `0` (the documented + /// "no active window" sentinel) rather than leaving the last X11 + /// window's id stale and misleading. + fn set_active_window(&self, xid: Option<u32>) { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, PropMode}; + use smithay::reexports::x11rb::wrapper::ConnectionExt as _; + if let Err(e) = self.conn.change_property32(PropMode::REPLACE, self.root, self.net_active_window, AtomEnum::WINDOW, &[xid.unwrap_or(0)]) { + log::warn!("xwayland: failed to set _NET_ACTIVE_WINDOW: {e}"); + return; + } + let _ = self.conn.flush(); + } + + fn set_client_list(&self, xids: &[u32]) { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, PropMode}; + use smithay::reexports::x11rb::wrapper::ConnectionExt as _; + // Same order for both: EWMH only defines a strict order for the + // `_STACKING` variant (bottom-to-top), and `stacking_order` is + // already srdwm's one authoritative ordering of its windows - a + // second, differently-ordered list for plain `_NET_CLIENT_LIST` + // would need tracking mapping order separately for no real benefit. + for (atom, name) in [(self.net_client_list, "_NET_CLIENT_LIST"), (self.net_client_list_stacking, "_NET_CLIENT_LIST_STACKING")] { + if let Err(e) = self.conn.change_property32(PropMode::REPLACE, self.root, atom, AtomEnum::WINDOW, xids) { + log::warn!("xwayland: failed to set {name}: {e}"); + return; + } + } + let _ = self.conn.flush(); + } +} + +impl CompState { + /// Call on every focus change (from `set_keyboard_focus`, the single + /// chokepoint every focus path already goes through). `surface` is + /// whatever just gained keyboard focus; resolves to an X11 window id + /// only if that surface's window is XWayland-backed. + pub(crate) fn update_net_active_window(&self, surface: Option<&smithay::reexports::wayland_server::protocol::wl_surface::WlSurface>) { + let Some(ewmh) = &self.ewmh else { return }; + let id = surface.and_then(|s| self.surface_to_id.get(s)).copied(); + let xid = id.and_then(|id| self.id_to_window.get(&id)).and_then(|w| w.x11_surface()).map(|x| x.window_id()); + ewmh.set_active_window(xid); + // Global-menu properties are usually set once, shortly after a + // client registers on the session bus - which can race a window's + // own initial map, so reading them only at map time would miss a + // client that finished that registration a moment later. Refreshed + // here instead: every real focus change is a natural, already- + // existing hook, and a menu only actually needs to be current for + // whichever window is focused right now anyway. `read_global_menu` + // returning `None` (the common case for anything non-GTK, or a + // GTK app with no menu to export) correctly clears a stale value + // from a previous window that used to occupy this `id`. + if let (Some(id), Some(xid)) = (id, xid) { + let menu = ewmh.read_global_menu(xid); + if let Some(w) = self.wm.borrow_mut().window_mut(id) { + w.global_menu = menu; + } + } + } + + /// Call whenever the set of mapped windows changes (X11 window map, + /// unmap, or destroy - see the `XwmHandler` methods below). + pub(crate) fn update_net_client_list(&self) { + let Some(ewmh) = &self.ewmh else { return }; + let xids: Vec<u32> = self + .wm + .borrow() + .stacking_order() + .filter_map(|w| self.id_to_window.get(&w.id)) + .filter_map(|w| w.x11_surface()) + .map(|x| x.window_id()) + .collect(); + ewmh.set_client_list(&xids); + } +} + /// Writes a small shell script named `Xwayland` to a private directory and /// prepends that directory to this process's own `PATH` - the next /// `Command::new("Xwayland")` (namely `XWayland::spawn`'s, which copies @@ -195,10 +482,23 @@ impl CompState { let _ = surface.configure(Rectangle::new((geom.x, geom.y + TITLEBAR_HEIGHT as i32).into(), (geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into())); self.space.map_element(dwindow.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), true); - self.surface_to_id.insert(wl_surface, id); + self.surface_to_id.insert(wl_surface.clone(), id); self.id_to_window.insert(id, dwindow); self.redraw_decoration_buffer(id); + // `WindowManager::add_window` already made this the focused window + // in srdwm's own bookkeeping (it unconditionally does, for every + // new window), but that's purely internal state - without this, a + // freshly-opened XWayland app never receives a single keystroke + // until it's clicked, and (found investigating a downstream EWMH + // report) `_NET_ACTIVE_WINDOW` never updates either, since this is + // `set_keyboard_focus`'s only caller for X11 windows and that's the + // sole place `_NET_ACTIVE_WINDOW` gets written. The xdg-shell path + // (`new_managed_window` in state.rs) already does this; this is the + // equivalent X11 creation path, which never got the same fix. + self.set_keyboard_focus(Some(wl_surface)); self.pending.borrow_mut().push(CoreEvent::WindowCreated(id)); + self.update_net_client_list(); + crate::foreign_toplevel::window_created(self, id); } fn remove_x11_window(&mut self, xid: X11Window) { @@ -207,8 +507,20 @@ impl CompState { self.space.unmap_elem(&w); } self.decorations.remove(&id); + // Same reason as `state.rs`'s native `remove_window`: don't leave + // the context menu open against a window that's about to stop + // existing. + if self.context_menu.as_ref().is_some_and(|m| m.window == id) { + self.close_context_menu(); + } self.wm.borrow_mut().remove_window(id); self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id)); + crate::foreign_toplevel::window_closed(self, id); + // Same reason as the equivalent call in `state.rs`'s native + // `remove_window`: core may have already moved focus to whatever's + // now on top, and the Wayland/X11 side needs to be told to follow. + crate::input::sync_keyboard_focus(self); + self.update_net_client_list(); } } @@ -320,6 +632,70 @@ impl XwmHandler for CompState { } } + /// The same six requests found missing for native Wayland windows + /// (`XdgShellHandler`'s `maximize_request`/`unmaximize_request`/ + /// `fullscreen_request`/`unfullscreen_request`/`minimize_request`, + /// see `protocols.rs`) exist here too, under EWMH/ICCCM naming -- + /// `_NET_WM_STATE_MAXIMIZED_VERT`/`_HORZ`, `_NET_WM_STATE_FULLSCREEN`, + /// `_NET_WM_STATE_HIDDEN` toggled via a client message - and were + /// equally unimplemented, silently doing nothing for any XWayland + /// app's own window-menu maximize/minimize/fullscreen action. `move_ + /// request`/`resize_request` right below were already implemented, + /// which is what made this omission easy to miss; the drag/resize + /// half of this class of gap already had parity, only the state- + /// toggle half didn't. `unminimize_request` has no native-Wayland + /// equivalent to mirror - xdg-shell has no client-initiated "restore + /// from minimized" request at all, only EWMH does. + fn maximize_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if !self.wm.borrow().window(id).is_some_and(|w| w.maximized) { + self.wm.borrow_mut().toggle_maximize(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + + fn unmaximize_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if self.wm.borrow().window(id).is_some_and(|w| w.maximized) { + self.wm.borrow_mut().toggle_maximize(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + + fn fullscreen_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if !self.wm.borrow().is_fullscreen(id) { + self.wm.borrow_mut().toggle_fullscreen(id); + self.redraw_decoration_buffer(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + + fn unfullscreen_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if self.wm.borrow().is_fullscreen(id) { + self.wm.borrow_mut().toggle_fullscreen(id); + self.redraw_decoration_buffer(id); + self.sync_geometry(id); + crate::foreign_toplevel::send_state(self, id); + } + } + + fn minimize_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + self.wm.borrow_mut().minimize_window(id); + crate::foreign_toplevel::send_state(self, id); + } + + fn unminimize_request(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + self.wm.borrow_mut().restore_window(id); + crate::foreign_toplevel::send_state(self, id); + } + fn resize_request(&mut self, _xwm: XwmId, window: X11Surface, _button: u32, resize_edge: X11ResizeEdge) { let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; let pos = self.seat.get_pointer().map(|p| p.current_location()).unwrap_or_default(); diff --git a/crates/x11/Cargo.toml b/crates/x11/Cargo.toml index 332bb03..99007d2 100644 --- a/crates/x11/Cargo.toml +++ b/crates/x11/Cargo.toml @@ -11,3 +11,7 @@ srdwm-platform.workspace = true log.workspace = true thiserror.workspace = true x11rb = { version = "0.13", features = ["randr", "resource_manager"] } +# Only for `poll(2)` on the X11 connection's own fd in `poll_events` - see +# that method's doc comment on why a bounded wait replaced the previous +# indefinite `wait_for_event()` block. +libc = "0.2" diff --git a/crates/x11/src/lib.rs b/crates/x11/src/lib.rs index d025efa..ad52daa 100644 --- a/crates/x11/src/lib.rs +++ b/crates/x11/src/lib.rs @@ -29,6 +29,7 @@ use srdwm_core::{Monitor, Rect, WindowManager}; use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult}; use std::cell::RefCell; use std::collections::HashMap; +use std::os::unix::io::AsRawFd; use std::rc::Rc; use x11rb::connection::Connection; use x11rb::protocol::randr::ConnectionExt as _; @@ -67,6 +68,35 @@ fn err(e: impl std::fmt::Display) -> PlatformError { PlatformError::Other(e.to_string()) } +/// Finds which of `ModMask::M1`..`M5` a keycode is bound to, given a +/// `GetModifierMappingReply`'s flattened `keycodes` list (8 fixed slots -- +/// Shift, Lock, Control, Mod1..Mod5 - each `keycodes_per_modifier` long, +/// zero-padded). Only scans the Mod1..Mod5 slots (indices 3..8): Shift/ +/// Lock/Control are never where Num Lock lands in practice, and this is +/// only ever called looking for it. Returns an empty mask if the keycode +/// isn't bound to any modifier at all (a keyboard with no Num Lock key, or +/// a keycode of `0` from a lookup that found nothing). +fn modmask_for_keycode_in_mod_slots(keycode: u8, keycodes_per_modifier: usize, keycodes: &[u8]) -> ModMask { + if keycode == 0 || keycodes_per_modifier == 0 { + return ModMask::from(0u16); + } + (3..8usize) + .find(|&slot| { + let start = slot * keycodes_per_modifier; + keycodes.get(start..start + keycodes_per_modifier).is_some_and(|ks| ks.contains(&keycode)) + }) + .map(|slot| ModMask::from(1u16 << slot)) + .unwrap_or(ModMask::from(0u16)) +} + +/// Packs an RGB triple into the `0x00RRGGBB` pixel value X11's +/// `border_pixel`/GC `foreground` etc. expect on a TrueColor visual -- +/// matching the format the hardcoded titlebar colour constants in +/// `redraw_decoration` already use. +fn rgb_to_pixel((r, g, b): (u8, u8, u8)) -> u32 { + ((r as u32) << 16) | ((g as u32) << 8) | (b as u32) +} + pub struct X11Platform { conn: RustConnection, root: XWindow, @@ -80,6 +110,17 @@ pub struct X11Platform { max_keycode: u8, keysyms_per_keycode: u8, keyboard_mapping: Vec<u32>, + /// Whichever of `ModMask::M1`..`M5` the server has Num Lock bound to -- + /// see `grab_keybindings`'s doc comment for why this needs grabbing + /// alongside every binding, not just the modifiers a config actually + /// asked for. + numlock_mask: ModMask, + /// `srd`'s control socket - see `srdwm_platform::IpcServer`'s module + /// doc comment. `None` if binding it failed (a stale socket from a + /// still-running instance, an unwritable runtime dir): the compositor + /// itself still starts either way, matching how the Wayland backends + /// already treat this as non-fatal. + ipc: Option<srdwm_platform::IpcServer>, } impl X11Platform { @@ -121,8 +162,47 @@ impl X11Platform { let keysyms_per_keycode = mapping.keysyms_per_keycode; let keyboard_mapping = mapping.keysyms; + // Num Lock's modifier bit is not fixed by the X11 spec (unlike Caps + // Lock, which is always `ModMask::LOCK`) - it's whichever of + // Mod1..Mod5 the server happens to have bound it to, keyboard- and + // OS-dependent. Found the same way every other X11 WM does: look up + // Num Lock's keycode (keysym `0xff7f`, XK_Num_Lock) in the keyboard + // mapping just queried above, then find which modifier slot's + // keycode list contains it. See `grab_keybindings`'s doc comment + // for why this is needed at all. + let numlock_mask = { + const XK_NUM_LOCK: u32 = 0xff7f; + let numlock_keycode = (min_keycode..=max_keycode).find(|&kc| { + let idx = (kc - min_keycode) as usize * keysyms_per_keycode as usize; + keyboard_mapping.get(idx).copied() == Some(XK_NUM_LOCK) + }); + match numlock_keycode { + Some(kc) => { + let modmap = conn.get_modifier_mapping().map_err(err)?.reply().map_err(err)?; + let per = modmap.keycodes_per_modifier() as usize; + modmask_for_keycode_in_mod_slots(kc, per, &modmap.keycodes) + } + None => ModMask::from(0u16), + } + }; + conn.flush().map_err(err)?; + // Same socket name convention as the Wayland backends + // (`srdwm-<display>.sock`) - there, `<display>` is the Wayland + // socket's own name; here, the only display identity X11 has is + // `$DISPLAY` itself (e.g. `:0`), which is exactly what every X + // client - including a nested Xephyr/Xnest session used for + // testing - already keys off to tell one server from another. + let display_name = std::env::var("DISPLAY").unwrap_or_else(|_| "x11".to_string()); + let ipc = match srdwm_platform::IpcServer::bind(&display_name) { + Ok(ipc) => Some(ipc), + Err(e) => { + log::warn!("failed to bind srd IPC socket for display '{display_name}': {e}"); + None + } + }; + Ok(Self { conn, root, @@ -136,6 +216,8 @@ impl X11Platform { max_keycode, keysyms_per_keycode, keyboard_mapping, + numlock_mask, + ipc, }) } @@ -194,20 +276,26 @@ impl X11Platform { /// Grabs the given `"Mod4+Shift+Return"`-style key combos on the root /// window so their KeyPress events reach us even when a client has /// input focus. Call after loading config (once bindings are known). + /// + /// A `KeyPress`'s modifier state includes whichever lock modifiers + /// happen to be toggled on (Num Lock, Caps Lock) in addition to + /// whatever the binding actually asked for - `XGrabKey` matches state + /// *exactly*, not as a subset, so a grab registered only for e.g. + /// `Mod4` never fires the moment Num Lock is on, since the real event's + /// state is `Mod4 | numlock_mask` instead. Every real X11 WM (i3, + /// bspwm, dwm) grabs each binding once per combination of the lock + /// modifiers for exactly this reason; this one previously didn't, + /// which meant every keybinding silently stopped firing the instant + /// Num Lock was toggled on - not a missing feature, a basic X11 + /// correctness requirement that was simply never implemented. pub fn grab_keybindings(&mut self, combos: &[String]) -> PlatformResult<()> { + // The four combinations of "Num Lock toggled or not" x "Caps Lock + // toggled or not" - Scroll Lock is deliberately not covered here, + // matching the convention every WM referenced above also follows + // (rarely present on modern keyboards, rarely toggled when it is). + let lock_variants = [ModMask::from(0u16), self.numlock_mask, ModMask::LOCK, self.numlock_mask | ModMask::LOCK]; for combo in combos { - let parts: Vec<&str> = combo.split('+').collect(); - let Some((key_name, mod_parts)) = parts.split_last() else { continue }; - let mut modifiers = Modifiers::empty(); - for m in mod_parts { - modifiers |= match *m { - "Ctrl" => Modifiers::CTRL, - "Shift" => Modifiers::SHIFT, - "Alt" => Modifiers::ALT, - "Mod4" | "Super" => Modifiers::SUPER, - _ => Modifiers::empty(), - }; - } + let Some((modifiers, key_name)) = srdwm_core::parse_key_combo(combo) else { continue }; let Some(keysym) = keysyms::name_to_keysym(key_name) else { log::warn!("cannot grab '{combo}': unknown key name '{key_name}'"); continue; @@ -217,9 +305,11 @@ impl X11Platform { continue; }; let mask = Self::modmask_for(modifiers); - self.conn - .grab_key(true, self.root, mask, keycode, GrabMode::ASYNC, GrabMode::ASYNC) - .map_err(err)?; + for lock in lock_variants { + self.conn + .grab_key(true, self.root, mask | lock, keycode, GrabMode::ASYNC, GrabMode::ASYNC) + .map_err(err)?; + } } self.conn.flush().map_err(err)?; Ok(()) @@ -228,12 +318,15 @@ impl X11Platform { fn manage_new_window(&mut self, client: XWindow) -> PlatformResult<Option<Event>> { let geom = self.conn.get_geometry(client).map_err(err)?.reply().map_err(err)?; let title = self.window_title(client).unwrap_or_default(); + let (instance, class) = self.window_class(client); let supports_delete = self.supports_wm_delete(client); let id = { let mut wm = self.wm.borrow_mut(); let id = wm.alloc_window_id(); let mut w = CoreWindow::new(id, title); + w.app_id = class; + w.instance = instance; w.geometry = Rect::new(geom.x as i32, geom.y as i32, geom.width as u32, geom.height as u32 + TITLEBAR_HEIGHT); wm.add_window(w); id @@ -251,6 +344,18 @@ impl X11Platform { | EventMask::EXPOSURE, ) .background_pixel(self.conn.setup().roots[0].white_pixel); + // `Window.border_color`/`border_width` were tracked in + // `srdwm_core::Window` and settable via `srd.window.set_border_*`, + // but nothing ever actually drew a border with them on this + // backend - `set_border_color`/`set_border_width` below only + // updated the stored struct field. X11 windows have a native + // server-drawn border (`border_pixel`/the `create_window` + // `border-width` parameter, both unconditionally 0 here before), + // so this uses that rather than hand-rendering one - the X server + // draws it, no extra composite work needed. + let border_color = self.wm.borrow().window(id).map(|w| w.border_color).unwrap_or((0x31, 0x32, 0x44)); + let border_width = self.wm.borrow().window(id).map(|w| w.border_width).unwrap_or(0); + let aux = aux.border_pixel(rgb_to_pixel(border_color)); self.conn .create_window( COPY_DEPTH_FROM_PARENT, @@ -260,7 +365,7 @@ impl X11Platform { placed.y as i16, placed.width as u16, placed.height as u16, - 0, + border_width as u16, WindowClass::INPUT_OUTPUT, 0, &aux, @@ -326,6 +431,28 @@ impl X11Platform { String::from_utf8(reply.value).ok() } + /// Reads `WM_CLASS` and splits it into `(instance, class)` - the + /// property is two NUL-terminated strings back to back, instance first + /// (ICCCM 4.1.2.5). Was never read at all before this: `manage_new_window` + /// only ever set `Window::title`, leaving `app_id` permanently empty on + /// every X11 window - meaning every `srd.rule({ class = ... }, ...)` + /// silently failed to match anything on this backend, the same root + /// cause `with_toplevel_app_id`'s doc comment describes already having + /// been found and fixed for native Wayland windows earlier. Returns + /// `("", "")` if the property is missing or malformed rather than an + /// `Option`, since both halves are used unconditionally either way. + fn window_class(&self, client: XWindow) -> (String, String) { + let Ok(cookie) = self.conn.get_property(false, client, x11rb::protocol::xproto::AtomEnum::WM_CLASS, x11rb::protocol::xproto::AtomEnum::STRING, 0, 1024) + else { + return (String::new(), String::new()); + }; + let Ok(reply) = cookie.reply() else { return (String::new(), String::new()) }; + let mut parts = reply.value.split(|&b| b == 0).map(|s| String::from_utf8_lossy(s).into_owned()); + let instance = parts.next().unwrap_or_default(); + let class = parts.next().unwrap_or_default(); + (instance, class) + } + fn supports_wm_delete(&self, client: XWindow) -> bool { let Ok(cookie) = self.conn.get_property(false, client, self.atoms.WM_PROTOCOLS, x11rb::protocol::xproto::AtomEnum::ATOM, 0, 32) else { return false; @@ -531,18 +658,40 @@ impl Platform for X11Platform { PlatformKind::X11 } + /// Was `wait_for_event()` (blocks indefinitely for the first event, + /// only draining any backlog after that), which left `srd`'s IPC socket + /// - polled at the end of this method - unresponsive for as long as + /// nothing happened on the X11 connection at all: no keypress, no mouse + /// motion, nothing. A script sitting on `srd clients` while the user's + /// hands were off the keyboard for a few seconds would just hang for + /// exactly that long. Replaced with a bounded `poll(2)` on the + /// connection's own fd (`~16ms`, matching the Wayland backends' own + /// frame-ish cadence) so this method always returns roughly that often + /// regardless of X11 activity, draining whatever's actually arrived + /// (zero or more events) each time rather than requiring at least one. fn poll_events(&mut self) -> PlatformResult<Vec<Event>> { self.conn.flush().map_err(err)?; - let mut out = Vec::new(); - let first = self.conn.wait_for_event().map_err(err)?; - if let Some(e) = self.handle_event(first)? { - out.push(e); + let fd = self.conn.stream().as_raw_fd(); + let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 }; + // Safety: `pfd` is a valid, live `pollfd` for the duration of this + // call, and `poll` writes only into `revents`, which is never read + // here - the return value alone (ready vs. timed out) is what + // matters, so a spurious wake or a timeout are both fine outcomes. + unsafe { + libc::poll(&mut pfd, 1, 16); } + + let mut out = Vec::new(); while let Some(ev) = self.conn.poll_for_event().map_err(err)? { if let Some(e) = self.handle_event(ev)? { out.push(e); } } + if let Some(ipc) = self.ipc.as_mut() { + if ipc.poll(&self.wm) { + out.push(Event::WorkspaceChanged); + } + } Ok(out) } @@ -577,6 +726,14 @@ impl Platform for X11Platform { fn apply_geometry(&mut self, window: WindowId, geometry: Rect) -> PlatformResult<()> { let Some(frame) = self.frames.get(&window) else { return Ok(()) }; let (frame_id, client_id) = (frame.frame, frame.client); + // The titlebar band is only actually reserved when the window is + // decorated - e.g. a `srd.rule(...)` that sets `decorated = false` + // - otherwise the client keeps getting offset down by, and + // shrunk by, a titlebar that `redraw_decoration` (below) is + // correctly not drawing at all, leaving a blank strip and the + // frame visibly not matching what's inside it. + let decorated = self.wm.borrow().window(window).map(|w| w.decorated).unwrap_or(true); + let band = if decorated { TITLEBAR_HEIGHT } else { 0 }; self.conn .configure_window( frame_id, @@ -586,7 +743,7 @@ impl Platform for X11Platform { self.conn .configure_window( client_id, - &ConfigureWindowAux::new().x(0).y(TITLEBAR_HEIGHT as i32).width(geometry.width).height(geometry.height.saturating_sub(TITLEBAR_HEIGHT)), + &ConfigureWindowAux::new().x(0).y(band as i32).width(geometry.width).height(geometry.height.saturating_sub(band)), ) .map_err(err)?; self.conn.flush().map_err(err)?; @@ -640,6 +797,10 @@ impl Platform for X11Platform { if let Some(w) = self.wm.borrow_mut().window_mut(window) { w.border_color = rgb; } + if let Some(frame) = self.frame_for(window) { + self.conn.change_window_attributes(frame, &ChangeWindowAttributesAux::new().border_pixel(rgb_to_pixel(rgb))).map_err(err)?; + self.conn.flush().map_err(err)?; + } Ok(()) } @@ -647,6 +808,10 @@ impl Platform for X11Platform { if let Some(w) = self.wm.borrow_mut().window_mut(window) { w.border_width = width; } + if let Some(frame) = self.frame_for(window) { + self.conn.configure_window(frame, &ConfigureWindowAux::new().border_width(width)).map_err(err)?; + self.conn.flush().map_err(err)?; + } Ok(()) } @@ -655,7 +820,9 @@ impl Platform for X11Platform { return Ok(()); } let Some(frame) = self.frame_for(window) else { return Ok(()) }; - let (bg, fg) = if focused { (0x2e3440u32, 0x88c0d0u32) } else { (0x2e3440u32, 0x4c566a) }; + let theme = self.wm.borrow().theme; + let bg = rgb_to_pixel(theme.titlebar_bg); + let fg = rgb_to_pixel(if focused { theme.titlebar_fg_focused } else { theme.titlebar_fg_unfocused }); self.conn.change_gc(self.gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(bg)).map_err(err)?; self.conn @@ -704,3 +871,55 @@ impl Platform for X11Platform { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a flattened `GetModifierMappingReply.keycodes`-shaped slice: + /// 8 slots (Shift, Lock, Control, Mod1..Mod5) of `per` keycodes each, + /// zero-padded, with `assignments` placing one real keycode into + /// specific slots. + fn modmap(per: usize, assignments: &[(usize, u8)]) -> Vec<u8> { + let mut v = vec![0u8; per * 8]; + for &(slot, kc) in assignments { + v[slot * per] = kc; + } + v + } + + #[test] + fn finds_numlock_on_mod2_the_common_case() { + let keycodes = modmap(2, &[(4, 77)]); // slot 4 == Mod2 + assert_eq!(modmask_for_keycode_in_mod_slots(77, 2, &keycodes), ModMask::M2); + } + + #[test] + fn finds_numlock_on_mod5_an_uncommon_but_real_layout() { + let keycodes = modmap(2, &[(7, 90)]); // slot 7 == Mod5 + assert_eq!(modmask_for_keycode_in_mod_slots(90, 2, &keycodes), ModMask::M5); + } + + #[test] + fn ignores_the_keycode_if_it_only_appears_in_shift_lock_or_control() { + // A keycode bound to Lock (e.g. Caps Lock's own keycode) must never + // be mistaken for Num Lock - only slots 3..8 (Mod1..Mod5) count. + let keycodes = modmap(2, &[(1, 66)]); // slot 1 == Lock + assert_eq!(modmask_for_keycode_in_mod_slots(66, 2, &keycodes), ModMask::from(0u16)); + } + + #[test] + fn keycode_zero_never_matches_even_if_a_slot_is_unpadded_zero() { + // Unused modifier slots are zero-padded, so keycode 0 must never + // resolve to a mask - otherwise a keyboard with no Num Lock key at + // all would spuriously "find" it in the first empty slot. + let keycodes = modmap(2, &[]); + assert_eq!(modmask_for_keycode_in_mod_slots(0, 2, &keycodes), ModMask::from(0u16)); + } + + #[test] + fn no_match_anywhere_returns_empty_mask() { + let keycodes = modmap(2, &[(3, 50)]); + assert_eq!(modmask_for_keycode_in_mod_slots(99, 2, &keycodes), ModMask::from(0u16)); + } +} |