srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-04-02 01:07:00 +0200
committersrdusr <[email protected]>2024-04-02 01:07:00 +0200
commit43175c78a5b450eb108738187b72e80d36a7bf5d (patch)
treedbbadbcc18162f766f5af653d81882aaaa6f5e41
parent8110bb2773b6c841029a51eca7971f42a36f480c (diff)
downloadsrdwm-43175c78a5b450eb108738187b72e80d36a7bf5d.tar.gz
srdwm-43175c78a5b450eb108738187b72e80d36a7bf5d.zip
Add window rules, real config validation, and a Wayland DRM/udev backend
- srd.rule(): match windows by title/class, apply floating/maximized/ workspace/geometry/decoration actions on creation (crates/core/src/rules.rs) - srd.validate_config()/srd.debug.*: real range/format checks and status/profiling helpers, replacing the always-true stub - Wayland titlebar text rendering via fontdue, unit-tested without a display (crates/wayland/src/decoration.rs) - Wayland precise keybinding matching, replacing the "any Super-held key" heuristic, sharing the keysym table with X11 (moved to crates/core/src/keysyms.rs) - Wayland DRM/udev backend (crates/wayland/src/udev.rs): runs as the real compositor on a bare TTY via libseat/libinput/KMS, software rendering via Pixman + dumb buffers (no GBM/EGL required) - srdwm_platform::detect() fix, found via VM testing: a bare TTY with no DISPLAY/WAYLAND_DISPLAY now correctly resolves to Wayland instead of an X11 backend that can never work there - XWayland integration groundwork (crates/wayland/src/xwayland.rs): spawn, X11Wm, and full XwmHandler event routing into the same WindowManager/ Space pipeline as native clients. Windows don't render yet - a real glamor-vs-software-renderer conflict in XWayland's own fallback path, root-caused via WAYLAND_DEBUG tracing and documented in docs/IMPLEMENTATION_STATUS.md rather than worked around blind. All verified live in an isolated QEMU VM: X11 backend shows two decorated, correctly-tiled xterms with real title text; the DRM/udev Wayland backend opens the GPU, initializes input, and scans out a rendered frame via KMS page-flip.
-rw-r--r--Cargo.lock315
-rw-r--r--config/srd/rules.lua17
-rw-r--r--crates/config/src/lib.rs277
-rw-r--r--crates/core/src/keysyms.rs (renamed from crates/x11/src/keysyms.rs)0
-rw-r--r--crates/core/src/lib.rs5
-rw-r--r--crates/core/src/manager.rs81
-rw-r--r--crates/core/src/rules.rs85
-rw-r--r--crates/platform/src/lib.rs61
-rw-r--r--crates/wayland/Cargo.toml14
-rw-r--r--crates/wayland/src/decoration.rs191
-rw-r--r--crates/wayland/src/lib.rs225
-rw-r--r--crates/wayland/src/udev.rs554
-rw-r--r--crates/wayland/src/xwayland.rs240
-rw-r--r--crates/x11/src/lib.rs8
-rw-r--r--docs/DEFAULTS.md46
-rw-r--r--docs/IMPLEMENTATION_STATUS.md161
16 files changed, 2163 insertions, 117 deletions
diff --git a/Cargo.lock b/Cargo.lock
index abb2c6a..5484ebc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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