srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-05-30 16:10:00 +0200
committersrdusr <[email protected]>2024-05-30 16:10:00 +0200
commit1c175642d073689ca11b9252411ea5f8446007d0 (patch)
tree00b8f9062cf1261d2d2642c059a14ff5790522e5
parent3d3057ae384ef7389284af8988410889e99c6bb9 (diff)
downloadsrdwm-1c175642d073689ca11b9252411ea5f8446007d0.tar.gz
srdwm-1c175642d073689ca11b9252411ea5f8446007d0.zip
Fix decoration drift during animated transitions; checkpoint IPC/global-menu/output-management work
Border/titlebar decoration was built from Window.geometry (the animation's final target) in both wayland backends' render loops, while sync_geometry already draws a window's actual content at window_anims' interpolated rect during any maximize/fullscreen/open-slide tween. Border and content read two different rectangles for the whole transition, so the border visibly detached from the window it was outlining - reported as "borders aren't flush." Both udev.rs and winit.rs now read the same animated rect for titlebar placement, border-strip placement, and the occlusion test against later windows in stacking order. Verified: cargo build --workspace, cargo clippy (0 new warnings), cargo test -p srdwm-core (111/111). Also checkpoints substantial protocol/IPC work from prior sessions that had accumulated uncommitted: gtk-shell1 support (gtk_shell.rs, the vendored gtk-shell.xml, xwayland.rs's X11-side mirror) backing the global app menu; zwlr_foreign_toplevel_manager_v1 (foreign_toplevel.rs) broadcasting distinct maximized/minimized/fullscreen/activated state per window; output_management (ext-output-management + layer-shell exclusive-zone reservation tracking); workspace.rs and context_menu.rs; a Unix-socket IPC crate (platform/src/ ipc.rs) and a `srd` control-CLI crate (crates/ctl); xkb_config.rs; and a theme module (core/src/theme.rs). A peer session working the AGS shell concurrently verified several of these live against a running srdwm: the global menu rendering a real app's File/Edit menu over gtk-shell1, and foreign-toplevel correctly reporting maximized and fullscreen as independent, non-simultaneous states with the geometry each implies (maximize stops at a reserved top bar and past a dock; fullscreen reaches the true monitor edge).
-rw-r--r--Cargo.lock56
-rw-r--r--Cargo.toml3
-rw-r--r--crates/config/src/lib.rs208
-rw-r--r--crates/core/Cargo.toml1
-rw-r--r--crates/core/src/event.rs110
-rw-r--r--crates/core/src/geometry.rs146
-rw-r--r--crates/core/src/keysyms.rs4
-rw-r--r--crates/core/src/lib.rs7
-rw-r--r--crates/core/src/manager.rs706
-rw-r--r--crates/core/src/monitor.rs20
-rw-r--r--crates/core/src/placement.rs22
-rw-r--r--crates/core/src/rules.rs110
-rw-r--r--crates/core/src/theme.rs77
-rw-r--r--crates/core/src/window.rs236
-rw-r--r--crates/ctl/Cargo.toml17
-rw-r--r--crates/ctl/src/main.rs126
-rw-r--r--crates/platform/Cargo.toml5
-rw-r--r--crates/platform/src/ipc.rs504
-rw-r--r--crates/platform/src/lib.rs3
-rw-r--r--crates/srdwm/Cargo.toml8
-rw-r--r--crates/srdwm/src/main.rs358
-rw-r--r--crates/wayland/Cargo.toml18
-rw-r--r--crates/wayland/protocols/gtk-shell.xml116
-rw-r--r--crates/wayland/src/context_menu.rs133
-rw-r--r--crates/wayland/src/cursor.rs473
-rw-r--r--crates/wayland/src/decoration.rs437
-rw-r--r--crates/wayland/src/elements.rs229
-rw-r--r--crates/wayland/src/foreign_toplevel.rs379
-rw-r--r--crates/wayland/src/gamma_control.rs90
-rw-r--r--crates/wayland/src/gtk_shell.rs116
-rw-r--r--crates/wayland/src/gtk_shell_protocol.rs29
-rw-r--r--crates/wayland/src/input.rs299
-rw-r--r--crates/wayland/src/lib.rs9
-rw-r--r--crates/wayland/src/lock.rs13
-rw-r--r--crates/wayland/src/output_management.rs559
-rw-r--r--crates/wayland/src/output_power.rs99
-rw-r--r--crates/wayland/src/protocols.rs532
-rw-r--r--crates/wayland/src/screencopy.rs35
-rw-r--r--crates/wayland/src/state.rs840
-rw-r--r--crates/wayland/src/udev.rs640
-rw-r--r--crates/wayland/src/winit.rs419
-rw-r--r--crates/wayland/src/workspace.rs251
-rw-r--r--crates/wayland/src/xkb_config.rs102
-rw-r--r--crates/wayland/src/xwayland.rs380
-rw-r--r--crates/x11/Cargo.toml4
-rw-r--r--crates/x11/src/lib.rs263
-rw-r--r--docs/DEFAULTS.md2
-rw-r--r--docs/IMPLEMENTATION_STATUS.md1484
-rw-r--r--docs/PANEL_SUPPORT_TODO.md411
49 files changed, 10832 insertions, 257 deletions
diff --git a/Cargo.lock b/Cargo.lock
index cfead32..9ec6b77 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -804,6 +804,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
name = "jiff"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1030,6 +1036,15 @@ dependencies = [
]
[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
name = "mlua"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1677,6 +1692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
+ "serde_derive",
]
[[package]]
@@ -1700,6 +1716,19 @@ dependencies = [
]
[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1826,10 +1855,18 @@ dependencies = [
]
[[package]]
+name = "srd-cli"
+version = "0.1.0"
+dependencies = [
+ "srdwm-platform",
+]
+
+[[package]]
name = "srdwm"
version = "0.1.0"
dependencies = [
"env_logger",
+ "libc",
"log",
"srdwm-config",
"srdwm-core",
@@ -1857,6 +1894,7 @@ version = "0.1.0"
dependencies = [
"bitflags 2.13.0",
"log",
+ "regex",
]
[[package]]
@@ -1876,7 +1914,10 @@ name = "srdwm-platform"
version = "0.1.0"
dependencies = [
"log",
+ "serde",
+ "serde_json",
"srdwm-core",
+ "tempfile",
"thiserror 2.0.18",
]
@@ -1886,11 +1927,17 @@ version = "0.1.0"
dependencies = [
"fontdue",
"log",
+ "memmap2",
+ "serde",
+ "serde_json",
"smithay",
"srdwm-core",
"srdwm-platform",
"thiserror 2.0.18",
+ "wayland-backend",
+ "wayland-protocols",
"wayland-protocols-wlr",
+ "wayland-scanner",
]
[[package]]
@@ -1907,6 +1954,7 @@ dependencies = [
name = "srdwm-x11"
version = "0.1.0"
dependencies = [
+ "libc",
"log",
"srdwm-core",
"srdwm-platform",
@@ -2304,7 +2352,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
+ "libc",
"log",
+ "memoffset",
"once_cell",
"pkg-config",
]
@@ -2742,3 +2792,9 @@ dependencies = [
"quote",
"syn",
]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index 40aa207..4c12acc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ members = [
"crates/wayland",
"crates/windows",
"crates/macos",
+ "crates/ctl",
]
[workspace.package]
@@ -31,6 +32,8 @@ srdwm-macos = { path = "crates/macos" }
log = "0.4"
env_logger = "0.11"
thiserror = "2"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
[profile.release]
lto = "thin"
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: &current }) {
+ 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 &current {
+ 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) = &current_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(&current_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));
+ }
+}
diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md
index d645413..cbcbeda 100644
--- a/docs/DEFAULTS.md
+++ b/docs/DEFAULTS.md
@@ -92,7 +92,7 @@ srd.layout.configure("tiling", {
### Dynamic Layout (`layout.dynamic.*`)
```lua
srd.layout.configure("dynamic", {
- snap_threshold = 50, -- Default: 50
+ snap_threshold = 20, -- Default: 20
grid_size = 6, -- Default: 6
cascade_offset = 30, -- Default: 30
smart_placement = true, -- Default: true
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index efecf4a..43e2c5f 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -540,8 +540,14 @@ built them:
its declared hotspot, so an I-beam over text or a hand over a link shows
the app's image rather than srdwm's arrow. The built-in arrow remains the
fallback for when no client has set one (over decorations and the
- desktop). Named shapes (`CursorIcon::Text` etc.) still fall back to the
- arrow; most toolkits set a surface, so this is rarely visible.
+ desktop). Named shapes (`CursorIcon::Named`, e.g. what a client requests
+ via `wp_cursor_shape_v1` instead of uploading a surface) now render as
+ real per-shape bitmaps too - text (I-beam), and the four resize
+ directions (`ew`/`ns`/`nesw`/`nwse`) - rather than falling back to the
+ arrow; everything else still uses the arrow. The WM itself also drives
+ this: hovering a resize edge or actively resizing sets the matching
+ shape even for clients that never touch the cursor protocol themselves
+ (`crates/wayland/src/input.rs`'s `update_cursor_shape`).
- **Key repeat for bindings** (`srd.bind_repeat`, Hyprland's `binde`).
Held volume/brightness keys and switcher cycling now repeat at the seat's
own rate (200ms delay, 25/s). Driven from the poll loop rather than a
@@ -561,6 +567,1478 @@ built them:
why Hyprland ships `extend_border_grab_area`.
- **Config path** is now `~/.config/srd` (or `$XDG_CONFIG_HOME/srd`),
not `~/.config/srdwm/srd` - the extra level said the same thing twice.
+- **`wl_pointer.frame` was never sent.** `input.rs`'s motion/button handlers
+ called `PointerHandle::motion`/`button` correctly, but never followed up
+ with `PointerHandle::frame` - confirmed by reading smithay's own
+ `DefaultGrab`: `motion`/`button` there just forward to the handle, they
+ never call `frame` themselves, so it's entirely on the compositor to send
+ it. Per protocol (required since `wl_pointer` v5; this compositor
+ advertises v9) `frame` is what tells a client "the events since the last
+ one are a single atomic update, act on them now" - without it, any
+ client that correctly waits for it (which is most real ones, including
+ Firefox and wezterm, confirmed live: neither registered a click or a
+ drag-selection with the cursor squarely on the target) never actually
+ processes motion or button state it was sent. The one place `frame` was
+ already being sent - the scroll/axis path - worked the whole time,
+ which is why this went unnoticed for so long: motion and clicks looked
+ fine from the compositor's own side (cursor tracked correctly, our own
+ decoration hit-testing and window management never touch this path at
+ all, since a decoration click is intercepted before ever reaching a
+ client), and every mouse-only window-management item in the list above
+ still worked perfectly, since none of it depends on a client ever
+ processing anything. Only clicks/drags that needed to reach a client's
+ *content* were silently inert. This is very likely the real root cause
+ behind most of a night's worth of "clicking/scrolling doesn't work"
+ reports that survived several other real, necessary fixes (subsurface
+ routing, decoration geometry, `app_id` never being set for native
+ Wayland windows) without going away.
+- **Undecorated windows had a phantom titlebar.** `ResizeEdge::hit_test`
+ applied its top-`TITLEBAR_HEIGHT`-band/button logic unconditionally,
+ regardless of `Window.decorated` - a window's allocated geometry always
+ reserves that space (placement doesn't shrink it just because decoration
+ later gets turned off), so for an undecorated window, hit-testing still
+ swallowed clicks in that band as a phantom drag/close/maximize/minimize
+ hit instead of ever reaching the client. Only became visible once
+ `decorated = false` rules could actually apply to anything (see the
+ `app_id` fix above) - Firefox's own tab strip/URL bar live exactly in
+ that band. Fixed by gating the band/button check on `decorated`; plain
+ resize-from-edge still applies either way. Exposed a second, previously
+ unreachable gap while fixing it: `resize_edge_at`'s match arms had no
+ case for a plain top edge (only the two top corners), so a window with no
+ titlebar to intercept top-area clicks at all still couldn't resize from
+ a plain top-edge drag - added.
+- **Cursor movement alone triggers a full frame-callback broadcast to every
+ mapped window on an output, not just ones near the cursor.** Found live:
+ `wezterm-gui --class scratchpad` confirmed via repeated `ps` sampling to
+ be sustaining ~140% CPU continuously on an otherwise-idle session, on a
+ machine already under real memory pressure (1.3GB swap in use of 3.7GB
+ total). Root cause: the has-damage gate added earlier (frame callbacks
+ only sent `if has_damage`, replacing a prior unconditional-every-frame
+ send) operates on a single output-wide boolean. Moving the pointer
+ legitimately damages the small region around it (old position needs
+ redrawing without the cursor, new position with it - this part of
+ smithay's damage tracking is correct and not a bug), but that alone
+ currently marks the whole output "damaged" and every mapped window gets
+ told to redraw, whether or not the cursor is anywhere near its content.
+ Ruled out one candidate mechanism directly from
+ `smithay-0.7.0/src/backend/renderer/element/memory.rs`:
+ `MemoryRenderBufferRenderElement::from_buffer()`'s `Id` is cloned from
+ the underlying `MemoryRenderBuffer`'s own stable id, not freshly
+ generated per call, so cached buffers (cursor bitmaps, per-window
+ decoration/border buffers) do have stable identity across frames --
+ the damage is real, not an artifact of identity churn. **Fixed** with the
+ per-window damage-region intersection this entry originally called for,
+ turns out `smithay::backend::renderer::damage`'s own `RenderOutputResult`
+ already hands back the exact physical-space damage rectangles it drew
+ from (`.damage: Option<&Vec<Rectangle<i32, Physical>>>`) - no second
+ damage tracker or double render needed. `elements.rs`'s
+ `windows_touched_by_damage` filters `space.elements()` down to windows
+ whose `Space::element_geometry` (converted to physical space via the
+ output's own scale) overlaps at least one of those rects, and both
+ backends' frame-callback dispatch now iterates that instead of every
+ mapped window. udev.rs's per-head loop had to carry the damage rects
+ alongside each presented `Output` into `presented` (previously just
+ `Vec<Output>`), since the frame-callback loop runs after `udev`'s mutable
+ borrow ends and needs that frame's damage by then. **Verified live**: the
+ exact reproducer from the original finding (`wezterm-gui --class
+ scratchpad`, idle) measured at 4-6% CPU on the real session after this
+ build went live, down from the ~140% recorded before the fix.
+ **Follow-up regression, found and fixed the same day**: narrowing which
+ windows get a callback to damage-overlap alone can *starve* a window
+ instead of merely under-notifying it. `send_frame` only answers a
+ *pending* `wl_surface.frame` request; GTK's frame-clock model (Firefox's
+ Wayland vsync source included) paces every repaint through that
+ callback, even the very first one after being idle - there is no
+ "just commit immediately" fallback path. If a window's *own* new content
+ is what would produce the next frame's damage, but producing it needs a
+ callback this filter is withholding because the *previous* frame's
+ damage didn't overlap it, nothing ever arrives to unstick it - reported
+ live as clicks in Firefox intermittently doing nothing until the cursor
+ was moved again (moving the cursor across the window is what
+ incidentally supplied overlapping damage). Fixed by adding
+ `always_notify` to `windows_touched_by_damage`: the focused window and
+ whatever window is currently under the pointer always get a callback,
+ damage-overlap or not - a fixed, small cost (at most two windows)
+ covering exactly the two cases user input targets.
+ **Second follow-up, same day**: that first version still didn't work --
+ `always_notify` was folded into the loop over `presented` (the outputs
+ that actually had damage this tick), so it only ever ran on a tick that
+ *already* had damage from something else. The one tick it needed to run
+ on - the output has *no* damage at all, cursor stationary, nothing else
+ happening - is exactly the tick that loop never executes for. Reported
+ live as clicks in Firefox still doing nothing at all, not intermittently.
+ Fixed by moving the `always_notify` frame-send into its own pass, after
+ and independent of the `presented` loop, run unconditionally (only
+ gated on `!locked`) every tick regardless of whether anything presented.
+ `windows_touched_by_damage` itself went back to a pure damage-overlap
+ filter with no `always_notify` parameter, now that the two mechanisms
+ are fully independent - see its doc comment.
+- **Two precise, single-function omissions, found in the same investigation
+ (a codebase-wide audit prompted by "clicking still doesn't work" and
+ "window bars still look detached" both persisting after everything
+ above), that together explain both symptoms for the two cases they cover
+ (native Wayland windows; XWayland windows) far more completely than the
+ frame-callback fixes above did on their own:**
+ - **Drag/resize end never told the compositor about the final snap.**
+ `handle_pointer_button`'s button-release branch called
+ `WindowManager::end_drag`/`end_resize` and stopped - it never called
+ `sync_geometry` afterward. `end_drag` can snap the geometry one more
+ time after the last `update_drag` already moved the window
+ (`SmartPlacement::snap_zone`: dragging to a screen edge or the top,
+ exactly the ordinary "tile left/right" and "drag-to-maximize"
+ gestures, not a rare corner case). The border and titlebar redraw
+ fresh from live `Window.geometry` every single frame, so they jumped
+ to the snapped rect immediately; 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,
+ persisting until something unrelated (any keypress, a new window)
+ happened to trigger a `dirty`-driven resync. Click routing desynced
+ the same way in the meantime: `hit_test`/`window_at` read the
+ now-snapped 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 did this correctly
+ (`crates/x11/src/lib.rs`'s `ButtonRelease` handler calls
+ `sync_geometry` right after `end_drag`/`end_resize`) - `input.rs` is
+ explicitly the module shared by both backends for exactly this kind of
+ logic, and this one call site never got the same fix ported over.
+ Fixed by capturing `wm.focused_id()` before ending the drag/resize
+ (reliable: `start_drag`/`start_resize` both focus the window they
+ grab, and nothing else can change focus while a grab has the pointer
+ captured) and calling `state.sync_geometry(id)` after, mirroring the
+ X11 pattern exactly.
+ - **XWayland windows were never reconfigured past their initial map.**
+ `sync_geometry` only had a branch for `w.toplevel()` (native
+ xdg-shell) - there was no `w.x11_surface()` branch calling
+ `X11Surface::configure()` at all. `space.map_element` still moved
+ smithay's own tracked position (hit-testing/stacking stayed nominally
+ consistent) 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 - confirmed by
+ grepping the whole crate: `X11Surface::configure()` was called exactly
+ once anywhere, at initial mapping (`xwayland.rs`'s
+ `finish_x11_window_setup`), never again after. Every drag, resize,
+ maximize, edge-snap, or tiling re-layout of an XWayland-backed app
+ (any X11-only client - xterm, many GTK3/Qt5/Java apps, anything not
+ forced into native-Wayland mode) left its actual content frozen at its
+ original mapped size and position *forever*, while srdwm's own border
+ and titlebar moved freely around it - a second, independent, and
+ steady-state-permanent (not just a momentary post-drag glitch) cause
+ of "decoration doesn't look connected to the window." Fixed by adding
+ the missing branch, calling `x11.configure()` with the current
+ geometry. Unlike the xdg-shell branch (gated on `size_changed`, since
+ xdg-shell position is a purely compositor-side concept never
+ communicated to the client), the X11 branch reconfigures on *every*
+ `sync_geometry` call regardless of whether size changed - an X11
+ client's on-screen position is real window state it has to be told
+ about on every move, the same way a real X11 window manager sends
+ continuous `ConfigureNotify` during an interactive drag.
+ Neither fix is a refactor: both are a handful of lines in one function
+ each, and both had an already-correct reference pattern sitting
+ elsewhere in the same codebase to copy (X11's own `ButtonRelease`
+ handler; XWayland's own initial-map `configure()` call) rather than a
+ new mechanism to invent. The underlying process gap worth noting: there
+ is no invariant (test or type-level) forcing every geometry-mutating
+ call site to route through `sync_geometry` - that is exactly the class
+ of bug that let both of these happen and both survive `manager.rs`'s
+ own unit tests (which verify the snap/placement math in isolation and
+ never cross the boundary into "did the backend actually get told").
+- **`xdg_popup` was entirely unimplemented - not a missing feature so much
+ as a client-hanging bug.** `new_popup` was a bare no-op: no
+ `send_configure`, no tracking, no rendering. Per xdg-shell, a popup's
+ first `wl_surface.commit()` cannot proceed without a prior
+ `xdg_surface.configure`; GTK4's Wayland backend (and most real toolkits)
+ blocks that commit in a synchronous roundtrip waiting for it, so every
+ popup hung its client forever, not intermittently. GTK4 implements both
+ tooltips and `Gtk.Popover` as `xdg_popup` - a peer session's gdb
+ backtrace (blocked in `wl_display_dispatch_queue` under
+ `gtk_widget_show`) traced this to exactly that path, and the AGS shell
+ alone has 74+ tooltip/popover usages, so this was hit constantly, just
+ never attributed (hovering a bar icon is not a memorable action).
+ Fixed: `new_popup` now sets pending geometry from the positioner
+ (`PositionerState::get_geometry()`, unconstrained - see below) and
+ configures; `reposition_request` re-geometries and
+ `send_repositioned`s; `commit()` advances `PopupManager`'s
+ unmapped-to-mapped tracking and prunes dead ones. Rendered as ordinary
+ surface-tree elements (`render_elements_from_surface_tree`, same
+ mechanism a client-set cursor image already used) positioned at their
+ parent toplevel's on-screen location plus `PopupManager`'s tracked
+ offset, added to `custom_elements` alongside the cursor/borders/
+ decorations - popups are never `space.map_element`'d, so without this
+ `render_output`'s automatic per-space-element rendering would never see
+ them even once configured. One thing this does NOT do: geometry is not
+ clamped to the output (`PositionerState::get_unconstrained_geometry`
+ needs a target rect in the parent's surface-local space, a real
+ follow-up); a popup positioned very close to a screen edge may render
+ partly off it. Cosmetic, not a hang.
+ **Follow-up, same investigation**: implicit grab + dismiss-on-outside-
+ click (`grab()`) was left as a no-op believing `PopupManager::grab_popup`
+ needed `CompState`'s `SeatHandler::KeyboardFocus` to implement
+ `WaylandFocus + From<PopupKind>`, which it supposedly didn't. Rechecked
+ while implementing `move_request`/`resize_request` below (same trait,
+ adjacent methods) - it already did: `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 this got rechecked; the earlier note
+ just hadn't been revisited. Fixed: `grab()` now calls `grab_popup`,
+ installs the returned `PopupGrab`'s default `PopupKeyboardGrab`/
+ `PopupPointerGrab` on the seat, and lets smithay's own default grab
+ implementations handle the dismiss-on-outside-click behavior (their own
+ documented purpose).
+- **`zwlr_foreign_toplevel_handle_v1.app_id`/`.title` were sent as empty
+ strings on every window, always** - see `PANEL_SUPPORT_TODO.md`'s P1
+ section for the root cause and fix (re-read on every `commit()` rather
+ than once at role-assignment time). Both `Window.app_id`/`.title` and
+ everything downstream of them (`srd.rule({ class = ... })`, the
+ foreign-toplevel protocol) were affected identically, since both read
+ the same fields populated the same way.
+- **Titlebar button icons were drawn one full button-width left of where
+ clicking them actually registered.** `decoration.rs`'s three
+ `draw_*_icon` calls passed `right_offset` values of `height`, `height*2`,
+ `height*3` for close/maximize/minimize; `button_box`'s formula
+ (`right = width - right_offset`) means those land the icons in the
+ *second*, *third*, and *fourth* button-width squares from the right
+ edge, not the first, second, and third squares `ResizeEdge::hit_test` in
+ `crates/core/src/window.rs` actually assigns to Close/Maximize/Minimize.
+ Concretely: the true Close hit-zone (the rightmost `TITLEBAR_HEIGHT`
+ pixels) was visually blank; the drawn "X" icon sat in the square that
+ hit_test treats as Maximize; the drawn square icon sat in the square
+ hit_test treats as Minimize; the drawn minimize line sat past all three
+ button bands, in plain drag territory. Every titlebar button was
+ therefore one click-target to the right of its own icon. Fixed by
+ changing the three offsets to `0`, `height`, `height*2` respectively, so
+ each icon lands in the same square hit_test assigns it. A regression
+ test (`button_icons_are_drawn_in_the_squares_hit_test_assigns_them`)
+ renders a titlebar, finds each icon's drawn square, and asserts
+ `hit_test` at that square's centre reports the matching button --
+ confirmed it fails on the pre-fix offsets and passes on the corrected
+ ones.
+- **A bordered window's titlebar rounded its own top corners while the
+ border frame around it stayed square**, leaving a small gap at exactly
+ those two corners where the rounded cutout exposed whatever was behind
+ the window instead of the border - undermining `border_strips`, whose
+ whole purpose was making the titlebar read as part of the window rather
+ than a strip bolted on top of it (reported live as "bars/decorations
+ don't feel part of the window"). `render_titlebar` now takes a
+ `round_corners: bool`; `redraw_decoration_buffer` passes
+ `w.border_width == 0`, so a bordered window's titlebar stays flush-square
+ with its border (no gap, fully continuous frame) and a borderless window
+ keeps the rounded top corners it had no square frame to clash with.
+- **Window borders never actually rendered on the real (udev) backend --
+ every window looked like a bare titlebar floating over content, with no
+ frame at all.** Confirmed by pixel-sampling a live screenshot: the
+ transition from desktop background straight to the titlebar's own pixels
+ had zero border-coloured pixels in between, not merely a faint or
+ dim border - literally none. Root cause: `border_strips` rendered each
+ strip as a cached 1x1 solid-colour `MemoryRenderBuffer`, stretched to the
+ strip's real size via `MemoryRenderBufferRenderElement::from_buffer`'s
+ `size` override (upscaling a tiny buffer, the same trick the cursor
+ bitmaps use). Smithay's `PixmanRenderer` - the udev backend's software
+ renderer - hardcodes `src_image.set_repeat(Repeat::None)` on every
+ imported texture with no per-call override in 0.7.0. Combined with
+ bilinear upscale filtering, sampling a 1x1 image stretched across a much
+ larger destination has no valid neighbouring texels under `Repeat::None`
+ to blend against, so it rendered fully transparent. Decorations (the
+ titlebar bitmap) never hit this because they pass `size: None` - a
+ real, non-stretched, buffer-native-size bitmap, `scale == 1.0`, so
+ pixman's transform-and-sample path is skipped entirely. The winit (GPU/
+ GLES) backend was never affected: OpenGL's texture sampler returns a 1x1
+ texture's single texel regardless of wrap mode, so the exact same code
+ "worked" there by accident - meaning this bug was invisible in every
+ nested/dev-session test and only ever showed up on real hardware, the
+ one backend that actually matters for daily driving. Fixed by rendering
+ borders as `SolidColorRenderElement` instead (`smithay::backend::renderer::element::solid`,
+ new `Solid` variant on `OverlayElement`) - a native `Frame::draw_solid`
+ fill with no texture import or sampling involved at all, so this backend
+ difference cannot affect it. Also removed the now-dead 1x1-buffer
+ machinery it replaced (`CompState::border_buffers`, `border_buffer_for`,
+ `decoration::solid_pixel`). Not yet re-verified live on the real udev
+ backend - needs a restart.
+- **`zwp_linux_dmabuf_v1`** - no client could ever hand over a GPU buffer;
+ GTK4 in particular tried to open a DRM render node to allocate one
+ anyway, found no dmabuf global to negotiate through, and crashed instead
+ of falling back gracefully, forcing `GSK_RENDERER=cairo` (full software
+ rendering) on every GTK4 client just to survive. Full account, including
+ why this works at all given the udev backend's `PixmanRenderer` has no
+ GPU pipeline, in `docs/PANEL_SUPPORT_TODO.md`'s P0.3. Live-verified in an
+ isolated nested instance that the global is now advertised and a real
+ client connects/maps normally; full GPU-allocated-buffer round trip
+ needs a retest on real hardware, this sandbox has no working DRM render
+ node to allocate one against.
+- **`xdg_activation_v1`** - a launcher's freshly-spawned app had no way to
+ raise itself once its window mapped; it just opened unfocused behind
+ everything. `request_activation` reuses the same `focus_window` path a
+ dock's foreign-toplevel "activate" already goes through. Full account in
+ `docs/PANEL_SUPPORT_TODO.md`'s P1. Live-verified the same way as dmabuf
+ above: the global advertises correctly in an isolated nested instance,
+ a real client connects/maps normally.
+- **The entire compositor is one thread: input dispatch, client protocol
+ dispatch, and rendering all run serially inside `UdevPlatform::poll_events`
+ (`crates/wayland/src/udev.rs`, `~line 1149`) every tick.** Found while
+ investigating a fresh "clicking still doesn't work" report by checking
+ `~/.local/state/wm-session-*.log` for anything libinput itself had to say:
+ it did --
+ ```
+ libinput error: event10 - Logitech USB Optical Mouse: client bug: event
+ processing lagging behind by 831ms, your system is too slow
+ libinput error: client bug: timer button-debounce-...: scheduled expiry
+ is in the past (-216ms), your system is too slow
+ libinput error: WARNING: log rate limit exceeded (5 msgs per 3600000ms).
+ Discarding future messages.
+ ```
+ i.e. libinput's own watchdog saw the compositor fail to call back into it
+ for most of a second, on the *mouse and keyboard event sources both* --
+ and then it rate-limited itself to 5 messages/hour, so the *absence* of
+ further such lines in the log is not evidence this stopped happening,
+ only that libinput stopped reporting it. Correlated with timestamps of
+ `libEGL warning: failed to get driver name` and
+ `[WARN audioipc2_server::server] Promotion of content process thread to
+ real-time` right alongside it - `audioipc2` is Firefox's own audio IPC
+ subsystem, meaning this specific incident lines up with Firefox's
+ multi-process startup (GPU/audio/content processes all spawning at once)
+ putting the whole system under enough transient CPU/memory pressure that
+ even the compositor's own event loop missed its scheduling window --
+ consistent with this being a genuinely resource-constrained machine (see
+ the earlier-documented 1.3GB-swap finding). A direct IPC round-trip check
+ (`srd clients`, timed) immediately after finding this came back at a
+ steady 10-20ms, so the loop is not *currently* stalling - this specific
+ 831ms incident is not, by itself, an explanation for an ongoing "clicking
+ doesn't work" complaint reported well after it happened. It is still a
+ real, reproducible architectural weak point: because `poll_events` calls
+ `event_loop.dispatch(16ms timeout)` *then* runs `render_udev_frame()`
+ synchronously before looping back, any single slow render pass (a burst
+ of new windows/layer-surfaces all redrawing at once, say) directly adds
+ to input latency, with nothing to prioritize input processing over
+ rendering when both are contending for the same thread. Not fixed --
+ the real fix (moving libinput's event source dispatch to its own thread,
+ independent of the render cadence) is a genuine, non-trivial
+ restructuring of the event loop, not a targeted patch, and is exactly
+ the kind of thing worth scoping as a deliberate follow-up rather than
+ rushing into the same pass that found it.
+- **`toggle_fullscreen`'s exit path hardcoded `Window.decorated = true`
+ unconditionally - the actual root cause behind persistent "clicking
+ doesn't work" and "decoration looks detached" reports on Firefox, found
+ by adding temporary diagnostic logging to the click-routing path and
+ correlating a live "clicked the back button, it minimized the window
+ instead" report against it.** The log showed `hit_test` returning
+ `Some((firefox_id, TitlebarHit::Drag))` for a click on Firefox's own
+ toolbar - impossible if `Window.decorated` were actually `false`, which
+ is what `srd.rule({ class = "firefox" }, { decorated = false })` sets it
+ to (Firefox draws its own close/minimize/maximize row and negotiates
+ client-side decoration once, at startup, via `zxdg_toplevel_decoration_v1`
+ - see the `XdgDecorationHandler` doc comment). Root cause: entering
+ fullscreen correctly saved nothing and set `decorated = false`
+ (fullscreen has no titlebar, always), but *exiting* fullscreen hardcoded
+ it back to `true` regardless of what it was before entering - correct
+ for a normally-decorated window, wrong for anything a rule set to
+ `false`. Since Firefox never renegotiates its decoration mode again
+ after the initial handshake, nothing would ever set it back. `Super+f`/
+ `Super+m`/`Super+z` are all bound to `srd.window.fullscreen()` in the
+ shipped config - an easy, plausible accidental press during any long
+ session, not a rare edge case. Once `decorated` was wrongly `true`,
+ every click in what srdwm now (incorrectly) treated as the top
+ `TITLEBAR_HEIGHT` band of Firefox's window got swallowed as a fake
+ drag/button hit instead of ever reaching the client - matching both
+ "clicking doesn't work" (band clicks silently absorbed) and "I have to
+ click somewhere else for it to register" (only clicks *below* the band
+ ever reached Firefox) exactly. Fixed with a new `Window.restore_decorated:
+ Option<bool>` field, following the exact same save/restore pattern
+ `restore_geometry` already uses for `toggle_maximize`/`toggle_fullscreen`'s
+ geometry: entering fullscreen now saves the current `decorated` value
+ before forcing `false`, exiting restores it instead of hardcoding `true`.
+ Regression test:
+ `fullscreen_round_trip_restores_a_client_side_decorated_window_to_undecorated`.
+ Diagnostic logging (temporary, since removed - see below) confirmed no
+ client-side keyboard double-dispatch at the point srdwm receives events
+ from libinput (every key logged shows a clean, single Pressed/Released
+ pair) - the separately-reported "typing feels sensitive, letters
+ double" bug is not the same mechanism as this one. It was tracked down
+ later in this same pass to a too-short repeat delay, not server-side
+ duplication; see the `REPEAT_DELAY`/`add_keyboard` entry further down.
+- **`maximize_request`/`unmaximize_request`/`fullscreen_request`/
+ `unfullscreen_request`/`minimize_request` were all still smithay's
+ default no-op (or configure-only) implementations - found immediately
+ after the `toggle_fullscreen` fix above, checking what else in
+ `XdgShellHandler` might share its blast radius.** These are the
+ *client-initiated* equivalent of the pointer-driven titlebar-button
+ handlers already in `input.rs`: 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, indistinguishable from a client bug: the button
+ visibly existed and could be clicked, nothing happened, no error. Fixed
+ by wiring all five to the same `WindowManager` calls (`toggle_maximize`,
+ `toggle_fullscreen`, `minimize_window`) the pointer path already uses,
+ including the same `redraw_decoration_buffer`-before-`sync_geometry`
+ ordering `set_decorated_from_mode` established for anything that flips
+ `Window.decorated` (fullscreen does; maximize/minimize don't).
+- **`move_request`/`resize_request` were *also* still smithay's default
+ no-op implementations - the single biggest gap found in this whole
+ investigation.** This is how a client-side-decorated window gets dragged
+ or resized by its own titlebar/edges at all. A window srdwm draws its
+ own decoration for never needed this (`TitlebarHit::Drag`/`Resize` in
+ `input.rs` detect the click directly, since srdwm owns 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 one was the modifier+drag-anywhere gesture
+ (`bindm`), which most users have no reason to know exists and doesn't
+ cover resize-from-a-specific-edge at all. Fixed by reusing 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 was needed here at all, just the same start call from a different
+ trigger. `xdg_toplevel::ResizeEdge`'s 8 real edge values map directly
+ onto `srdwm_core::ResizeEdge`; the protocol's `None` value is a no-op
+ (nothing sensible to default it to that wouldn't be a guess).
+- **`PopupManager::grab_popup` was believed blocked on `CompState`'s
+ `SeatHandler` associated types not satisfying its `WaylandFocus +
+ From<PopupKind>` bound, and `grab()` was left a no-op on that basis --
+ rechecked while implementing `move_request`/`resize_request` above
+ (same trait, adjacent methods) and the bound was already met**:
+ `KeyboardFocus`/`PointerFocus` are both plain `WlSurface`, smithay
+ itself provides `impl From<PopupKind> for WlSurface`, and
+ `WlSurface: From<WlSurface>` trivially. No blocker ever existed by the
+ time of this pass; the earlier note just hadn't been revisited since it
+ was written. Fixed: `grab()` now calls `grab_popup`, installs the
+ returned `PopupGrab`'s default `PopupKeyboardGrab`/`PopupPointerGrab` on
+ the seat, and lets smithay's own default grab implementations handle
+ dismiss-on-outside-click (their documented purpose) - a popup (tooltip,
+ dropdown, context menu) now closes when you click outside it, instead
+ of only when its own client decides to close it.
+- **`popup_targets` (the render path a mapped `xdg_popup` needs to find
+ its on-screen position) only ever scanned toplevel windows for a popup
+ parent, never layer-shell surfaces - so any popup parented to a bar or
+ launcher's own layer surface was fully functional and completely
+ invisible.** `zwlr_layer_surface_v1.get_popup` (what a bar's own
+ dropdown/context menu uses to parent a popup to itself) is a wholly
+ separate request from `xdg_surface.get_popup`, but both funnel into the
+ same `XdgShellHandler::new_popup`/`commit()` tracking path regardless of
+ which kind of surface ends up as the parent - so the popup was always
+ correctly configured and tracked (a bar's dropdown would open and
+ accept clicks), it just never had a `PopupTarget` to render relative to.
+ Fixed by also gathering every mapped layer-shell surface, across every
+ output, as a candidate popup parent alongside toplevel windows.
+- **The same six no-op requests found missing from `XdgShellHandler`
+ (`maximize_request`/`unmaximize_request`/`fullscreen_request`/
+ `unfullscreen_request`/`minimize_request`, plus `unminimize_request`,
+ which has no native-Wayland equivalent) were equally unimplemented for
+ XWayland's `XwmHandler` - the EWMH/ICCCM equivalent
+ (`_NET_WM_STATE_MAXIMIZED_VERT`/`_HORZ`, `_NET_WM_STATE_FULLSCREEN`,
+ `_NET_WM_STATE_HIDDEN`).** `move_request`/`resize_request` were
+ *already* correctly implemented for XWayland (`crates/wayland/src/
+ xwayland.rs`), which is what made the state-toggle half of this gap easy
+ to miss - the drag/resize half already had parity with native Wayland;
+ the maximize/fullscreen/minimize half didn't. Any XWayland app's own
+ window-menu maximize/minimize/fullscreen action (older GTK3/Qt5/Java
+ apps, anything not forced into native-Wayland mode) was a silent no-op.
+ Fixed the same way as the native-Wayland five: wired to the same
+ `WindowManager` calls, with the same `redraw_decoration_buffer`-before-
+ `sync_geometry` ordering for fullscreen specifically (it flips
+ `Window.decorated` for an XWayland window's `Window` entry exactly the
+ same way it does for a native one).
+- **`zwlr_foreign_toplevel_management_v1` wasn't re-broadcasting
+ maximize/fullscreen/minimize state changes that didn't originate from
+ the protocol itself.** `foreign_toplevel::send_state` was already called
+ from `set_maximized`/`set_minimized`/`announce`/`update_activated`, but
+ never from the pointer-driven titlebar handlers in `input.rs`
+ (`TitlebarHit::Maximize`/`Minimize`, and double-click-to-maximize on
+ `TitlebarHit::Drag`), nor from any of the client-initiated
+ `XdgShellHandler`/`XwmHandler` requests just fixed above - so a dock or
+ panel's own maximized/minimized indicator went stale the moment a user
+ clicked a titlebar button directly, or a client asked for the state
+ change itself, instead of driving it through the dock. Fixed by adding
+ the same `send_state` call to all eleven sites (3 in `input.rs`, 5 in
+ `protocols.rs`, 6 in `xwayland.rs` - `unminimize_request` included,
+ even though it calls `restore_window` rather than `minimize_window`).
+ One trigger remains unfixed: a *compositor keybinding*
+ (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`) still doesn't
+ re-broadcast, because `crates/config` (the Lua engine, shared with the
+ X11/macOS backends) only ever holds a `WindowManager` reference, never
+ `CompState` - it has no way to reach a Wayland-protocol-specific
+ function that lives one layer up, in the crate that depends on it. See
+ `docs/PANEL_SUPPORT_TODO.md`'s P1 section for the full note; closing it
+ needs a small design decision (a dirty-window callback, or a periodic
+ diff/broadcast pass in `CompState`'s own tick), not a one-line patch.
+- **`zwlr_foreign_toplevel_handle_v1.set_fullscreen`/`.unset_fullscreen`
+ were silent no-ops**, and `Fullscreen` was missing from the state bytes
+ the handle ever sent - so a dock had no way to ask a window to
+ fullscreen, and couldn't tell if one already was, even though
+ `toggle_fullscreen` was exactly as reachable from `foreign_toplevel.rs`
+ as `toggle_maximize` already was (both are plain `WindowManager`
+ methods). Left over from before `move_request`/`resize_request` and the
+ five `XdgShellHandler` fullscreen/maximize/minimize requests were fixed
+ earlier in this pass - at the time this module was originally written,
+ the doc comment's given reason ("no `srd.window.fullscreen()`-equivalent
+ entry point reachable from here") was accurate; it stopped being true
+ once those fixes landed, and nothing had gone back to revisit it. Fixed
+ by adding `set_fullscreen`, mirroring `set_maximized`'s shape (including
+ the same `redraw_decoration_buffer`-before-`sync_geometry` ordering
+ fullscreen always needs), and adding `State::Fullscreen` to
+ `state_flags`/`send_state_to`. The request's `output` argument is
+ ignored as a hint only, matching `fullscreen_request`'s `_output` in
+ `protocols.rs`.
+- **`update_cursor_shape` left the resize cursor stuck once the pointer
+ moved from one of srdwm's own decoration edges straight onto the bare
+ desktop.** Its early-return for "over a client surface, let the client
+ drive its own cursor" didn't distinguish that case from "over nothing at
+ all" - on the empty desktop there is no client to ever call
+ `set_cursor` and reset it, so whatever named icon was showing (a resize
+ arrow, most noticeably) stayed forever until the pointer happened to
+ cross back onto some client's content. Fixed by threading through
+ whether any window is actually under the pointer (`over_content`,
+ computed once in `handle_pointer_position` from the same `under` lookup
+ already used for click routing) and falling back to `CursorIcon::Default`
+ instead of returning early when it's `false`.
+- **`cursor.rs` only had dedicated art for two shapes (text entry, the four
+ resize directions) - every other named shape a client can request,
+ including `CursorIcon::Pointer` (the hand shown over every hyperlink and
+ most other clickable non-form controls, by far the most common named
+ shape after the plain arrow), silently fell back to the same arrow shown
+ everywhere else.** Added three more built-in bitmaps following the same
+ solid-black-plus-white-halo style the resize/text shapes already use:
+ `crosshair_bitmap` (a plain centered `+`), `move_bitmap` (a four-way
+ arrow, built the same way `diagonal_resize_bitmap`'s arrowheads are, just
+ aimed at all four cardinal directions), and `pointer_bitmap` (a blocky
+ pointing hand - an upright finger over a wider palm block, hotspot at
+ the fingertip via a new `POINTER_HOTSPOT` constant rather than the
+ centered hotspot every other non-arrow shape uses). Everything else
+ (grab, wait, help, and the dozen or so rarer named shapes) still falls
+ back to the arrow - deliberately scoped to the shapes common enough to
+ be immediately noticeable when wrong, same reasoning the module's own doc
+ comment already gives for why it isn't a full XCursor theme.
+- **`ext_workspace_manager_v1` (`crates/wayland/src/workspace.rs`) turned
+ out to already be fully implemented and wired into both backends'
+ globals - `docs/PANEL_SUPPORT_TODO.md`'s P1 list had it marked as not
+ done, which was simply stale.** Auditing every `WindowManager::
+ switch_workspace` call site (the same technique that found `foreign_
+ toplevel`'s broadcast gap) turned up a real, smaller gap instead: the
+ `SUPER+scroll` workspace-cycle gesture in `input.rs` called
+ `switch_workspace` directly without calling `workspace::
+ broadcast_active_workspace` afterward, so a dock's workspace pill only
+ ever tracked switches driven through this protocol's own `activate`
+ request - identical shape to the maximize/fullscreen/minimize gap fixed
+ above, just for workspaces instead of window state. Fixed by making
+ `broadcast_active_workspace` `pub(crate)` and calling it from the scroll
+ gesture. The `srd.workspace.next()`/`.prev()`/`.switch()` Lua API has the
+ identical gap for the identical reason (`crates/config` only ever holds a
+ `WindowManager` reference, never `CompState`) and remains open, same as
+ the Lua-keybinding gap already noted for maximize/fullscreen/minimize.
+- **The keyboard repeat delay was hardcoded to 200ms - likely the real
+ explanation behind the separately-reported "typing feels sensitive,
+ letters double" symptom, which the earlier investigation above confirmed
+ was not server-side event duplication.** Found by comparing against
+ Hyprland's default (`repeat_delay = 600`), after a live report that
+ typing felt noticeably more sensitive under srdwm than under other
+ compositors on the same hardware - the machine already had a Hyprland
+ config on disk (`~/.config/hypr/hyprland.conf`) confirming it had
+ actually been used there before, on this same keyboard, without that
+ complaint. `repeat_info` is sent to a client once and the client manages
+ its own repeat timer entirely on its own from then on (confirmed reading
+ `smithay`'s `wayland/seat/keyboard.rs`: `repeat_info` is only ever sent
+ on bind and via `change_repeat_info`, never re-driven per keystroke) --
+ so a 200ms delay means any key held even slightly past a fifth of a
+ second, which is well within normal variance in how long a real
+ keystroke's finger-down/finger-up dwell actually is, starts the client's
+ own repeat and inserts an unintended extra character. This is
+ indistinguishable from "double-typing" to the person typing, entirely
+ client-side, and was invisible to the earlier diagnostic logging (which
+ only watched the compositor's own reception/dispatch, both of which were
+ and remain genuinely clean). Changed both backends' `add_keyboard(..,
+ 200, 25)` to `600, 25`, and `state.rs`'s `REPEAT_DELAY` constant (used
+ for the compositor's own held-keybinding repeat, kept in sync with the
+ seat's setting by design) to match. The double-typing bug is no longer
+ listed as unexplained - this is the leading fix for it, though not yet
+ live-verified against a real typing session (see the "don't restart
+ without permission" note elsewhere in this doc/session).
+- **`xdg_toplevel`'s `Activated` state was never sent to any client at
+ all - found investigating a live report that a single open window,
+ with nothing else it could possibly be losing focus to, still didn't
+ look focused.** `set_keyboard_focus` (the one chokepoint every focus
+ change already goes through) only ever called `keyboard.set_focus`,
+ which delivers `wl_keyboard.enter`/`leave` - real input focus - but
+ says nothing about `xdg_toplevel` state. The only place `Activated` was
+ ever touched was `foreign_toplevel::update_activated`, which is a
+ completely different, dock-facing protocol
+ (`zwlr_foreign_toplevel_handle_v1`), not the client's own window. GTK4/
+ libadwaita's `:backdrop` CSS pseudo-class - and most other toolkits'
+ equivalent - keys off exactly the state that was never sent, so any
+ client that draws its own focus indicator this way (a differently
+ colored/styled titlebar when "unfocused") looked permanently unfocused
+ regardless of real keyboard focus or window count: a lone window is
+ never *not* the focused one, so this was actually the easiest case to
+ notice it in, not a coincidence. Fixed with `set_window_activated`, a
+ new helper next to `set_keyboard_focus` that calls `smithay::desktop::
+ Window::set_activated` (which already unifies the xdg-shell and X11
+ cases - sets the pending state for the former, talks straight to the X
+ connection for the latter) for both the window losing focus and the one
+ gaining it, sending a `send_configure` for the native-Wayland case since
+ `set_activated` alone only queues the pending state.
+
+- **Every `class`-based `srd.rule` silently never matched a native Wayland
+ window - the actual root cause of Firefox's persistent double titlebar,
+ and (same mechanism) of every other rule in the shipped `rules.lua`
+ quietly not applying to native-Wayland apps (floating for `mpv`/`vlc`/
+ `pavucontrol`, workspace assignment for `discord`/`Spotify`, etc.).
+ Found by nesting srdwm under the live session with an isolated
+ `XDG_RUNTIME_DIR` (symlinking only the host socket in, under a name
+ that doesn't collide with the guest's own auto-picked server socket --
+ reusing the same name once caused the *next* nested launch to fail to
+ connect out, since the guest's own `bind_auto` had overwritten the
+ symlink) and screenshotting a real Firefox window via `grim`.**
+ `WindowManager::add_window` matches rules once, against whatever
+ `title`/`app_id` the `Window` already has - but `new_managed_window`
+ populates those from `XdgToplevelSurfaceData` at `get_toplevel` role-
+ assignment time, which is *before* essentially every real client's
+ `set_title`/`set_app_id`/first commit, not racily but every single
+ time. So `add_window` always evaluated rules against an empty
+ `app_id`, and `srd.rule({ class = "firefox" }, { decorated = false })`
+ - meant to stop srdwm drawing a second titlebar over Firefox's own --
+ could never match. `sync_toplevel_metadata` already re-read the real
+ `app_id`/`title` on every commit (for the foreign-toplevel broadcast),
+ but never gave rules a second chance. Fixed with `Window::
+ rules_applied` (sticky once real identity has been evaluated, so a
+ later unrelated title change - a browser tab switching - can't
+ re-match and re-apply) and `WindowManager::reapply_rules_if_pending`,
+ called from `sync_toplevel_metadata` once `title`/`app_id` actually
+ change *and a rule actually matched* (see the next entry for why that
+ second condition matters), followed by the same `redraw_decoration_
+ buffer`-then-`sync_geometry` ordering `set_decorated_from_mode` uses for
+ any `decorated` flip. XWayland was never affected: `map_window_request`
+ (`xwayland.rs`) sets `app_id` from `X11Surface::class()` synchronously,
+ before `add_window` runs. Regression test: `class_rule_applies_once_
+ app_id_is_known_after_creation`. Screenshot-verified end to end in the
+ isolated nested instance: Firefox's own CSD tab strip is now the only
+ chrome, with no srdwm-drawn band above it.
+- **The nested (winit) backend's own debug window was titled "Smithay"**
+ - the hardcoded default `smithay::backend::winit::init()` uses
+ internally, surfaced while investigating the above and reasonably
+ read as "this compositor is just smithay with no work of its own on
+ top" rather than the dev-window cosmetic default it actually is.
+ Switched to `winit::init_from_attributes` with `.with_title("srdwm")`,
+ the only behavioral difference from `init()`.
+- **`reapply_rules_if_pending` calling `sync_geometry` unconditionally on
+ every title/app_id change (not just the first, rule-matching one) was a
+ real bug in its own right, independent of the fix above.**
+ `sync_geometry` calls `Space::map_element`, which - per smithay 0.7.0's
+ own source - always re-stacks its target to the top on every call
+ (remove, push to the end, stable-sort by `z_index`; every window shares
+ the same default `z_index`, and `activate: bool` only toggles the
+ `xdg_toplevel` activated *state*, it does not gate the restack). A
+ window's title changing is not a user action and has nothing to do with
+ raising it - but it happens constantly for perfectly ordinary reasons
+ (a browser tab finishing a page load) long after the window's own
+ creation. Fixed by having `reapply_rules_if_pending` return whether a
+ rule actually matched (`Some`, not just "already evaluated"), and only
+ calling `redraw_decoration_buffer`/`sync_geometry` from
+ `sync_toplevel_metadata` when it did - the overwhelmingly common case
+ (no matching rule) now correctly does neither.
+- **FIXED (a later pass, same session) - the window-stacking bug above:
+ two overlapping native Wayland toplevels compositing in the wrong
+ front-to-back order was real, reproducible, and root-caused by
+ instrumenting a locally vendored copy of smithay directly (`vendor/`
+ + `[patch.crates-io]`, both removed once done - see the commit/diff
+ history if the technique is needed again).** `eprintln!`s in
+ `OutputDamageTracker`'s `damage_output_internal` (the element-collect
+ loop) and its draw loop, plus one in `sync_geometry`, proved the actual
+ mechanism: `smithay::desktop::space::Space::map_element` - 0.7.0's only
+ way to update an element's tracked position - *always* re-stacks its
+ target to the top of `Space`'s internal order as a side effect, `activate`
+ argument or not (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: a title/app_id changing, an
+ ordinary resize frame, anything that touches position or size. Two
+ windows created moments apart each independently go through their own
+ startup title/app_id negotiation, each triggering a handful of
+ `sync_geometry` calls purely from that - so whichever one's startup
+ sequence happened to settle *last* silently won `Space`'s notion of "on
+ top", a race with no relationship to which window `WindowManager` (or
+ the user) actually considered focused. This is why it looked like
+ "whichever was created first always wins" in early testing and why every
+ srdwm-side rendering variable (push order, forced full redraws, buffer
+ age) made no difference - none of them touched the actual mechanism.
+ Fixed with `CompState::resync_stacking_order`, called right after every
+ `map_element` in `sync_geometry`: re-applies `WindowManager.order`
+ (already the single source of truth for stacking, and already what
+ `hit_test`/`window_at`/rendering itself use) to `Space` via
+ `raise_element` in bottom-to-top sequence, so the two can never drift
+ apart again regardless of why `sync_geometry` was called. Verified live,
+ repeatedly, in an isolated nested instance: two overlapping `wezterm`
+ windows with no rules involved, staggered by both 5s and 2s to stress
+ the startup-timing race, correctly show the focused one on top every
+ time; `srd dispatch focus <id>` on the backgrounded one correctly brings
+ its content to the front, confirming this tracks real focus changes and
+ isn't just "newest window wins" by coincidence.
+
+## `ext_idle_notify_v1` / `zwp_idle_inhibit_manager_v1` (added this pass)
+
+Both use smithay's own complete, built-in modules (`wayland::idle_notify`/
+`idle_inhibit`) - unlike everything else hand-written in this crate against
+raw protocol bindings, neither needed that: smithay already ships full
+working server-side implementations of both, timers included.
+
+- `IdleNotifierHandler`/`IdleInhibitHandler` implemented for `CompState`
+ (`protocols.rs`); `input::notify_idle_activity` calls `IdleNotifierState::
+ notify_activity` from all four real input paths (`handle_pointer_position`,
+ `handle_pointer_button`, `handle_keyboard_key_event`,
+ `handle_workspace_scroll`, which every `PointerAxis` event already routes
+ through) - deliberately including while the session is locked, since idle
+ activity is about the seat, not about which surface an event reaches.
+ Throttled to once per 250ms: `notify_activity` removes and re-inserts a
+ calloop timer per live notification on every call with no throttling of
+ its own, and idle timeouts are measured in minutes, so nothing needs
+ finer resolution than that - the same class of hot-path-per-motion-event
+ cost this session's earlier diagnostic-logging regression already proved
+ worth being careful around, just cheap enough (in-memory bookkeeping, not
+ synchronous I/O) that throttling rather than omitting it was the right
+ call.
+- `IdleInhibitHandler::inhibit`/`uninhibit` track inhibiting surfaces in a
+ new `CompState::idle_inhibiting_surfaces` list and call `IdleNotifierState::
+ set_is_inhibited` accordingly. Deliberately not workspace-visibility-aware
+ (an inhibiting window on a workspace you've switched away from still
+ keeps the system awake) - see that field's own doc comment for the
+ reasoning. `remove_window` also clears a window's entry as a safety net:
+ smithay's own `IdleInhibitorState` only calls `uninhibit` on an explicit
+ `destroy` request, never on ungraceful client death, so a crashed video
+ player would otherwise hold the whole system awake forever with no
+ client left to ever release it.
+- **The winit (nested/dev) backend genuinely has no `calloop` event loop of
+ its own at all** (documented in `ipc.rs`'s own module doc comment,
+ predating this work) - but `IdleNotifierState::new` requires a real
+ `LoopHandle` to register its per-notification timers against, and its
+ handler trait must return a real, working state unconditionally (no
+ `Option` in the signature), so this couldn't be skipped for that backend
+ without either breaking the shared `CompState` type both backends use or
+ advertising a global whose events would then simply never fire - worse
+ than not having the protocol at all. Gave `WaylandPlatform` (winit.rs) a
+ second, narrowly-scoped `calloop::EventLoop<'static, CompState>` used
+ for nothing except hosting these timers, dispatched non-blocking
+ (`Duration::ZERO`) once per `poll_events` tick alongside the existing
+ per-tick work. The udev backend already had a real event loop for this
+ to use directly, no new plumbing needed there.
+
+## Placement, dragging, fullscreen-vs-dock, decoration polish (added this pass)
+
+- **`Monitor` conflated two genuinely different rectangles into one
+ `geometry` field: the exclusive-zone-shrunk usable area (what
+ placement/tiling/maximize should respect) and the output's true full
+ rect (what fullscreen - and a window being interactively dragged --
+ should be able to reach or cross).** Reported as two symptoms of the
+ same cause: fullscreen (`Super+z`) stopped short of a dock's reserved
+ strip instead of covering it like every other compositor's fullscreen
+ does, and a floating window being dragged could not be moved into that
+ strip at all - not merely discouraged, physically unreachable at any
+ drag speed or angle, since `update_drag`'s clamp used the same shrunk
+ `geometry`. Fixed by adding `Monitor::full_geometry` (defaults to
+ `geometry` for any backend not yet taught the distinction), populated
+ in `udev.rs`/`winit.rs`'s `monitors()` from the output's real mode size
+ rather than `non_exclusive_zone()`. `toggle_fullscreen` and
+ `WindowManager::update_drag`'s clamp now use `full_geometry`;
+ `toggle_maximize`, `SmartPlacement`'s grid/cascade, and `snap_zone`'s
+ top-edge-maximize case deliberately still use `geometry` - a *new*
+ window's placement and a top-edge snap are both "maximize", which
+ should keep avoiding the dock the same as before. Regression tests:
+ `fullscreen_covers_the_full_monitor_ignoring_a_dock_reservation`,
+ `maximize_still_respects_the_dock_reservation`,
+ `dragging_a_window_can_cross_into_the_dock_reserved_strip`.
+- **`PlacementConfig::snap_threshold` (edge-magnetism distance for
+ Windows-Snap-style drag-to-edge) was still reported as too sensitive
+ at 20px, the value an earlier pass already reduced it to from 50.**
+ Compounded by the `full_geometry` fix above: before that fix, a
+ dragged window could never actually reach the true screen edge behind
+ a dock, so it could get within the old 20px threshold of `snap_zone`'s
+ comparison edge well before the cursor was anywhere near a real edge.
+ Reduced to 8px - tight enough to require deliberately reaching the
+ edge, not just moving generally toward it. `drag_ending_near_edge_
+ snaps_to_half_screen` updated to match (drags to a point 8px inside
+ the edge instead of 20px).
+- **Every bordered window (the default - `border_width: 2` unless a
+ rule zeroes it) rendered with square corners, while only the rare
+ borderless window got the rounded titlebar treatment** - reported as
+ "not all window borders are rounded," which is more precisely "almost
+ none are." `render_titlebar`'s `round_corners` was deliberately `false`
+ whenever bordered, specifically to avoid a titlebar rounding its own
+ top corners while the square border frame around it didn't (a visible
+ gap at exactly those two corners). Fixed the mismatch from the other
+ side instead of disabling rounding: new `decoration::render_border_top`
+ renders the border's top strip as its own small rounded-corner bitmap
+ (reusing `round_top_corners`, radius `CORNER_RADIUS + border_width` so
+ the cut continues outward from the titlebar's), wired into both
+ backends' render loops in place of a plain `SolidColorRenderElement`
+ for that one strip - the other three (bottom/left/right) don't touch a
+ visible corner and stay solid fills. `redraw_decoration_buffer` now
+ always passes `round_corners = true`. Regression test:
+ `border_top_rounds_its_own_top_corners_to_match_the_titlebar`.
+- **`srd clients`' IPC response carried no geometry** - raised by AGS
+ (the panel this project's `PANEL_SUPPORT_TODO.md`/AGS's own
+ `BACKLOG.md` were written against): its Overview/window-switcher needs
+ window rectangles to lay out miniatures to scale, and neither
+ `zwlr_foreign_toplevel_management_v1` nor `ext_foreign_toplevel_list_v1`
+ carries geometry at all, by design of those protocols - this
+ compositor's own IPC was the only place it could come from. Added
+ `x`/`y`/`width`/`height` (the same global logical-pixel space
+ everything else here uses) to `ClientInfo` in `ipc.rs`.
+- **FIXED (a later pass, same session): the window z-order bug noted
+ here originally as unfixed.** See the entry directly above the
+ idle-notify section for the full writeup - root cause was `Space::
+ map_element` silently re-stacking on every `sync_geometry` call,
+ fixed with `CompState::resync_stacking_order`.
+
+## Input-accuracy pass: cursor shape, scroll (added a later pass, same session)
+
+- **The built-in arrow cursor's tail forked into two legs of visibly
+ different widths - one tapering to a point like the rest of the
+ shape, the other a constant-width block that never tapered, ending in
+ an abrupt flat stop.** Looked fine glanced at in a full screenshot; the
+ asymmetry only became obvious rendering the bitmap in isolation at a
+ large scale, which is what actually caught it, after being told directly
+ that a screenshot glance wasn't good enough - correctly. Redesigned as
+ a single triangular foot mirroring the head's own taper. Regression
+ test: `arrow_tail_is_a_single_tapering_shape_not_a_lopsided_fork`,
+ which asserts every tail row is one contiguous opaque run with
+ non-growing width - it fails against the old bitmap.
+- **Scrolling forwarded to clients was missing two things `PointerAxisEvent`
+ actually provides, one of them a real protocol requirement, not a nicety.**
+ `AxisSource::Finger` (a touchpad) - per `AxisFrame::source`'s own doc
+ comment - *requires* a `stop()` event on the frame where the axis
+ genuinely has no more motion (`event.amount(axis)` returns `None`);
+ nothing ever sent one, on either backend. A client has no reliable way to
+ know a two-finger scroll gesture ended without it, which matters for
+ kinetic/momentum scrolling and for not leaving a gesture "stuck" as far
+ as the client's concerned right before the next one starts - exactly
+ the class of thing that reads as "scrolling doesn't really work" rather
+ than "no events arrive" (discrete wheel scrolling, needing no stop event,
+ was never affected). Also added `amount_v120` (discrete wheel steps,
+ `AxisFrame::v120`) alongside the existing pixel `value()` - optional per
+ protocol, but some clients use it to tell a physical wheel click from
+ smooth scrolling. Both fixed in `udev.rs`'s `PointerAxis` handler.
+- **The nested (winit/dev) backend had no scroll handling at all --
+ `InputEvent::PointerAxis` fell into a catch-all and was silently
+ dropped, unconditionally, regardless of device.** Only really affects
+ development/nested testing (the real session runs on `udev.rs`, already
+ fixed above), but worth closing since it's exactly the kind of gap that
+ would have made this specific bug class impossible to verify by testing
+ nested in the first place. Added the same forwarding (stop/v120 included)
+ as udev.rs's fix.
+- **Window borders (`decoration::border_strips`) are drawn `border_width`
+ pixels outside a window's `geometry`, but hit-testing only ever checked
+ `geometry` itself** - so the visible border was a dead zone: hovering it
+ showed no resize cursor and it couldn't be grabbed, even though it's what
+ visually reads as the window's actual edge. `ResizeEdge::hit_test` now
+ takes `border_width` and widens the containment check by that much on
+ every side; `resize_edge_at`'s own margin comparisons needed no matching
+ change, since they already treat anything at or outside `frame`'s edge as
+ maximally "near". Regression test:
+ `border_pixels_are_hoverable_not_a_dead_zone`.
+- **New feature: `zwlr_output_power_management_v1`** (DPMS on/off per
+ output) - `crates/wayland/src/output_power.rs`, hand-written against
+ `wayland-protocols-wlr`'s raw bindings (no smithay helper exists, same
+ pattern as `screencopy.rs`). udev/DRM backend only: there's no real
+ display to power down when nested under a host compositor, so the global
+ is genuinely not created there (`CompState::_output_power_state` is
+ `Option`, `None` for `winit.rs`) rather than advertised-and-always-
+ failing. `CompState::set_output_power` finds the connector's generic KMS
+ "DPMS" property by name (`drm-rs` has no dedicated call for this, only
+ the same `get_properties`/`set_property` every connector property goes
+ through) and sets it via the raw `DRM_MODE_DPMS_ON`/`_OFF` UAPI values
+ (hardcoded rather than pulling in `drm-sys` for two constants that have
+ been stable since DPMS was added to the KMS UAPI). Complements, and is
+ deliberately independent of, `ext_idle_notify_v1`: that protocol only
+ tells a client the seat went idle, it has no way to blank a screen
+ itself - a real "screen off after N minutes idle" feature needs an idle
+ daemon watching the former and calling `set_mode` on this. Built and
+ tested; **not yet live-verified against real DRM hardware** - this
+ session's nested-instance testing setup stopped being able to nest under
+ the live host partway through this pass (a `winit`-backend "Failed to
+ initialize an event loop" that reproduces identically against the
+ already-installed, pre-existing binary too, so it's an environment
+ change - the live session switched host compositors mid-session - not
+ a regression from this work), so this needs a real restart to confirm
+ the DPMS property is actually found and set correctly on real hardware.
+- **New feature: `zwlr_gamma_control_manager_v1`** (per-output gamma ramp --
+ night light / `gammastep`/`wlsunset`) - `crates/wayland/src/
+ gamma_control.rs`, same hand-written-against-raw-bindings pattern and
+ same udev-only/`Option` reasoning as `output_power.rs` right above.
+ `gamma_size` (sent when a client creates a control object) and the
+ actual ramp length both come from the CRTC's own `gamma_length` via
+ `drm-rs`'s `get_crtc`. The interesting part is `set_gamma`: the
+ protocol hands the table over as a **memory-mapped fd**, not a value on
+ the wire (`size` `u16`s per channel, red/green/blue back to back) --
+ `memmap2` (already a transitive dependency of smithay's own `wl_shm`
+ handling, just not previously used directly by this crate) maps it
+ read-only, and the three channel slices are read out by hand
+ (`u16::from_ne_bytes` per pair - no cross-endianness concern, client
+ and compositor are always the same machine) before handing them to
+ `drm-rs`'s `set_gamma`. Built and tested; **not yet live-verified
+ against real hardware**, for the same nested-testing-environment reason
+ as `output_power.rs` above (needs a real restart).
+
+- **New feature: window animations** (`general.animations`/
+ `general.animation_duration`) - these two config keys were validated/
+ defaulted by `crates/config` but nothing ever read them, same dead-config
+ bug class already found and fixed for `workspace.count`. Wiring lives in
+ `crates/srdwm/src/main.rs`'s new `apply_general_settings`, read into two
+ new `WindowManager` fields (`animations_enabled`, `animation_duration_ms`)
+ the same way `apply_workspace_count` already reads `workspace.count` --
+ and, found as a side effect of tracing this same wiring gap,
+ `general.window_gap` had the identical bug (`TilingConfig::default()`
+ hardcoded `gap_inner: 8, gap_outer: 16` regardless of what `init.lua`
+ set; now read into `WindowManager.tiling` there too).
+ Deliberately geometry-only, no fade/scale-of-content: content is
+ composited through `self.space` (see `resync_stacking_order`'s doc
+ comment for why per-window custom render elements were ruled out earlier
+ this session as the content path), which has no per-element alpha/scale
+ knob independent of the rest of the output - reintroducing that path
+ just for animations would have reopened the not-fully-understood
+ z-order risk that path carried before its root cause was found. What
+ *is* safe to animate through `self.space` is exactly what interactive
+ drag/resize already proves out on every single motion frame: a
+ `Window.geometry` change applied via `map_element` and (on a size
+ change) `xdg_toplevel.configure`. `crates/wayland/src/state.rs` reuses
+ that exact mechanism at a fixed ~60fps cadence instead of on pointer
+ motion:
+ - `Window` (core) gained `anim_from: Option<Rect>`, set by
+ `WindowManager::toggle_maximize`/`toggle_fullscreen` to the geometry
+ just moved *from*, only when `animations_enabled`. Interactive drag/
+ resize never sets it, so those still track the pointer 1:1 with no
+ tween.
+ - `sync_geometry` (wayland) takes (reads and clears) `anim_from`; if set
+ and different from the target, it registers a `WindowAnim` (eased
+ ease-out-cubic interpolation) and applies the tween's current rect
+ instead of jumping straight to `geometry`.
+ - `CompState::tick_animations`, called once per frame from both
+ backends' `poll_events` (`render_frame`/`render_udev_frame`), advances
+ every in-flight tween and re-runs `sync_geometry` for it; a finished
+ tween is dropped *before* its last call so that call lands exactly on
+ `Window.geometry`, not on the eased curve's last sub-pixel step.
+ - New windows get a small "open-slide" tween too (`new_managed_window`
+ sets `anim_from` to a rect ~24px below the resting position, same
+ size) - position-only, deliberately no resize, since a freshly-
+ mapping client's first paint may not have arrived yet and repeatedly
+ reconfiguring it to intermediate sizes during that window risked
+ looking worse than no animation, not better. Window *close* is not
+ animated - the client's resources are already gone by the time
+ srdwm knows about it, so there is nothing left to tween without
+ rendering a static last-frame texture, a materially different (and
+ separate) problem, left for a later pass.
+ Covered by unit tests in both `srdwm-core` (`maximize_records_anim_from_
+ when_animations_enabled`, the disabled-config counterpart, and the
+ fullscreen equivalent) and `srdwm-wayland` (`WindowAnim`'s easing:
+ starts at `from`, ends exactly at `to`, strictly between on every axis
+ midway). **Not yet live-verified** against a real interactive maximize/
+ fullscreen/open, for the same nested-testing-environment reason as the
+ DPMS/gamma-control entries above (needs a real restart).
+ **Note for whoever restarts next**: the shipped `~/.config/srd/init.lua`
+ currently has `srd.set("general.animations", false)` (carried over from
+ a prior Hyprland config, back when srdwm had no animation support at
+ all) - with this wiring in place that line now actually takes effect
+ and animations will stay off under the current config. Flip it (or
+ remove the line, since the built-in default is `true`) to see this.
+
+- **Fixed: dock/panel state going stale after a compositor keybinding**
+ (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`,
+ `srd.workspace.next()`/`.prev()`/`.switch()`) - both were long-standing,
+ explicitly documented gaps in `docs/PANEL_SUPPORT_TODO.md`'s P1 section:
+ `crates/config` is the platform-agnostic scripting engine (shared with
+ the X11/macOS backends) and only ever holds a `WindowManager` reference,
+ never `CompState`, so a Lua-driven state change had no way to reach
+ `foreign_toplevel::send_state`/`workspace::broadcast_active_workspace`,
+ which are Wayland-protocol-specific and live one layer up. Every *other*
+ trigger (pointer titlebar actions, a client's own request, the
+ `SUPER+scroll` workspace gesture) already re-broadcast correctly; only
+ the Lua-bound paths were silently stale. Closed with the periodic-diff
+ option that entry already named as the alternative to threading a
+ callback through `WindowManager`: `CompState::tick_dirty_broadcasts`,
+ called once a frame from both backends' `poll_events` (same cadence as
+ the animation tween's `tick_animations`) --
+ `foreign_toplevel::broadcast_dirty_state` diffs `maximized`/`minimized`/
+ `fullscreen` per window against what was last sent and re-broadcasts
+ anything changed; `workspace::broadcast_dirty_active` diffs the current
+ workspace id and only calls `broadcast_active_workspace` (real protocol
+ traffic to every handle, not a cheap comparison) on an actual change.
+ This catches the specific documented gap and, being a diff against live
+ `WindowManager` state rather than a per-call-site hook, any future
+ keybinding/API that changes the same state without its own broadcast
+ call, with nothing further to remember.
+
+- **FIXED: sustained high CPU usage, and no clean shutdown on `SIGTERM`.**
+ Found chasing a real report - a peer session working on the AGS shell
+ measured srdwm at 33% CPU on a 4-core machine while it was the live
+ compositor, and separately found a stale `srdwm-<display>.sock` left
+ behind after a session switch away from srdwm, with nothing listening on
+ it. Both were real, and both are now fixed:
+ - **The winit (nested/dev) backend's render loop had no pacing at all.**
+ `poll_events` called `render_frame` -> full render + `swap_buffers`
+ every single iteration with nothing in between that ever blocked:
+ `pump_winit`'s `dispatch_new_events` polls and returns immediately
+ either way, and smithay 0.7.0's winit backend hardcodes
+ `vsync: false` on the EGL surface it creates (every entry point into
+ `backend/winit/mod.rs` does this, not just the one this backend uses
+ for its custom `WindowAttributes` - confirmed by reading the crate
+ source directly), so `swap_buffers` never waits for a display refresh
+ either. Measured live in an isolated nested instance, zero windows
+ open: **52.5% of one core, sustained** (`ps -o %cpu`). Fixed by giving
+ `idle_event_loop.dispatch` (already called every tick for `ext_idle_
+ notify_v1`'s timers) a real timeout - the remaining budget until
+ `TARGET_FRAME_TIME` (1/60s) has elapsed since the last frame, instead
+ of always `Duration::ZERO` - rather than adding a second, separate
+ sleep. Re-measured after the fix, same isolated instance, same zero
+ windows: **7.1-7.4%**, roughly a 7x reduction. The udev/DRM backend
+ was not affected the same way - its `poll_events` already blocks on
+ `event_loop.dispatch(Some(Duration::from_millis(16)), ...)` - but see
+ the border-buffer fix below for a *second*, independent way a bordered
+ window could have kept it rendering (and page-flipping) every frame
+ regardless of pacing.
+ - **Border strips were rebuilt from scratch every render frame, in both
+ backends, with a fresh `Id` every time** - the top strip via
+ `decoration::render_border_top` + a brand-new `MemoryRenderBuffer`,
+ the other three via `SolidColorRenderElement::new(Id::new(), ...)`.
+ Confirmed by reading smithay 0.7.0's `OutputDamageTracker::
+ damage_output_internal` directly: it 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 no match is *ever*
+ found, so every bordered window's border was marked damaged on every
+ single frame, forever - not a wider damage rect occasionally, actual
+ damage unconditionally, every time, for as long as any window with
+ `border_width > 0` was on screen (the default, so in practice always).
+ On the udev backend this meant `has_damage` was permanently true and a
+ real DRM page flip fired every ~16ms regardless of whether the screen
+ had changed at all - the output could never actually go idle. Fixed
+ by caching the top strip's bitmap the same way (and at the same
+ trigger points - creation, a resize, a rule re-applying) the titlebar
+ already was, in a new `CompState::border_top_decorations`, and by
+ giving the other three strips a *persistent* `SolidColorBuffer` per
+ window (`CompState::border_side_buffers`) updated in place via
+ `.update()` every frame instead of rebuilt - `SolidColorBuffer::
+ update` only bumps its internal commit counter when the size or
+ colour actually changed, which is what lets the tracker correctly see
+ "nothing changed" on a static screen. Since caching the titlebar-
+ adjacent border-top bitmap meant it could go stale on a focus change
+ (colour depends on focused/unfocused) where nothing was rebuilding it
+ before, `set_window_activated` (the real focus chokepoint) now calls
+ `redraw_decoration_buffer` on an actual activation change too - which
+ incidentally fixes a second, pre-existing bug in its own right: the
+ *titlebar text* colour was never being refreshed on focus change
+ either, only whenever some unrelated resize happened to trigger a
+ redraw regardless.
+ - **No `SIGTERM`/`SIGINT` handler at all.** Default disposition is
+ immediate termination with no Rust `Drop` impl ever running, so an
+ external shutdown (a session manager or `systemd-logind` ending the
+ session normally, not a crash or a `kill -9`) skipped `IpcServer::
+ drop` (`crates/wayland/src/ipc.rs`) and left its socket file behind.
+ Confirmed live testing the fix: the *old* binary genuinely ignored
+ `SIGTERM` outright (`SigCgt` in `/proc/<pid>/status` showed something
+ in the dependency chain - not srdwm's own code, which installed no
+ handler - already catching it) and needed `SIGKILL` to die at all;
+ the new binary, with `crates/srdwm/src/main.rs`'s `install_signal_
+ handlers` called first thing in `main`, exits cleanly on `SIGTERM` and
+ removes every socket (`srdwm-<display>.sock` and the Wayland display
+ socket itself) as part of that exit. The handler itself only sets a
+ process-wide `AtomicBool` (the one thing async-signal-safe to do);
+ the main loop polls it once per iteration and, if set, routes through
+ `running.set(false)` - the exact same path `srd.quit()` already
+ uses - rather than a second, separate shutdown path. Unix only
+ (`cfg(unix)`, covering Linux and macOS): `libc` added as a direct,
+ `cfg(unix)`-gated dependency of `crates/srdwm` for this (already
+ present transitively at the identical version, so no new dependency
+ tree growth); Windows gets a no-op stub, `SIGKILL` cannot be caught by
+ any process on any platform so a teardown that escalates straight to
+ that is not fixable from here.
+
+- **A priority pass driven by a deep-dive comparison against sway, Hyprland,
+ river, i3, bspwm, dwm, komorebi, GlazeWM, yabai, AeroSpace and Amethyst**
+ (full writeup published as a standalone artifact; the gaps found there
+ drove this whole pass, roughly in priority order):
+ - **FIXED: X11 keybindings silently broke under Num Lock.**
+ `grab_keybindings` grabbed each combo's raw modifier mask only, not
+ once per lock-modifier combination (none/Num Lock/Caps Lock/both) --
+ every real X11 WM (i3, bspwm, dwm) does this because `XGrabKey`
+ matches state exactly, not as a subset, so a real keypress's state
+ (which always includes whichever lock modifiers happen to be toggled)
+ never matched a grab that didn't. Num Lock's own modifier bit isn't
+ fixed by the X11 spec (unlike Caps Lock, always `ModMask::LOCK`) --
+ found via `get_modifier_mapping` + the keycode `XK_Num_Lock` (`0xff7f`)
+ resolves to, at connect time. `crates/x11/src/lib.rs`.
+ - **FIXED: a much larger dead-config surface than previously known.**
+ Auditing every `set(...)` in `crates/config`'s defaults block against
+ real read sites (the same class of bug `window_gap`/`general.
+ animations` turned out to be) found ~80 more validated-but-never-read
+ keys. Wired the ones with an unambiguous, already-rendered counterpart:
+ `theme.decorations.title_bar.background`/`border.active_color`/
+ `border.width` (a new `srdwm_core::ThemeConfig` on `WindowManager`,
+ replacing hardcoded Nord-palette constants that happened to match --
+ live on both the Wayland and X11 backends), `workspace.names` (a new
+ `WindowManager::rename_workspace`), and `workspace.auto_back_and_forth`
+ (sway's "reselecting the active workspace jumps back to the previous
+ one" behavior, a new `previous_workspace` field). The rest (`debug.*`,
+ `performance.*`, `platform.*.use_*`, the `layout.tiling`/`dynamic`/
+ `floating` namespace, `window.remember_*`) are real features, not
+ one-line wiring fixes, and stay open - listed below.
+ - **FIXED: `WindowMatch` was the least capable rule matcher of any WM
+ compared**, including static-config dwm - only exact `class` and
+ substring `title_contains`. Added `title_regex`/`class_regex` (Rust
+ `regex` crate, case-sensitive unless the pattern starts with `(?i)`)
+ and `instance` (X11 `WM_CLASS`'s instance half) to `srdwm_core::
+ WindowMatch`, all ANDed with the existing fields, and to `srd.rule`'s
+ Lua API. Found and fixed a second, more fundamental bug in the same
+ pass: **the X11 backend never read `WM_CLASS` at all**, so `app_id`
+ was permanently empty on every X11 window and every class-based rule
+ silently failed to match anything - the same root cause already found
+ and fixed for native Wayland windows earlier this project
+ (`with_toplevel_app_id`'s doc comment), just never ported to X11.
+ `crates/x11/src/lib.rs`'s new `window_class`.
+ - **NEW: scratchpad** (`srd.window.scratchpad()`/`.scratchpad_show()`) --
+ sway's `move scratchpad`/`scratchpad show`, the single most-used
+ "quick terminal" pattern in tiling WMs and the biggest single feature
+ gap the comparison found. `WindowManager::scratchpad_add`/
+ `scratchpad_show` reuse the existing `minimized`+`visible_windows`
+ mechanism (a new `Window::scratchpad` field is purely a pool-
+ membership marker) rather than inventing new visibility plumbing, so
+ it rides on the same generic `sync()` diff in `crates/srdwm/src/
+ main.rs` that already drives minimize/restore/geometry/decoration for
+ every backend - no Wayland- or X11-specific wiring needed at all.
+ Showing a hidden scratchpad window moves it onto whichever workspace
+ is current, matching sway rather than pinning it to wherever it was
+ hidden from.
+ - **NEW: `srd`'s IPC gained event subscription** (`{"cmd":"subscribe"}`,
+ `srd subscribe`) - the single highest-leverage gap the comparison
+ found: every compositor compared (sway, Hyprland, i3, bspwm) has an
+ event-subscribe side to its IPC; `srd`'s was poll-only, which is
+ exactly why an AGS peer session had to poll raw `wlr-foreign-toplevel`
+ from a separate Python helper instead of using this socket at all. A
+ `subscribe` connection gets an immediate snapshot, then one more
+ `{"event":"clients",...}` line every time the window list actually
+ changes (diffed once a tick, skipped entirely when nothing did) --
+ kept open rather than the usual one-request-one-response-close, the
+ one exception to that shape. Live-verified end to end against a real
+ client in a nested instance: initial empty snapshot, a push on
+ Alacritty opening, a push back to empty on it closing.
+ **`crates/wayland/src/ipc.rs` moved to `crates/platform/src/ipc.rs`**
+ as part of this - it never actually touched anything Wayland-specific
+ (pure `WindowManager` + sockets), so this also closed a separate,
+ bigger gap the comparison found: **the X11 backend had no IPC server
+ at all**, not even the pre-existing one-shot version. Wiring it in
+ also required fixing `X11Platform::poll_events`, which used to block
+ indefinitely on `wait_for_event()` - meaning the IPC socket (and
+ everything else) would've gone unresponsive for as long as nothing
+ happened on the X11 connection at all. Replaced with a bounded
+ `poll(2)` (~16ms, matching the Wayland backends' own cadence) on the
+ connection's own fd. `crates/ctl` (the `srd` CLI) updated to pick the
+ right `WAYLAND_DISPLAY`-vs-`DISPLAY` socket-naming key via `srdwm_
+ platform::detect()` (previously Wayland-only, hardcoded) and to
+ support `srd subscribe`.
+
+- **New feature: `zwlr_output_management_v1`** - the top item on the
+ field-survey comparison's cross-platform priority list. Lets a settings
+ panel enumerate every output (name, description, physical size, make/
+ model, every supported mode, current position/scale/transform/enabled
+ state) and request changes. `crates/wayland/src/output_management.rs`,
+ hand-written against `wayland-protocols-wlr`'s raw server bindings, same
+ pattern as `foreign_toplevel.rs`/`workspace.rs`. Not `Option`-gated per
+ backend like DPMS/gamma-control - enumeration and applying position/
+ scale/transform go through `Output::change_current_state`, which already
+ works identically on both.
+ - **Deliberately conservative on `apply`/`test`**: disabling/enabling a
+ head (srdwm has no concept of an output that exists but isn't mapped)
+ and switching resolution/refresh rate (real DRM mode-setting - finding
+ the matching connector mode and reprogramming the CRTC, substantial
+ hardware-dependent work, and this environment has no multi-mode
+ hardware to verify it against regardless) both fail honestly (`failed`)
+ rather than silently no-op. A `set_mode`/`set_custom_mode` matching the
+ head's *already-current* mode - the common case, since a real panel
+ echoes back existing state alongside whatever it's actually changing --
+ is accepted as a no-op.
+ - Moving a head keeps three separately-cached copies of output position
+ in step (`Output` itself, `CompState::outputs` used for hit-testing,
+ and on the udev backend `UdevHead::location` used to translate render
+ geometry into head-local space) via a dedicated `apply_output_position`
+ helper - each exists for its own documented reason and would
+ otherwise silently drift apart after an output-management-driven move.
+ - Hotplug and `apply()`-driven changes share one path: `broadcast_dirty_
+ outputs`, called once a frame from `tick_dirty_broadcasts` (the same
+ "diff once a tick, only do real work on a real change" shape as
+ `foreign_toplevel`/`workspace`'s own broadcasts), diffs the live output
+ set/state against what was last sent and creates/destroys head objects
+ for anything that appeared/disappeared, re-sending current state on
+ everything else.
+ - **Live-verified end to end**, not just unit-tested: a `pywayland`-based
+ client generated from the real protocol XML, run against a nested
+ instance. Confirmed correct `head`/`mode` enumeration (name
+ `srdwm-wayland`, description, make `srdwm`/model `winit`, 8 accumulated
+ modes from the nested window's own resize history, current mode,
+ position, transform, scale) matching the actual running instance; a
+ real `create_configuration` -> `enable_head` -> `set_position(777,
+ 333)` -> `apply()` round trip returned `succeeded` and the head's
+ position genuinely changed, confirmed by the compositor pushing a
+ fresh `position` event on the same client handle; a `disable_head` ->
+ `apply()` correctly returned `failed` rather than silently doing
+ nothing. No errors or panics in the compositor log across all three
+ rounds.
+
+## Live daily-driving pass: border occlusion, real focus sync, EWMH init (added this pass)
+
+srdwm became the actual live compositor for the first time this session
+(not nested, not tested via Hyprland), which surfaced three real bugs no
+amount of nested/unit testing had caught:
+
+- **Border-bleed-through.** `smithay::desktop::space::render_output`
+ always composites `custom_elements` (this codebase's borders/decoration)
+ above *every* window's own content, with no way to interleave by real
+ stacking order - confirmed live: a cascade of terminals showed every
+ earlier window's border strip as solid lines cutting through the
+ frontmost window. Fixed with occlusion-aware clipping:
+ `srdwm_core::Rect::subtract_all` decomposes each border strip against
+ every window stacked in front of it into the sub-rectangles still
+ visible, rendered from a growable per-window pool of persistent
+ `SolidColorBuffer`s (`crates/wayland/src/elements.rs`'s
+ `visible_border_fragments`/`border_fragment_buffer`) since the fragment
+ count varies frame to frame with whatever's currently stacked above.
+- **`Platform::focus` was dead code.** Declared in the trait, implemented
+ by both Wayland backends, but never called anywhere in `main.rs`'s event
+ loop - so IPC/Lua-driven focus changes (`srd dispatch focus`,
+ scratchpad) updated `WindowManager`'s own bookkeeping but never real
+ Wayland keyboard focus or X11 `_NET_ACTIVE_WINDOW`. Caught while
+ verifying `_NET_ACTIVE_WINDOW` for a peer session's dock. Fixed by
+ wiring `sync()` (`crates/srdwm/src/main.rs`) to call `platform.focus()`
+ on the resolved focus target every tick, and by having both backends'
+ `Platform::focus` impls call the full `crate::input::focus_window` path
+ instead of only touching core state.
+- **`_NET_CLIENT_LIST` could start life stale.** Not reproduced under a
+ clean session (confirmed via a same-machine peer's repro harness: both
+ graceful close and SIGKILL prune the property within a second), but a
+ session restarting while the X root window's properties persist across
+ it was a real, cheap-to-close gap - nothing guaranteed a client only
+ ever read the property after this compositor's own first
+ `update_net_client_list()` call. `EwmhState::connect`
+ (`crates/wayland/src/xwayland.rs`) now clears `_NET_CLIENT_LIST`/
+ `_STACKING` immediately after interning the EWMH atoms, before any
+ window has ever mapped - a session can no longer start life advertising
+ windows it has never seen.
+
+None of these three were found by reading the code or by the existing
+test suite - all three came from actually running srdwm live, opening
+real windows, and checking real EWMH/render state against what a real
+client would see.
+
+## AGS dock parity: maximize/fullscreen live-resync, IME, `floating` in IPC (added this pass)
+
+Raised by the same AGS peer session porting its dock off Hyprland-only
+`hyprctl` calls, this time chasing a real user-reported symptom rather
+than a protocol trace:
+
+- **A maximized window didn't grow when a dock released its reserved
+ space.** `WindowManager::set_monitors` (`crates/core/src/manager.rs`,
+ called whenever a layer-shell client's exclusive zone changes) already
+ recomputed `Monitor::geometry`/`full_geometry` correctly, but never
+ touched the `geometry` of a window that was *already* maximized or
+ fullscreen - it kept whatever rect it was sized to at the moment it was
+ toggled on. An auto-hide dock dropping its zone to 0 while a window was
+ maximized therefore left that window stuck at its old, dock-shrunk size
+ until manually un-maximized and re-maximized. Fixed: `set_monitors` now
+ re-syncs any maximized/fullscreen window's `geometry` to its monitor's
+ current `geometry`/`full_geometry` (whichever mode it's in) in the same
+ pass. Confirmed `maximized`/`fullscreen` map deliberately onto
+ Hyprland's monocle/true-fullscreen modes respectively (`toggle_maximize`
+ targets the exclusive-zone-shrunk usable rect, `toggle_fullscreen` the
+ true full rect) - not incidental, an existing doc comment on
+ `toggle_fullscreen` already said as much. Three new regression tests.
+- **`zwp_text_input_manager_v3` + `zwp_input_method_v2`** - see
+ `docs/PANEL_SUPPORT_TODO.md`'s P2 entry for the full writeup.
+- **`floating` added to `srd clients`.** The dock's overlap check needs to
+ tell a layout-placed tiled window sitting flush against its reserved
+ edge (expected) from a user-dragged floating window actually overlapping
+ it (a real overlap) - geometry alone can't distinguish the two.
+ `Window.floating` was already a first-class field; this was a direct
+ passthrough in `crates/platform/src/ipc.rs`.
+
+## Live daily-driving pass, round 2: real stacking-order bug, titlebar occlusion gap (added this pass)
+
+Found live, on the real restarted (non-nested) session, by actually opening
+overlapping windows and screenshotting rather than reading code:
+
+- **Focus never visibly "stuck" to a window - root cause.** `main.rs`'s
+ `sync()` drove its per-window `apply_geometry`/`redraw_decoration` loop
+ off `WindowManager::visible_windows()`, which iterates a `HashMap` in
+ arbitrary order. Both of those `Platform` calls end up calling
+ `CompState::sync_geometry`, 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. Since `sync()` runs
+ on essentially any dirty event (a keystroke, a resize frame, a workspace
+ poll - many times a second in normal use), every single tick re-shuffled
+ every visible window's on-screen stacking order to whatever the `HashMap`
+ happened to yield that pass, completely unrelated to which window was
+ actually focused. Confirmed live: a freshly-launched, logically-focused
+ `gnome-calculator` window (`srd clients` reporting `focused: true`)
+ rendered entirely *behind* an older, unfocused terminal on every
+ screenshot. Fixed: `sync()` now iterates `visible_windows_front_to_back()`
+ (real stacking order) reversed, bottom-to-top, so 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 every tick. This was very likely the dominant contributor to several
+ distinct-sounding live reports this session (focus not sticking after
+ interacting with a panel, borders/decoration looking desynced from their
+ window) rather than three separate bugs.
+- **A background window's titlebar bled through a foreground window.** The
+ border-occlusion fix earlier this pass (see the section above) only ever
+ touched the border *strips* - the titlebar bitmap itself (`self.
+ decorations`, holding the title text and min/max/close buttons) was still
+ pushed into `custom_elements` completely unconditionally, with a comment
+ claiming "titlebar/content ... occlude correctly on their own paths",
+ which was true for content but never actually true for the titlebar.
+ Reported live as "still see the behind window's bar." Fixed in both
+ `udev.rs` and `winit.rs`: the titlebar push now gets the same all-or-
+ nothing occlusion test the top border strip already used (skip the draw
+ entirely once the titlebar's rect is fully covered by windows stacked in
+ front of it).
+- **Cursor appearance investigated, not a bug.** The built-in arrow (white
+ fill, black outline) renders correctly - confirmed by cropping the exact
+ same bitmap over a light background (crisp, fully outlined) versus a
+ dark terminal (outline blends into the background, leaving only the
+ white fill visible, which reads as "looks weird" without being
+ corrupted). A real but purely cosmetic contrast issue, not a rendering
+ bug; not touched this pass since it needs a considered redesign (a halo/
+ double-outline, the same technique real cursor themes use) rather than a
+ one-line fix, and the existing bitmap has its own passing shape-
+ regression tests that a hasty change could easily break.
+
+## Titlebar window menu + global menu support (added this pass)
+
+Two features, both requested directly: make the titlebar/border
+interactions "highly functional" (the user's own words, after several
+rounds of live bug reports), and start on optional global-menu support now
+that srdwm is a real, independent compositor rather than something
+adapting to an existing one.
+
+- **Right-click titlebar window menu.** Right-click on a titlebar
+ previously did nothing at all - the only right-button behaviour
+ anywhere was the SUPER+right-drag resize gesture, and only with the
+ modifier held. `context_menu.rs` (state/hit-testing, pure and unit-
+ tested) + `decoration::render_context_menu` (the pixels, also unit-
+ tested) + wiring in `input.rs`/`state.rs`/`udev.rs`/`winit.rs`. Four
+ actions - Minimize, Maximize/Restore (label reflects current state),
+ Always on Top (checkmark-prefixed once pinned), Close - no submenus, no
+ live hover highlight (would need the render buffer rebuilt on every
+ motion event over the menu; not worth the per-frame cost for a first
+ pass). A press anywhere while the menu is open resolves it (selects a
+ row) or dismisses it; neither case falls through to whatever a normal
+ click there would have done. Cleaned up if the window it belongs to
+ closes out from under it (crash, kill, or its own Close action racing
+ ahead), on both the native and XWayland removal paths.
+- **Middle-click titlebar lowers the window.** The convention several X11
+ WMs (twm, fvwm, IceWM) have always had; srdwm never did. New
+ `WindowManager::lower_window` (opposite of the existing `raise_window`,
+ same pinned-window protection via `restack_pinned`).
+- **Global menu: address over the protocol, content over D-Bus.** Per
+ design input from an AGS peer session already building the consuming
+ shell: a compositor should carry the menu's *address* (D-Bus bus name +
+ object paths) and leave the content on D-Bus, where GTK4 already
+ exports it as a real `GMenuModel` (`org.gtk.Menus`/`org.gtk.Actions`)
+ that GTK consumes natively - submenus, toggles, accelerators, icons,
+ all for free, with no model-walking/rendering code of this compositor's
+ own to have gaps in. New `srdwm_core::GlobalMenu { bus_name, menu_path,
+ app_path, window_path }`, exposed as `global_menu` on every `srd
+ clients` entry (`null` when a window hasn't exported one).
+ - **XWayland**: `EwmhState::read_global_menu` (`xwayland.rs`) reads the
+ four `_GTK_*`/`_UNITY_OBJECT_PATH` atoms straight off the X11 window,
+ refreshed on every real focus change (the properties are usually set
+ once, shortly after the client's own D-Bus registration completes,
+ which can race its window's initial map - reading only at map time
+ would miss a client that finished registering a moment later; a focus
+ change is a natural, already-existing hook, and the menu only needs
+ to be current for whichever window is actually focused anyway).
+ - **Wayland-native**: GTK's own private `gtk_shell1`/`gtk_surface1`
+ protocol, generated at compile time from a vendored copy of GTK's own
+ XML (`crates/wayland/protocols/gtk-shell.xml`) via `wayland-scanner`
+ - no published `wayland-protocols-*` crate exists for this one, so
+ it's generated the same way `wayland-protocols-wlr` generates its own
+ bindings internally, server-side only. `gtk_shell.rs` sends
+ `capabilities` (both `global_app_menu`/`global_menu_bar` bits) right
+ after a client binds the global - GTK only bothers calling
+ `set_dbus_properties` later if it saw that first - then stores
+ whatever a `gtk_surface1.set_dbus_properties` request carries onto
+ the matching `WindowId`'s `global_menu`, and clears it again if the
+ object is destroyed while the window itself stays open.
+ - **Known, expected limitation, not a bug**: even with a perfect
+ address, most non-GTK apps show nothing - the app has to actually
+ export a `GMenuModel` over D-Bus itself (`appmenu-gtk-module` for
+ real GtkMenuShell-based GTK apps; Qt and anything self-drawing its
+ own menus, LibreOffice's VCL toolkit included, generally won't).
+ Flagged by the same AGS session ahead of building the consumer, from
+ their own live testing against LibreOffice.
+ - Not yet live-verified against a real client on either path - built
+ and compiles cleanly (workspace-wide, including the new
+ `wayland-scanner`/`wayland-backend` build-time codegen dependency),
+ pending the same restart as everything else this pass.
+
+## Live daily-driving pass, round 3: real full_geometry bug, titlebar fragments, `srd monitors` (added this pass)
+
+All found live, on the real session, chasing specific user-reported
+symptoms rather than by reading code first:
+
+- **Fullscreen never actually covered a bar/dock - confirmed by
+ triggering it and reading the resulting geometry back over IPC.**
+ Landed at exactly the reserved-zone-shrunk rect on a 1920x1080 output,
+ not the true output. Root cause: all three real backends'
+ `monitors()` (`udev.rs`, `winit.rs`, `crates/x11`) construct `Monitor`
+ via `Monitor::new(id, name, rect)` where `rect` is *already*
+ zone-shrunk, and `Monitor::new` defaults `full_geometry` to whatever
+ `geometry` was constructed with - so `full_geometry` was silently
+ identical to `geometry` for every real monitor either Wayland backend
+ ever reported. `toggle_fullscreen`'s entire "ignore the reserved zone"
+ design was a no-op in practice, and the existing unit tests (which
+ construct `Monitor` by hand with the two fields genuinely different)
+ couldn't have caught it - they exercise the invariant, never the
+ construction path that was collapsing it. Fixed in `udev.rs`/
+ `winit.rs` by setting `full_geometry` separately from the true output
+ size; `crates/x11` doesn't have the same zone-shrinking step at all, so
+ it wasn't affected. Live-reverified twice more afterward: once via a
+ real `waybar` in a nested instance with `srd monitors` (see below)
+ showing `geometry`/`full_geometry` correctly split by the bar's exact
+ 36px, and independently by the AGS peer session against their own real
+ dock.
+- **Titlebar bleed-through, the sequel.** The occlusion fix from the
+ previous pass (see above) was all-or-nothing: skip the titlebar draw
+ only once *fully* covered. A titlebar only *partially* covered - the
+ common case for overlapping/cascaded windows - still drew in full,
+ bleeding through the covered part, reported live as the same "still
+ see the behind window's bar" complaint the earlier fix was supposed to
+ close. Now fragment-clipped the same way the three solid border strips
+ already were, using `MemoryRenderBufferRenderElement::from_buffer`'s
+ `src` parameter to crop the bitmap itself per visible fragment instead
+ of drawing the whole thing or nothing.
+- **`Bottom`/`Background` layer surfaces were unclickable, full stop.**
+ Found while investigating a live "clicking the dock does nothing"
+ report (the dock itself turned out to be `Layer::Top`, already
+ checked, so not the cause of that specific report - but a real,
+ separate gap all the same): `layer_surface_under` only ever checked
+ `Layer::Overlay`/`Layer::Top`; nothing in the click or motion path ever
+ checked `Bottom`/`Background` at all, so a surface at either layer
+ (a desktop-icons layer, a wallpaper daemon wanting clicks) could never
+ receive pointer input regardless of what was or wasn't covering it.
+ Fixed by checking `Bottom`/`Background` as a final fallback, after
+ windows/decoration/`Overlay`/`Top` all come up empty - correct
+ ordering, since `Bottom`/`Background` are meant to sit *behind* normal
+ windows while `Overlay`/`Top` sit in front of everything.
+- **`srd monitors`.** Requested directly by the AGS peer session after
+ two separate live-debugging rounds (the maximize-past-dock and
+ fullscreen-past-dock questions above) each took several turns of
+ indirect reasoning that reading this back directly would have settled
+ immediately. Returns `geometry` (usable, what maximize/tiling target)
+ and `full_geometry` (true output, what fullscreen targets) per
+ monitor. Live-verified with a real `waybar` in a nested instance.
+- **The `srd` CLI binary itself hadn't been reinstalled all session.**
+ Every fix this whole multi-pass session reinstalled `srdwm` (the
+ compositor); nothing had reinstalled the separate `srd` binary (the
+ control CLI) even once, so `srd monitors` above genuinely didn't exist
+ on the invoking side until this was caught and fixed. `srd` isn't a
+ long-running process, so this needed no restart to pick up - unlike
+ `srdwm` itself, every `srd` invocation from this point on already sees
+ the current binary.
## Not implemented anywhere yet
@@ -571,7 +2049,5 @@ Wayland backend section above. What is left:
- **Multi-GPU** - only the primary GPU's connectors are driven. A GPU
appearing or disappearing is logged and ignored.
-- Animations (`general.animations`/`animation_duration` config keys exist
- and are read into defaults, but nothing consumes them yet).
- A native GUI settings app (the legacy project's `GUI_SETTINGS.md` was
pure design doc even in C++; not revisited here).
diff --git a/docs/PANEL_SUPPORT_TODO.md b/docs/PANEL_SUPPORT_TODO.md
new file mode 100644
index 0000000..33a2214
--- /dev/null
+++ b/docs/PANEL_SUPPORT_TODO.md
@@ -0,0 +1,411 @@
+# Desktop-shell / panel support - TODO
+
+Findings from trying to run a GTK4 + gtk4-layer-shell panel (AGS/Astal) on srdwm.
+Everything below was measured against the **running binary built from this
+worktree at 2026-08-12 17:32**, not read out of the source - re-verified after
+that rebuild, so it is current, and reproduced both on the real session and on a
+nested `srdwm --wayland`.
+
+The C++ tree on `main` is stale and misleading for this (its
+`wayland_platform.cc` has `layer_shell_` commented out while the live binary
+advertises `zwlr_layer_shell_v1` v4) - ignore it.
+
+## What srdwm advertises today (16 globals)
+
+```
+ext_session_lock_manager_v1 (v1) wl_compositor (v5)
+wl_data_device_manager (v3) wl_output (v4)
+wl_seat (v9) wl_shm (v2)
+wl_subcompositor (v1) wp_fractional_scale_manager_v1 (v1)
+wp_viewporter (v1) xdg_wm_base (v6)
+zwlr_data_control_manager_v1 (v2) zwlr_layer_shell_v1 (v4)
+zwlr_screencopy_manager_v1 (v2) zwp_primary_selection_device_manager_v1 (v1)
+zxdg_decoration_manager_v1 (v1) zxdg_output_manager_v1 (v3)
+```
+
+Layer-shell v4, session lock, data-control and xdg-output is a good base.
+
+---
+
+## P0 - the panel cannot stay connected
+
+### 1. FIXED - smithay posted `invalid_size` on a commit after role destroy
+
+**Root cause (smithay 0.7.0, upstream) and the local workaround, both verified
+end to end on 2026-08-12 19:28. A GTK4 panel now runs on srdwm.**
+
+#### What was wrong
+
+`src/wayland/shell/wlr_layer/handlers.rs:125` registers a **pre-commit hook** on
+the `wl_surface` at `get_layer_surface` time. That hook runs on every subsequent
+commit of that surface, forever, and validates the layer-shell cached state:
+
+```rust
+if pending.size.w == 0 && !pending.anchor.anchored_horizontally() {
+ guard.surface.post_error(Error::InvalidSize,
+ "width 0 requested without setting left and right anchors");
+}
+```
+
+When the client destroys the layer surface, smithay `.reset()`s
+`LayerSurfaceCachedState` to `Default` (size `0x0`, no anchors) rather than
+removing it, and does not unregister the hook. So the next commit on that
+`wl_surface` validates the reset state, fails, posts a protocol error, and the
+connection dies.
+
+gtk4-layer-shell does create -> commit -> destroy -> commit as part of normal
+setup, which is why every GTK4 panel died and waybar (one surface, never
+destroyed) did not. Nothing to do with `wl_output` being named or nil.
+
+#### Why it could not be fixed behind the hook
+
+- Smithay's hook is registered at `get_layer_surface`, *before* our
+ `new_layer_surface` callback runs.
+- `destroyed()` calls our `layer_destroyed` and then resets the cached state
+ unconditionally, so anything set there is overwritten.
+- Smithay discards the `HookId` from its own `add_pre_commit_hook`, so its hook
+ cannot be removed.
+
+#### The fix that works: get in front of it
+
+Three facts from the 0.7.0 source make it possible:
+
+1. `add_pre_commit_hook` / `remove_pre_commit_hook` are public API.
+2. Hooks are a `Vec`, **pushed and iterated in registration order**
+ (`tree.rs`: `pre_commit_hooks.push(hook)`, `for hook in hooks`).
+3. `CompositorHandler::new_surface` fires from `wl_compositor::CreateSurface`,
+ strictly before any `get_layer_surface` on that surface.
+
+So a hook registered in `new_surface` sits at index 0 and runs *before*
+smithay's. Implemented in `crates/wayland/src/{protocols,state}.rs`:
+
+- `CompState` gains `dead_layer_surfaces: HashSet<WlSurface>`.
+- `layer_destroyed` inserts the surface.
+- `new_surface` registers a per-surface pre-commit hook that is a no-op unless
+ the surface is in that set, in which case it nudges `pending.size.w`/`.h` from
+ 0 to 1 on whichever axis is unanchored - making the state self-consistent
+ before smithay validates it.
+
+**Caveat, keep the comment in the code:** this depends on hook execution being
+registration order. That holds in 0.7.0 and it is a plain `Vec`, but it is not a
+documented API guarantee - a future smithay could reorder and this would silently
+stop working. Pin the version, and remove this once upstream fixes the root bug.
+0.7.0 (2025-06-24) is the newest release on crates.io, so there is nothing to
+bump to today.
+
+#### Verification
+
+Against the exact binary (sha256 `7faf27f1...`):
+
+```
+minimal repro (lsmin.py, raw protocol, no GTK):
+ 3 layer surfaces, no destroy SURVIVED
+ create, destroy, create SURVIVED
+ create, destroy, RECOMMIT surface, create SURVIVED <- was CONNECTION KILLED
+invalid_size errors posted: 0
+```
+
+AGS end to end on a nested srdwm: **runs, 0 errors, all 5 gtk4-layer-shell
+surfaces created (3 nil / 2 named output), 13 configures sent, 5 buffers
+attached, still alive after 60s.** Screenshot confirms the bar and dock actually
+render - clock, resources graph, tray, power button, dock icons. The workspace
+pill is correctly absent, since srdwm exposes no workspace protocol (see P1).
+
+### 2. Send a protocol error instead of closing the socket silently
+
+When the connection does go, there is **no `wl_display.error` event at all** - the
+socket simply closes. From the client that is indistinguishable from a compositor
+crash. `wl_resource_post_error` (or smithay's equivalent) on any fault would have
+turned this whole investigation into one log line.
+
+### 3. FIXED - No dmabuf - every client was forced through shm
+
+Neither `zwp_linux_dmabuf_v1` nor `wl_drm` was advertised, so no client could hand
+over a GPU buffer. GTK4 defaults to its Vulkan renderer and died:
+
+```
+vkGetPhysicalDeviceSurfaceFormatsKHR(): A surface is no longer available. (VK_ERROR_SURFACE_LOST_KHR)
+libEGL warning: failed to get driver name for fd -1
+libEGL warning: MESA-LOADER: failed to retrieve device information
+```
+
+`GSK_RENDERER=cairo` was required for any GTK4 client to get as far as the
+layer-shell hang above. On a 4-core / 3.8GB laptop, pushing every client
+through software rendering is a large permanent cost.
+
+**Fixed**: `zwp_linux_dmabuf_v1` v3 (`DmabufState::create_global`, `crates/wayland/src/protocols.rs`'s
+`DmabufHandler` impl, `delegate_dmabuf!`). The non-obvious part is that this works
+at all on the udev backend despite it rendering entirely in software
+(`PixmanRenderer`, no GPU pipeline - see `udev.rs`'s module doc comment):
+smithay's `PixmanRenderer` already implements `ImportDma`, importing a dmabuf by
+mmap'ing it and reading the pixels directly, which only works for the
+`Linear` modifier - exactly the one `PixmanRenderer::dmabuf_formats()`
+advertises. A client (GTK4 included) still allocates through its own
+EGL/gbm path against the real DRM render node - untouched by this
+compositor either way - and hands over a Linear-modifier dmabuf, which
+pixman reads straight off with no GPU involvement on the compositor side.
+`dmabuf_imported` eagerly validates by actually importing on the udev
+backend (`self.udev`'s `PixmanRenderer` is reachable from `CompState`
+directly); the winit (nested/dev) backend's `GlesRenderer` lives outside
+`CompState` (a sibling field on `WaylandPlatform`) and isn't reachable the
+same way, so buffers are accepted there without eager validation and
+imported lazily the first time they're actually rendered, same as every
+other buffer type. Went with v3 (`create_global`, a plain format list)
+over v4's `..._with_default_feedback` (which needs a `main_device` `dev_t`
+to steer multi-GPU clients toward the right render node) - a real gap
+worth closing later, not required for a single-GPU client to allocate and
+hand over a Linear-modifier buffer, which is all this backend can use
+regardless. Live-verified in an isolated nested instance (`SRDWM_NESTED=1`,
+so the session's real autostart never fired): `zwp_linux_dmabuf_v1` version
+3 shows up in a real client's (`wezterm`) registry bind over
+`WAYLAND_DEBUG=1`, and the client connects and maps a window normally with
+no protocol error or crash. Actually handing over a GPU-allocated dmabuf
+buffer end to end (GTK4 with `GSK_RENDERER` unset, in particular) is not
+yet re-verified live - this development sandbox has no working `/dev/dri`
+render node to allocate one against (`eglGetPlatformDisplay: EGL_BAD_ALLOC`
+even on the winit backend's own `GlesRenderer`), so that retest needs the
+user's real machine.
+
+---
+
+## P1 - protocols a bar needs, none of them workaroundable client-side
+
+The data never leaves the compositor, so a panel simply cannot show these.
+Current `delegate_*` set in `crates/wayland/src/`: compositor, data_control,
+data_device, fractional_scale, layer_shell, output, primary_selection, seat,
+session_lock, shm, viewporter, xdg_decoration, xdg_shell, xwayland_shell.
+
+- [x] DONE - **`zwlr_foreign_toplevel_management_v1`**'s `app_id`/`title` were
+ empty on the wire (state/activated worked) - live-verified by a peer
+ session's raw protocol capture against a running dock. Root cause:
+ `new_toplevel` fires at `xdg_surface.get_toplevel()` (role
+ assignment), which happens before a client's `set_title`/`set_app_id`/
+ first commit for essentially every real client, not racily but every
+ single time - so `new_managed_window` read `XdgToplevelSurfaceData`
+ before either was ever set. Fixed by re-reading both on every
+ `commit()` (`sync_toplevel_metadata` in `state.rs`) and re-announcing
+ to foreign-toplevel listeners when either changed.
+- [x] DONE (not yet live-verified against AGS) - **`zwlr_foreign_toplevel_management_v1`**
+ - enumerate windows with title/app_id/state, and activate/close/
+ maximize/minimize them. Went with the wlr protocol over the newer
+ `ext_foreign_toplevel_list_v1` + a separate management protocol:
+ smithay 0.7 only has a built-in helper for the `ext_` list-only half,
+ but the wlr one covers enumeration *and* interactivity in a single
+ interface, which is what was actually asked for (see the downstream
+ report this was scoped against: "click-to-focus in the dock, the
+ focused-app highlight, alt-tab actually switching, middle-click-to-
+ close" - none of that is possible from list-only enumeration alone).
+ Hand-written against `wayland-protocols-wlr`'s raw server bindings,
+ same pattern as `screencopy.rs` (`crates/wayland/src/foreign_toplevel.rs`).
+ Override-redirect windows are deliberately never announced, matching
+ `_NET_CLIENT_LIST`/ICCCM. `Activated` state is kept live on every
+ focus change (the one thing that actually needed to be, per the
+ report above).
+ DONE - maximize/fullscreen/minimize changes that originate from the
+ pointer (titlebar button, double-click) or from a client's own
+ request (`xdg_toplevel`'s `set_maximized`/`unset_maximized`/
+ `set_fullscreen`/`unset_fullscreen`/`set_minimized`, and the matching
+ X11/XWayland WM hints) now all call `foreign_toplevel::send_state`,
+ so a dock/panel sees the change regardless of which of those three
+ triggered it.
+ FIXED - the fourth trigger, a *compositor keybinding*
+ (`srd.window.maximize()`/`.fullscreen()`/`.minimize()` from the Lua
+ config), did not re-broadcast: `crates/config` only ever holds a
+ `WindowManager` reference, never `CompState`, so it cannot reach
+ `foreign_toplevel::send_state` directly. Went with the periodic-diff
+ option this entry named as the alternative to a callback: `CompState::
+ tick_dirty_broadcasts` (called once a frame from both backends'
+ `poll_events`, same cadence as the animation tween's `tick_animations`)
+ diffs `maximized`/`minimized`/`fullscreen` per window against what was
+ last sent and re-broadcasts anything that changed, regardless of which
+ call site changed it - catches this gap and any future one the same
+ way, rather than one more call site to remember. See
+ `foreign_toplevel::broadcast_dirty_state`'s doc comment.
+- [x] DONE - **`ext_workspace_manager_v1`** - enumerate workspaces,
+ which is active, and switch. This list entry was stale: the whole
+ protocol (`crates/wayland/src/workspace.rs`) was already implemented
+ and wired into both backends' globals - one flat `ext_workspace_
+ group_handle_v1` shared by every output (srdwm has no per-output
+ workspace concept), `activate` requests routed to `switch_workspace`,
+ `create_workspace`/`remove`/`assign`/`deactivate` deliberately not
+ advertised as capabilities (srdwm's workspace set is fixed at
+ startup). What *was* a real gap, found auditing every `switch_
+ workspace` call site the same way `foreign_toplevel`'s broadcast gap
+ was found: the `SUPER+scroll` workspace-cycle gesture in `input.rs`
+ called `switch_workspace` directly without calling `workspace::
+ broadcast_active_workspace` afterward, so a dock's workspace pill
+ only ever updated when a switch came through this protocol's own
+ `activate` request, going stale the moment the scroll gesture (or the
+ `srd.workspace.next()`/`.prev()`/`.switch()` Lua API, which has the
+ identical gap for the identical `crates/config`-has-no-`CompState`
+ reason `foreign_toplevel`'s keybinding gap does) changed it instead.
+ Fixed the `input.rs` scroll-gesture call site directly; the Lua-API
+ call sites are covered by the same `tick_dirty_broadcasts` fix as the
+ maximize/fullscreen/minimize keybinding gap above - `workspace::
+ broadcast_dirty_active` diffs the current workspace id once a frame
+ and only re-broadcasts on an actual change (unlike the foreign-
+ toplevel diff, calling `broadcast_active_workspace` unconditionally
+ would be real protocol traffic every frame, not a cheap comparison,
+ so this gates it first).
+- [x] FIXED (not yet re-verified live) - **`zwlr_screencopy_manager_v1`: `grim`
+ hangs on the DRM/udev session but WORKS on the nested winit backend.**
+ Root cause found by reading `render_udev_frame` in
+ `crates/wayland/src/udev.rs`: it drains *all* of
+ `CompState::screencopy_pending` once per call and services the whole
+ batch against whichever head is first in that pass's `ready` list (heads
+ with a page-flip in flight are filtered out of `ready`). If `ready` is
+ empty on a given call - every head mid-flip - the drained captures were
+ dropped at function exit with neither `ready` nor `failed` ever sent,
+ which is indistinguishable from a hang to the client. Two related bugs
+ fixed together: (1) `PendingCapture`/`FrameData` now carry the `Output`
+ the capture actually targets, and captures are partitioned per-head
+ inside the loop instead of all going to the first head (also fixes
+ multi-monitor captures reading the wrong screen's framebuffer); (2)
+ anything left unserviced after the loop - wrong head not ready this
+ pass, or the two earlier-return paths (`udev` not yet initialized,
+ session inactive after a VT switch) - is put back into
+ `screencopy_pending` instead of dropped, so the next call gets another
+ chance rather than the client waiting forever. Needs a `grim` retest
+ against the real DRM session to confirm.
+- [x] DONE - **`xdg_activation_v1`** - so a launcher can raise the app it just
+ spawned instead of it opening unfocused behind everything.
+ `XdgActivationState` + `XdgActivationHandler` (`crates/wayland/src/protocols.rs`),
+ `delegate_xdg_activation!`. No token bookkeeping of our own - the
+ default `token_created` already accepts every token, fine for a
+ single-user session with no cross-client trust boundary to enforce.
+ `request_activation` maps the activating surface to a `WindowId` via
+ `surface_to_id` and reuses the same `focus_window` path a dock's
+ foreign-toplevel "activate" request already goes through. A no-op,
+ not an error, if the surface isn't tracked yet (activation racing
+ ahead of the window's own mapping) - the protocol doesn't require
+ honoring every activation. Live-verified in an isolated nested
+ instance: `xdg_activation_v1` version 1 advertises correctly, a real
+ client connects and maps normally, no crash.
+
+---
+
+## P2 - full-desktop quality of life
+
+- [x] DONE (built, tested, installed; not yet live-verified against a real
+ lock daemon) - **`ext_idle_notify_v1` + `zwp_idle_inhibit_manager_v1`**
+ - idle lock, and inhibiting it during video playback. Both use
+ smithay's own built-in modules; see `docs/IMPLEMENTATION_STATUS.md`
+ for the full writeup, including the one real gap (not workspace-
+ visibility-aware) and the winit-backend calloop-loop wrinkle this
+ needed to work around.
+- [x] DONE (built, tested, live-verified end to end in a nested instance
+ against a real protocol client) - **`zwlr_output_management_v1`** -
+ display arrangement/scale from a settings panel. `crates/wayland/src/
+ output_management.rs`. Not `Option`-gated like DPMS/gamma-control --
+ enumeration and applying position/scale/transform work identically
+ on both backends via `Output::change_current_state`. Deliberately
+ does not support disabling/enabling a head or switching resolution/
+ refresh rate (real DRM mode-setting, hardware-dependent work this
+ pass didn't attempt) - both fail honestly (`failed` event) rather
+ than silently no-op. Live-verified with a `pywayland`-based test
+ client generated from the real protocol XML: binds the manager,
+ receives correct `head`/`mode` events (name, description, make,
+ model, 8 accumulated modes, current mode, position, transform,
+ scale) matching the running nested instance; `create_configuration`
+ -> `enable_head` -> `set_position(777, 333)` -> `apply()` returns
+ `succeeded` and the head's position genuinely changes (confirmed via
+ a pushed `position` event on the same handle); `disable_head` ->
+ `apply()` correctly returns `failed` rather than silently doing
+ nothing.
+- [x] DONE (built, tested; not yet live-verified against real hardware --
+ see `docs/IMPLEMENTATION_STATUS.md`) - **`zwlr_output_power_management_v1`**
+ - DPMS on/off per output, for an idle daemon or settings panel.
+ `crates/wayland/src/output_power.rs`, udev/DRM backend only.
+- [x] DONE (built, tested; not yet live-verified against real hardware --
+ see `docs/IMPLEMENTATION_STATUS.md`) - **`zwlr_gamma_control_manager_v1`**
+ - night light / `gammastep`/`wlsunset`. `crates/wayland/src/
+ gamma_control.rs`, udev/DRM backend only.
+- [ ] `zwlr_virtual_pointer_manager_v1` + `zwp_virtual_keyboard_manager_v1` -
+ required by `ydotool` and by any automated UI testing.
+- [x] DONE - **`wp_cursor_shape_manager_v1`** - `protocols.rs`'s
+ `TabletSeatHandler` doc comment and `delegate_cursor_shape!(CompState)`
+ confirm this was already wired up (routes into `SeatHandler::
+ cursor_image`, same path a client-drawn cursor surface uses); this
+ list entry had simply never been updated. What actually needed work
+ was `cursor.rs`'s *art*, not the protocol - see `IMPLEMENTATION_
+ STATUS.md`'s entry on the new crosshair/move/pointer-hand bitmaps.
+- [ ] `zwp_pointer_constraints_v1` + `zwp_relative_pointer_manager_v1` - games.
+- [ ] `wp_presentation` - accurate frame timing.
+- [x] DONE (built, tested; not yet live-verified against a real IME) -
+ **`zwp_text_input_manager_v3` + `zwp_input_method_v2`** - IME (fcitx5,
+ ibus: CJK/dead-key composition, emoji picker). Both are smithay
+ built-ins (`TextInputManagerState`/`InputMethodManagerState`),
+ registered in `state.rs`/`udev.rs`/`winit.rs`; `InputMethodHandler`
+ impl in `protocols.rs`. Focus tracking needed no wiring at all --
+ `CompState::KeyboardFocus` is a plain `WlSurface`, and smithay's own
+ blanket `impl KeyboardTarget for WlSurface` already drives `seat.
+ text_input()`/`seat.input_method()` from inside `enter`/`leave`,
+ which `set_keyboard_focus`'s existing `keyboard.set_focus(...)` call
+ already triggers. The IME's own composition/candidate popup is
+ tracked as a `PopupKind::InputMethod` in the same `PopupManager`
+ that already owns every `xdg_popup` - `elements.rs`'s
+ `popup_render_elements` renders both kinds identically, so the popup
+ actually draws rather than existing invisibly.
+- [ ] `wp_single_pixel_buffer_v1`, `xdg_foreign_v2`.
+
+---
+
+## P3 - integration, not protocol
+
+- [x] DONE - **An IPC/CLI.** `srd` (`crates/ctl/`) talks to a
+ per-display Unix socket (`$XDG_RUNTIME_DIR/srdwm-<display>.sock`,
+ `crates/platform/src/ipc.rs`'s `IpcServer` - moved out of
+ `crates/wayland` since it never touched anything Wayland-specific,
+ so both backends can share one implementation - polled once per
+ tick in all three backends now) with a JSON protocol: `clients`
+ (id/app_id/title/workspace/focused/minimized/visible/scratchpad/
+ floating/x/y/width/height per window - `floating` added for an AGS
+ dock's auto-hide overlap check, which needs to tell a layout-placed
+ tiled window flush against its reserved edge from a user-dragged
+ floating one actually overlapping it; geometry alone can't
+ distinguish the two), `dispatch <toggle_visibility|focus|
+ close> <id>`, and now `subscribe` - a full event-stream like `niri
+ msg -j`'s: the one connection stays open and gets pushed a fresh
+ `clients` snapshot every time the window list actually changes,
+ instead of a client having to poll and diff it themselves. This was
+ the single highest-leverage gap found comparing srdwm's IPC against
+ sway/Hyprland/i3/bspwm's own (all of which already have this) --
+ an AGS peer session had hit exactly this wall building a Python
+ helper to poll raw `wlr-foreign-toplevel` instead of using this
+ socket at all. Live-verified end to end against a real client
+ (Alacritty) in a nested instance. **X11 previously had no IPC server
+ of any kind** - the crate move fixed that too, along with
+ `X11Platform::poll_events` used to block indefinitely on
+ `wait_for_event()`, which would have left the socket unresponsive
+ between X11 events; now a bounded `poll(2)` (~16ms).
+- [x] DONE (not yet live - built and installed, pending restart) - **Set
+ `XDG_CURRENT_DESKTOP=srdwm`** for the session. Set in `udev.rs`
+ (deliberately not `winit.rs`, since that's the nested/dev backend) --
+ no longer inherited from whatever compositor the session was
+ previously started under.
+- [x] DONE (already correct, no change needed) - **Remove runtime sockets
+ on exit.** `IpcServer`'s `Drop` impl already removes its socket path
+ unconditionally; verified while auditing `foreign_toplevel`/protocol
+ cleanup paths this pass.
+- [ ] `jq: parse error: Invalid numeric literal at line 1, column 28` repeats
+ continuously in `~/.local/state/wm-session-latest.log` - something in the
+ session scripts pipes non-JSON into `jq` in a loop. Harmless, but it buries
+ real errors.
+
+---
+
+## How to reproduce
+
+```sh
+# nested, so it doesn't disturb a running session
+WAYLAND_DISPLAY=wayland-0 srdwm --wayland &
+
+# panel, forced onto the software renderer (see P0.3)
+WAYLAND_DISPLAY=wayland-1 GSK_RENDERER=cairo WAYLAND_DEBUG=1 ags run 2>&1 | tee trace.log
+
+# the whole bug in one number - this prints 0, and must not:
+grep -E '^\[[0-9]' trace.log | grep -v ' -> ' | grep -c layer_surface
+```
+
+For comparison, the same panel on niri: runs clean with zero errors and three
+mapped `gtk4-layer-shell` surfaces.