diff options
| author | srdusr <[email protected]> | 2024-04-02 01:07:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-04-02 01:07:00 +0200 |
| commit | 43175c78a5b450eb108738187b72e80d36a7bf5d (patch) | |
| tree | dbbadbcc18162f766f5af653d81882aaaa6f5e41 /crates/wayland | |
| parent | 8110bb2773b6c841029a51eca7971f42a36f480c (diff) | |
| download | srdwm-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.toml | 14 | ||||
| -rw-r--r-- | crates/wayland/src/decoration.rs | 191 | ||||
| -rw-r--r-- | crates/wayland/src/lib.rs | 225 | ||||
| -rw-r--r-- | crates/wayland/src/udev.rs | 554 | ||||
| -rw-r--r-- | crates/wayland/src/xwayland.rs | 240 |
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); + } +} |