srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland
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 /crates/wayland
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.
Diffstat (limited to 'crates/wayland')
-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
5 files changed, 1169 insertions, 55 deletions
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);
+ }
+}