diff options
| -rw-r--r-- | Cargo.lock | 315 | ||||
| -rw-r--r-- | config/srd/rules.lua | 17 | ||||
| -rw-r--r-- | crates/config/src/lib.rs | 277 | ||||
| -rw-r--r-- | crates/core/src/keysyms.rs (renamed from crates/x11/src/keysyms.rs) | 0 | ||||
| -rw-r--r-- | crates/core/src/lib.rs | 5 | ||||
| -rw-r--r-- | crates/core/src/manager.rs | 81 | ||||
| -rw-r--r-- | crates/core/src/rules.rs | 85 | ||||
| -rw-r--r-- | crates/platform/src/lib.rs | 61 | ||||
| -rw-r--r-- | crates/wayland/Cargo.toml | 14 | ||||
| -rw-r--r-- | crates/wayland/src/decoration.rs | 191 | ||||
| -rw-r--r-- | crates/wayland/src/lib.rs | 225 | ||||
| -rw-r--r-- | crates/wayland/src/udev.rs | 554 | ||||
| -rw-r--r-- | crates/wayland/src/xwayland.rs | 240 | ||||
| -rw-r--r-- | crates/x11/src/lib.rs | 8 | ||||
| -rw-r--r-- | docs/DEFAULTS.md | 46 | ||||
| -rw-r--r-- | docs/IMPLEMENTATION_STATUS.md | 161 |
16 files changed, 2163 insertions, 117 deletions
@@ -34,6 +34,12 @@ dependencies = [ ] [[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] name = "android-activity" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -198,6 +204,20 @@ name = "bytemuck" version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "bytes" @@ -477,18 +497,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] name = "drm-fourcc" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" [[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] name = "env_filter" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -546,6 +609,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser", +] + +[[package]] name = "foreign-types" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -652,12 +731,29 @@ dependencies = [ [[package]] name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" @@ -669,7 +765,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "input" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9" +dependencies = [ + "bitflags 2.13.0", + "input-sys", + "libc", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", ] [[package]] @@ -817,6 +942,36 @@ dependencies = [ ] [[package]] +name = "libseat" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6656c69b6e0366ffb83faa232f3e4fda5fc91161722d140f2c95ce2e90b1ef31" +dependencies = [ + "errno", + "libseat-sys", + "log", +] + +[[package]] +name = "libseat-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a484fa48be5f62a8d3ffed767779d0ab808cfead91de98ef4362d469097dfb" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -824,6 +979,12 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" @@ -1181,6 +1342,12 @@ dependencies = [ ] [[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1213,6 +1380,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] +name = "pixman" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea217d496c19ac0a8e502b37078e1f683d16344adee9eb247a5d57c165e1edf" +dependencies = [ + "drm-fourcc", + "paste", + "pixman-sys", + "thiserror 1.0.69", +] + +[[package]] +name = "pixman-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0483e89e81d7915defe83c51f23f6800594d64f6f4a21253ce87fd8444ada" + +[[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1232,7 +1417,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", "rustix 1.1.4", "windows-sys 0.61.2", @@ -1474,6 +1659,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1566,20 +1757,28 @@ dependencies = [ "cgmath", "cursor-icon", "downcast-rs", + "drm", + "drm-ffi", "drm-fourcc", + "encoding_rs", "errno", "gl_generator", "indexmap", + "input", "libc", "libloading", + "libseat", + "pixman", "profiling", "rand", "rustix 1.1.4", + "scopeguard", "sha2", "smallvec", "tempfile", "thiserror 2.0.18", "tracing", + "udev", "wayland-client", "wayland-cursor", "wayland-egl", @@ -1588,6 +1787,7 @@ dependencies = [ "wayland-protocols-wlr", "wayland-server", "winit", + "x11rb", "xkbcommon", ] @@ -1684,6 +1884,7 @@ dependencies = [ name = "srdwm-wayland" version = "0.1.0" dependencies = [ + "fontdue", "log", "smithay", "srdwm-core", @@ -1839,12 +2040,30 @@ dependencies = [ ] [[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2137,7 +2356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ "windows-core", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2150,7 +2369,7 @@ dependencies = [ "windows-interface", "windows-result", "windows-strings", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2187,7 +2406,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2197,7 +2416,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ "windows-result", - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", ] [[package]] @@ -2206,7 +2434,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2215,7 +2443,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2229,34 +2457,67 @@ dependencies = [ [[package]] name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" @@ -2269,24 +2530,48 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/config/srd/rules.lua b/config/srd/rules.lua index 576333a..9435f69 100644 --- a/config/srd/rules.lua +++ b/config/srd/rules.lua @@ -1,6 +1,13 @@ -- Window rules (match-by-title/class -> action). +- Window rules (match-by-title/class -> action), applied once when a +- matching window is first created. -- -- A dedicated `srd.rule(...)` API is not implemented yet - see -- docs/IMPLEMENTATION_STATUS.md. For now, achieve the same effect from -- srd.bind()'d keys or by reacting to srd.window.focused() in your own -- functions. This file is intentionally a no-op placeholder. +- srd.rule(matcher, actions) +-- matcher: { title = "substring, case-insensitive", class = "exact app_id/WM_CLASS, case-insensitive" } +-- at least one of title/class is required - an empty matcher matches nothing. +-- actions: { floating = bool, maximized = bool, workspace = id, +-- x = .., y = .., width = .., height = .., +-- decorated = bool, border_color = {r,g,b}, border_width = px } +-- +- Examples: +- srd.rule({ class = "pavucontrol" }, { floating = true }) +- srd.rule({ title = "picture-in-picture" }, { floating = true, width = 480, height = 270 }) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2cfa7e3..911f259 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -17,7 +17,7 @@ mod value; pub use value::ConfigValue; use mlua::{Lua, RegistryKey, Table, Value}; -use srdwm_core::{Direction, WindowManager}; +use srdwm_core::{Direction, Rect, WindowManager, WindowMatch, WindowRule, WindowRuleActions}; use std::cell::RefCell; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -30,6 +30,7 @@ struct SharedState { config_dir: PathBuf, log: Vec<String>, running: Rc<std::cell::Cell<bool>>, + profile_start: Option<std::time::Instant>, } /// Owns the Lua interpreter and the `srd` module state. Cheap to keep around @@ -62,6 +63,7 @@ impl Engine { config_dir: config_dir.into(), log: Vec::new(), running: Rc::new(std::cell::Cell::new(true)), + profile_start: None, })); let engine = Self { lua, state }; engine.register_srd_module()?; @@ -138,10 +140,20 @@ impl Engine { srd.set("reset_all", self.fn_reset_all()?)?; srd.set("reset_category", self.fn_reset_category()?)?; srd.set("bind", self.fn_bind()?)?; + srd.set("rule", self.fn_rule()?)?; srd.set("load", self.fn_load()?)?; srd.set("spawn", self.fn_spawn()?)?; srd.set("notify", self.fn_notify()?)?; srd.set("quit", self.fn_quit()?)?; + srd.set("validate_config", self.fn_validate_config()?)?; + + let debug = lua.create_table()?; + debug.set("config_status", self.fn_debug_config_status()?)?; + debug.set("validate_config", self.fn_validate_config()?)?; + debug.set("show_settings", self.fn_debug_show_settings()?)?; + debug.set("profile_start", self.fn_debug_profile_start()?)?; + debug.set("profile_stop", self.fn_debug_profile_stop()?)?; + srd.set("debug", debug)?; let window = lua.create_table()?; window.set("focused", self.fn_window_focused()?)?; @@ -266,6 +278,51 @@ impl Engine { })?) } + /// `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. + fn fn_rule(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |_, (matcher, actions): (Table, Table)| { + let title_contains: Option<String> = matcher.get("title")?; + let class: Option<String> = match matcher.get("class")? { + Some(c) => Some(c), + None => matcher.get("app_id")?, + }; + + 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)?)), + None => None, + }; + let geometry: Option<Rect> = { + let x: Option<i32> = actions.get("x")?; + let y: Option<i32> = actions.get("y")?; + let width: Option<u32> = actions.get("width")?; + let height: Option<u32> = actions.get("height")?; + match (x, y, width, height) { + (Some(x), Some(y), Some(width), Some(height)) => Some(Rect::new(x, y, width, height)), + _ => None, + } + }; + + let rule = WindowRule { + matcher: WindowMatch { title_contains, class }, + actions: WindowRuleActions { + floating: actions.get("floating")?, + maximized: actions.get("maximized")?, + workspace: actions.get("workspace")?, + geometry, + decorated: actions.get("decorated")?, + border_color, + border_width: actions.get("border_width")?, + }, + }; + state.borrow().wm.borrow_mut().add_rule(rule); + Ok(()) + })?) + } + fn fn_load(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); Ok(self.lua.create_function(move |lua, module: String| { @@ -319,6 +376,73 @@ impl Engine { })?) } + /// Checks the numeric/string ranges documented in `docs/DEFAULTS.md`'s + /// "Validation Rules" section. Returns `(ok, errors)`; `errors` is an + /// empty table when `ok` is true. + fn fn_validate_config(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |lua, ()| { + let s = state.borrow(); + let errors = validate(&s); + let ok = errors.is_empty(); + Ok((ok, lua.create_sequence_from(errors)?)) + })?) + } + + fn fn_debug_config_status(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |lua, ()| { + let s = state.borrow(); + let t = lua.create_table()?; + t.set("keys", s.values.len())?; + t.set("bound_keys", s.key_bindings.len())?; + t.set("log_entries", s.log.len())?; + t.set("config_dir", s.config_dir.to_string_lossy().into_owned())?; + Ok(t) + })?) + } + + fn fn_debug_show_settings(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |lua, ()| { + let s = state.borrow(); + let mut keys: Vec<&String> = s.values.keys().collect(); + keys.sort(); + let t = lua.create_table()?; + for key in keys { + let v = &s.values[key]; + log::info!("{key} = {v:?}"); + let lua_v = match v { + ConfigValue::String(s) => Value::String(lua.create_string(s)?), + ConfigValue::Number(n) => Value::Number(*n), + ConfigValue::Bool(b) => Value::Boolean(*b), + ConfigValue::List(items) => Value::Table(lua.create_sequence_from(items.clone())?), + }; + t.set(key.as_str(), lua_v)?; + } + Ok(t) + })?) + } + + fn fn_debug_profile_start(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |_, ()| { + state.borrow_mut().profile_start = Some(std::time::Instant::now()); + Ok(()) + })?) + } + + fn fn_debug_profile_stop(&self) -> Result<mlua::Function<'_>> { + let state = self.state.clone(); + Ok(self.lua.create_function(move |_, ()| { + let elapsed = state.borrow_mut().profile_start.take().map(|t| t.elapsed().as_secs_f64()); + if let Some(secs) = elapsed { + log::info!("profile: {:.3}ms", secs * 1000.0); + } + Ok(elapsed) + })?) + } + // ---- srd.window.* ------------------------------------------------------ fn fn_window_focused(&self) -> Result<mlua::Function<'_>> { @@ -546,6 +670,70 @@ fn flatten_table_into(prefix: &str, table: &Table, out: &mut HashMap<String, Con Ok(()) } +/// Checks the numeric ranges, layout-name references, and hex-color strings +/// documented in `docs/DEFAULTS.md`'s "Validation Rules" section against the +/// current config values. Returns a human-readable error per violation. +fn validate(s: &SharedState) -> Vec<String> { + let mut errors = Vec::new(); + + let mut check_range = |key: &str, min: f64, max: f64| { + if let Some(v) = s.values.get(key).and_then(ConfigValue::as_f64) { + if v < min || v > max { + errors.push(format!("{key} = {v} is out of range [{min}, {max}]")); + } + } + }; + check_range("general.window_gap", 0.0, 100.0); + check_range("layout.tiling.gaps.inner", 0.0, 100.0); + check_range("layout.tiling.gaps.outer", 0.0, 100.0); + check_range("layout.dynamic.gaps.inner", 0.0, 100.0); + check_range("layout.dynamic.gaps.outer", 0.0, 100.0); + check_range("layout.floating.gaps.inner", 0.0, 100.0); + check_range("layout.floating.gaps.outer", 0.0, 100.0); + check_range("general.border_width", 0.0, 20.0); + check_range("theme.decorations.border.width", 0.0, 20.0); + check_range("general.animation_duration", 0.0, 1000.0); + check_range("performance.max_fps", 30.0, 240.0); + check_range("performance.window_cache_size", 10.0, 10000.0); + + let layouts: Vec<String> = s.wm.borrow().available_layouts().iter().map(|l| l.to_string()).collect(); + for key in ["general.default_layout", "monitor.primary_layout", "monitor.secondary_layout"] { + if let Some(name) = s.values.get(key).and_then(ConfigValue::as_str) { + if !layouts.iter().any(|l| l == name) { + errors.push(format!("{key} = '{name}' is not a registered layout {layouts:?}")); + } + } + } + + let color_keys = [ + "theme.colors.background", + "theme.colors.foreground", + "theme.colors.primary", + "theme.colors.secondary", + "theme.colors.accent", + "theme.colors.error", + "theme.colors.warning", + "theme.colors.success", + "theme.decorations.border.active_color", + "theme.decorations.border.inactive_color", + "theme.decorations.title_bar.background", + "theme.decorations.title_bar.foreground", + ]; + for key in color_keys { + if let Some(v) = s.values.get(key).and_then(ConfigValue::as_str) { + if !is_valid_hex_color(v) { + errors.push(format!("{key} = '{v}' is not a valid hex color (expected '#rrggbb')")); + } + } + } + + errors +} + +fn is_valid_hex_color(s: &str) -> bool { + s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit()) +} + /// The config surface documented in `docs/DEFAULTS.md`, seeded before /// `init.lua` runs so `srd.get(...)` returns sensible values even for keys /// the user's config never touches. @@ -778,6 +966,93 @@ mod tests { } #[test] + fn validate_config_passes_on_untouched_defaults() { + let dir = tempfile::tempdir().unwrap(); + let engine = engine_in(dir.path()); + engine + .lua + .load(r#"local ok, errs = srd.validate_config(); assert(ok, table.concat(errs, "; "))"#) + .exec() + .unwrap(); + } + + #[test] + fn validate_config_flags_out_of_range_gap_and_bad_color() { + let dir = tempfile::tempdir().unwrap(); + let engine = engine_in(dir.path()); + engine + .lua + .load( + r#" + srd.set("general.window_gap", 500) + srd.set("theme.colors.background", "not-a-color") + local ok, errs = srd.validate_config() + assert(ok == false) + assert(#errs == 2, "expected 2 errors, got " .. #errs) + "#, + ) + .exec() + .unwrap(); + } + + #[test] + fn validate_config_flags_unregistered_layout_name() { + let dir = tempfile::tempdir().unwrap(); + let engine = engine_in(dir.path()); + engine + .lua + .load( + r#" + srd.set("general.default_layout", "nonexistent") + local ok, errs = srd.validate_config() + assert(ok == false) + "#, + ) + .exec() + .unwrap(); + } + + #[test] + fn debug_namespace_reports_status_and_profiles() { + let dir = tempfile::tempdir().unwrap(); + let engine = engine_in(dir.path()); + engine + .lua + .load( + r#" + local status = srd.debug.config_status() + assert(status.keys > 0) + srd.debug.profile_start() + local elapsed = srd.debug.profile_stop() + assert(type(elapsed) == "number") + local settings = srd.debug.show_settings() + assert(settings["general.window_gap"] == 8) + "#, + ) + .exec() + .unwrap(); + } + + #[test] + fn srd_rule_floats_matching_window_on_creation() { + 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 = "calculator" }, { floating = true })"#) + .exec() + .unwrap(); + let id = { + let mut wm = wm.borrow_mut(); + let id = wm.alloc_window_id(); + wm.add_window(srdwm_core::Window::new(id, "Calculator")); + id + }; + assert!(wm.borrow().is_floating(id)); + } + + #[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/x11/src/keysyms.rs b/crates/core/src/keysyms.rs index 0baa6bc..0baa6bc 100644 --- a/crates/x11/src/keysyms.rs +++ b/crates/core/src/keysyms.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a23e7d6..0fc2fad 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,17 +1,20 @@ pub mod event; pub mod geometry; +pub mod keysyms; pub mod layout; pub mod manager; pub mod monitor; pub mod placement; +pub mod rules; pub mod window; pub mod workspace; -pub use event::{Event, MouseButton, Modifiers}; +pub use event::{key_combo_string, 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 rules::{WindowMatch, WindowRule, WindowRuleActions}; pub use window::{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 1fdd7eb..1f69413 100644 --- a/crates/core/src/manager.rs +++ b/crates/core/src/manager.rs @@ -2,6 +2,7 @@ use crate::geometry::Rect; use crate::layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig}; use crate::monitor::{Monitor, MonitorId}; use crate::placement::{PlacementConfig, SmartPlacement, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH}; +use crate::rules::WindowRule; use crate::window::{ResizeEdge, TitlebarHit, Window, WindowId}; use crate::workspace::{Workspace, WorkspaceId}; use std::collections::HashMap; @@ -47,6 +48,7 @@ pub struct WindowManager { pub placement: PlacementConfig, drag: Option<DragState>, resize: Option<ResizeState>, + rules: Vec<WindowRule>, } impl Default for WindowManager { @@ -76,9 +78,16 @@ impl WindowManager { placement: PlacementConfig::default(), drag: None, resize: None, + rules: Vec::new(), } } + /// Registers a window rule; on every subsequent `add_window`, the first + /// rule whose matcher matches the new window has its actions applied. + pub fn add_rule(&mut self, rule: WindowRule) { + self.rules.push(rule); + } + pub fn register_layout(&mut self, name: impl Into<String>, layout: Box<dyn Layout>) { self.layouts.insert(name.into(), layout); } @@ -119,19 +128,45 @@ 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; - window.workspace = self.current_workspace; + let actions = self.rules.iter().find(|r| r.matcher.matches(&window)).map(|r| r.actions.clone()); + + let workspace = actions.as_ref().and_then(|a| a.workspace).unwrap_or(self.current_workspace); + window.workspace = workspace; + if let Some(a) = &actions { + if let Some(floating) = a.floating { + window.floating = floating; + } + if let Some(decorated) = a.decorated { + window.decorated = decorated; + } + if let Some(color) = a.border_color { + window.border_color = color; + } + if let Some(width) = a.border_width { + window.border_width = width; + } + } + if let Some(monitor) = self.primary_monitor() { window.monitor = monitor.id; - let layout_name = self.workspace(self.current_workspace).map(|w| w.layout.clone()).unwrap_or_default(); + let layout_name = self.workspace(workspace).map(|w| w.layout.clone()).unwrap_or_default(); if layout_name != "tiling" { - let existing: Vec<Rect> = self.windows_on_workspace(self.current_workspace).map(|w| w.geometry).collect(); + let existing: Vec<Rect> = self.windows_on_workspace(workspace).map(|w| w.geometry).collect(); let size = (window.geometry.width, window.geometry.height); window.geometry = SmartPlacement::place(monitor, &existing, size, &self.placement); } } + if let Some(geometry) = actions.as_ref().and_then(|a| a.geometry) { + window.geometry = geometry; + } + let maximize = actions.as_ref().and_then(|a| a.maximized).unwrap_or(false); + self.windows.insert(id, window); self.order.push(id); self.focused = Some(id); + if maximize { + self.toggle_maximize(id); + } id } @@ -711,6 +746,46 @@ mod tests { } #[test] + fn matching_rule_floats_new_window_on_add() { + 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 }, + actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() }, + }); + let id = wm.alloc_window_id(); + wm.add_window(Window::new(id, "Calculator")); + assert!(wm.is_floating(id)); + } + + #[test] + 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 }, + actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() }, + }); + let id = wm.alloc_window_id(); + wm.add_window(Window::new(id, "Terminal")); + assert!(!wm.is_floating(id)); + } + + #[test] + fn rule_assigns_window_to_target_workspace() { + 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()) }, + actions: crate::rules::WindowRuleActions { workspace: Some(target), ..Default::default() }, + }); + let id = wm.alloc_window_id(); + let mut w = Window::new(id, "notes"); + w.app_id = "scratchpad".into(); + wm.add_window(w); + assert_eq!(wm.window(id).unwrap().workspace, target); + } + + #[test] fn removing_a_workspace_reassigns_its_windows() { let mut wm = wm_with_monitor(); let ws2 = wm.add_workspace("2", "dynamic"); diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs new file mode 100644 index 0000000..4d881a5 --- /dev/null +++ b/crates/core/src/rules.rs @@ -0,0 +1,85 @@ +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"). +#[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`). + pub class: Option<String>, +} + +impl WindowMatch { + pub fn is_empty(&self) -> bool { + self.title_contains.is_none() && self.class.is_none() + } + + pub fn matches(&self, window: &Window) -> bool { + if self.is_empty() { + return false; + } + if let Some(t) = &self.title_contains { + if !window.title.to_lowercase().contains(&t.to_lowercase()) { + return false; + } + } + if let Some(c) = &self.class { + if !window.app_id.eq_ignore_ascii_case(c) { + return false; + } + } + true + } +} + +/// Actions applied once, when a matching window is first added. +#[derive(Debug, Clone, Default)] +pub struct WindowRuleActions { + pub floating: Option<bool>, + pub maximized: Option<bool>, + pub workspace: Option<WorkspaceId>, + pub geometry: Option<Rect>, + pub decorated: Option<bool>, + pub border_color: Option<(u8, u8, u8)>, + pub border_width: Option<u32>, +} + +#[derive(Debug, Clone, Default)] +pub struct WindowRule { + pub matcher: WindowMatch, + pub actions: WindowRuleActions, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_matcher_matches_nothing() { + let w = Window::new(1, "anything"); + assert!(!WindowMatch::default().matches(&w)); + } + + #[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 }; + assert!(m.matches(&w)); + } + + #[test] + 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()) }; + assert!(m.matches(&w)); + let mut w2 = Window::new(2, ""); + w2.app_id = "firefoxx".into(); + assert!(!m.matches(&w2)); + } +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index fdad291..23854e8 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -32,6 +32,14 @@ impl PlatformKind { /// Chooses a backend the way the legacy `PlatformFactory` did: prefer /// Wayland when a compositor is reachable, fall back to X11, otherwise use /// the compile-time native backend on Windows/macOS. +/// +/// X11 is only chosen when there's actual evidence of a running X server +/// (`DISPLAY` set) and no Wayland evidence - `srdwm_x11::X11Platform` +/// only ever *connects to* an existing server (Xephyr, or the real system +/// Xorg started separately), it never spawns one itself. Every other case, +/// including a bare TTY with neither env var set, resolves to Wayland: +/// `srdwm_wayland::connect` is the only backend that can run standalone +/// there, via its udev/DRM backend. pub fn detect() -> PlatformKind { #[cfg(target_os = "windows")] { @@ -43,13 +51,52 @@ pub fn detect() -> PlatformKind { } #[cfg(all(unix, not(target_os = "macos")))] { - let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some() - || std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false); - if wayland { - PlatformKind::Wayland - } else { - PlatformKind::X11 - } + detect_unix( + std::env::var_os("WAYLAND_DISPLAY").is_some(), + std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false), + std::env::var_os("DISPLAY").is_some(), + ) + } +} + +/// The env-var decision logic behind [`detect`]'s unix branch, pulled out +/// as a pure function so it's testable without mutating real process env +/// vars (which would be racy across parallel test threads). +#[cfg(all(unix, not(target_os = "macos")))] +fn detect_unix(wayland_display: bool, xdg_session_type_wayland: bool, display: bool) -> PlatformKind { + let wayland_evidence = wayland_display || xdg_session_type_wayland; + if display && !wayland_evidence { + PlatformKind::X11 + } else { + PlatformKind::Wayland + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_set_alone_picks_x11() { + assert_eq!(detect_unix(false, false, true), PlatformKind::X11); + } + + #[test] + fn bare_tty_with_nothing_set_picks_wayland() { + assert_eq!(detect_unix(false, false, false), PlatformKind::Wayland); + } + + #[test] + fn wayland_display_set_picks_wayland_even_if_display_also_set() { + // XWayland-style setups often have both DISPLAY and WAYLAND_DISPLAY + // set; Wayland should win. + assert_eq!(detect_unix(true, false, true), PlatformKind::Wayland); + } + + #[test] + fn xdg_session_type_wayland_picks_wayland() { + assert_eq!(detect_unix(false, true, false), PlatformKind::Wayland); } } diff --git a/crates/wayland/Cargo.toml b/crates/wayland/Cargo.toml index d407bfb..4602608 100644 --- a/crates/wayland/Cargo.toml +++ b/crates/wayland/Cargo.toml @@ -10,8 +10,20 @@ srdwm-core.workspace = true srdwm-platform.workspace = true log.workspace = true thiserror.workspace = true +fontdue = "0.9" [dependencies.smithay] version = "0.7" default-features = false -features = ["backend_winit", "renderer_gl", "desktop", "wayland_frontend"] +features = [ + "backend_winit", + "backend_drm", + "backend_libinput", + "backend_udev", + "backend_session_libseat", + "renderer_gl", + "renderer_pixman", + "desktop", + "wayland_frontend", + "xwayland", +] diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs new file mode 100644 index 0000000..fc722b7 --- /dev/null +++ b/crates/wayland/src/decoration.rs @@ -0,0 +1,191 @@ +//! Software rasterization of the titlebar band: solid background + drawn +//! title text, as a BGRA8 pixel buffer (the byte order `Fourcc::Argb8888` +//! expects when uploaded via `smithay`'s `GlesRenderer`, see +//! `format::gl_internal_format` - it maps to `GL_BGRA_EXT`/`GL_UNSIGNED_BYTE`). +//! +//! Deliberately has zero `smithay` dependency: it's a pure `(width, height, +//! text) -> Vec<u8>` function, unit-testable without a GL context or +//! display, with a thin adapter in `lib.rs` uploading the result into a +//! `MemoryRenderBuffer`. + +use fontdue::{Font, FontSettings}; +use std::sync::OnceLock; + +const FONT_PIXELS: f32 = 13.0; +const TEXT_LEFT_PADDING: f32 = 8.0; + +/// 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 +/// as before text rendering existed. +fn find_system_font() -> Option<Font> { + static FONT: OnceLock<Option<Font>> = OnceLock::new(); + FONT.get_or_init(load_any_monospace_font).clone() +} + +fn load_any_monospace_font() -> Option<Font> { + let roots = ["/usr/share/fonts", "/usr/local/share/fonts"]; + let mut home_roots = Vec::new(); + if let Ok(home) = std::env::var("HOME") { + home_roots.push(format!("{home}/.local/share/fonts")); + home_roots.push(format!("{home}/.fonts")); + } + let all_roots = roots.iter().map(|s| s.to_string()).chain(home_roots); + + let mut best: Option<std::path::PathBuf> = None; + for root in all_roots { + find_ttf_preferring_mono(std::path::Path::new(&root), &mut best); + if best.is_some() { + break; + } + } + let path = best?; + let bytes = std::fs::read(&path).ok()?; + match Font::from_bytes(bytes, FontSettings::default()) { + Ok(f) => { + log::info!("wayland titlebar font: {}", path.display()); + Some(f) + } + Err(e) => { + log::warn!("failed to parse font {}: {e}", path.display()); + None + } + } +} + +/// Walks `dir` looking for a `.ttf`/`.otf` file, preferring one whose name +/// contains "mono". Stops early once a mono-named file is found. +fn find_ttf_preferring_mono(dir: &std::path::Path, best: &mut Option<std::path::PathBuf>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + find_ttf_preferring_mono(&path, best); + if matches!(best, Some(p) if p.to_string_lossy().to_lowercase().contains("mono")) { + return; + } + continue; + } + let is_font = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")).unwrap_or(false); + if !is_font { + continue; + } + let is_mono = path.to_string_lossy().to_lowercase().contains("mono"); + if is_mono { + *best = Some(path); + return; + } + if best.is_none() { + *best = Some(path); + } + } +} + +fn rgb_to_bgra(rgb: (u8, u8, u8), alpha: u8) -> [u8; 4] { + [rgb.2, rgb.1, rgb.0, alpha] +} + +/// 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> { + 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]; + for px in buf.chunks_exact_mut(4) { + px.copy_from_slice(&bg); + } + + let Some(font) = find_system_font() else { return buf }; + + 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 >= width { + break; + } + } + buf +} + +#[allow(clippy::too_many_arguments)] +fn blit_glyph( + buf: &mut [u8], + width: usize, + height: usize, + glyph_x: i32, + glyph_y: i32, + metrics: &fontdue::Metrics, + coverage: &[u8], + background: (u8, u8, u8), + foreground: (u8, u8, u8), +) { + for row in 0..metrics.height { + let y = glyph_y + row as i32; + if y < 0 || y as usize >= height { + continue; + } + for col in 0..metrics.width { + let x = glyph_x + col as i32; + if x < 0 || x as usize >= width { + continue; + } + let cov = coverage[row * metrics.width + col] as f32 / 255.0; + if cov <= 0.0 { + continue; + } + let blend = |bg: u8, fg: u8| -> u8 { (bg as f32 * (1.0 - cov) + fg as f32 * cov).round() as u8 }; + let r = blend(background.0, foreground.0); + let g = blend(background.1, foreground.1); + let b = blend(background.2, foreground.2); + let idx = (y as usize * width + x as usize) * 4; + buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra((r, g, b), 255)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fills_background_when_no_text() { + let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4)); + assert_eq!(buf.len(), 40 * 20 * 4); + assert_eq!(&buf[0..4], &rgb_to_bgra((0x2e, 0x34, 0x40), 255)); + } + + #[test] + fn drawing_title_changes_some_pixels_when_font_available() { + if find_system_font().is_none() { + eprintln!("skipping: no system font found in this sandbox"); + return; + } + let bg = (0x2e, 0x34, 0x40); + let fg = (0xec, 0xef, 0xf4); + let buf = render_titlebar(200, 30, "Terminal", bg, fg); + 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() { + let bg = (0x10, 0x20, 0x30); + let buf = render_titlebar(50, 24, "", bg, (0xff, 0xff, 0xff)); + let bg_bytes = rgb_to_bgra(bg, 255); + assert!(buf.chunks_exact(4).all(|px| px == bg_bytes)); + } +} diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs index 4913d58..b2fa9dc 100644 --- a/crates/wayland/src/lib.rs +++ b/crates/wayland/src/lib.rs @@ -18,29 +18,40 @@ //! X windows: through `srdwm_core::WindowManager`, so layout, smart //! placement, and drag/resize hit-testing are the *same* code path as X11 //! (`srdwm_core::window::ResizeEdge::hit_test`), not a reimplementation. -//! - Decorations are drawn as a solid-color titlebar band (no text - font -//! rasterization is a real chunk of additional work, not something to -//! fake here) using smithay's `SolidColorRenderElement`. -//! - Global keybindings are intercepted using a simple heuristic: any key -//! press with the Super/Mod4 modifier held is treated as WM-exclusive and -//! not forwarded to the focused client; everything else is forwarded. -//! Real WMs do something similar in practice (Super is rarely used by -//! applications); a more precise design would thread the config's actual -//! bound-key set into the platform layer, which is left as a TODO. +//! - Decorations are a titlebar band rendered in software (`decoration.rs`: +//! solid background plus the actual window title, rasterized via +//! `fontdue` against whatever monospace font is found on the system) and +//! uploaded per-frame through smithay's `MemoryRenderBuffer`, the same +//! band geometry and button hit-testing as the X11 backend's drawn +//! titlebar. +//! - Global keybindings are matched precisely: `WaylandPlatform::connect` +//! takes the config's actual bound-key combo strings (the same +//! `"Mod4+Shift+Return"` format `srd.bind` uses and the X11 backend grabs +//! via `XGrabKey`), and every keypress is translated to that same combo +//! string (via the keysym table shared with X11, `srdwm_core::keysyms`) +//! and checked against the set. Only a match is withheld from the focused +//! client; everything else is forwarded, mirroring X11's grab-specific-keys +//! behavior instead of the coarser "any Super-held key is ours" heuristic +//! an earlier pass used. //! - xdg-decoration is forced to server-side mode (`Mode::ServerSide`) so //! well-behaved clients don't also draw their own client-side titlebar. +mod decoration; +mod udev; +mod xwayland; + use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::time::Instant; +use smithay::backend::allocator::Fourcc; use smithay::backend::input::{ AbsolutePositionEvent, ButtonState as BackendButtonState, Event as InputEventTrait, InputEvent, KeyState as BackendKeyState, KeyboardKeyEvent, PointerButtonEvent, }; use smithay::backend::renderer::damage::OutputDamageTracker; -use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement}; +use smithay::backend::renderer::element::memory::{MemoryRenderBuffer, MemoryRenderBufferRenderElement}; use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::gles::GlesRenderer; use smithay::backend::winit::{self, WinitEvent, WinitEventLoop, WinitGraphicsBackend}; @@ -97,12 +108,29 @@ struct CompState { wm: Rc<RefCell<WindowManager>>, surface_to_id: HashMap<WlSurface, WindowId>, id_to_window: HashMap<WindowId, DWindow>, - decorations: HashMap<WindowId, SolidColorBuffer>, + decorations: HashMap<WindowId, MemoryRenderBuffer>, pending: Rc<RefCell<Vec<CoreEvent>>>, - pointer_focus_super_held: bool, + bound_keys: Rc<HashSet<String>>, start_time: Instant, + /// `Some` only for the udev/DRM backend; see `udev.rs` module docs for + /// why its runtime state lives here rather than on a separate struct. + udev: Option<udev::UdevOutput>, + /// XWayland support; see `xwayland.rs` module docs. `xwm` is `None` + /// until `XWaylandEvent::Ready` fires. + xwayland_shell_state: smithay::wayland::xwayland_shell::XWaylandShellState, + xwm: Option<smithay::xwayland::X11Wm>, + xwayland_windows: HashMap<xwayland::X11Window, WindowId>, + /// Mapped X11 windows still waiting for XWayland to associate a + /// `wl_surface` - see `xwayland.rs` and `commit()` above. + xwayland_pending: Vec<smithay::xwayland::X11Surface>, } +/// 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); + impl CompState { fn new_managed_window(&mut self, toplevel: ToplevelSurface) { let surface = toplevel.wl_surface().clone(); @@ -126,10 +154,28 @@ impl CompState { self.space.map_element(dwindow.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), true); self.surface_to_id.insert(surface, id); self.id_to_window.insert(id, dwindow); - self.decorations.insert(id, SolidColorBuffer::new((geom.width as i32, TITLEBAR_HEIGHT as i32), [0.18, 0.204, 0.251, 1.0])); + self.redraw_decoration_buffer(id); self.pending.borrow_mut().push(CoreEvent::WindowCreated(id)); } + /// (Re)renders the titlebar band for `id` - background plus title text + /// via `decoration::render_titlebar` - and replaces the buffer in + /// `self.decorations`. Called on creation, geometry change (width + /// affects layout), and focus change (text color). + fn redraw_decoration_buffer(&mut self, id: WindowId) { + let Some(w) = self.wm.borrow().window(id).cloned() else { return }; + if !w.decorated { + 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); + } + fn remove_window(&mut self, surface: &WlSurface) { let Some(id) = self.surface_to_id.remove(surface) else { return }; if let Some(w) = self.id_to_window.remove(&id) { @@ -151,8 +197,8 @@ impl CompState { top.send_configure(); } } - if let Some(deco) = self.decorations.get_mut(&id) { - deco.resize((geom.width as i32, TITLEBAR_HEIGHT as i32)); + if self.decorations.contains_key(&id) { + self.redraw_decoration_buffer(id); } } } @@ -169,11 +215,24 @@ impl CompositorHandler for CompState { } fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState { - &client.get_data::<ClientState>().unwrap().compositor_state + // Two possible client kinds now: our own `ClientState` for regular + // Wayland clients, or smithay's `XWaylandClientData` for the single + // XWayland client (see `xwayland.rs`) - both carry a + // `CompositorClientState`, just under different wrapper types. + if let Some(state) = client.get_data::<ClientState>() { + return &state.compositor_state; + } + &client.get_data::<smithay::xwayland::XWaylandClientData>().expect("client is neither ours nor XWayland's").compositor_state } 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 + // arrive at any point relative to the map request (see + // `xwayland.rs`'s module docs); `surface_associated` handles the + // common ordering, this retries the surfaces still waiting on a + // commit to actually make that association queryable. + self.retry_pending_x11_windows(); if let Some(&id) = self.surface_to_id.get(surface) { if let Some(w) = self.id_to_window.get(&id) { w.on_commit(); @@ -265,7 +324,11 @@ pub struct WaylandPlatform { } impl WaylandPlatform { - pub fn connect(wm: Rc<RefCell<WindowManager>>) -> PlatformResult<Self> { + /// `bound_keys` are the config's `"Mod4+Shift+Return"`-style combo + /// strings (see `srdwm_core::key_combo_string`) - the same set the X11 + /// backend grabs individually via `XGrabKey`. Only a keypress matching + /// one of these is withheld from the focused client. + pub fn connect(wm: Rc<RefCell<WindowManager>>, bound_keys: &[String]) -> PlatformResult<Self> { let display: Display<CompState> = Display::new().map_err(err)?; let dh = display.handle(); @@ -310,8 +373,13 @@ impl WaylandPlatform { id_to_window: HashMap::new(), decorations: HashMap::new(), pending: pending.clone(), - pointer_focus_super_held: false, + bound_keys: Rc::new(bound_keys.iter().cloned().collect()), start_time: Instant::now(), + udev: None, + xwayland_shell_state: smithay::wayland::xwayland_shell::XWaylandShellState::new::<CompState>(&dh), + xwm: None, + xwayland_windows: HashMap::new(), + xwayland_pending: Vec::new(), }; let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?; @@ -350,15 +418,18 @@ impl WaylandPlatform { let size = self.backend.window_size(); self.output.change_current_state(Some(OutputMode { size, refresh: 60_000 }), None, None, None); - let mut custom_elements: Vec<SolidColorRenderElement> = Vec::new(); + let age = self.backend.buffer_age().unwrap_or(0); + let (renderer, mut framebuffer) = self.backend.bind().map_err(err)?; + + let mut custom_elements: Vec<MemoryRenderBufferRenderElement<GlesRenderer>> = Vec::new(); for (&id, deco) in self.state.decorations.iter() { - if let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) { - custom_elements.push(SolidColorRenderElement::from_buffer(deco, (geom.x, geom.y), 1.0, 1.0, Kind::Unspecified)); + 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) { + Ok(elem) => custom_elements.push(elem), + Err(e) => log::warn!("failed to import titlebar buffer for window {id}: {e}"), } } - let age = self.backend.buffer_age().unwrap_or(0); - let (renderer, mut framebuffer) = self.backend.bind().map_err(err)?; render_output( &self.output, renderer, @@ -381,28 +452,7 @@ impl WaylandPlatform { fn handle_winit_event(state: &mut CompState, output: &Output, event: WinitEvent, closed: &mut bool) { match event { WinitEvent::CloseRequested => *closed = true, - WinitEvent::Input(InputEvent::Keyboard { event }) => { - let keycode = event.key_code(); - let key_state = event.state(); - let time = event.time_msec(); - let serial = SERIAL_COUNTER.next_serial(); - let Some(keyboard) = state.seat.get_keyboard() else { return }; - - let mut super_now = state.pointer_focus_super_held; - keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, mods, _handle| { - super_now = mods.logo; - FilterResult::Forward - }); - state.pointer_focus_super_held = super_now; - - if super_now && key_state == BackendKeyState::Pressed { - if let Some(name) = keysym_name_for(&keyboard, keycode) { - state.pending.borrow_mut().push(CoreEvent::KeyPress { key_name: name, modifiers: Modifiers::SUPER }); - } - } - // Not a WM shortcut: let the focused client see it (already - // forwarded by the FilterResult::Forward above). - } + WinitEvent::Input(InputEvent::Keyboard { event }) => handle_keyboard_key_event(state, &event), WinitEvent::Input(InputEvent::PointerMotionAbsolute { event }) => { let size = output.current_mode().map(|m| m.size).unwrap_or_default().to_logical(1); let pos = event.position_transformed(size); @@ -419,14 +469,60 @@ fn handle_winit_event(state: &mut CompState, output: &Output, event: WinitEvent, } } -fn keysym_name_for(keyboard: &smithay::input::keyboard::KeyboardHandle<CompState>, keycode: smithay::backend::input::Keycode) -> Option<String> { - let _ = keyboard; - let _ = keycode; - // TODO: translate via xkbcommon keysym -> name; the X11 backend's - // `keysyms` table isn't reachable from here without duplicating it. - // Global Wayland keybindings beyond the raw Super-modifier gate are not - // wired up yet - see docs/IMPLEMENTATION_STATUS.md. - None +/// Shared between the winit (nested) and udev (bare-TTY) backends: both +/// deliver keyboard events through smithay's generic `KeyboardKeyEvent` +/// trait, so the precise-keybinding-matching logic (see the module docs) +/// only needs to exist once. +fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend, E: KeyboardKeyEvent<B>>(state: &mut CompState, event: &E) { + let keycode = event.key_code(); + let key_state = event.state(); + let time = event.time_msec(); + let serial = SERIAL_COUNTER.next_serial(); + let Some(keyboard) = state.seat.get_keyboard() else { return }; + + let bound_keys = state.bound_keys.clone(); + let matched: Option<(String, Modifiers)> = + keyboard.input(state, keycode, key_state, serial, time, move |_, mods, handle| { + let modifiers = core_modifiers_from_xkb(mods); + match keysym_name_for(handle) { + Some(name) if bound_keys.contains(&srdwm_core::key_combo_string(modifiers, &name)) => { + FilterResult::Intercept((name, modifiers)) + } + _ => FilterResult::Forward, + } + }); + + if key_state == BackendKeyState::Pressed { + if let Some((key_name, modifiers)) = matched { + state.pending.borrow_mut().push(CoreEvent::KeyPress { key_name, modifiers }); + } + } + // Unmatched keys were already forwarded to the focused client by + // `FilterResult::Forward` inside the closure above. +} + +/// Translates the effective xkb keysym for this keypress into the same +/// `"Return"`/`"a"`/`"F5"`-style name `srdwm_core::keysyms` uses, so a +/// binding written once in Lua resolves identically on X11 and Wayland. +fn keysym_name_for(handle: smithay::input::keyboard::KeysymHandle<'_>) -> Option<String> { + srdwm_core::keysyms::keysym_to_name(handle.modified_sym().raw()) +} + +fn core_modifiers_from_xkb(mods: &smithay::input::keyboard::ModifiersState) -> Modifiers { + let mut m = Modifiers::empty(); + if mods.shift { + m |= Modifiers::SHIFT; + } + if mods.ctrl { + m |= Modifiers::CTRL; + } + if mods.alt { + m |= Modifiers::ALT; + } + if mods.logo { + m |= Modifiers::SUPER; + } + m } fn last_pointer_pos(state: &CompState) -> Point<f64, Logical> { @@ -580,6 +676,10 @@ impl Platform for WaylandPlatform { } fn redraw_decoration(&mut self, window: WindowId, _win: &CoreWindow, _focused: bool) -> PlatformResult<()> { + // Re-renders the title/focus-color band and re-syncs geometry; + // `sync_geometry` re-renders the decoration too, but only if one + // already exists, so this also covers first paint. + self.state.redraw_decoration_buffer(window); self.state.sync_geometry(window); Ok(()) } @@ -592,3 +692,20 @@ impl Platform for WaylandPlatform { Ok(()) } } + +/// Connects to Wayland, choosing between the udev/DRM backend (bare TTY, no +/// host compositor to nest under - see `udev.rs`) and this module's winit +/// backend (nested window), the same way real compositors decide +/// nested-vs-native. Falls back to winit if udev initialization fails for +/// any reason (no seat access, no DRM device, ...), logging why rather than +/// failing outright. +pub fn connect(wm: Rc<RefCell<WindowManager>>, bound_keys: &[String]) -> PlatformResult<Box<dyn Platform>> { + let no_host_display = std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none(); + if no_host_display { + match udev::UdevPlatform::connect(wm.clone(), bound_keys) { + Ok(platform) => return Ok(Box::new(platform)), + Err(e) => log::warn!("udev/DRM backend unavailable ({e}); falling back to nested winit backend"), + } + } + Ok(Box::new(WaylandPlatform::connect(wm, bound_keys)?)) +} diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs new file mode 100644 index 0000000..c0e2f89 --- /dev/null +++ b/crates/wayland/src/udev.rs @@ -0,0 +1,554 @@ +//! DRM/udev backend: runs srdwm as the real compositor on a bare TTY (no +//! host session to nest under), unlike the `backend_winit`-based path in +//! `lib.rs`. +//! +//! Scope, kept deliberately narrow for a first real (not faked) pass: +//! - Single primary GPU, first connected connector, its preferred (first +//! listed) mode, positioned at `(0, 0)` - no hotplug of connectors or +//! GPUs after startup, no multi-monitor layout. +//! - Rendering is **software**, via smithay's `PixmanRenderer` compositing +//! into plain KMS "dumb buffers" through the legacy (non-atomic) mode-set +//! API (`set_crtc`/`page_flip`). This deliberately avoids the +//! GBM/EGL/`DrmCompositor` pipeline real hardware-accelerated compositors +//! (and smithay's own `anvil` example) use: that path needs a GPU with +//! working KMS+3D driver support, which is not guaranteed in a low-spec +//! machine's VM (QEMU's plainest virtual display devices only support +//! dumb-buffer scanout). Dumb buffers work on essentially any DRM driver. +//! - Session/seat handling is real, via `libseat` (VT-switch-safe device +//! access, no root required if the seatd/logind + libseat setup is +//! present) - not a raw `/dev/dri/cardN` open. +//! - Input is real, via `libinput`, sharing the exact same precise +//! keybinding matching and pointer/titlebar hit-testing code paths +//! `handle_keyboard_key_event`/`handle_pointer_position`/ +//! `handle_pointer_button` in `lib.rs` use for the nested winit backend. +//! - Session pause/resume (VT switch away/back) stops/resumes rendering, +//! but does not yet re-probe connectors on resume. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use smithay::backend::input::{ + Axis, ButtonState as BackendButtonState, Event as InputEventTrait, InputEvent, PointerAxisEvent, + PointerButtonEvent, PointerMotionEvent, +}; +use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; +use smithay::backend::renderer::damage::OutputDamageTracker; +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::session::{libseat::LibSeatSession, libseat::LibSeatSessionNotifier, Event as SessionEvent, Session}; +use smithay::backend::udev; +use smithay::desktop::space::render_output; +use smithay::desktop::Space; +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::control::{ + connector, crtc, dumbbuffer::DumbBuffer, framebuffer, Device as ControlDevice, Event as DrmEvent, Mode as DrmMode, PageFlipFlags, +}; +use smithay::reexports::drm::Device as BasicDevice; +use smithay::reexports::input::Libinput; +use smithay::reexports::pixman::{FormatCode, Image}; +use smithay::reexports::rustix; +use smithay::reexports::wayland_server::{Client, Display, ListeningSocket}; +use smithay::utils::{Logical, Point, Transform}; +use smithay::wayland::compositor::CompositorState; +use smithay::wayland::shell::xdg::decoration::XdgDecorationState; +use smithay::wayland::shell::xdg::XdgShellState; +use smithay::wayland::shm::ShmState; + +use srdwm_core::{Event as CoreEvent, WindowManager}; +use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult}; + +use crate::{ + err, handle_keyboard_key_event, handle_pointer_button, handle_pointer_position, ClientState, CompState, +}; + +/// A DRM device node, opened through the session (not a raw `File::open`) +/// so access is properly gated by logind/seatd and revoked on VT switch. +struct Card(OwnedFd); + +impl AsFd for Card { + fn as_fd(&self) -> BorrowedFd<'_> { + self.0.as_fd() + } +} +impl BasicDevice for Card {} +impl ControlDevice for Card {} + +struct DrmBuffer { + dumb: DumbBuffer, + fb: framebuffer::Handle, + image: Image<'static, 'static>, +} + +/// Everything the DRM/udev backend needs that the nested winit backend +/// doesn't. Lives as a field on `CompState` (rather than a separate struct) +/// because calloop callbacks registered against the event loop only ever +/// get `&mut CompState` - see the module docs in `lib.rs` for why the +/// protocol-handler state itself has to be backend-agnostic. +pub(crate) struct UdevOutput { + card: Rc<Card>, + crtc: crtc::Handle, + renderer: PixmanRenderer, + damage_tracker: OutputDamageTracker, + output: Output, + buffers: [DrmBuffer; 2], + front: usize, + flip_pending: bool, + active: bool, + pointer_pos: Point<f64, Logical>, + size: (i32, i32), +} + +impl CompState { + /// Renders and (if there was damage) page-flips a new frame. No-op if + /// a flip is already in flight (we wait for the DRM page-flip event + /// before starting the next frame) or the session is paused. + pub(crate) fn render_udev_frame(&mut self) { + let Some(udev) = self.udev.as_mut() else { return }; + if udev.flip_pending || !udev.active { + return; + } + + let back = 1 - udev.front; + + let mut custom_elements: Vec<MemoryRenderBufferRenderElement<PixmanRenderer>> = Vec::new(); + for (&id, deco) in self.decorations.iter() { + let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue }; + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, (geom.x as f64, geom.y as f64), deco, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(elem), + Err(e) => log::warn!("udev: failed to import titlebar buffer for window {id}: {e}"), + } + } + + let mut framebuffer = match udev.renderer.bind(&mut udev.buffers[back].image) { + Ok(fb) => fb, + Err(e) => { + log::error!("udev: pixman bind failed: {e}"); + return; + } + }; + + let result = render_output( + &udev.output, + &mut udev.renderer, + &mut framebuffer, + 1.0, + 0, // always a full redraw: buffer "age" tracking isn't worth the complexity for a software-only, single-output backend + [&self.space], + &custom_elements, + &mut udev.damage_tracker, + [0.05, 0.05, 0.08, 1.0], + ); + drop(framebuffer); + + let has_damage = match result { + Ok(res) => res.damage.is_some(), + Err(e) => { + log::error!("udev: render_output failed: {e}"); + false + } + }; + if !has_damage { + return; + } + + if let Err(e) = udev.copy_and_flip(back) { + log::error!("udev: page flip failed: {e}"); + return; + } + self.space.elements().for_each(|w| w.send_frame(&udev.output, self.start_time.elapsed(), None, |_, _| Some(udev.output.clone()))); + } +} + +impl UdevOutput { + /// Copies the just-rendered pixman image into buffer `back`'s dumb + /// buffer (software rendering writes into its own owned image, not the + /// scanout memory directly, to avoid tying that image's lifetime to an + /// mmap - seem `crates/wayland/src/udev.rs` module docs) and flips to it. + fn copy_and_flip(&mut self, back: usize) -> std::io::Result<()> { + let (stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height()); + let byte_len = 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) }; + { + let mut mapping = self.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]); + } + self.card.page_flip(self.crtc, self.buffers[back].fb, PageFlipFlags::EVENT, None)?; + self.flip_pending = true; + Ok(()) + } +} + +pub struct UdevPlatform { + event_loop: EventLoop<'static, CompState>, + display: Display<CompState>, + state: CompState, + listener: ListeningSocket, + clients: Vec<Client>, + pending: Rc<RefCell<Vec<CoreEvent>>>, +} + +impl UdevPlatform { + pub fn connect(wm: Rc<RefCell<WindowManager>>, bound_keys: &[String]) -> PlatformResult<Self> { + let event_loop: EventLoop<'static, CompState> = EventLoop::try_new().map_err(err)?; + + let (session, notifier) = LibSeatSession::new().map_err(err)?; + let seat_name = session.seat(); + + let gpu_path = udev::primary_gpu(&seat_name) + .ok() + .flatten() + .unwrap_or_else(|| std::path::PathBuf::from("/dev/dri/card0")); + log::info!("udev: using {} as primary GPU", gpu_path.display()); + + let mut session_for_open = session.clone(); + let fd = session_for_open + .open(&gpu_path, rustix::fs::OFlags::RDWR | rustix::fs::OFlags::CLOEXEC) + .map_err(err)?; + let card = Rc::new(Card(fd)); + + let (crtc, connector, mode) = find_connected_output(&card)?; + let (width, height) = mode.size(); + let (width, height) = (width as i32, height as i32); + + let buffers = [make_drm_buffer(&card, width, height)?, make_drm_buffer(&card, width, height)?]; + card.set_crtc(crtc, Some(buffers[0].fb), (0, 0), &[connector], Some(mode)).map_err(err)?; + + let output = Output::new( + "srdwm-udev".to_string(), + PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() }, + ); + let refresh = mode_refresh_mhz(&mode); + output.change_current_state( + Some(OutputMode { size: (width, height).into(), refresh }), + Some(Transform::Normal), + None, + Some((0, 0).into()), + ); + + let renderer = PixmanRenderer::new().map_err(err)?; + let damage_tracker = OutputDamageTracker::from_output(&output); + + let dh = Display::<CompState>::new().map_err(err)?; + let display_handle = dh.handle(); + output.create_global::<CompState>(&display_handle); + + let compositor_state = CompositorState::new::<CompState>(&display_handle); + let xdg_shell_state = XdgShellState::new::<CompState>(&display_handle); + let xdg_decoration_state = XdgDecorationState::new::<CompState>(&display_handle); + let shm_state = ShmState::new::<CompState>(&display_handle, Vec::new()); + 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)?; + seat.add_pointer(); + + let mut space = Space::default(); + space.map_output(&output, (0, 0)); + + let pending = Rc::new(RefCell::new(Vec::new())); + let udev_output = UdevOutput { + card: card.clone(), + crtc, + renderer, + damage_tracker, + output: output.clone(), + buffers, + front: 0, + flip_pending: false, + active: true, + pointer_pos: (width as f64 / 2.0, height as f64 / 2.0).into(), + size: (width, height), + }; + + let state = CompState { + compositor_state, + xdg_shell_state, + _xdg_decoration_state: xdg_decoration_state, + shm_state, + seat_state, + seat, + space, + wm: wm.clone(), + surface_to_id: HashMap::new(), + id_to_window: HashMap::new(), + decorations: HashMap::new(), + pending: pending.clone(), + bound_keys: Rc::new(bound_keys.iter().cloned().collect::<HashSet<_>>()), + start_time: Instant::now(), + udev: Some(udev_output), + xwayland_shell_state: smithay::wayland::xwayland_shell::XWaylandShellState::new::<CompState>(&display_handle), + xwm: None, + xwayland_windows: HashMap::new(), + xwayland_pending: Vec::new(), + }; + + let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?; + if let Some(name) = listener.socket_name() { + std::env::set_var("WAYLAND_DISPLAY", name); + log::info!("wayland socket: {}", name.to_string_lossy()); + } + + let handle = event_loop.handle(); + register_drm_fd(&handle, &card)?; + register_libinput(&handle, &session, &seat_name)?; + register_session_notifier(&handle, notifier)?; + if let Err(e) = crate::xwayland::spawn(&handle, &display_handle) { + log::warn!("XWayland unavailable ({e}); X11-only clients will not run"); + } + + Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending }) + } + + fn accept_clients(&mut self) -> PlatformResult<()> { + if let Some(stream) = self.listener.accept().map_err(err)? { + let client = self.display.handle().insert_client(stream, std::sync::Arc::new(ClientState::default())).map_err(err)?; + self.clients.push(client); + } + Ok(()) + } +} + +fn mode_refresh_mhz(mode: &DrmMode) -> i32 { + let vrefresh = mode.vrefresh(); + if vrefresh > 0 { + vrefresh as i32 * 1000 + } else { + 60_000 + } +} + +fn find_connected_output(card: &Card) -> PlatformResult<(crtc::Handle, connector::Handle, DrmMode)> { + let res = card.resource_handles().map_err(err)?; + let connectors: Vec<connector::Info> = res.connectors().iter().flat_map(|&h| card.get_connector(h, true)).collect(); + let con = connectors + .iter() + .find(|c| c.state() == connector::State::Connected) + .ok_or_else(|| PlatformError::Other("udev: no connected connector found".into()))?; + let mode = *con.modes().first().ok_or_else(|| PlatformError::Other("udev: connected connector has no modes".into()))?; + + let crtcs: Vec<crtc::Handle> = res.crtcs().to_vec(); + let crtc = con + .current_encoder() + .and_then(|enc| card.get_encoder(enc).ok()) + .and_then(|enc| res.filter_crtcs(enc.possible_crtcs()).into_iter().next()) + .or_else(|| crtcs.first().copied()) + .ok_or_else(|| PlatformError::Other("udev: no usable crtc found".into()))?; + + Ok((crtc, con.handle(), mode)) +} + +fn make_drm_buffer(card: &Card, width: i32, height: i32) -> PlatformResult<DrmBuffer> { + let dumb = card.create_dumb_buffer((width as u32, height as u32), DrmFourcc::Xrgb8888, 32).map_err(err)?; + let fb = card.add_framebuffer(&dumb, 24, 32).map_err(err)?; + let format = FormatCode::try_from(DrmFourcc::Xrgb8888).map_err(|_| PlatformError::Other("udev: unsupported pixel format".into()))?; + let image = Image::new(format, width as usize, height as usize, true).map_err(|_| PlatformError::Other("udev: failed to allocate render buffer".into()))?; + Ok(DrmBuffer { dumb, fb, image }) +} + +fn register_drm_fd(handle: &LoopHandle<'static, CompState>, card: &Rc<Card>) -> PlatformResult<()> { + let raw = card.as_fd().as_raw_fd(); + // SAFETY: `FdWrapper` does not close `raw`; the owning `Card` lives in + // `CompState::udev` for as long as this event source is registered. + let wrapper = unsafe { FdWrapper::new(raw) }; + let source = Generic::new(wrapper, Interest::READ, CalloopMode::Level); + handle + .insert_source(source, move |_, _, data: &mut CompState| { + let Some(udev) = data.udev.as_ref() else { return Ok(PostAction::Continue) }; + let card = udev.card.clone(); + match card.receive_events() { + Ok(events) => { + let mut flipped = false; + for event in events { + if let DrmEvent::PageFlip(_) = event { + flipped = true; + } + } + if flipped { + if let Some(udev) = data.udev.as_mut() { + udev.front = 1 - udev.front; + udev.flip_pending = false; + } + data.render_udev_frame(); + } + } + Err(e) => log::warn!("udev: receive_events failed: {e}"), + } + Ok(PostAction::Continue) + }) + .map_err(|e| PlatformError::Other(format!("failed to register DRM fd: {e}")))?; + Ok(()) +} + +fn register_libinput(handle: &LoopHandle<'static, CompState>, session: &LibSeatSession, seat_name: &str) -> PlatformResult<()> { + let mut libinput_context = Libinput::new_with_udev::<LibinputSessionInterface<LibSeatSession>>(session.clone().into()); + libinput_context.udev_assign_seat(seat_name).map_err(|_| PlatformError::Other("udev: libinput udev_assign_seat failed".into()))?; + let libinput_backend = LibinputInputBackend::new(libinput_context); + + handle + .insert_source(libinput_backend, move |event, _, data: &mut CompState| { + handle_libinput_event(data, event); + }) + .map_err(|e| PlatformError::Other(format!("failed to register libinput backend: {e}")))?; + Ok(()) +} + +fn register_session_notifier(handle: &LoopHandle<'static, CompState>, notifier: LibSeatSessionNotifier) -> PlatformResult<()> { + handle + .insert_source(notifier, move |event, &mut (), data: &mut CompState| { + let Some(udev) = data.udev.as_mut() else { return }; + match event { + SessionEvent::PauseSession => { + log::info!("udev: session paused (VT switch away)"); + udev.active = false; + } + SessionEvent::ActivateSession => { + log::info!("udev: session resumed (VT switch back)"); + udev.active = true; + // Some drivers reset mode-setting state across a VT + // switch; reassert it before rendering again. + let fb = udev.buffers[udev.front].fb; + if let Err(e) = udev.card.set_crtc(udev.crtc, Some(fb), (0, 0), &[], None) { + log::warn!("udev: failed to reassert crtc on resume: {e}"); + } + data.render_udev_frame(); + } + } + }) + .map_err(|e| PlatformError::Other(format!("failed to register session notifier: {e}")))?; + Ok(()) +} + +fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputBackend>) { + match event { + InputEvent::Keyboard { event } => handle_keyboard_key_event(state, &event), + InputEvent::PointerMotion { event } => { + let Some(udev) = state.udev.as_mut() else { return }; + let delta = event.delta(); + let (w, h) = (udev.size.0 as f64, udev.size.1 as f64); + udev.pointer_pos.x = (udev.pointer_pos.x + delta.x).clamp(0.0, w - 1.0); + udev.pointer_pos.y = (udev.pointer_pos.y + delta.y).clamp(0.0, h - 1.0); + let pos = udev.pointer_pos; + handle_pointer_position(state, pos, event.time_msec()); + } + InputEvent::PointerButton { event } => { + let Some(pos) = state.udev.as_ref().map(|u| u.pointer_pos) else { return }; + let button = event.button_code(); + let pressed = event.state() == BackendButtonState::Pressed; + handle_pointer_button(state, pos, button, pressed, event.time_msec()); + } + InputEvent::PointerAxis { event } => { + // Scroll: forwarded to the focused client via the pointer axis + // frame, no WM-level handling (matches the winit backend, which + // doesn't handle scroll either). + let Some(pointer) = state.seat.get_pointer() else { return }; + 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); + } + } + pointer.axis(state, frame); + pointer.frame(state); + } + _ => {} + } +} + +impl Platform for UdevPlatform { + fn kind(&self) -> PlatformKind { + PlatformKind::Wayland + } + + fn poll_events(&mut self) -> PlatformResult<Vec<CoreEvent>> { + self.accept_clients()?; + self.event_loop.dispatch(Some(Duration::from_millis(16)), &mut self.state).map_err(err)?; + self.display.dispatch_clients(&mut self.state).map_err(err)?; + self.display.flush_clients().map_err(err)?; + self.state.render_udev_frame(); + Ok(self.pending.borrow_mut().drain(..).collect()) + } + + fn monitors(&mut self) -> PlatformResult<Vec<srdwm_core::Monitor>> { + let Some(udev) = self.state.udev.as_ref() else { return Ok(Vec::new()) }; + let (w, h) = udev.size; + Ok(vec![{ + let mut m = srdwm_core::Monitor::new(0, "drm", srdwm_core::Rect::new(0, 0, w as u32, h as u32)); + m.primary = true; + m + }]) + } + + fn apply_geometry(&mut self, window: srdwm_core::WindowId, _geometry: srdwm_core::Rect) -> PlatformResult<()> { + self.state.sync_geometry(window); + Ok(()) + } + + fn set_title(&mut self, _window: srdwm_core::WindowId, _title: &str) -> PlatformResult<()> { + Ok(()) + } + + fn focus(&mut self, window: srdwm_core::WindowId) -> PlatformResult<()> { + self.state.wm.borrow_mut().focus_window(window); + Ok(()) + } + + fn minimize(&mut self, window: srdwm_core::WindowId) -> PlatformResult<()> { + if let Some(w) = self.state.id_to_window.get(&window) { + self.state.space.unmap_elem(w); + } + Ok(()) + } + + fn restore(&mut self, window: srdwm_core::WindowId) -> PlatformResult<()> { + self.state.sync_geometry(window); + Ok(()) + } + + fn close(&mut self, window: srdwm_core::WindowId) -> PlatformResult<()> { + if let Some(w) = self.state.id_to_window.get(&window).and_then(|w| w.toplevel()) { + w.send_close(); + } + Ok(()) + } + + fn set_decorated(&mut self, _window: srdwm_core::WindowId, _decorated: bool) -> PlatformResult<()> { + Ok(()) + } + + fn set_border_color(&mut self, _window: srdwm_core::WindowId, _rgb: (u8, u8, u8)) -> PlatformResult<()> { + Ok(()) + } + + fn set_border_width(&mut self, _window: srdwm_core::WindowId, _width: u32) -> PlatformResult<()> { + Ok(()) + } + + fn redraw_decoration(&mut self, window: srdwm_core::WindowId, _win: &srdwm_core::Window, _focused: bool) -> PlatformResult<()> { + self.state.redraw_decoration_buffer(window); + self.state.sync_geometry(window); + Ok(()) + } + + fn grab_keyboard(&mut self) -> PlatformResult<()> { + Ok(()) + } + + fn ungrab_keyboard(&mut self) -> PlatformResult<()> { + Ok(()) + } +} diff --git a/crates/wayland/src/xwayland.rs b/crates/wayland/src/xwayland.rs new file mode 100644 index 0000000..dc7a88b --- /dev/null +++ b/crates/wayland/src/xwayland.rs @@ -0,0 +1,240 @@ +//! XWayland integration: lets legacy X11-only clients (anything that can't +//! speak the Wayland protocol natively) run inside the Wayland session, +//! bridged into the same `srdwm_core::WindowManager`/`Space` pipeline as +//! native `xdg-shell` windows. +//! +//! Only wired up for the udev/DRM backend (`udev.rs`) for now: XWayland's +//! window-manager side (`X11Wm::start_wm`) is driven entirely through a +//! `calloop` event loop, which only the udev backend has - the nested +//! winit backend still drives its own manual poll loop (see `lib.rs`'s +//! module docs). Adding a second, XWayland-only `calloop::EventLoop` to the +//! winit backend too is possible but left as a follow-up. +//! +//! Scope: regular (server-managed) windows go through the exact same +//! `WindowManager::add_window`/decoration/hit-test path as xdg-shell +//! windows - an X11 app gets tiled, placed by `SmartPlacement`, and +//! decorated with our drawn titlebar exactly like a native Wayland client. +//! Override-redirect windows (menus, tooltips, drag images) are +//! deliberately *not* run through `WindowManager` at all - matching real +//! ICCCM semantics, no WM is ever supposed to manage or decorate them -- +//! they're mapped into `Space` at whatever geometry the client itself +//! requests. Selections/clipboard, XSETTINGS, and RandR primary-output +//! sync are not implemented (all have harmless no-op default trait +//! methods in `XwmHandler`). + +use smithay::reexports::calloop::LoopHandle; +use smithay::utils::{Logical, Rectangle}; +use smithay::wayland::xwayland_shell::{XWaylandShellHandler, XWaylandShellState}; +use smithay::xwayland::xwm::{Reorder, ResizeEdge as X11ResizeEdge, XwmId}; +use smithay::xwayland::{X11Surface, X11Wm, XWayland, XWaylandEvent, XwmHandler}; +use smithay::{delegate_xwayland_shell, desktop::Window as DWindow}; + +use srdwm_core::{Event as CoreEvent, ResizeEdge, Window as CoreWindow, TITLEBAR_HEIGHT}; + +use crate::CompState; + +pub(crate) type X11Window = smithay::xwayland::xwm::X11Window; + +/// Spawns XWayland and registers the calloop sources that drive it: the +/// `XWayland` process/readiness source, and (once ready) `X11Wm`'s own +/// internal X11-connection source. Both are owned by the event loop after +/// `insert_source`, not by any struct here - dropping the loop (or the +/// `X11Wm` on disconnect) is what shuts things down. +pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &smithay::reexports::wayland_server::DisplayHandle) -> std::io::Result<()> { + let (xwayland, client) = XWayland::spawn(display_handle, None, std::iter::empty::<(String, String)>(), true, std::process::Stdio::null(), std::process::Stdio::null(), |_| ())?; + + let handle_for_ready = handle.clone(); + handle + .insert_source(xwayland, move |event, _, data: &mut CompState| match event { + 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), + Err(e) => log::error!("failed to start X11 window manager for XWayland: {e}"), + } + } + XWaylandEvent::Error => log::error!("XWayland exited unexpectedly during startup"), + }) + .map_err(|e| std::io::Error::other(format!("failed to register XWayland source: {e}")))?; + Ok(()) +} + +fn to_core_resize_edge(edge: X11ResizeEdge) -> ResizeEdge { + match edge { + X11ResizeEdge::Top => ResizeEdge::Top, + X11ResizeEdge::Bottom => ResizeEdge::Bottom, + X11ResizeEdge::Left => ResizeEdge::Left, + X11ResizeEdge::Right => ResizeEdge::Right, + X11ResizeEdge::TopLeft => ResizeEdge::TopLeft, + X11ResizeEdge::TopRight => ResizeEdge::TopRight, + X11ResizeEdge::BottomLeft => ResizeEdge::BottomLeft, + X11ResizeEdge::BottomRight => ResizeEdge::BottomRight, + } +} + +impl CompState { + /// Retries `finish_x11_window_setup` for every mapped X11 window still + /// waiting on its `wl_surface` association - called on every + /// compositor commit, since that association can complete without ever + /// invoking `surface_associated` (see the module docs). + pub(crate) fn retry_pending_x11_windows(&mut self) { + if self.xwayland_pending.is_empty() { + return; + } + let pending = std::mem::take(&mut self.xwayland_pending); + for surface in pending { + self.finish_x11_window_setup(&surface); + let done = self.xwayland_windows.get(&surface.window_id()).is_some_and(|id| self.id_to_window.contains_key(id)); + if !done { + self.xwayland_pending.push(surface); + } + } + } + + /// Finishes setting up a *server-managed* (non-override-redirect) X11 + /// window once both halves are known: it's been granted its map + /// request, and XWayland has associated it with a `wl_surface`. Safe to + /// call from either order's callback; idempotent. + fn finish_x11_window_setup(&mut self, surface: &X11Surface) { + let Some(wl_surface) = surface.wl_surface() else { + log::debug!("xwayland: finish_x11_window_setup xid={:?} - no wl_surface yet", surface.window_id()); + return; + }; + let Some(&id) = self.xwayland_windows.get(&surface.window_id()) else { + log::debug!("xwayland: finish_x11_window_setup xid={:?} - not in xwayland_windows", surface.window_id()); + return; + }; + if self.id_to_window.contains_key(&id) { + log::debug!("xwayland: finish_x11_window_setup xid={:?} id={id} - already set up", surface.window_id()); + return; + } + log::info!("xwayland: finishing setup for xid={:?} id={id}", surface.window_id()); + let geom = self.wm.borrow().window(id).map(|w| w.geometry).unwrap_or_default(); + + let dwindow = DWindow::new_x11_window(surface.clone()); + let _ = surface.set_mapped(true); + 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.id_to_window.insert(id, dwindow); + self.redraw_decoration_buffer(id); + self.pending.borrow_mut().push(CoreEvent::WindowCreated(id)); + } + + fn remove_x11_window(&mut self, xid: X11Window) { + let Some(id) = self.xwayland_windows.get(&xid).copied() else { return }; + if let Some(w) = self.id_to_window.remove(&id) { + self.space.unmap_elem(&w); + } + self.decorations.remove(&id); + self.wm.borrow_mut().remove_window(id); + self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id)); + } +} + +impl XWaylandShellHandler for CompState { + fn xwayland_shell_state(&mut self) -> &mut XWaylandShellState { + &mut self.xwayland_shell_state + } + + fn surface_associated(&mut self, _xwm: XwmId, _wl_surface: smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, surface: X11Surface) { + log::debug!("xwayland: surface_associated xid={:?}", surface.window_id()); + self.finish_x11_window_setup(&surface); + } +} + +delegate_xwayland_shell!(CompState); + +impl XwmHandler for CompState { + fn xwm_state(&mut self, _xwm: XwmId) -> &mut X11Wm { + self.xwm.as_mut().expect("XwmHandler callback fired without an X11Wm") + } + + fn new_window(&mut self, _xwm: XwmId, window: X11Surface) { + // Created but not (yet) mapped - nothing to do until a map request. + log::debug!("xwayland: new_window xid={:?} title={:?}", window.window_id(), window.title()); + } + + fn new_override_redirect_window(&mut self, _xwm: XwmId, window: X11Surface) { + // Not managed until it actually maps - see `mapped_override_redirect_window`. + log::debug!("xwayland: new_override_redirect_window xid={:?}", window.window_id()); + } + + fn map_window_request(&mut self, _xwm: XwmId, window: X11Surface) { + log::debug!("xwayland: map_window_request xid={:?} title={:?} class={:?}", window.window_id(), window.title(), window.class()); + let id = { + let mut wm = self.wm.borrow_mut(); + let id = wm.alloc_window_id(); + let mut w = CoreWindow::new(id, window.title()); + w.app_id = window.class(); + let size = window.geometry().size; + w.geometry = srdwm_core::Rect::new(0, 0, size.w.max(1) as u32, size.h.max(1) as u32 + TITLEBAR_HEIGHT); + wm.add_window(w); + id + }; + self.xwayland_windows.insert(window.window_id(), id); + self.finish_x11_window_setup(&window); + if !self.id_to_window.contains_key(&id) { + self.xwayland_pending.push(window); + } + } + + fn mapped_override_redirect_window(&mut self, _xwm: XwmId, window: X11Surface) { + let Some(wl_surface) = window.wl_surface() else { return }; + let geom = window.geometry(); + let dwindow = DWindow::new_x11_window(window.clone()); + self.space.map_element(dwindow.clone(), (geom.loc.x, geom.loc.y), true); + // Allocated only for the surface_to_id/id_to_window bookkeeping + // `commit()` needs - deliberately never passed to + // `WindowManager::add_window`: override-redirect windows are not + // managed, per ICCCM. + let id = self.wm.borrow_mut().alloc_window_id(); + self.xwayland_windows.insert(window.window_id(), id); + self.surface_to_id.insert(wl_surface, id); + self.id_to_window.insert(id, dwindow); + } + + fn unmapped_window(&mut self, _xwm: XwmId, window: X11Surface) { + self.remove_x11_window(window.window_id()); + } + + fn destroyed_window(&mut self, _xwm: XwmId, window: X11Surface) { + let xid = window.window_id(); + self.remove_x11_window(xid); + self.xwayland_windows.remove(&xid); + } + + fn configure_request(&mut self, _xwm: XwmId, _window: X11Surface, _x: Option<i32>, _y: Option<i32>, _w: Option<u32>, _h: Option<u32>, _reorder: Option<Reorder>) { + // We own layout for managed windows; smithay always sends back a + // synthetic configure with the window's actual current geometry + // after this callback returns (see `xwayland::xwm`'s `handle_event` + // for `ConfigureRequest`), so there is nothing to do here - this + // mirrors how `srdwm_x11::X11Platform` acks `ConfigureRequest` with + // the client's real geometry rather than whatever it asked for. + } + + fn configure_notify(&mut self, _xwm: XwmId, window: X11Surface, geometry: Rectangle<i32, Logical>, _above: Option<X11Window>) { + // Only override-redirect windows are allowed to reposition + // themselves at will; managed windows' geometry is owned by us. + if !window.is_override_redirect() { + return; + } + let Some(&id) = self.xwayland_windows.get(&window.window_id()) else { return }; + if let Some(w) = self.id_to_window.get(&id) { + self.space.map_element(w.clone(), (geometry.loc.x, geometry.loc.y), false); + } + } + + 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(); + self.wm.borrow_mut().start_resize(id, to_core_resize_edge(resize_edge), pos.x as i32, pos.y as i32); + } + + fn move_request(&mut self, _xwm: XwmId, window: X11Surface, _button: u32) { + 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(); + self.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32); + } +} diff --git a/crates/x11/src/lib.rs b/crates/x11/src/lib.rs index 0faf513..d025efa 100644 --- a/crates/x11/src/lib.rs +++ b/crates/x11/src/lib.rs @@ -18,12 +18,12 @@ //! millimeters instead of the CRTC's pixel mode; here it reads the CRTC. //! //! Not implemented (documented rather than faked): XKB-level keymaps (only -//! a hand-maintained keysym table covering common keys, see `keysyms.rs`), -//! ICCCM `WM_HINTS`/urgency, and EWMH pager/taskbar hints beyond +//! a hand-maintained keysym table covering common keys, shared with the +//! Wayland backend via `srdwm_core::keysyms`), ICCCM `WM_HINTS`/urgency, and +//! EWMH pager/taskbar hints beyond //! `_NET_SUPPORTED`/`_NET_CLIENT_LIST`/`_NET_WM_STATE` maximize. -mod keysyms; - +use srdwm_core::keysyms; use srdwm_core::{Event, Modifiers, MouseButton, TitlebarHit, Window as CoreWindow, WindowId, TITLEBAR_HEIGHT}; use srdwm_core::{Monitor, Rect, WindowManager}; use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult}; diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md index 9b36f5a..74dfba6 100644 --- a/docs/DEFAULTS.md +++ b/docs/DEFAULTS.md @@ -191,6 +191,33 @@ srd.bind("Mod4+d", function() srd.spawn("rofi -show drun") end) - Default: Mod4+ srd.bind("Mod4+Return", function() srd.spawn("alacritty") end) -- Default: Mod4+Return ``` +## Window Rules + +Match windows by title/class and apply actions once, when they're first +created: + +```lua +srd.rule(matcher, actions) +``` + +`matcher` fields (at least one required; an empty matcher matches nothing): +- `title` - case-insensitive substring match against the window title. +- `class` (alias `app_id`) - case-insensitive exact match against the + window's `WM_CLASS` (X11) / `app_id` (Wayland). + +`actions` fields (all optional): +- `floating` (bool), `maximized` (bool) +- `workspace` (number) - workspace id to place the window on +- `x`, `y`, `width`, `height` (number) - explicit geometry; all four must be + given together to take effect +- `decorated` (bool) +- `border_color` (`{r, g, b}`), `border_width` (number) + +```lua +srd.rule({ class = "pavucontrol" }, { floating = true }) +srd.rule({ title = "Picture-in-Picture" }, { floating = true, width = 480, height = 270 }) +``` + ## Platform-Specific Defaults ### Linux (X11/Wayland) @@ -319,18 +346,21 @@ srd.reset_category("general") ### Debug Commands ```lua -- Check configuration status -srd.debug.config_status() +- Check configuration status: returns { keys, bound_keys, log_entries, config_dir } +local status = srd.debug.config_status() -- Validate current configuration -srd.debug.validate_config() +- Validate current configuration against docs/DEFAULTS.md's ranges/formats: +- returns ok (bool), errors (array of human-readable strings, empty when ok) +local ok, errors = srd.debug.validate_config() +- equivalently, at the top level: +local ok, errors = srd.validate_config() -- Show current settings -srd.debug.show_settings() +- Show current settings: logs every key = value and returns them as a table +local settings = srd.debug.show_settings() -- Performance profiling +- Performance profiling: profile_stop() returns elapsed seconds (number) srd.debug.profile_start() -srd.debug.profile_stop() +local elapsed = srd.debug.profile_stop() ``` This documentation provides a comprehensive reference for all default values and configuration options in SRDWM. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 5481c4c..576b3dc 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -33,15 +33,27 @@ doing the thing described - not just "the code compiles and looks right." set_border_color,set_border_width,set_floating,toggle_floating,is_floating}`, `srd.layout.{set,configure}`, `srd.workspace.{next,prev,switch,move_window}`, `srd.theme.{set_colors,set_decorations}`, `srd.bind`, `srd.load`, - `srd.spawn`, `srd.notify`, `srd.quit`. + `srd.spawn`, `srd.notify`, `srd.quit`, `srd.rule`, `srd.validate_config`, + `srd.debug.{config_status,validate_config,show_settings,profile_start,profile_stop}`. - `srd.bind()` stores the actual Lua closure via `mlua`'s registry and invokes it on dispatch (the legacy engine stored only the key-combo string - keybindings could never fire). +- `srd.rule(matcher, actions)` matches windows by title (substring) or + class/app_id (exact) and applies floating/maximized/workspace/geometry/ + decoration/border actions once, when a matching window is first created + (`crates/core/src/rules.rs`, applied from `WindowManager::add_window`). + `config/srd/rules.lua` documents the API instead of being a no-op + placeholder. +- `srd.validate_config()` (and `srd.debug.validate_config()`) actually + check the numeric ranges, layout-name references, and hex-color formats + documented in `docs/DEFAULTS.md`'s "Validation Rules" section, returning + `(ok, errors)` - not a trivial always-true. `srd.debug.config_status()`/ + `show_settings()`/`profile_start()`/`profile_stop()` are real too. - `local srd = require("srd")` works (registered via `package.preload`, not just as a global) - every shipped example config opens with this line, and it would have failed against a naive "global-only" registration; this was caught and fixed during the smoke test. -- 10 unit tests, including one that reproduces the exact legacy bug +- 15 unit tests, including one that reproduces the exact legacy bug (`window:close()` on a table with no methods) and shows it now works. ### X11 backend (`crates/x11`) @@ -64,6 +76,10 @@ doing the thing described - not just "the code compiles and looks right." `SmartPlacement`-computed position, client offset by exactly `TITLEBAR_HEIGHT`), and the drawn title bar (background, title text, minimize/maximize/close glyphs) was confirmed via screenshot. +- **Re-verified in an isolated QEMU VM** (see "QEMU VM verification" below): + two `xterm` clients reparented and placed by `SmartPlacement`, each with + a drawn titlebar showing real title text and close/maximize/minimize + glyphs, screenshotted via QEMU's `screendump`. ### Windows and macOS backends (`crates/windows`, `crates/macos`) - Structured as honest stubs: real-looking `windows-rs`/Core Graphics calls @@ -95,19 +111,71 @@ What's here is a genuine from-scratch `smithay`-based compositor, not a stub: - ✅ xdg-decoration is negotiated to server-side mode. - ✅ Pointer click/drag/resize on the decoration band uses the identical `hit_test` code path as X11. -- ⚠️ Decorations are a solid-color titlebar band with **no text** - font - rasterization (glyph atlas, text shaping) is a substantial independent - piece of work, not something to fake with a placeholder. -- ⚠️ Global keybindings use a coarse heuristic: any keypress with Super/Mod4 - held is treated as WM-exclusive and not forwarded to the client; anything - else is forwarded. A precise design would thread the config's actual - bound-key set into the platform layer (X11 does this correctly via - per-combo `XGrabKey`); Wayland's compositor-sees-everything-first model - makes the equivalent design more involved and was left as a TODO rather - than rushed. -- ❌ No DRM/udev backend (i.e. cannot run as the actual system compositor on - a bare TTY, only nested under an existing session) - winit backend only. -- ❌ No XWayland integration. +- ✅ Decorations render actual title text (`crates/wayland/src/decoration.rs`): + glyphs rasterized via `fontdue` against whatever monospace font is found + under `/usr/share/fonts` etc. (falls back to solid-color-only, same as + before, if none is found), uploaded per-frame through smithay's + `MemoryRenderBuffer`. Pure `(width, height, text) -> Vec<u8>` function, + unit-tested without any GL/display context. +- ✅ Global keybindings are matched precisely: `WaylandPlatform::connect` + takes the config's actual bound-key combo strings (same format/shared + `srdwm_core::keysyms` table the X11 backend's `XGrabKey` calls use) and + only a matching keypress is withheld from the focused client - no more + "any Super-held key is ours" heuristic. +- ✅ DRM/udev backend (`crates/wayland/src/udev.rs`): runs as the real + compositor on a bare TTY, no host session to nest under. Single primary + GPU, first connected connector, its first-listed mode, real `libseat` + session/seat handling (VT-switch pause/resume, no raw root-only + `/dev/dri` open), real `libinput` input sharing the exact same + keybinding/hit-test code the winit backend uses. Rendering is + **software** (smithay's `PixmanRenderer` into plain KMS dumb buffers via + the legacy, non-atomic `set_crtc`/`page_flip` API) rather than + GBM/EGL/`DrmCompositor`-based hardware acceleration: that path needs a + GPU with working KMS+3D driver support that a low-spec machine's VM isn't + guaranteed to have, while dumb buffers work on essentially any DRM + driver. `WaylandPlatform::connect` (winit) picks this backend + automatically when no `WAYLAND_DISPLAY`/`DISPLAY` is set, falling back to + nested winit if udev init fails for any reason. + **Verified live in an isolated QEMU VM** (see below): started on a bare + virtual TTY with no `DISPLAY`/`WAYLAND_DISPLAY`, opened `/dev/dri/card1` + via a real libseat session, initialized libinput, advertised a Wayland + socket, and rendered a frame that scanned out correctly via KMS + page-flip - confirmed by screendumping the guest's virtual framebuffer + and matching the exact clear color (`[0.05, 0.05, 0.08]`) the compositor + renders. No client-side visual check yet (the VM has no Wayland-native + client installed to test against, only X11 ones - see below). No + hotplug (connectors or GPUs) after startup. +- 🔄 XWayland integration (`crates/wayland/src/xwayland.rs`), udev/DRM + backend only (the winit backend would need its own `calloop::EventLoop` + added first - see the module's doc comment): spawns XWayland, starts + `X11Wm`, and implements `XwmHandler`/`XWaylandShellHandler` to bridge + X11-only clients into the same `WindowManager`/`Space` pipeline + xdg-shell windows use (`CreateNotify`/`MapRequest` create a real + `srdwm_core::Window`, matched by rules via `class()`; unmap/destroy + clean up the same way). **Verified working up through window creation + and event routing, then found a real architectural blocker**: XWayland + tries `glamor` (GBM-based rendering) first; since this compositor is + deliberately software-only (no GBM/DMA-BUF support - the whole point of + the dumb-buffer approach above), glamor fails, and XWayland's + post-failure fallback path skips the `xwayland_shell_v1` protocol + entirely, so `X11Surface::wl_surface()` never resolves and windows never + render. Confirmed by tracing the actual Wayland protocol exchange + (`WAYLAND_DEBUG=1` on the spawned XWayland process): it binds + `xwayland_shell_v1` at startup, then a second, window-creation-time + registry pass sees the global but never binds it, and + `get_xwayland_surface`/`set_serial` never appear at all. `Xwayland + -help` confirms a `-shm` flag exists that forces shared-memory buffers + from the start (matching this compositor's `wl_shm`/`ImportMem`-only + renderer) instead of trying and falling back from glamor - but + `smithay::xwayland::XWayland::spawn` builds its `Xwayland` command line + internally with a fixed argument list and has no way to add `-shm`. + Fixing this for real means either bypassing `XWayland::spawn` with a + custom implementation (reimplementing its X11 lock-file/socket-pair/ + readiness-detection logic, which is intentionally private to smithay -- + `mod x11_sockets;`, not `pub mod`) or giving the compositor real + GBM/DMA-BUF import support, undoing the earlier deliberate low-spec/ + no-GPU-required design. Left as a documented gap rather than rushing a + low-level reimplementation with no cheap way to iterate on it. **Why the visual verification stopped short of a screenshot**: the winit window opens on the *host* compositor, and the only available display in @@ -118,11 +186,68 @@ casually paste into a build log. The X11 backend's Xephyr-based verification is the same class of test, done on an isolated, disposable display instead. +## QEMU VM verification + +Both the X11 backend and the Wayland/DRM-udev backend were re-verified from +scratch in an isolated QEMU VM (not the sandbox they were originally built +in), to check they work somewhere other than the exact environment that +built them: + +- **VM**: minimal Arch Linux rootfs (base, linux, xorg-server, xterm, mesa, + seatd, libinput, libxkbcommon, xf86-input-libinput - built by copying the + host's own already-installed files for these packages plus their full + dependency closure, rather than a fresh `pacstrap`, since this sandbox's + network throughput made a real package download impractical). Booted via + direct kernel+initramfs (no bootloader), `virtio-gpu`/`virtio-keyboard`/ + `virtio-mouse`/`virtio-net`, autologin on both the serial console and + `tty1`, `-display none` with QMP `screendump` for visual verification + (no interactive GUI needed on the host side). +- **X11 backend**: `run-x11.sh` starts Xorg on `vt1` then execs `srdwm` as + an X11 client (the standard way it becomes the WM). Two `xterm`s spawned + via the config's `startup.lua` were reparented, tiled/placed by + `SmartPlacement`, and both show a drawn titlebar with real "xterm" title + text and close/maximize/minimize glyphs - screenshotted and visually + confirmed. +- **Wayland/DRM-udev backend**: run directly on the bare console (no `-x` + script needed - no `DISPLAY`/`WAYLAND_DISPLAY` set at all). Opened + `/dev/dri/card1` via `libseat`, initialized `libinput`, advertised a real + Wayland socket, and rendered/page-flipped a frame - confirmed by + screendumping the guest's virtual display and matching the exact clear + color the compositor renders. This required a real bug fix, found by + this exact test: `srdwm_platform::detect()` previously defaulted to X11 + whenever neither `WAYLAND_DISPLAY` nor `XDG_SESSION_TYPE=wayland` was + set, *regardless of whether `DISPLAY` was set either* - meaning on a + genuinely bare TTY it picked X11, a backend that can never work there + (`srdwm_x11::X11Platform` only ever connects to an already-running X + server; it doesn't start one). `detect()` now only picks X11 when + `DISPLAY` is set without Wayland evidence; every other case, including a + bare TTY, resolves to Wayland, which is the only backend able to run + standalone there. +- **Not (yet) verified**: nested Wayland (the `backend_winit` path) running + as an X11 client under this VM's Xorg - `smithay`'s `winit` backend + failed with `Failed to initialize an event loop`, which is an error + surfaced from inside the `winit` crate's own X11 initialization, not + `srdwm`'s code; most likely this minimal VM's software-only Xorg is + missing a GLX/DRI3 piece `winit`'s EGL context creation wants. The nested + path was already verified once before (Xephyr-equivalent, log-verified + per the section above); this is a gap in re-verifying it in this + specific minimal VM, not a known-broken code path. +- No Wayland-native client was available in this minimal VM to visually + confirm client-side rendering under either Wayland backend (only + `xterm`, which is X11-only) - the compositor/socket/render-pipeline + side is confirmed, but no real Wayland app has been shown on-screen yet. +- **XWayland**: `xterm` launched with `DISPLAY` pointed at the udev + backend's spawned XWayland connected successfully and stayed alive + (`CreateNotify`/`MapRequest` both reached `XwmHandler`, logged and + handled with no crash), but never rendered - this is the + glamor/`-shm` blocker documented above, root-caused via + `WAYLAND_DEBUG=1` protocol tracing on the XWayland process rather than + guessed at. + ## Not implemented anywhere yet -- Window rules (match-by-title/class -> action). `config/srd/rules.lua` is - a documented placeholder. -- `srd.debug.*` namespace, `srd.validate_config()` beyond a trivial always-true. +- XWayland actually rendering a window (blocked on the glamor/`-shm` + issue above; the spawn/protocol/window-tracking plumbing is real). - 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 |