diff options
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | crates/srdwm/Cargo.toml | 27 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 149 | ||||
| -rw-r--r-- | crates/wayland/Cargo.toml | 4 | ||||
| -rw-r--r-- | crates/wayland/src/input.rs | 277 | ||||
| -rw-r--r-- | crates/wayland/src/lib.rs | 702 | ||||
| -rw-r--r-- | crates/wayland/src/lock.rs | 171 | ||||
| -rw-r--r-- | crates/wayland/src/protocols.rs | 237 | ||||
| -rw-r--r-- | crates/wayland/src/screencopy.rs | 302 | ||||
| -rw-r--r-- | crates/wayland/src/state.rs | 356 | ||||
| -rw-r--r-- | crates/wayland/src/udev.rs | 508 | ||||
| -rw-r--r-- | crates/wayland/src/winit.rs | 412 | ||||
| -rw-r--r-- | crates/wayland/src/xwayland.rs | 2 | ||||
| -rw-r--r-- | docs/IMPLEMENTATION_STATUS.md | 223 |
15 files changed, 2524 insertions, 855 deletions
@@ -12,8 +12,12 @@ Testing/ compile_commands.json # Binaries and libraries -srdwm -SRDWM +# Anchored to the repo root: an unanchored `srdwm` matches *any* path +# component with that name, which silently excluded the whole +# `crates/srdwm/` source crate - the binary crate the workspace lists as a +# member, so a fresh clone could not build. +/srdwm +/SRDWM *.o *.obj *.a @@ -1890,6 +1890,7 @@ dependencies = [ "srdwm-core", "srdwm-platform", "thiserror 2.0.18", + "wayland-protocols-wlr", ] [[package]] diff --git a/crates/srdwm/Cargo.toml b/crates/srdwm/Cargo.toml new file mode 100644 index 0000000..d7af2ee --- /dev/null +++ b/crates/srdwm/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "srdwm" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SRDWM - a cross-platform, Lua-configured window manager" + +[[bin]] +name = "srdwm" +path = "src/main.rs" + +[dependencies] +srdwm-core.workspace = true +srdwm-platform.workspace = true +srdwm-config.workspace = true +log.workspace = true +env_logger.workspace = true + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +srdwm-x11.workspace = true +srdwm-wayland.workspace = true + +[target.'cfg(windows)'.dependencies] +srdwm-windows.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +srdwm-macos.workspace = true diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs new file mode 100644 index 0000000..cc7755e --- /dev/null +++ b/crates/srdwm/src/main.rs @@ -0,0 +1,149 @@ +use srdwm_config::Engine; +use srdwm_core::{Event, WindowManager}; +use srdwm_platform::{Platform, PlatformKind}; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + +fn config_dir() -> PathBuf { + if let Ok(p) = std::env::var("SRDWM_CONFIG_PATH") { + return PathBuf::from(p); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("srdwm/srd"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".config/srdwm/srd"); + } + PathBuf::from("config/srd") +} + +/// Applies `general.default_layout` to the workspaces that exist at +/// startup. +/// +/// srdwm is dynamic-first, not a tiling WM: the built-in default is +/// `"dynamic"`, where windows keep whatever geometry they have and only +/// *new* ones are positioned (by `SmartPlacement`, with Windows-style +/// drag-to-edge snapping). Tiling is one opt-in layout among several. +/// +/// Run *after* `load_init`, so a config that sets the key takes effect; +/// `srd.layout.set()` is the separate runtime switch (the shipped config +/// only calls it from key bindings), so this does not fight with it. +fn apply_default_layout(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) { + let name = engine.get_string("general.default_layout", "dynamic"); + let mut wm = wm.borrow_mut(); + if !wm.available_layouts().iter().any(|l| *l == name) { + log::warn!("general.default_layout = '{name}' is not a registered layout; keeping built-in default"); + return; + } + let ids: Vec<_> = wm.workspaces().iter().map(|w| w.id).collect(); + for id in ids { + wm.set_layout(id, name.clone()); + } + log::info!("default layout: {name}"); +} + +/// Applies the WindowManager's current layout decisions to the real +/// platform: re-tiles the active workspace if needed, then pushes geometry +/// and decoration state for every visible window. +fn sync(wm: &Rc<RefCell<WindowManager>>, platform: &mut dyn Platform) { + let ws = wm.borrow().current_workspace(); + wm.borrow_mut().arrange_workspace(ws); + + let focused = wm.borrow().focused_id(); + let snapshot: Vec<_> = wm.borrow().visible_windows().cloned().collect(); + for w in snapshot { + if let Err(e) = platform.apply_geometry(w.id, w.geometry) { + log::warn!("apply_geometry({}) failed: {e}", w.id); + } + if let Err(e) = platform.redraw_decoration(w.id, &w, focused == Some(w.id)) { + log::warn!("redraw_decoration({}) failed: {e}", w.id); + } + } +} + +fn main() -> Result<(), Box<dyn std::error::Error>> { + env_logger::init(); + log::info!("srdwm starting"); + + let wm = Rc::new(RefCell::new(WindowManager::new())); + let dir = config_dir(); + let engine = Engine::new(wm.clone(), &dir)?; + match engine.load_init() { + Ok(()) => log::info!("loaded config from {}", dir.display()), + Err(e) => log::warn!("no usable config at {} ({e}); running with built-in defaults", dir.display()), + } + apply_default_layout(&engine, &wm); + let running = engine.running_flag(); + + let kind = srdwm_platform::detect(); + log::info!("selected platform backend: {}", kind.name()); + + let mut platform: Box<dyn Platform> = match kind { + #[cfg(all(unix, not(target_os = "macos")))] + PlatformKind::X11 => { + let mut p = srdwm_x11::X11Platform::connect(wm.clone())?; + let combos = engine.bound_keys(); + p.grab_keybindings(&combos)?; + log::info!("grabbed {} keybinding(s)", combos.len()); + Box::new(p) + } + #[cfg(all(unix, not(target_os = "macos")))] + PlatformKind::Wayland => { + let combos = engine.bound_keys(); + log::info!("{} keybinding(s) will be intercepted from clients", combos.len()); + srdwm_wayland::connect(wm.clone(), &combos)? + } + #[cfg(windows)] + PlatformKind::Windows => Box::new(srdwm_windows::WindowsPlatform::new()?), + #[cfg(target_os = "macos")] + PlatformKind::MacOS => Box::new(srdwm_macos::MacOsPlatform::new()?), + #[allow(unreachable_patterns)] + other => return Err(format!("platform backend '{}' is not available on this build", other.name()).into()), + }; + + let monitors = platform.monitors()?; + log::info!("detected {} monitor(s)", monitors.len()); + wm.borrow_mut().set_monitors(monitors); + + sync(&wm, platform.as_mut()); + + while running.get() { + let events = match platform.poll_events() { + Ok(events) => events, + Err(e) => { + log::error!("poll_events failed: {e}"); + break; + } + }; + + let mut dirty = false; + for event in events { + match event { + Event::KeyPress { key_name, modifiers } => { + let combo = format!("{modifiers}{key_name}"); + if !engine.dispatch_keybinding(&combo) { + log::debug!("no binding for '{combo}'"); + } + dirty = true; + } + Event::WindowCreated(id) => { + log::info!("window {id} created"); + dirty = true; + } + Event::WindowDestroyed(id) => { + log::info!("window {id} destroyed"); + dirty = true; + } + Event::WindowMoved { .. } | Event::WindowResized { .. } => dirty = true, + _ => {} + } + } + if dirty { + sync(&wm, platform.as_mut()); + } + } + + log::info!("srdwm shutting down"); + Ok(()) +} diff --git a/crates/wayland/Cargo.toml b/crates/wayland/Cargo.toml index 4602608..fb86dd8 100644 --- a/crates/wayland/Cargo.toml +++ b/crates/wayland/Cargo.toml @@ -11,6 +11,10 @@ srdwm-platform.workspace = true log.workspace = true thiserror.workspace = true fontdue = "0.9" +# smithay 0.7 ships no wlr-screencopy helper, so `src/screencopy.rs` +# implements that protocol directly against these server bindings. Pinned to +# the same version smithay itself depends on, so both see one set of types. +wayland-protocols-wlr = { version = "0.3.12", features = ["server"] } [dependencies.smithay] version = "0.7" diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs new file mode 100644 index 0000000..76607ee --- /dev/null +++ b/crates/wayland/src/input.rs @@ -0,0 +1,277 @@ +//! Input routing: keyboard, pointer, and what "focus" means. +//! +//! Shared by both backends - smithay delivers keyboard/pointer events +//! through generic `InputBackend` traits, so the precise keybinding matching +//! and titlebar hit-testing exist once here and are called from the winit +//! backend ([`crate::winit`]) and the libinput/udev one ([`crate::udev`]) +//! alike. +//! +//! Every function that routes an event checks the session lock first: while +//! locked, input goes to the lock surface and nowhere else. See +//! [`crate::lock`]. + +use smithay::backend::input::{ButtonState as BackendButtonState, KeyState as BackendKeyState, KeyboardKeyEvent}; +use smithay::desktop::{layer_map_for_output, Window as DWindow, WindowSurfaceType}; +use smithay::input::keyboard::FilterResult; +use smithay::input::pointer::{ButtonEvent, MotionEvent}; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::utils::{Logical, Point, SERIAL_COUNTER}; +use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, Layer}; + +use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, WindowId}; + +use crate::state::CompState; + +pub(crate) fn last_pointer_pos(state: &CompState) -> Point<f64, Logical> { + state.seat.get_pointer().map(|p| p.current_location()).unwrap_or_default() +} + +/// Topmost layer-shell surface (if any) under `pos`, checked in the same +/// above-everything-else stacking order `space_render_elements` renders +/// `Overlay`/`Top` layers in (bars, launchers, notifications, lock UIs). +/// `Background`/`Bottom` layers (wallpapers) deliberately aren't checked +/// here: nothing in scope for the daily-driver gate needs pointer input +/// routed to them, and space windows should stay clickable over a +/// wallpaper. +/// `pos` is in the global space; layer geometry is relative to its own +/// output, so the pointer is translated into output-local coordinates +/// before hit-testing and the result translated back out. +pub(crate) fn layer_surface_under(state: &CompState, pos: Point<f64, Logical>) -> Option<(WlSurface, Point<i32, Logical>)> { + let entry = state.output_at(pos)?; + let origin = entry.location; + let local = pos - origin.to_f64(); + let map = layer_map_for_output(&entry.output); + for layer_kind in [Layer::Overlay, Layer::Top] { + let Some(layer) = map.layer_under(layer_kind, local) else { continue }; + let Some(geo) = map.layer_geometry(layer) else { continue }; + if let Some((surface, surface_loc)) = layer.surface_under(local - geo.loc.to_f64(), WindowSurfaceType::ALL) { + return Some((surface, origin + geo.loc + surface_loc)); + } + } + None +} + +pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Logical>, time: u32) { + // Locked: pointer motion goes to the lock surface only. No hit-testing + // against windows/decorations, so no hover, no drag, no resize. + if state.lock.locked { + let surface = state.any_lock_surface().cloned(); + if let Some(pointer) = state.seat.get_pointer() { + let focus = surface.map(|s| (s, Point::from((0, 0)).to_f64())); + pointer.motion(state, focus, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); + } + return; + } + + let layer_hit = layer_surface_under(state, pos); + let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32); + let under = state.space.element_under(pos).map(|(w, loc)| (w.clone(), loc)); + + let Some(pointer) = state.seat.get_pointer() else { return }; + if let Some((surface, loc)) = layer_hit { + let surface_loc = pos - loc.to_f64(); + pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time }); + } else if hit.is_some() { + // Over our own decoration - no client focus. + pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); + } else if let Some((window, loc)) = under { + if let Some(surface) = window.toplevel().map(|t| t.wl_surface().clone()) { + let surface_loc = pos - loc.to_f64(); + pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time }); + } + } else { + pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); + } + + let mut wm = state.wm.borrow_mut(); + let dragging_or_resizing = wm.is_dragging() || wm.is_resizing(); + if wm.is_dragging() { + wm.update_drag(pos.x as i32, pos.y as i32); + } else if wm.is_resizing() { + wm.update_resize(pos.x as i32, pos.y as i32); + } + let focused = wm.focused_id(); + drop(wm); + if dragging_or_resizing { + if let Some(id) = focused { + state.sync_geometry(id); + } + } +} + +/// The underlying `wl_surface` for a mapped window, regardless of whether +/// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` -- +/// `desktop::Window` exposes these as two separate accessors with no +/// shared one. +pub(crate) fn dwindow_wl_surface(w: &DWindow) -> Option<WlSurface> { + if let Some(top) = w.toplevel() { + return Some(top.wl_surface().clone()); + } + w.x11_surface().and_then(|x| x.wl_surface()) +} + +/// Requests a client close its window, whichever kind it is. +pub(crate) fn close_dwindow(w: &DWindow) { + if let Some(top) = w.toplevel() { + top.send_close(); + } else if let Some(x11) = w.x11_surface() { + let _ = x11.close(); + } +} + +/// Focuses `id` in our own `WindowManager` *and* gives its surface real +/// Wayland/X11 keyboard focus - without this, a window can be raised and +/// tiled correctly yet never receive a single keystroke. +pub(crate) fn focus_window(state: &mut CompState, id: WindowId) { + state.wm.borrow_mut().focus_window(id); + state.pending.borrow_mut().push(CoreEvent::WindowFocused(id)); + let surface = state.id_to_window.get(&id).and_then(dwindow_wl_surface); + // Routed through `set_keyboard_focus` (rather than calling + // `KeyboardHandle::set_focus` directly) so clipboard/primary-selection + // focus follows window focus too - see that method's doc comment. + state.set_keyboard_focus(surface); +} + +pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logical>, button: u32, pressed: bool, time: u32) { + const BTN_LEFT: u32 = 0x110; + let serial = SERIAL_COUNTER.next_serial(); + + // Locked: forward the click to the lock surface (it may have a button or + // a text field) but never let it focus, raise, drag, or close a window. + if state.lock.locked { + if let Some(pointer) = state.seat.get_pointer() { + let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released }; + pointer.button(state, &ButtonEvent { serial, time, button, state: button_state }); + } + return; + } + + if pressed && button == BTN_LEFT { + let layer_hit = layer_surface_under(state, pos); + if let Some((surface, _)) = &layer_hit { + // Look the surface up on whichever output actually holds it. + let on_demand = state + .outputs() + .find_map(|output| { + layer_map_for_output(output) + .layer_for_surface(surface, WindowSurfaceType::ALL) + .map(|l| { + l.can_receive_keyboard_focus() + && l.cached_state().keyboard_interactivity != KeyboardInteractivity::Exclusive + }) + }) + .unwrap_or(false); + // `Exclusive` layers (lock screens, exclusive launchers) already + // hold focus from `ensure_layer_initial_configure` and keep it + // regardless of where else is clicked; only `OnDemand` layers + // (e.g. a bar's search field) claim it on click. + if on_demand { + state.set_keyboard_focus(Some(surface.clone())); + } + } + let hit = if layer_hit.is_some() { None } else { state.wm.borrow().hit_test(pos.x as i32, pos.y as i32) }; + if let Some((id, hit)) = hit { + focus_window(state, id); + match hit { + TitlebarHit::Drag => state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32), + TitlebarHit::Close => { + if let Some(w) = state.id_to_window.get(&id) { + close_dwindow(w); + } + } + TitlebarHit::Maximize => { + state.wm.borrow_mut().toggle_maximize(id); + state.sync_geometry(id); + } + TitlebarHit::Minimize => state.wm.borrow_mut().minimize_window(id), + TitlebarHit::Resize(edge) => state.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32), + } + } else if layer_hit.is_none() { + if let Some((window, _loc)) = state.space.element_under(pos) { + let window = window.clone(); + state.space.raise_element(&window, true); + if let Some(&id) = dwindow_wl_surface(&window).and_then(|s| state.surface_to_id.get(&s)) { + focus_window(state, id); + } + } + } + } else if !pressed { + let mut wm = state.wm.borrow_mut(); + if wm.is_dragging() { + wm.end_drag(); + } else if wm.is_resizing() { + wm.end_resize(); + } + } + + if let Some(pointer) = state.seat.get_pointer() { + let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released }; + pointer.button(state, &ButtonEvent { serial, time, button, state: button_state }); + } +} + +/// 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. +pub(crate) 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 }; + + // While the session is locked, every key goes to the lock surface and + // *nothing* is treated as a WM keybinding. Skipping this would leave the + // lock trivially bypassable - the config binds spawn commands + // (`Mod4+Return` opens a terminal), so honouring bindings here would let + // anyone at a locked screen run arbitrary programs. + if state.lock.locked { + keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, _, _| FilterResult::Forward); + 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. +pub(crate) fn keysym_name_for(handle: smithay::input::keyboard::KeysymHandle<'_>) -> Option<String> { + srdwm_core::keysyms::keysym_to_name(handle.modified_sym().raw()) +} + +pub(crate) 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 +} diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs index fa56517..053e6ad 100644 --- a/crates/wayland/src/lib.rs +++ b/crates/wayland/src/lib.rs @@ -37,707 +37,29 @@ //! well-behaved clients don't also draw their own client-side titlebar. mod decoration; +mod input; +mod lock; +mod protocols; +mod screencopy; +mod state; mod udev; +mod winit; mod xwayland; use std::cell::RefCell; -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::memory::{MemoryRenderBuffer, MemoryRenderBufferRenderElement}; -use smithay::backend::renderer::element::Kind; -use smithay::backend::renderer::gles::GlesRenderer; -use smithay::backend::winit::{self, WinitEvent, WinitEventLoop, WinitGraphicsBackend}; -use smithay::desktop::space::render_output; -use smithay::desktop::{Space, Window as DWindow}; -use smithay::input::keyboard::FilterResult; -use smithay::input::pointer::{ButtonEvent, CursorImageStatus, MotionEvent}; -use smithay::input::{Seat, SeatHandler, SeatState}; -use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Subpixel}; -use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode; -use smithay::reexports::wayland_server::backend::{ClientData, ClientId, DisconnectReason}; -use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer; -use smithay::reexports::wayland_server::protocol::wl_seat; -use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; -use smithay::reexports::wayland_server::{Client, Display, ListeningSocket}; -use smithay::reexports::winit::platform::pump_events::PumpStatus; -use smithay::utils::{Logical, Point, Serial, Transform, SERIAL_COUNTER}; -use smithay::wayland::buffer::BufferHandler; -use smithay::wayland::compositor::{CompositorClientState, CompositorHandler, CompositorState}; -use smithay::wayland::shell::xdg::decoration::{XdgDecorationHandler, XdgDecorationState}; -use smithay::wayland::shell::xdg::{ - PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, XdgToplevelSurfaceData, -}; -use smithay::wayland::shm::{ShmHandler, ShmState}; -use smithay::{delegate_compositor, delegate_output, delegate_seat, delegate_shm, delegate_xdg_decoration, delegate_xdg_shell}; +use srdwm_core::WindowManager; +use srdwm_platform::{Platform, PlatformError, Result as PlatformResult}; -use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, Window as CoreWindow, WindowId, WindowManager, TITLEBAR_HEIGHT}; -use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult}; +pub use winit::WaylandPlatform; -fn err(e: impl std::fmt::Display) -> PlatformError { +/// Shared error shim: every backend turns foreign errors into +/// `PlatformError::Other` the same way. +pub(crate) fn err(e: impl std::fmt::Display) -> PlatformError { PlatformError::Other(e.to_string()) } -#[derive(Default)] -struct ClientState { - compositor_state: CompositorClientState, -} -impl ClientData for ClientState { - fn initialized(&self, _client_id: ClientId) {} - fn disconnected(&self, _client_id: ClientId, _reason: DisconnectReason) {} -} - -/// Everything smithay's protocol handlers need `&mut` access to. This is the -/// `D` type parameter of `Display<D>` - every `delegate_*!` macro below -/// requires the corresponding `*Handler` trait to be implemented on it. -struct CompState { - compositor_state: CompositorState, - xdg_shell_state: XdgShellState, - _xdg_decoration_state: XdgDecorationState, - shm_state: ShmState, - seat_state: SeatState<CompState>, - seat: Seat<CompState>, - space: Space<DWindow>, - wm: Rc<RefCell<WindowManager>>, - surface_to_id: HashMap<WlSurface, WindowId>, - id_to_window: HashMap<WindowId, DWindow>, - decorations: HashMap<WindowId, MemoryRenderBuffer>, - pending: Rc<RefCell<Vec<CoreEvent>>>, - 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(); - let id = { - let mut wm = self.wm.borrow_mut(); - let id = wm.alloc_window_id(); - let title = with_toplevel_title(&toplevel).unwrap_or_default(); - let mut w = CoreWindow::new(id, title); - w.geometry = srdwm_core::Rect::new(0, 0, 800, 600 + TITLEBAR_HEIGHT as i32 as u32); - wm.add_window(w); - id - }; - let geom = self.wm.borrow().window(id).map(|w| w.geometry).unwrap_or_default(); - - let dwindow = DWindow::new_wayland_window(toplevel.clone()); - toplevel.with_pending_state(|state| { - state.size = Some((geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into()); - }); - toplevel.send_configure(); - - self.space.map_element(dwindow.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), true); - self.surface_to_id.insert(surface, id); - self.id_to_window.insert(id, dwindow); - 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) { - self.space.unmap_elem(&w); - } - self.decorations.remove(&id); - self.wm.borrow_mut().remove_window(id); - self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id)); - } - - fn sync_geometry(&mut self, id: WindowId) { - let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { return }; - if let Some(w) = self.id_to_window.get(&id) { - self.space.map_element(w.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), false); - if let Some(top) = w.toplevel() { - top.with_pending_state(|state| { - state.size = Some((geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into()); - }); - top.send_configure(); - } - } - if self.decorations.contains_key(&id) { - self.redraw_decoration_buffer(id); - } - } -} - -fn with_toplevel_title(toplevel: &ToplevelSurface) -> Option<String> { - smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| { - states.data_map.get::<XdgToplevelSurfaceData>().map(|d| d.lock().unwrap().title.clone().unwrap_or_default()) - }) -} - -impl CompositorHandler for CompState { - fn compositor_state(&mut self) -> &mut CompositorState { - &mut self.compositor_state - } - - fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState { - // 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(); - } - } - } -} - -impl XdgShellHandler for CompState { - fn xdg_shell_state(&mut self) -> &mut XdgShellState { - &mut self.xdg_shell_state - } - - fn new_toplevel(&mut self, surface: ToplevelSurface) { - self.new_managed_window(surface); - } - - fn new_popup(&mut self, _surface: PopupSurface, _positioner: PositionerState) {} - - fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) {} - - fn reposition_request(&mut self, _surface: PopupSurface, _positioner: PositionerState, _token: u32) {} - - fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { - self.remove_window(surface.wl_surface()); - } -} - -impl XdgDecorationHandler for CompState { - fn new_decoration(&mut self, toplevel: ToplevelSurface) { - toplevel.with_pending_state(|state| { - state.decoration_mode = Some(DecorationMode::ServerSide); - }); - } - - fn request_mode(&mut self, toplevel: ToplevelSurface, _mode: DecorationMode) { - toplevel.with_pending_state(|state| { - state.decoration_mode = Some(DecorationMode::ServerSide); - }); - toplevel.send_configure(); - } - - fn unset_mode(&mut self, _toplevel: ToplevelSurface) {} -} - -impl ShmHandler for CompState { - fn shm_state(&self) -> &ShmState { - &self.shm_state - } -} - -impl BufferHandler for CompState { - fn buffer_destroyed(&mut self, _buffer: &WlBuffer) {} -} - -impl smithay::wayland::output::OutputHandler for CompState {} - -impl SeatHandler for CompState { - type KeyboardFocus = WlSurface; - type PointerFocus = WlSurface; - type TouchFocus = WlSurface; - - fn seat_state(&mut self) -> &mut SeatState<Self> { - &mut self.seat_state - } - - fn focus_changed(&mut self, _seat: &Seat<Self>, _focused: Option<&WlSurface>) {} - fn cursor_image(&mut self, _seat: &Seat<Self>, _image: CursorImageStatus) {} -} - -delegate_compositor!(CompState); -delegate_xdg_shell!(CompState); -delegate_xdg_decoration!(CompState); -delegate_shm!(CompState); -delegate_seat!(CompState); -delegate_output!(CompState); -// TODO: delegate_layer_shell! (wlr-layer-shell-unstable-v1) - bars, -// launchers, notification daemons, and lock-screen UIs are layer -// surfaces, not xdg_toplevels; none of them can run under srdwm without -// this. See docs/IMPLEMENTATION_STATUS.md's "Not implemented anywhere -// yet" section. -// TODO: delegate_data_device! (wl_data_device_manager) - no clipboard/ -// drag-and-drop between Wayland clients without it. -// TODO: session-lock (ext-session-lock-v1) - no way for a lock-screen -// client to actually lock input/display without it. - -pub struct WaylandPlatform { - display: Display<CompState>, - state: CompState, - backend: WinitGraphicsBackend<GlesRenderer>, - winit_events: WinitEventLoop, - damage_tracker: OutputDamageTracker, - output: Output, - listener: ListeningSocket, - clients: Vec<Client>, - pending: Rc<RefCell<Vec<CoreEvent>>>, - wm: Rc<RefCell<WindowManager>>, -} - -impl WaylandPlatform { - /// `bound_keys` are the config's `"Mod4+Shift+Return"`-style combo - /// strings (see `srdwm_core::key_combo_string`) - the same set the X11 - /// 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(); - - let (backend, winit_events) = winit::init::<GlesRenderer>().map_err(err)?; - let size = backend.window_size(); - - let output = Output::new( - "srdwm-wayland".to_string(), - PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "winit".into() }, - ); - output.change_current_state( - Some(OutputMode { size, refresh: 60_000 }), - Some(Transform::Normal), - None, - Some((0, 0).into()), - ); - output.create_global::<CompState>(&dh); - - let compositor_state = CompositorState::new::<CompState>(&dh); - let xdg_shell_state = XdgShellState::new::<CompState>(&dh); - let xdg_decoration_state = XdgDecorationState::new::<CompState>(&dh); - let shm_state = ShmState::new::<CompState>(&dh, Vec::new()); - let mut seat_state = SeatState::new(); - let mut seat = seat_state.new_wl_seat(&dh, "seat0"); - seat.add_keyboard(Default::default(), 200, 25).map_err(err)?; - seat.add_pointer(); - - let mut space = Space::default(); - space.map_output(&output, (0, 0)); - - let pending = Rc::new(RefCell::new(Vec::new())); - 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()), - 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)?; - if let Some(name) = listener.socket_name() { - std::env::set_var("WAYLAND_DISPLAY", name); - log::info!("wayland socket: {}", name.to_string_lossy()); - } - - let damage_tracker = OutputDamageTracker::from_output(&output); - - Ok(Self { display, state, backend, winit_events, damage_tracker, output, listener, clients: Vec::new(), pending, wm }) - } - - 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 pump_winit(&mut self) -> PlatformResult<bool> { - let mut closed = false; - let state = &mut self.state; - let output = &self.output; - let pump = self.winit_events.dispatch_new_events(|event| { - handle_winit_event(state, output, event, &mut closed); - }); - if matches!(pump, PumpStatus::Exit(_)) { - closed = true; - } - Ok(closed) - } - - fn render_frame(&mut self) -> PlatformResult<()> { - let size = self.backend.window_size(); - self.output.change_current_state(Some(OutputMode { size, refresh: 60_000 }), None, None, None); - - 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() { - 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}"), - } - } - - render_output( - &self.output, - renderer, - &mut framebuffer, - 1.0, - age, - [&self.state.space], - &custom_elements, - &mut self.damage_tracker, - [0.05, 0.05, 0.08, 1.0], - ) - .map_err(err)?; - drop(framebuffer); - self.backend.submit(None).map_err(err)?; - self.state.space.elements().for_each(|w| w.send_frame(&self.output, self.state.start_time.elapsed(), None, |_, _| Some(self.output.clone()))); - Ok(()) - } -} - -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 }) => 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); - handle_pointer_position(state, pos, event.time_msec()); - } - WinitEvent::Input(InputEvent::PointerButton { event }) => { - let pos = last_pointer_pos(state); - let button = event.button_code(); - let pressed = event.state() == BackendButtonState::Pressed; - handle_pointer_button(state, pos, button, pressed, event.time_msec()); - } - WinitEvent::Resized { .. } => {} - _ => {} - } -} - -/// 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> { - state.seat.get_pointer().map(|p| p.current_location()).unwrap_or_default() -} - -fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Logical>, time: u32) { - let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32); - let under = state.space.element_under(pos).map(|(w, loc)| (w.clone(), loc)); - - let Some(pointer) = state.seat.get_pointer() else { return }; - if hit.is_some() { - // Over our own decoration - no client focus. - pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); - } else if let Some((window, loc)) = under { - if let Some(surface) = window.toplevel().map(|t| t.wl_surface().clone()) { - let surface_loc = pos - loc.to_f64(); - pointer.motion(state, Some((surface, loc.to_f64())), &MotionEvent { location: surface_loc, serial: SERIAL_COUNTER.next_serial(), time }); - } - } else { - pointer.motion(state, None, &MotionEvent { location: pos, serial: SERIAL_COUNTER.next_serial(), time }); - } - - let mut wm = state.wm.borrow_mut(); - let dragging_or_resizing = wm.is_dragging() || wm.is_resizing(); - if wm.is_dragging() { - wm.update_drag(pos.x as i32, pos.y as i32); - } else if wm.is_resizing() { - wm.update_resize(pos.x as i32, pos.y as i32); - } - let focused = wm.focused_id(); - drop(wm); - if dragging_or_resizing { - if let Some(id) = focused { - state.sync_geometry(id); - } - } -} - -/// The underlying `wl_surface` for a mapped window, regardless of whether -/// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` -- -/// `desktop::Window` exposes these as two separate accessors with no -/// shared one. -fn dwindow_wl_surface(w: &DWindow) -> Option<WlSurface> { - if let Some(top) = w.toplevel() { - return Some(top.wl_surface().clone()); - } - w.x11_surface().and_then(|x| x.wl_surface()) -} - -/// Requests a client close its window, whichever kind it is. -fn close_dwindow(w: &DWindow) { - if let Some(top) = w.toplevel() { - top.send_close(); - } else if let Some(x11) = w.x11_surface() { - let _ = x11.close(); - } -} - -/// Focuses `id` in our own `WindowManager` *and* gives its surface real -/// Wayland/X11 keyboard focus - without this, a window can be raised and -/// tiled correctly yet never receive a single keystroke. -fn focus_window(state: &mut CompState, id: WindowId) { - state.wm.borrow_mut().focus_window(id); - state.pending.borrow_mut().push(CoreEvent::WindowFocused(id)); - let surface = state.id_to_window.get(&id).and_then(dwindow_wl_surface); - if let Some(keyboard) = state.seat.get_keyboard() { - let serial = SERIAL_COUNTER.next_serial(); - keyboard.set_focus(state, surface, serial); - } -} - -fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logical>, button: u32, pressed: bool, time: u32) { - const BTN_LEFT: u32 = 0x110; - let serial = SERIAL_COUNTER.next_serial(); - - if pressed && button == BTN_LEFT { - let hit = state.wm.borrow().hit_test(pos.x as i32, pos.y as i32); - if let Some((id, hit)) = hit { - focus_window(state, id); - match hit { - TitlebarHit::Drag => state.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32), - TitlebarHit::Close => { - if let Some(w) = state.id_to_window.get(&id) { - close_dwindow(w); - } - } - TitlebarHit::Maximize => { - state.wm.borrow_mut().toggle_maximize(id); - state.sync_geometry(id); - } - TitlebarHit::Minimize => state.wm.borrow_mut().minimize_window(id), - TitlebarHit::Resize(edge) => state.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32), - } - } else if let Some((window, _loc)) = state.space.element_under(pos) { - let window = window.clone(); - state.space.raise_element(&window, true); - if let Some(&id) = dwindow_wl_surface(&window).and_then(|s| state.surface_to_id.get(&s)) { - focus_window(state, id); - } - } - } else if !pressed { - let mut wm = state.wm.borrow_mut(); - if wm.is_dragging() { - wm.end_drag(); - } else if wm.is_resizing() { - wm.end_resize(); - } - } - - if let Some(pointer) = state.seat.get_pointer() { - let button_state = if pressed { BackendButtonState::Pressed } else { BackendButtonState::Released }; - pointer.button(state, &ButtonEvent { serial, time, button, state: button_state }); - } -} - -impl Platform for WaylandPlatform { - fn kind(&self) -> PlatformKind { - PlatformKind::Wayland - } - - fn poll_events(&mut self) -> PlatformResult<Vec<CoreEvent>> { - self.accept_clients()?; - let closed = self.pump_winit()?; - if closed { - return Err(PlatformError::Other("compositor window closed".into())); - } - self.display.dispatch_clients(&mut self.state).map_err(err)?; - self.display.flush_clients().map_err(err)?; - self.render_frame()?; - Ok(self.pending.borrow_mut().drain(..).collect()) - } - - fn monitors(&mut self) -> PlatformResult<Vec<srdwm_core::Monitor>> { - let size = self.backend.window_size(); - Ok(vec![{ - let mut m = srdwm_core::Monitor::new(0, "winit", srdwm_core::Rect::new(0, 0, size.w as u32, size.h as u32)); - m.primary = true; - m - }]) - } - - fn apply_geometry(&mut self, window: WindowId, geometry: srdwm_core::Rect) -> PlatformResult<()> { - let _ = geometry; - self.state.sync_geometry(window); - Ok(()) - } - - fn set_title(&mut self, _window: WindowId, _title: &str) -> PlatformResult<()> { - Ok(()) - } - - fn focus(&mut self, window: WindowId) -> PlatformResult<()> { - self.state.wm.borrow_mut().focus_window(window); - Ok(()) - } - - fn minimize(&mut self, window: 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: WindowId) -> PlatformResult<()> { - self.state.sync_geometry(window); - Ok(()) - } - - fn close(&mut self, window: 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: WindowId, _decorated: bool) -> PlatformResult<()> { - Ok(()) - } - - fn set_border_color(&mut self, _window: WindowId, _rgb: (u8, u8, u8)) -> PlatformResult<()> { - Ok(()) - } - - fn set_border_width(&mut self, _window: WindowId, _width: u32) -> PlatformResult<()> { - Ok(()) - } - - 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(()) - } - - fn grab_keyboard(&mut self) -> PlatformResult<()> { - Ok(()) - } - - fn ungrab_keyboard(&mut self) -> PlatformResult<()> { - 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 diff --git a/crates/wayland/src/lock.rs b/crates/wayland/src/lock.rs new file mode 100644 index 0000000..51b6158 --- /dev/null +++ b/crates/wayland/src/lock.rs @@ -0,0 +1,171 @@ +//! Session lock (`ext-session-lock-v1`). +//! +//! Kept as its own module because the security-relevant invariant spans +//! state, protocol handling *and* rendering: while [`SessionLock::locked`] +//! is set, no client surface other than the lock surface may be drawn or +//! receive input. The render helpers here are the "drawn" half; the input +//! half is the locked-session branches in [`crate::input`], and the focus +//! half is the guard in [`crate::state::CompState::set_keyboard_focus`]. + +use smithay::backend::renderer::element::surface::{render_elements_from_surface_tree, WaylandSurfaceRenderElement}; +use smithay::backend::renderer::element::Kind; +use smithay::output::Output; +use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::wayland::session_lock::{LockSurface, SessionLockHandler, SessionLockManagerState, SessionLocker}; + +use crate::input::dwindow_wl_surface; +use std::collections::{HashMap, HashSet}; + +use crate::state::CompState; + +/// Session-lock runtime state (`ext-session-lock-v1`). +/// +/// The security-relevant invariant is that `locked` gates *both* rendering +/// and input: while it is set, no client surface other than a lock surface +/// is drawn or receives events. `pending_confirm` exists because the +/// protocol requires the compositor to confirm the lock only *after* a frame +/// with no client content has actually been presented - confirming earlier +/// would tell the locker "the screen is safe" while the user's windows were +/// still on screen. So `lock()` only stashes the confirmation here, and the +/// render paths call `confirm_lock_if_presented()` once such a frame is out. +/// +/// With multiple monitors the locker creates **one lock surface per +/// output**, and the confirmation must wait for *every* output to have both +/// a surface and a presented frame - otherwise a second monitor could still +/// be showing the desktop at the moment the locker is told the session is +/// locked. +#[derive(Default)] +pub(crate) struct SessionLock { + pub(crate) locked: bool, + /// Lock surface per output, keyed by `Output::name()`. + pub(crate) surfaces: HashMap<String, LockSurface>, + pub(crate) pending_confirm: Option<SessionLocker>, + /// Outputs that have presented a client-content-free frame since the + /// lock request, keyed the same way. + pub(crate) presented: HashSet<String>, +} + +impl CompState { + /// Records that `output` has presented a lock frame, and confirms the + /// lock once *every* output has done so. Called from both backends right + /// after a frame is presented. See `SessionLock::pending_confirm`. + pub(crate) fn confirm_lock_if_presented(&mut self, output: &Output) { + if !self.lock.locked || self.lock.pending_confirm.is_none() { + return; + } + self.lock.presented.insert(output.name()); + + // Every output must be both covered by a lock surface and have shown + // a frame of it. An output still missing its surface means the locker + // hasn't got to it yet - keep waiting rather than confirm early. + let all_covered = self + .outputs() + .all(|o| self.lock.surfaces.contains_key(&o.name()) && self.lock.presented.contains(&o.name())); + if !all_covered { + return; + } + if let Some(confirm) = self.lock.pending_confirm.take() { + log::info!("session lock: all {} output(s) presented a cleared frame, confirming lock", self.outputs.len()); + confirm.lock(); + } + } + + /// The lock surface covering `output`, if we are locked and the locker + /// has created one for it. Used by the render paths and input routing. + pub(crate) fn lock_surface_for(&self, output: &Output) -> Option<&WlSurface> { + if !self.lock.locked { + return None; + } + self.lock.surfaces.get(&output.name()).map(|s| s.wl_surface()) + } + + /// Any lock surface at all - used by input routing, which needs a + /// keyboard-focus target rather than a per-output one. + pub(crate) fn any_lock_surface(&self) -> Option<&WlSurface> { + if !self.lock.locked { + return None; + } + self.lock.surfaces.values().next().map(|s| s.wl_surface()) + } +} + +impl SessionLockHandler for CompState { + fn lock_state(&mut self) -> &mut SessionLockManagerState { + &mut self.session_lock_state + } + + fn lock(&mut self, confirmation: SessionLocker) { + log::info!("session lock: locking"); + self.lock.locked = true; + self.lock.pending_confirm = Some(confirmation); + // Drop keyboard focus off whatever client had it immediately, so no + // keystroke can reach a normal client in the window between the lock + // request and the lock surface being mapped. Focus moves to the lock + // surface in `new_surface` once it exists. + self.set_keyboard_focus(None); + } + + fn unlock(&mut self) { + log::info!("session lock: unlocking"); + self.lock.locked = false; + self.lock.surfaces.clear(); + self.lock.presented.clear(); + self.lock.pending_confirm = None; + // Hand focus back to whatever srdwm considers the focused window. + let surface = self + .wm + .borrow() + .focused_id() + .and_then(|id| self.id_to_window.get(&id).cloned()) + .and_then(|w| dwindow_wl_surface(&w)); + self.set_keyboard_focus(surface); + } + + fn new_surface(&mut self, surface: LockSurface, wl_output: WlOutput) { + // The locker creates one surface per output and names which; size it + // to that output specifically, since monitors differ in resolution. + let Some(entry) = self.output_for_wl(&wl_output) else { + log::warn!("session lock: lock surface for an output we don't drive; ignoring"); + return; + }; + let name = entry.output.name(); + let size = entry.size(); + surface.with_pending_state(|state| { + state.size = Some((size.w as u32, size.h as u32).into()); + }); + surface.send_configure(); + // Store before focusing: `set_keyboard_focus`'s locked-session guard + // checks the focus target *against* the stored lock surfaces, so + // setting it afterwards would make the guard reject this very + // surface. + let wl_surface = surface.wl_surface().clone(); + self.lock.surfaces.insert(name, surface); + self.set_keyboard_focus(Some(wl_surface)); + } +} + +/// Render elements for a locked session: the lock surface alone, at the +/// output origin. Deliberately *not* built from the `Space` or the +/// `LayerMap` - while locked, nothing else may reach the screen, so the +/// caller pairs this with an opaque black clear color. Shared by both +/// backends (winit and udev), which differ only in their renderer type. +/// Takes the surface rather than `&CompState` so the udev backend can call +/// it while already holding a `&mut` borrow of its own `UdevOutput`. +pub(crate) fn lock_render_elements<R>(lock_surface: Option<&WlSurface>, renderer: &mut R) -> Vec<WaylandSurfaceRenderElement<R>> +where + R: smithay::backend::renderer::Renderer + smithay::backend::renderer::ImportAll, + R::TextureId: Clone + 'static, +{ + let Some(surface) = lock_surface else { return Vec::new() }; + render_elements_from_surface_tree(renderer, surface, (0, 0), 1.0, 1.0, Kind::Unspecified) +} + +/// Frame callbacks for the lock surface. Without these the locker never +/// gets to draw a second frame - no cursor blink, no password-dot +/// feedback, no failed-attempt shake. +pub(crate) fn send_lock_frame(lock_surface: Option<&WlSurface>, output: &Output, time: std::time::Duration) { + if let Some(surface) = lock_surface { + smithay::desktop::utils::send_frames_surface_tree(surface, output, time, None, |_, _| Some(output.clone())); + } +} diff --git a/crates/wayland/src/protocols.rs b/crates/wayland/src/protocols.rs new file mode 100644 index 0000000..582a622 --- /dev/null +++ b/crates/wayland/src/protocols.rs @@ -0,0 +1,237 @@ +//! smithay protocol-handler implementations for [`CompState`], plus the +//! `delegate_*!` macros that route each protocol's dispatch to them. +//! +//! Deliberately thin: these methods translate a protocol event into a call +//! on [`crate::state`] (window bookkeeping) or [`crate::input`] (focus), and +//! hold no logic of their own beyond what the protocol itself dictates. The +//! session-lock handler is the one exception, living in [`crate::lock`] +//! alongside the rest of that feature. + +use smithay::desktop::{layer_map_for_output, LayerSurface as DesktopLayerSurface}; +use smithay::input::pointer::CursorImageStatus; +use smithay::input::{Seat, SeatHandler, SeatState}; +use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode; +use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer; +use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; +use smithay::reexports::wayland_server::protocol::wl_seat; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::Client; +use smithay::utils::Serial; +use smithay::wayland::buffer::BufferHandler; +use smithay::wayland::compositor::{CompositorClientState, CompositorHandler, CompositorState}; +use smithay::wayland::selection::data_device::{ + ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, +}; +use smithay::wayland::selection::primary_selection::{PrimarySelectionHandler, PrimarySelectionState}; +use smithay::wayland::selection::wlr_data_control::{DataControlHandler, DataControlState}; +use smithay::wayland::selection::SelectionHandler; +use smithay::wayland::shell::wlr_layer::{ + Layer, LayerSurface as WlrLayerSurface, WlrLayerShellHandler, WlrLayerShellState, +}; +use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler; +use smithay::wayland::shell::xdg::{PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState}; +use smithay::wayland::shm::{ShmHandler, ShmState}; +use smithay::{ + delegate_compositor, delegate_data_control, delegate_data_device, delegate_layer_shell, delegate_output, + delegate_primary_selection, delegate_seat, delegate_session_lock, delegate_shm, delegate_xdg_decoration, + delegate_xdg_shell, +}; + +use crate::state::{ClientState, CompState}; + +impl CompositorHandler for CompState { + fn compositor_state(&mut self) -> &mut CompositorState { + &mut self.compositor_state + } + + fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState { + // 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(); + } + } + self.ensure_layer_initial_configure(surface); + } +} + +impl XdgShellHandler for CompState { + fn xdg_shell_state(&mut self) -> &mut XdgShellState { + &mut self.xdg_shell_state + } + + fn new_toplevel(&mut self, surface: ToplevelSurface) { + self.new_managed_window(surface); + } + + fn new_popup(&mut self, _surface: PopupSurface, _positioner: PositionerState) {} + + fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) {} + + fn reposition_request(&mut self, _surface: PopupSurface, _positioner: PositionerState, _token: u32) {} + + fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { + self.remove_window(surface.wl_surface()); + } +} + +impl XdgDecorationHandler for CompState { + fn new_decoration(&mut self, toplevel: ToplevelSurface) { + toplevel.with_pending_state(|state| { + state.decoration_mode = Some(DecorationMode::ServerSide); + }); + } + + fn request_mode(&mut self, toplevel: ToplevelSurface, _mode: DecorationMode) { + toplevel.with_pending_state(|state| { + state.decoration_mode = Some(DecorationMode::ServerSide); + }); + toplevel.send_configure(); + } + + fn unset_mode(&mut self, _toplevel: ToplevelSurface) {} +} + +impl ShmHandler for CompState { + fn shm_state(&self) -> &ShmState { + &self.shm_state + } +} + +impl BufferHandler for CompState { + fn buffer_destroyed(&mut self, _buffer: &WlBuffer) {} +} + +impl smithay::wayland::output::OutputHandler for CompState {} + +impl SeatHandler for CompState { + type KeyboardFocus = WlSurface; + type PointerFocus = WlSurface; + type TouchFocus = WlSurface; + + fn seat_state(&mut self) -> &mut SeatState<Self> { + &mut self.seat_state + } + + fn focus_changed(&mut self, _seat: &Seat<Self>, _focused: Option<&WlSurface>) {} + fn cursor_image(&mut self, _seat: &Seat<Self>, _image: CursorImageStatus) {} +} + +impl WlrLayerShellHandler for CompState { + fn shell_state(&mut self) -> &mut WlrLayerShellState { + &mut self.layer_shell_state + } + + fn new_layer_surface(&mut self, surface: WlrLayerSurface, wl_output: Option<WlOutput>, _layer: Layer, namespace: String) { + // A client may name the output it wants (a bar on a specific + // monitor); if it doesn't, or names one we don't drive, it lands on + // the primary output. + let output = wl_output + .as_ref() + .and_then(|wl| self.output_for_wl(wl)) + .map(|e| e.output.clone()) + .or_else(|| self.primary_output().cloned()); + let Some(output) = output else { + log::warn!("wayland: layer surface requested but no output exists yet"); + return; + }; + let layer_surface = DesktopLayerSurface::new(surface, namespace); + let result = layer_map_for_output(&output).map_layer(&layer_surface); + if let Err(e) = result { + log::warn!("wayland: failed to map layer surface: {e}"); + } + } + + fn layer_destroyed(&mut self, surface: WlrLayerSurface) { + // The surface belongs to exactly one output's map, but which one is + // the client's choice, so unmap from whichever holds it. + for output in self.outputs().cloned().collect::<Vec<_>>() { + let mut map = layer_map_for_output(&output); + let found = map.layers().find(|l| l.layer_surface() == &surface).cloned(); + if let Some(layer) = found { + map.unmap_layer(&layer); + break; + } + } + // A lock/launcher surface holding exclusive keyboard focus just + // vanished (crash, or a normal close) - don't leave focus dangling + // on a dead surface. + if self.seat.get_keyboard().and_then(|k| k.current_focus()).as_ref() == Some(surface.wl_surface()) { + self.set_keyboard_focus(None); + } + } +} + +/// Clipboard/primary-selection/drag-and-drop. +/// +/// All three selection protocols below (`wl_data_device_manager`, +/// `zwp_primary_selection_v1`, `zwlr_data_control_manager_v1`) share +/// smithay's single `SelectionHandler`. Every transfer here is +/// *client-to-client*: one client owns the selection and writes the bytes +/// itself, and smithay wires the two ends together without the data passing +/// through us. `send_selection` is only ever called for a +/// **compositor-provided** selection (one this WM set itself via +/// `set_data_device_selection`), which srdwm never does - so it is +/// deliberately left unimplemented rather than faked. +impl SelectionHandler for CompState { + type SelectionUserData = (); +} + +impl DataDeviceHandler for CompState { + fn data_device_state(&self) -> &DataDeviceState { + &self.data_device_state + } +} + +// Drag-and-drop: the default trait methods already do the right thing for a +// compositor that doesn't draw its own drag icon or offer server-side drag +// sources - smithay runs the pointer grab and the offer/accept negotiation +// internally. Both are implemented empty (rather than skipped) because +// `DataDeviceHandler` requires them as supertraits. +impl ClientDndGrabHandler for CompState {} +impl ServerDndGrabHandler for CompState {} + +impl PrimarySelectionHandler for CompState { + fn primary_selection_state(&self) -> &PrimarySelectionState { + &self.primary_selection_state + } +} + +/// `zwlr_data_control_manager_v1`: lets a client read/watch the selection +/// without ever holding keyboard focus. This is what `wl-paste --watch` +/// (and thus `cliphist store`, which the user's session autostarts) needs +/// - a focus-following clipboard manager is impossible without it. +impl DataControlHandler for CompState { + fn data_control_state(&self) -> &DataControlState { + &self.data_control_state + } +} + +delegate_compositor!(CompState); +delegate_xdg_shell!(CompState); +delegate_xdg_decoration!(CompState); +delegate_shm!(CompState); +delegate_seat!(CompState); +delegate_output!(CompState); +delegate_layer_shell!(CompState); +delegate_data_device!(CompState); +delegate_primary_selection!(CompState); +delegate_data_control!(CompState); +delegate_session_lock!(CompState); diff --git a/crates/wayland/src/screencopy.rs b/crates/wayland/src/screencopy.rs new file mode 100644 index 0000000..324669c --- /dev/null +++ b/crates/wayland/src/screencopy.rs @@ -0,0 +1,302 @@ +//! `wlr-screencopy-unstable-v1`: lets a client ask the compositor for the +//! contents of an output (or a region of one). This is what `grim` uses, and +//! therefore what the user's `Print`/`Alt+Print` screenshot binds +//! (`grim`, `slurp | grim -g -`) and `wf-recorder` need. +//! +//! Unlike every other protocol this backend speaks, smithay 0.7 ships no +//! helper for this one - there is no `ScreencopyState`/`ScreencopyHandler` +//! to delegate to - so the `GlobalDispatch`/`Dispatch` plumbing below is +//! written out by hand against the raw `wayland-protocols-wlr` server +//! bindings. +//! +//! Capture is deferred, not immediate: a `copy` request only *queues* the +//! frame (`CompState::screencopy_pending`), and the actual pixels are read +//! back inside the render pass, from the framebuffer that was just drawn +//! (see `service_pending`). Doing it at request time would either capture +//! the previous frame or require an extra off-screen render of the whole +//! scene; reading back the real framebuffer is both cheaper and matches what +//! is actually on screen. + +use std::time::UNIX_EPOCH; + +use smithay::backend::allocator::Fourcc; +use smithay::backend::renderer::{ExportMem, Renderer}; +use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer; +use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; +use smithay::reexports::wayland_server::protocol::wl_shm; +use smithay::reexports::wayland_server::{ + Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, +}; +use smithay::utils::{Buffer as BufferCoord, Physical, Rectangle, Size}; +use smithay::wayland::shm::with_buffer_contents_mut; +use wayland_protocols_wlr::screencopy::v1::server::{ + zwlr_screencopy_frame_v1::{self, ZwlrScreencopyFrameV1}, + zwlr_screencopy_manager_v1::{self, ZwlrScreencopyManagerV1}, +}; + +use crate::state::CompState; + +/// Every pixel we hand out is 4 bytes; the protocol needs a stride and the +/// shm format that matches what `copy_framebuffer` is asked to produce. +const BYTES_PER_PIXEL: u32 = 4; +const CAPTURE_FOURCC: Fourcc = Fourcc::Xrgb8888; +const CAPTURE_SHM_FORMAT: wl_shm::Format = wl_shm::Format::Xrgb8888; + +/// The screencopy manager global. Held by `CompState` purely to keep the +/// global alive for the compositor's lifetime. +#[derive(Debug)] +pub struct ScreencopyState { + _global: smithay::reexports::wayland_server::backend::GlobalId, +} + +impl ScreencopyState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<ZwlrScreencopyManagerV1, ()> + 'static, + { + // Version 3 advertises `linux_dmabuf`/`buffer_done`, which this + // software-readback implementation does not support, so cap at 2 -- + // that still covers `copy_with_damage`, which `wf-recorder` uses. + Self { _global: dh.create_global::<D, ZwlrScreencopyManagerV1, _>(2, ()) } + } +} + +/// State attached to each `zwlr_screencopy_frame_v1`. +#[derive(Debug, Clone)] +pub struct FrameData { + /// Region of the output to capture, in physical pixels. + pub region: Rectangle<i32, Physical>, + /// Set once `copy`/`copy_with_damage` has been handled, so a second one + /// can be rejected with the protocol's `already_used` error. + pub used: bool, +} + +/// A capture that has been requested but not yet serviced. Drained by +/// `service_pending` during the next render pass. +#[derive(Debug)] +pub struct PendingCapture { + pub frame: ZwlrScreencopyFrameV1, + pub buffer: WlBuffer, + pub region: Rectangle<i32, Physical>, + /// `copy_with_damage` clients expect a `damage` event before `ready`. + pub with_damage: bool, +} + +impl GlobalDispatch<ZwlrScreencopyManagerV1, ()> for CompState { + fn bind( + _state: &mut Self, + _dh: &DisplayHandle, + _client: &Client, + manager: New<ZwlrScreencopyManagerV1>, + _data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(manager, ()); + } +} + +impl Dispatch<ZwlrScreencopyManagerV1, ()> for CompState { + fn request( + state: &mut Self, + _client: &Client, + _manager: &ZwlrScreencopyManagerV1, + request: zwlr_screencopy_manager_v1::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + use zwlr_screencopy_manager_v1::Request; + let (frame, region) = match request { + Request::CaptureOutput { frame, overlay_cursor: _, output } => { + (frame, state.output_capture_region(&output)) + } + Request::CaptureOutputRegion { frame, overlay_cursor: _, output, x, y, width, height } => { + // Clamp to the output: a client is free to ask for a region + // hanging off the edge (slurp will, at a screen border), and + // `copy_framebuffer` errors out on out-of-bounds reads. + let full = state.output_capture_region(&output); + let requested = Rectangle::new((x, y).into(), (width.max(0), height.max(0)).into()); + (frame, full.intersection(requested).unwrap_or_default()) + } + Request::Destroy => return, + _ => return, + }; + + let frame = data_init.init(frame, FrameData { region, used: false }); + if region.size.w <= 0 || region.size.h <= 0 { + // Nothing to capture (empty or fully off-screen region). + frame.failed(); + return; + } + frame.buffer( + CAPTURE_SHM_FORMAT, + region.size.w as u32, + region.size.h as u32, + region.size.w as u32 * BYTES_PER_PIXEL, + ); + } +} + +impl Dispatch<ZwlrScreencopyFrameV1, FrameData> for CompState { + fn request( + state: &mut Self, + _client: &Client, + frame: &ZwlrScreencopyFrameV1, + request: zwlr_screencopy_frame_v1::Request, + data: &FrameData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + use zwlr_screencopy_frame_v1::Request; + let (buffer, with_damage) = match request { + Request::Copy { buffer } => (buffer, false), + Request::CopyWithDamage { buffer } => (buffer, true), + Request::Destroy => { + state.screencopy_pending.retain(|p| &p.frame != frame); + return; + } + _ => return, + }; + + if data.used { + frame.post_error(zwlr_screencopy_frame_v1::Error::AlreadyUsed, "frame was already copied"); + return; + } + + // Validate the buffer really can hold the frame before promising a + // capture - a mismatch here would otherwise be a silent short write. + let expected_stride = data.region.size.w as u32 * BYTES_PER_PIXEL; + let ok = with_buffer_contents_mut(&buffer, |_ptr, len, spec| { + spec.format == CAPTURE_SHM_FORMAT + && spec.width == data.region.size.w + && spec.height == data.region.size.h + && spec.stride as u32 == expected_stride + && len >= (expected_stride * data.region.size.h as u32) as usize + }) + .unwrap_or(false); + if !ok { + frame.post_error( + zwlr_screencopy_frame_v1::Error::InvalidBuffer, + "buffer does not match the advertised format/size/stride", + ); + return; + } + + state.screencopy_pending.push(PendingCapture { + frame: frame.clone(), + buffer, + region: data.region, + with_damage, + }); + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, frame: &ZwlrScreencopyFrameV1, _data: &FrameData) { + state.screencopy_pending.retain(|p| &p.frame != frame); + } +} + +impl CompState { + /// Full-output capture region for the output the client named, in that + /// output's own physical pixels (captures read back a single output's + /// framebuffer, so coordinates are output-local, not global). + fn output_capture_region(&self, output: &WlOutput) -> Rectangle<i32, Physical> { + let size: Size<i32, Physical> = self + .output_for_wl(output) + .and_then(|e| e.output.current_mode()) + .map(|m| m.size) + .unwrap_or_default(); + Rectangle::from_size(size) + } +} + +/// Services every queued capture against the framebuffer that was just +/// rendered, then answers each client with `flags` + `ready` (or `failed`). +/// +/// Called from inside both backends' render passes, while `framebuffer` is +/// still bound and holds the current frame. +/// Rejects queued captures outright. Used while the session is locked: the +/// render pass draws only the lock surface, so there is no frame a capture +/// could legitimately be served from. Failing immediately is both correct +/// (the client gets an answer instead of hanging) and safer than leaving +/// requests queued, which would otherwise all fire against the first +/// *unlocked* frame after the screen is unlocked. +pub fn fail_pending(pending: Vec<PendingCapture>) { + for capture in pending { + if capture.frame.is_alive() { + capture.frame.failed(); + } + } +} + +/// Takes the queue by value rather than `&mut CompState` so the udev +/// backend can call it while already holding a `&mut` borrow of the +/// `UdevOutput` that owns its renderer. Callers drain +/// `CompState::screencopy_pending` before binding the renderer. +pub fn service_pending<R>(pending: Vec<PendingCapture>, renderer: &mut R, framebuffer: &R::Framebuffer<'_>) +where + R: Renderer + ExportMem, +{ + for pending in pending { + if !pending.frame.is_alive() { + continue; + } + match copy_region(renderer, framebuffer, pending.region, &pending.buffer) { + Ok(()) => { + // No `y_invert`: `copy_framebuffer` hands back rows already + // in top-down order for both renderers used here (verified + // against a real `grim` capture - an inverted image is the + // immediately visible symptom if this is ever wrong). + pending.frame.flags(zwlr_screencopy_frame_v1::Flags::empty()); + if pending.with_damage { + pending.frame.damage(0, 0, pending.region.size.w as u32, pending.region.size.h as u32); + } + let now = UNIX_EPOCH.elapsed().unwrap_or_default(); + let secs = now.as_secs(); + pending.frame.ready((secs >> 32) as u32, secs as u32, now.subsec_nanos()); + } + Err(e) => { + log::warn!("screencopy: capture failed: {e}"); + pending.frame.failed(); + } + } + } +} + +fn copy_region<R>( + renderer: &mut R, + framebuffer: &R::Framebuffer<'_>, + region: Rectangle<i32, Physical>, + buffer: &WlBuffer, +) -> Result<(), String> +where + R: Renderer + ExportMem, +{ + // `copy_framebuffer` works in buffer coordinates; with no output + // transform or fractional scale in play (see this backend's single + // `Output`), those are the same numbers as the physical ones. + let src: Rectangle<i32, BufferCoord> = Rectangle::new((region.loc.x, region.loc.y).into(), (region.size.w, region.size.h).into()); + let mapping = renderer + .copy_framebuffer(framebuffer, src, CAPTURE_FOURCC) + .map_err(|e| format!("copy_framebuffer: {e}"))?; + let pixels = renderer.map_texture(&mapping).map_err(|e| format!("map_texture: {e}"))?; + + let stride = region.size.w as usize * BYTES_PER_PIXEL as usize; + let needed = stride * region.size.h as usize; + if pixels.len() < needed { + return Err(format!("readback produced {} bytes, need {}", pixels.len(), needed)); + } + + with_buffer_contents_mut(buffer, |ptr, len, _spec| { + if len < needed { + return Err(format!("client buffer holds {len} bytes, need {needed}")); + } + // SAFETY: `with_buffer_contents_mut` guarantees `ptr` is valid for + // `len` bytes for the duration of this closure, and `needed <= len` + // was just checked. Source and destination are distinct mappings. + unsafe { + std::ptr::copy_nonoverlapping(pixels.as_ptr(), ptr, needed); + } + Ok(()) + }) + .map_err(|e| format!("client buffer not accessible: {e}"))? +} diff --git a/crates/wayland/src/state.rs b/crates/wayland/src/state.rs new file mode 100644 index 0000000..6bc6f1a --- /dev/null +++ b/crates/wayland/src/state.rs @@ -0,0 +1,356 @@ +//! Compositor state shared by both backends. +//! +//! [`CompState`] is the `D` type parameter of smithay's `Display<D>`: every +//! protocol handler in [`crate::protocols`] is implemented on it, and every +//! calloop callback the udev backend registers receives `&mut CompState`. +//! That makes it the one place backend-agnostic state has to live - hence +//! the `udev` field, which is `Some` only for the DRM backend. +//! +//! The inherent methods here are srdwm's own window bookkeeping (mapping +//! toplevels to `srdwm_core::WindowId`s, pushing geometry back out, keeping +//! titlebar buffers current). Protocol *reactions* live in +//! [`crate::protocols`]; input routing lives in [`crate::input`]. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use std::time::Instant; + +use smithay::backend::allocator::Fourcc; +use smithay::backend::renderer::element::memory::MemoryRenderBuffer; +use smithay::desktop::{layer_map_for_output, Space, Window as DWindow, WindowSurfaceType}; +use smithay::input::{Seat, SeatState}; +use smithay::output::Output; +use smithay::reexports::wayland_server::backend::{ClientData, ClientId, DisconnectReason}; +use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::{DisplayHandle, Resource}; +use smithay::utils::{Logical, Point, Rectangle, Size, Transform, SERIAL_COUNTER}; +use smithay::wayland::compositor::{with_states, CompositorClientState, CompositorState}; +use smithay::wayland::selection::data_device::{set_data_device_focus, DataDeviceState}; +use smithay::wayland::selection::primary_selection::{set_primary_focus, PrimarySelectionState}; +use smithay::wayland::selection::wlr_data_control::DataControlState; +use smithay::wayland::session_lock::SessionLockManagerState; +use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, LayerSurfaceData, WlrLayerShellState}; +use smithay::wayland::shell::xdg::{ToplevelSurface, XdgShellState, XdgToplevelSurfaceData}; +use smithay::wayland::shell::xdg::decoration::XdgDecorationState; +use smithay::wayland::shm::ShmState; + +use srdwm_core::{Event as CoreEvent, Window as CoreWindow, WindowId, WindowManager, TITLEBAR_HEIGHT}; + +use crate::lock::SessionLock; +use crate::{decoration, screencopy, udev, xwayland}; + +#[derive(Default)] +pub(crate) struct ClientState { + pub(crate) compositor_state: CompositorClientState, +} +impl ClientData for ClientState { + fn initialized(&self, _client_id: ClientId) {} + fn disconnected(&self, _client_id: ClientId, _reason: DisconnectReason) {} +} + +/// One output srdwm drives, and where it sits in the global coordinate +/// space. +/// +/// The smithay [`Output`] is the protocol object: it carries the +/// `wl_output` global and owns that output's `LayerMap`. +/// +/// A entry's **index in `CompState::outputs` is its +/// [`srdwm_core::Monitor`] id** - the udev backend builds both lists in +/// the same connector order, so core's already-multi-monitor-aware layout +/// code (`WindowManager::arrange_workspace` groups windows by monitor) +/// lines up with what is actually on screen without a separate mapping. +pub(crate) struct OutputEntry { + pub(crate) output: Output, + /// Origin of this output in the global space. Outputs are laid out + /// left-to-right, so this is `(sum of widths to the left, 0)`. + pub(crate) location: Point<i32, Logical>, +} + +impl OutputEntry { + /// Size in logical coordinates, or `(0, 0)` if no mode is set yet. + pub(crate) fn size(&self) -> Size<i32, Logical> { + self.output + .current_mode() + .map(|m| (m.size.w, m.size.h).into()) + .unwrap_or_default() + } + + /// This output's rectangle in the global space. + pub(crate) fn geometry(&self) -> Rectangle<i32, Logical> { + Rectangle::new(self.location, self.size()) + } +} + +/// Everything smithay's protocol handlers need `&mut` access to. This is the +/// `D` type parameter of `Display<D>` - every `delegate_*!` macro below +/// requires the corresponding `*Handler` trait to be implemented on it. +pub(crate) struct CompState { + pub(crate) compositor_state: CompositorState, + pub(crate) xdg_shell_state: XdgShellState, + pub(crate) _xdg_decoration_state: XdgDecorationState, + pub(crate) shm_state: ShmState, + pub(crate) seat_state: SeatState<CompState>, + pub(crate) seat: Seat<CompState>, + pub(crate) space: Space<DWindow>, + /// Every output srdwm drives, left-to-right in the global coordinate + /// space. The winit backend always has exactly one (its nested window); + /// the udev backend has one per connected connector. + /// + /// Nothing outside this module should index this directly - go through + /// [`CompState::primary_output`], [`CompState::output_at`] or + /// [`CompState::output_for_wl`], so the single- and multi-output cases + /// stay the same code path. + pub(crate) outputs: Vec<OutputEntry>, + pub(crate) layer_shell_state: WlrLayerShellState, + /// Needed by the selection (clipboard) protocols: `set_data_device_focus` + /// and `set_primary_focus` both take a `DisplayHandle`, and focus has to + /// be re-pointed on every focus change (see `set_keyboard_focus`). + pub(crate) dh: DisplayHandle, + pub(crate) data_device_state: DataDeviceState, + pub(crate) primary_selection_state: PrimarySelectionState, + pub(crate) data_control_state: DataControlState, + pub(crate) session_lock_state: SessionLockManagerState, + pub(crate) _screencopy_state: screencopy::ScreencopyState, + /// Captures requested via `wlr-screencopy` but not yet serviced; drained + /// inside the render pass (see `screencopy::service_pending`). + pub(crate) screencopy_pending: Vec<screencopy::PendingCapture>, + /// Session lock (`ext-session-lock-v1`). While `locked` is set, client + /// content is never rendered and input never reaches normal clients -- + /// see `SessionLockHandler` below. + pub(crate) lock: SessionLock, + pub(crate) wm: Rc<RefCell<WindowManager>>, + pub(crate) surface_to_id: HashMap<WlSurface, WindowId>, + pub(crate) id_to_window: HashMap<WindowId, DWindow>, + pub(crate) decorations: HashMap<WindowId, MemoryRenderBuffer>, + pub(crate) pending: Rc<RefCell<Vec<CoreEvent>>>, + pub(crate) bound_keys: Rc<HashSet<String>>, + pub(crate) 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. + pub(crate) udev: Option<udev::UdevState>, + /// XWayland support; see `xwayland.rs` module docs. `xwm` is `None` + /// until `XWaylandEvent::Ready` fires. + pub(crate) xwayland_shell_state: smithay::wayland::xwayland_shell::XWaylandShellState, + pub(crate) xwm: Option<smithay::xwayland::X11Wm>, + pub(crate) xwayland_windows: HashMap<xwayland::X11Window, WindowId>, + /// Mapped X11 windows still waiting for XWayland to associate a + /// `wl_surface` - see `xwayland.rs` and `commit()` above. + pub(crate) xwayland_pending: Vec<smithay::xwayland::X11Surface>, +} + +/// 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); + +/// Output lookup. Everything that used to reach for a single +/// `CompState::output` goes through one of these, so adding outputs did not +/// require every call site to learn about multiple ones. +impl CompState { + /// The output new surfaces land on when nothing else determines it. + /// First in the list, matching the udev backend's connector order. + pub(crate) fn primary_output(&self) -> Option<&Output> { + self.outputs.first().map(|e| &e.output) + } + + /// The output containing a point in the global space - pointer + /// hit-testing, and deciding which output a window belongs to. Falls + /// back to the primary output if the point is outside every output + /// (possible between mismatched-height monitors). + pub(crate) fn output_at(&self, pos: Point<f64, Logical>) -> Option<&OutputEntry> { + let point = pos.to_i32_round(); + self.outputs + .iter() + .find(|e| e.geometry().contains(point)) + .or_else(|| self.outputs.first()) + } + + /// Resolves a client-supplied `wl_output` to one of ours. Clients name + /// outputs in layer-shell, session-lock and screencopy requests. + pub(crate) fn output_for_wl(&self, wl: &WlOutput) -> Option<&OutputEntry> { + let output = Output::from_resource(wl)?; + self.outputs.iter().find(|e| e.output == output) + } + + /// Iterator over the smithay outputs, for render loops. + pub(crate) fn outputs(&self) -> impl Iterator<Item = &Output> { + self.outputs.iter().map(|e| &e.output) + } +} + +impl CompState { + pub(crate) fn new_managed_window(&mut self, toplevel: ToplevelSurface) { + let surface = toplevel.wl_surface().clone(); + let id = { + let mut wm = self.wm.borrow_mut(); + let id = wm.alloc_window_id(); + let title = with_toplevel_title(&toplevel).unwrap_or_default(); + let mut w = CoreWindow::new(id, title); + w.geometry = srdwm_core::Rect::new(0, 0, 800, 600 + TITLEBAR_HEIGHT as i32 as u32); + wm.add_window(w); + id + }; + let geom = self.wm.borrow().window(id).map(|w| w.geometry).unwrap_or_default(); + + let dwindow = DWindow::new_wayland_window(toplevel.clone()); + toplevel.with_pending_state(|state| { + state.size = Some((geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into()); + }); + toplevel.send_configure(); + + self.space.map_element(dwindow.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), true); + self.surface_to_id.insert(surface.clone(), id); + self.id_to_window.insert(id, dwindow); + self.redraw_decoration_buffer(id); + // `WindowManager::add_window` already made this the focused window in + // srdwm's own state, but that alone is purely internal bookkeeping -- + // without this, a freshly-opened window receives no keystrokes and + // can't copy/paste until it's clicked, because nothing ever gave it + // real Wayland keyboard/selection focus. (Same class of bug as the + // click-to-focus one fixed earlier; this is the creation path.) + self.set_keyboard_focus(Some(surface)); + 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). + pub(crate) 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); + } + + pub(crate) 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) { + self.space.unmap_elem(&w); + } + self.decorations.remove(&id); + self.wm.borrow_mut().remove_window(id); + self.pending.borrow_mut().push(CoreEvent::WindowDestroyed(id)); + } + + /// Layer surfaces need a configure sent in direct response to their + /// first commit (sending it any earlier violates the protocol - see + /// `smithay::desktop::LayerMap::arrange`'s doc comment on why `arrange` + /// itself deliberately won't send one). Also the point at which an + /// `Exclusive`-interactivity layer (e.g. a lock screen, or a launcher + /// configured to grab all keyboard input) claims keyboard focus, since + /// its `keyboard_interactivity` isn't reliably known until the client's + /// state has actually committed. + pub(crate) fn ensure_layer_initial_configure(&mut self, surface: &WlSurface) { + // A layer surface lives in exactly one output's `LayerMap` (whichever + // one `new_layer_surface` mapped it into), so find that output rather + // than assuming a single global one. + let found = self.outputs().find_map(|output| { + let layer = layer_map_for_output(output).layer_for_surface(surface, WindowSurfaceType::TOPLEVEL).cloned(); + layer.map(|l| (output.clone(), l)) + }); + let Some((output, layer)) = found else { return }; + + // Recompute geometry from whatever the client just committed + // (`set_size`/`set_anchor`/`set_margin`/`set_exclusive_zone` are all + // double-buffered, applied on this commit) *before* looking at + // `initial_configure_sent` - `map_layer`'s own `arrange()` call ran + // before the client had sent any of that, so without this, the + // first configure would carry stale, pre-request-processed geometry + // (verified live: wofi's `set_size(420, 550)` was otherwise ignored + // and it got stuck at the half-output fallback size instead). Every + // later commit needs the same treatment for live resizes/anchor + // changes; `arrange()` only actually sends a configure when + // something changed, so this is a no-op on a commit that didn't + // touch layer-shell state. + layer_map_for_output(&output).arrange(); + + let initial_configure_sent = with_states(surface, |states| { + states + .data_map + .get::<LayerSurfaceData>() + .map(|d| d.lock().unwrap().initial_configure_sent) + .unwrap_or(false) + }); + if !initial_configure_sent { + layer.layer_surface().send_configure(); + } + + // Checked on every commit, not just the first: a client can flip + // `keyboard_interactivity` to `Exclusive` after already being + // mapped (and this is also, in practice, where a freshly-mapped + // `Exclusive` surface - e.g. wofi, which requests it from the very + // first commit - actually gets focus, since `set_keyboard_focus` + // is idempotent against a surface that's already focused). + if layer.cached_state().keyboard_interactivity == KeyboardInteractivity::Exclusive { + self.set_keyboard_focus(Some(surface.clone())); + } + } + + /// Sets keyboard focus *and* selection (clipboard/primary) focus to the + /// same surface's client. These have to move together: the data-device + /// protocols only ever offer the current selection to the client that + /// holds selection focus, and only accept `set_selection` from it, so a + /// window that has keyboard focus but not data-device focus can neither + /// paste nor copy. + pub(crate) fn set_keyboard_focus(&mut self, surface: Option<WlSurface>) { + // While the session is locked, only the lock surface may hold focus. + // This is the single chokepoint that enforces it: without the guard, + // any path that focuses a window - notably `new_managed_window`, + // i.e. *a client simply opening a window* - would hand keyboard + // focus to a normal client at a locked screen. (Caught by an A/B + // test that counted `wl_keyboard.enter` events delivered to a client + // launched while locked; it was 1 before this guard, 0 after.) + if self.lock.locked { + // With multiple outputs there is a lock surface per output, and + // any of them is a legitimate focus target. + let is_lock_surface = surface + .as_ref() + .is_some_and(|s| self.lock.surfaces.values().any(|lock| lock.wl_surface() == s)); + if surface.is_some() && !is_lock_surface { + return; + } + } + let Some(keyboard) = self.seat.get_keyboard() else { return }; + if keyboard.current_focus() == surface { + return; + } + let client = surface.as_ref().and_then(|s| self.dh.get_client(s.id()).ok()); + set_data_device_focus(&self.dh.clone(), &self.seat.clone(), client.clone()); + set_primary_focus(&self.dh.clone(), &self.seat.clone(), client); + let serial = SERIAL_COUNTER.next_serial(); + keyboard.set_focus(self, surface, serial); + } + + pub(crate) fn sync_geometry(&mut self, id: WindowId) { + let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { return }; + if let Some(w) = self.id_to_window.get(&id) { + self.space.map_element(w.clone(), (geom.x, geom.y + TITLEBAR_HEIGHT as i32), false); + if let Some(top) = w.toplevel() { + top.with_pending_state(|state| { + state.size = Some((geom.width as i32, (geom.height - TITLEBAR_HEIGHT) as i32).into()); + }); + top.send_configure(); + } + } + if self.decorations.contains_key(&id) { + self.redraw_decoration_buffer(id); + } + } +} + +pub(crate) fn with_toplevel_title(toplevel: &ToplevelSurface) -> Option<String> { + smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| { + states.data_map.get::<XdgToplevelSurfaceData>().map(|d| d.lock().unwrap().title.clone().unwrap_or_default()) + }) +} diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs index dcd5f76..2b491e7 100644 --- a/crates/wayland/src/udev.rs +++ b/crates/wayland/src/udev.rs @@ -2,10 +2,12 @@ //! 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. +//! Scope: +//! - Single primary GPU, but **every** connected connector on it: each +//! becomes a [`UdevHead`] with its own scanout buffers, damage tracker +//! and page-flip state, laid out left-to-right in the global coordinate +//! space. Connectors are probed once at startup - no hotplug, and no +//! second GPU. //! - 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 @@ -36,7 +38,7 @@ use smithay::backend::input::{ }; use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; use smithay::backend::renderer::damage::OutputDamageTracker; -use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; +use smithay::backend::renderer::element::memory::{MemoryRenderBuffer, MemoryRenderBufferRenderElement}; use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::pixman::PixmanRenderer; use smithay::backend::renderer::Bind; @@ -51,7 +53,8 @@ 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, + connector, crtc, dumbbuffer::DumbBuffer, framebuffer, Device as ControlDevice, Event as DrmEvent, Mode as DrmMode, + ModeTypeFlags, PageFlipFlags, }; use smithay::reexports::drm::Device as BasicDevice; use smithay::reexports::input::Libinput; @@ -60,6 +63,9 @@ 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::selection::data_device::DataDeviceState; +use smithay::wayland::selection::primary_selection::PrimarySelectionState; +use smithay::wayland::selection::wlr_data_control::DataControlState; use smithay::wayland::shell::xdg::decoration::XdgDecorationState; use smithay::wayland::shell::xdg::XdgShellState; use smithay::wayland::shm::ShmState; @@ -67,13 +73,13 @@ 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, -}; +use crate::err; +use crate::input::{handle_keyboard_key_event, handle_pointer_button, handle_pointer_position}; +use crate::state::{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); +pub(crate) struct Card(OwnedFd); impl AsFd for Card { fn as_fd(&self) -> BorrowedFd<'_> { @@ -83,98 +89,198 @@ impl AsFd for Card { impl BasicDevice for Card {} impl ControlDevice for Card {} -struct DrmBuffer { +pub(crate) struct DrmBuffer { dumb: DumbBuffer, fb: framebuffer::Handle, image: Image<'static, 'static>, } +/// One connector+CRTC pair srdwm scans out to - i.e. one physical monitor. +/// +/// Each head owns its own scanout buffers, damage tracker and flip state, +/// because monitors have independent resolutions and refresh cycles: a flip +/// completing on one says nothing about the others. The *renderer* is not +/// here but on [`UdevState`], since all heads on one GPU share it. +pub(crate) struct UdevHead { + pub(crate) crtc: crtc::Handle, + pub(crate) output: Output, + pub(crate) damage_tracker: OutputDamageTracker, + pub(crate) buffers: [DrmBuffer; 2], + pub(crate) front: usize, + /// A flip is in flight; the next frame for this head waits for the DRM + /// page-flip event (matched by `crtc`) before starting. + pub(crate) flip_pending: bool, + /// Origin of this head in the global coordinate space. + pub(crate) location: Point<i32, Logical>, + pub(crate) size: (i32, i32), +} + /// 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), +pub(crate) struct UdevState { + pub(crate) card: Rc<Card>, + /// Shared by every head: one GPU, one software renderer. + pub(crate) renderer: PixmanRenderer, + pub(crate) heads: Vec<UdevHead>, + pub(crate) active: bool, + /// Pointer position in the *global* space, so it can cross between + /// monitors; clamped to the union of all head rectangles. + pub(crate) pointer_pos: Point<f64, Logical>, +} + +impl UdevState { + /// Bounding box of every head, used to clamp pointer motion. + fn bounds(&self) -> (f64, f64) { + let w = self.heads.iter().map(|h| h.location.x + h.size.0).max().unwrap_or(0); + let h = self.heads.iter().map(|h| h.location.y + h.size.1).max().unwrap_or(0); + (w as f64, h as f64) + } } 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. + /// Renders and (if there was damage) page-flips a new frame on every + /// head that is ready for one. A head with a flip still in flight is + /// skipped this pass and picked up when its page-flip event arrives, so + /// monitors on different refresh rates each run at their own pace + /// instead of the slowest one gating the rest. pub(crate) fn render_udev_frame(&mut self) { - let Some(udev) = self.udev.as_mut() else { return }; - if udev.flip_pending || !udev.active { + let locked = self.lock.locked; + let elapsed = self.start_time.elapsed(); + // Drained before the `&mut self.udev` borrow below, so screencopy can + // be serviced with the renderer that borrow owns. + let mut captures = std::mem::take(&mut self.screencopy_pending); + + // Which heads are eligible, and what each needs, gathered before the + // mutable borrow of `self.udev`. + let Some(udev) = self.udev.as_ref() else { return }; + if !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 ready: Vec<(usize, Output)> = udev + .heads + .iter() + .enumerate() + .filter(|(_, h)| !h.flip_pending) + .map(|(i, h)| (i, h.output.clone())) + .collect(); + + let mut presented: Vec<Output> = Vec::new(); + for (index, output) in ready { + let lock_surface = self.lock_surface_for(&output).cloned(); + + // Decoration elements are built per head: `from_buffer` needs the + // renderer, and geometry is translated into head-local space. + let origin = self.udev.as_ref().map(|u| u.heads[index].location).unwrap_or_default(); + let decorations: Vec<(srdwm_core::Rect, MemoryRenderBuffer)> = if locked { + Vec::new() + } else { + self.decorations + .iter() + .filter_map(|(&id, deco)| self.wm.borrow().window(id).map(|w| (w.geometry, deco.clone()))) + .collect() + }; + + let Some(udev) = self.udev.as_mut() else { return }; + let head = &mut udev.heads[index]; + let back = 1 - head.front; + + let mut custom_elements: Vec<MemoryRenderBufferRenderElement<PixmanRenderer>> = Vec::new(); + if !locked { + for (geom, deco) in &decorations { + let pos = ((geom.x - origin.x) as f64, (geom.y - origin.y) as f64); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, deco, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(elem), + Err(e) => log::warn!("udev: failed to import titlebar buffer: {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 lock_elements = if locked { + crate::lock::lock_render_elements(lock_surface.as_ref(), &mut udev.renderer) + } else { + Vec::new() + }; + + let head = &mut udev.heads[index]; + let mut framebuffer = match udev.renderer.bind(&mut head.buffers[back].image) { + Ok(fb) => fb, + Err(e) => { + log::error!("udev: pixman bind failed: {e}"); + continue; + } + }; + + // Locked heads draw the lock surface over opaque black and + // nothing else; unlocked heads draw the normal scene. + let result = if locked { + head.damage_tracker + .render_output(&mut udev.renderer, &mut framebuffer, 0, &lock_elements, [0.0, 0.0, 0.0, 1.0]) + .map(|r| r.damage.is_some()) + .map_err(|e| e.to_string()) + } else { + render_output( + &head.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 backend + [&self.space], + &custom_elements, + &mut head.damage_tracker, + [0.05, 0.05, 0.08, 1.0], + ) + .map(|r| r.damage.is_some()) + // Both arms reduce to "was there damage"; the two error types + // differ, so they are flattened to a message here. + .map_err(|e| e.to_string()) + }; + if !locked { + crate::screencopy::service_pending(std::mem::take(&mut captures), &mut udev.renderer, &framebuffer); } - }; + drop(framebuffer); - 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 + let has_damage = match result { + Ok(d) => d, + Err(e) => { + log::error!("udev: render_output failed: {e}"); + continue; + } + }; + if has_damage { + let head = &mut udev.heads[index]; + if let Err(e) = head.copy_and_flip(&udev.card, back) { + log::error!("udev: page flip failed: {e}"); + continue; + } } - }; - if !has_damage { - return; + presented.push(output); } - if let Err(e) = udev.copy_and_flip(back) { - log::error!("udev: page flip failed: {e}"); - return; + // Frame callbacks + lock confirmation, once the `udev` borrow is done. + for output in presented { + if locked { + let surface = self.lock_surface_for(&output).cloned(); + crate::lock::send_lock_frame(surface.as_ref(), &output, elapsed); + self.confirm_lock_if_presented(&output); + } else { + let out = output.clone(); + self.space.elements().for_each(|w| w.send_frame(&out, elapsed, None, |_, _| Some(out.clone()))); + } + } + if locked { + crate::screencopy::fail_pending(captures); } - self.space.elements().for_each(|w| w.send_frame(&udev.output, self.start_time.elapsed(), None, |_, _| Some(udev.output.clone()))); } } -impl UdevOutput { +impl UdevHead { /// 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<()> { + /// mmap - see this module's docs) and flips to it. + fn copy_and_flip(&mut self, card: &Card, back: usize) -> std::io::Result<()> { let (stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height()); let byte_len = stride * height; // SAFETY: `image` owns this memory and outlives the byte slice we @@ -182,12 +288,12 @@ impl UdevOutput { // 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 mut mapping = card.map_dumb_buffer(&mut self.buffers[back].dumb)?; let dst = mapping.as_mut(); let len = byte_len.min(dst.len()); dst[..len].copy_from_slice(&src[..len]); } - self.card.page_flip(self.crtc, self.buffers[back].fb, PageFlipFlags::EVENT, None)?; + card.page_flip(self.crtc, self.buffers[back].fb, PageFlipFlags::EVENT, None)?; self.flip_pending = true; Ok(()) } @@ -221,57 +327,93 @@ impl UdevPlatform { .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()), - ); + // Every connected connector becomes a head, laid out left-to-right. + let connected = find_connected_outputs(&card)?; + log::info!("udev: {} connected output(s)", connected.len()); 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 mut heads: Vec<UdevHead> = Vec::new(); + let mut output_entries: Vec<crate::state::OutputEntry> = Vec::new(); + let mut x_offset = 0; + for (index, probe) in connected.iter().enumerate() { + let (width, height) = probe.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(probe.crtc, Some(buffers[0].fb), (0, 0), &[probe.connector], Some(probe.mode)) + .map_err(err)?; + + // Named after the real connector (eDP-1, HDMI-A-1, ...) so + // clients and the user can tell monitors apart; `wl_output.name` + // is what a bar's per-monitor config keys off. + let output = Output::new( + probe.name.clone(), + PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() }, + ); + output.change_current_state( + Some(OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) }), + Some(Transform::Normal), + None, + Some((x_offset, 0).into()), + ); + output.set_preferred(OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) }); + output.create_global::<CompState>(&display_handle); + + let location: Point<i32, Logical> = (x_offset, 0).into(); + heads.push(UdevHead { + crtc: probe.crtc, + output: output.clone(), + damage_tracker: OutputDamageTracker::from_output(&output), + buffers, + front: 0, + flip_pending: false, + location, + size: (width, height), + }); + output_entries.push(crate::state::OutputEntry { output, location }); + log::info!("udev: head {index}: {} {width}x{height} at x={x_offset}", probe.name); + x_offset += width; + } + let Some(first) = heads.first() else { + return Err(PlatformError::Other("udev: no usable outputs".into())); + }; + // Pointer starts centred on the first head. + let (width, height) = first.size; + // xdg-output - see the matching comment in `lib.rs`'s + // `WaylandPlatform::connect` for why this isn't optional. + smithay::wayland::output::OutputManagerState::new_with_xdg_output::<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()); + // Selection (clipboard) protocols - see the matching block in + // `lib.rs`'s `WaylandPlatform::connect` for the ordering constraint. + let primary_selection_state = PrimarySelectionState::new::<CompState>(&display_handle); + let data_control_state = + DataControlState::new::<CompState, _>(&display_handle, Some(&primary_selection_state), |_| true); let mut seat_state = SeatState::new(); let mut seat = seat_state.new_wl_seat(&display_handle, "seat0"); seat.add_keyboard(Default::default(), 200, 25).map_err(err)?; seat.add_pointer(); + // Each output occupies its own slice of the global space, so a + // window's coordinates say which monitor it is on. let mut space = Space::default(); - space.map_output(&output, (0, 0)); + for entry in &output_entries { + space.map_output(&entry.output, (entry.location.x, entry.location.y)); + } let pending = Rc::new(RefCell::new(Vec::new())); - let udev_output = UdevOutput { + let udev_state = UdevState { card: card.clone(), - crtc, renderer, - damage_tracker, - output: output.clone(), - buffers, - front: 0, - flip_pending: false, + heads, active: true, pointer_pos: (width as f64 / 2.0, height as f64 / 2.0).into(), - size: (width, height), }; let state = CompState { @@ -282,6 +424,19 @@ impl UdevPlatform { seat_state, seat, space, + outputs: output_entries, + layer_shell_state: smithay::wayland::shell::wlr_layer::WlrLayerShellState::new::<CompState>(&display_handle), + dh: display_handle.clone(), + data_device_state: DataDeviceState::new::<CompState>(&display_handle), + primary_selection_state, + data_control_state, + session_lock_state: smithay::wayland::session_lock::SessionLockManagerState::new::<CompState, _>( + &display_handle, + |_| true, + ), + _screencopy_state: crate::screencopy::ScreencopyState::new::<CompState>(&display_handle), + screencopy_pending: Vec::new(), + lock: Default::default(), wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), @@ -289,7 +444,7 @@ impl UdevPlatform { pending: pending.clone(), bound_keys: Rc::new(bound_keys.iter().cloned().collect::<HashSet<_>>()), start_time: Instant::now(), - udev: Some(udev_output), + udev: Some(udev_state), xwayland_shell_state: smithay::wayland::xwayland_shell::XWaylandShellState::new::<CompState>(&display_handle), xwm: None, xwayland_windows: HashMap::new(), @@ -331,27 +486,70 @@ fn mode_refresh_mhz(mode: &DrmMode) -> i32 { } } -// TODO: multi-monitor - returns only the first connected connector; no -// hotplug, no independent per-output layout. See -// docs/IMPLEMENTATION_STATUS.md's "Not implemented anywhere yet" section. -fn find_connected_output(card: &Card) -> PlatformResult<(crtc::Handle, connector::Handle, DrmMode)> { +/// A connector we intend to drive, paired with the CRTC that will scan it +/// out. Produced once at startup by [`find_connected_outputs`]. +struct OutputProbe { + crtc: crtc::Handle, + connector: connector::Handle, + mode: DrmMode, + /// Connector name as the kernel reports it (`eDP-1`, `HDMI-A-1`, ...). + name: String, +} + +/// Every connected connector, each assigned a distinct CRTC. +/// +/// CRTCs are a finite hardware resource and cannot be shared, so a CRTC +/// already claimed by an earlier connector is skipped - a machine with more +/// connected monitors than CRTCs drives as many as the hardware allows and +/// logs the rest rather than failing outright. +/// +/// Still no hotplug: connectors are probed once at startup. Plugging a +/// monitor in later needs a udev event handler, which this backend does not +/// register yet (see `docs/IMPLEMENTATION_STATUS.md`). +fn find_connected_outputs(card: &Card) -> PlatformResult<Vec<OutputProbe>> { 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)) + + let mut used: Vec<crtc::Handle> = Vec::new(); + let mut probes = Vec::new(); + for con in connectors.iter().filter(|c| c.state() == connector::State::Connected) { + let name = format!("{:?}-{}", con.interface(), con.interface_id()); + // Prefer the mode the display advertises as PREFERRED (its native + // resolution) rather than whatever happens to be listed first -- + // the list order is not guaranteed, and picking wrong means running + // a monitor at the wrong resolution. Falls back to the first mode + // for connectors that flag none. + let Some(&mode) = con + .modes() + .iter() + .find(|m| m.mode_type().contains(ModeTypeFlags::PREFERRED)) + .or_else(|| con.modes().first()) + else { + log::warn!("udev: connector {name} is connected but reports no modes; skipping"); + continue; + }; + // Prefer the CRTC already driving this connector, else any free one + // the encoder can reach. + let candidates: Vec<crtc::Handle> = con + .current_encoder() + .and_then(|enc| card.get_encoder(enc).ok()) + .map(|enc| res.filter_crtcs(enc.possible_crtcs())) + .unwrap_or_default() + .into_iter() + .chain(res.crtcs().iter().copied()) + .collect(); + let Some(crtc) = candidates.into_iter().find(|c| !used.contains(c)) else { + log::warn!("udev: no free CRTC left for connector {name}; not driving it"); + continue; + }; + used.push(crtc); + probes.push(OutputProbe { crtc, connector: con.handle(), mode, name }); + } + + if probes.is_empty() { + return Err(PlatformError::Other("udev: no connected connector found".into())); + } + Ok(probes) } fn make_drm_buffer(card: &Card, width: i32, height: i32) -> PlatformResult<DrmBuffer> { @@ -374,17 +572,21 @@ fn register_drm_fd(handle: &LoopHandle<'static, CompState>, card: &Rc<Card>) -> let card = udev.card.clone(); match card.receive_events() { Ok(events) => { + // The event names the CRTC it came from, so with several + // monitors only that head advances - flipping all of + // them would desynchronise the others' buffers. let mut flipped = false; for event in events { - if let DrmEvent::PageFlip(_) = event { - flipped = true; + let DrmEvent::PageFlip(flip) = event else { continue }; + if let Some(udev) = data.udev.as_mut() { + if let Some(head) = udev.heads.iter_mut().find(|h| h.crtc == flip.crtc) { + head.front = 1 - head.front; + head.flip_pending = false; + 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(); } } @@ -422,10 +624,16 @@ fn register_session_notifier(handle: &LoopHandle<'static, CompState>, notifier: 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}"); + // switch; reassert every head before rendering again. + let card = udev.card.clone(); + for head in &mut udev.heads { + let fb = head.buffers[head.front].fb; + if let Err(e) = card.set_crtc(head.crtc, Some(fb), (0, 0), &[], None) { + log::warn!("udev: failed to reassert crtc on resume: {e}"); + } + // Force a full repaint: contents are undefined after + // the VT switch. + head.flip_pending = false; } data.render_udev_frame(); } @@ -441,9 +649,11 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB 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); + // Clamped to the union of every head, so the pointer travels + // between monitors instead of stopping at the first one's edge. + let (w, h) = udev.bounds(); + udev.pointer_pos.x = (udev.pointer_pos.x + delta.x).clamp(0.0, (w - 1.0).max(0.0)); + udev.pointer_pos.y = (udev.pointer_pos.y + delta.y).clamp(0.0, (h - 1.0).max(0.0)); let pos = udev.pointer_pos; handle_pointer_position(state, pos, event.time_msec()); } @@ -486,14 +696,24 @@ impl Platform for UdevPlatform { Ok(self.pending.borrow_mut().drain(..).collect()) } + /// One `srdwm_core::Monitor` per head, positioned in the global space. + /// This is what makes core's layout engine multi-monitor-aware in + /// practice: `arrange_workspace` groups windows by `monitor` and lays + /// each group out inside that monitor's rectangle. 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 - }]) + Ok(udev + .heads + .iter() + .enumerate() + .map(|(i, head)| { + let (w, h) = head.size; + let rect = srdwm_core::Rect::new(head.location.x, head.location.y, w as u32, h as u32); + let mut m = srdwm_core::Monitor::new(i as u32, head.output.name(), rect); + m.primary = i == 0; + m + }) + .collect()) } fn apply_geometry(&mut self, window: srdwm_core::WindowId, _geometry: srdwm_core::Rect) -> PlatformResult<()> { diff --git a/crates/wayland/src/winit.rs b/crates/wayland/src/winit.rs new file mode 100644 index 0000000..85be223 --- /dev/null +++ b/crates/wayland/src/winit.rs @@ -0,0 +1,412 @@ +//! Nested ("winit") backend: srdwm as a window on an existing +//! compositor/X server, the Wayland analogue of running the X11 backend +//! under Xephyr. Used for development and for the case where srdwm is +//! started from inside another session; the real bare-TTY path is +//! [`crate::udev`]. +//! +//! Both backends share all protocol state ([`crate::state::CompState`]), +//! input routing ([`crate::input`]) and lock behaviour ([`crate::lock`]); +//! what differs is only how a frame reaches a screen. + +use std::cell::RefCell; +use std::collections::HashMap; +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, PointerButtonEvent}; +use smithay::backend::renderer::damage::OutputDamageTracker; +use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; +use smithay::backend::renderer::element::Kind; +use smithay::backend::renderer::gles::GlesRenderer; +use smithay::backend::winit::{self, WinitEvent, WinitEventLoop, WinitGraphicsBackend}; +use smithay::desktop::space::render_output; +use smithay::desktop::{layer_map_for_output, Space}; +use smithay::input::SeatState; +use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Subpixel}; +use smithay::reexports::wayland_server::{Client, Display, ListeningSocket}; +use smithay::reexports::winit::platform::pump_events::PumpStatus; +use smithay::utils::Transform; +use smithay::wayland::compositor::CompositorState; +use smithay::wayland::selection::data_device::DataDeviceState; +use smithay::wayland::selection::primary_selection::PrimarySelectionState; +use smithay::wayland::selection::wlr_data_control::DataControlState; +use smithay::wayland::session_lock::SessionLockManagerState; +use smithay::wayland::shell::wlr_layer::WlrLayerShellState; +use smithay::wayland::shell::xdg::decoration::XdgDecorationState; +use smithay::wayland::shell::xdg::XdgShellState; +use smithay::wayland::shm::ShmState; + +use srdwm_core::{Event as CoreEvent, Window as CoreWindow, WindowId, WindowManager}; +use srdwm_platform::{Platform, PlatformError, PlatformKind, Result as PlatformResult}; + +use crate::input::{handle_keyboard_key_event, handle_pointer_button, handle_pointer_position, last_pointer_pos}; +use crate::lock::{lock_render_elements, send_lock_frame}; +use crate::lock::SessionLock; +use crate::state::{ClientState, CompState, OutputEntry}; +use crate::{err, screencopy}; + +pub struct WaylandPlatform { + display: Display<CompState>, + state: CompState, + backend: WinitGraphicsBackend<GlesRenderer>, + winit_events: WinitEventLoop, + damage_tracker: OutputDamageTracker, + output: Output, + listener: ListeningSocket, + clients: Vec<Client>, + pending: Rc<RefCell<Vec<CoreEvent>>>, + wm: Rc<RefCell<WindowManager>>, +} + +impl WaylandPlatform { + /// `bound_keys` are the config's `"Mod4+Shift+Return"`-style combo + /// strings (see `srdwm_core::key_combo_string`) - the same set the X11 + /// 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(); + + let (backend, winit_events) = winit::init::<GlesRenderer>().map_err(err)?; + let size = backend.window_size(); + + let output = Output::new( + "srdwm-wayland".to_string(), + PhysicalProperties { size: (0, 0).into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "winit".into() }, + ); + output.change_current_state( + Some(OutputMode { size, refresh: 60_000 }), + Some(Transform::Normal), + None, + Some((0, 0).into()), + ); + output.create_global::<CompState>(&dh); + // xdg-output (zxdg_output_manager_v1): several real layer-shell + // clients (confirmed live: wofi 1.5.3) unconditionally call + // `zxdg_output_manager_v1.get_xdg_output` while setting up a layer + // surface and don't null-check the manager proxy if the global was + // never advertised - so without this, those clients don't just + // fail gracefully, they segfault. Real wlroots compositors (e.g. + // Hyprland) always advertise it, which is why this only surfaced + // once a real Wayland-native client could connect at all (see + // `docs/IMPLEMENTATION_STATUS.md`). + smithay::wayland::output::OutputManagerState::new_with_xdg_output::<CompState>(&dh); + + let compositor_state = CompositorState::new::<CompState>(&dh); + let xdg_shell_state = XdgShellState::new::<CompState>(&dh); + let xdg_decoration_state = XdgDecorationState::new::<CompState>(&dh); + let shm_state = ShmState::new::<CompState>(&dh, Vec::new()); + // Order matters: data-control piggybacks on primary-selection's + // client filter, so it needs the already-built state by reference. + let primary_selection_state = PrimarySelectionState::new::<CompState>(&dh); + let data_control_state = DataControlState::new::<CompState, _>(&dh, Some(&primary_selection_state), |_| true); + let mut seat_state = SeatState::new(); + let mut seat = seat_state.new_wl_seat(&dh, "seat0"); + seat.add_keyboard(Default::default(), 200, 25).map_err(err)?; + seat.add_pointer(); + + let mut space = Space::default(); + space.map_output(&output, (0, 0)); + + let pending = Rc::new(RefCell::new(Vec::new())); + let state = CompState { + compositor_state, + xdg_shell_state, + _xdg_decoration_state: xdg_decoration_state, + shm_state, + seat_state, + seat, + space, + // The nested backend is inherently one output: a single window on + // the host. Multi-output is a udev/DRM concern. + outputs: vec![OutputEntry { output: output.clone(), location: (0, 0).into() }], + layer_shell_state: WlrLayerShellState::new::<CompState>(&dh), + dh: dh.clone(), + data_device_state: DataDeviceState::new::<CompState>(&dh), + primary_selection_state, + data_control_state, + session_lock_state: SessionLockManagerState::new::<CompState, _>(&dh, |_| true), + _screencopy_state: screencopy::ScreencopyState::new::<CompState>(&dh), + screencopy_pending: Vec::new(), + lock: SessionLock::default(), + 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()), + 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)?; + if let Some(name) = listener.socket_name() { + std::env::set_var("WAYLAND_DISPLAY", name); + log::info!("wayland socket: {}", name.to_string_lossy()); + } + + let damage_tracker = OutputDamageTracker::from_output(&output); + + Ok(Self { display, state, backend, winit_events, damage_tracker, output, listener, clients: Vec::new(), pending, wm }) + } + + 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 pump_winit(&mut self) -> PlatformResult<bool> { + let mut closed = false; + let state = &mut self.state; + let output = &self.output; + let pump = self.winit_events.dispatch_new_events(|event| { + handle_winit_event(state, output, event, &mut closed); + }); + if matches!(pump, PumpStatus::Exit(_)) { + closed = true; + } + Ok(closed) + } + + fn render_frame(&mut self) -> PlatformResult<()> { + let size = self.backend.window_size(); + let resized = self.output.current_mode().map(|m| m.size) != Some(size); + if resized { + // Only push a new output mode - and thus emit `wl_output.mode`/ + // `done` - when the size actually changed. This used to run + // unconditionally every frame; harmless with no Wayland-native + // client connected (the only way this was ever exercised before + // real layer-shell clients existed), but a real client bound to + // `wl_output` would otherwise be flooded with duplicate + // mode/done events at the render loop's full frame rate. + self.output.change_current_state(Some(OutputMode { size, refresh: 60_000 }), None, None, None); + layer_map_for_output(&self.output).arrange(); + } + + let age = self.backend.buffer_age().unwrap_or(0); + let (renderer, mut framebuffer) = self.backend.bind().map_err(err)?; + + // Locked: the lock surface over an opaque black clear, and nothing + // else - no windows, no decorations, no layer surfaces. + if self.state.lock.locked { + let lock_surface = self.state.lock_surface_for(&self.output).cloned(); + let elements = lock_render_elements(lock_surface.as_ref(), renderer); + self.damage_tracker + .render_output(renderer, &mut framebuffer, age, &elements, [0.0, 0.0, 0.0, 1.0]) + .map_err(err)?; + drop(framebuffer); + self.backend.submit(None).map_err(err)?; + send_lock_frame(lock_surface.as_ref(), &self.output, self.state.start_time.elapsed()); + self.state.confirm_lock_if_presented(&self.output); + screencopy::fail_pending(std::mem::take(&mut self.state.screencopy_pending)); + return Ok(()); + } + + let mut custom_elements: Vec<MemoryRenderBufferRenderElement<GlesRenderer>> = Vec::new(); + for (&id, deco) in self.state.decorations.iter() { + let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue }; + match MemoryRenderBufferRenderElement::from_buffer(renderer, (geom.x as f64, geom.y as f64), deco, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(elem), + Err(e) => log::warn!("failed to import titlebar buffer for window {id}: {e}"), + } + } + + render_output( + &self.output, + renderer, + &mut framebuffer, + 1.0, + age, + [&self.state.space], + &custom_elements, + &mut self.damage_tracker, + [0.05, 0.05, 0.08, 1.0], + ) + .map_err(err)?; + drop(framebuffer); + self.backend.submit(None).map_err(err)?; + self.state.space.elements().for_each(|w| w.send_frame(&self.output, self.state.start_time.elapsed(), None, |_, _| Some(self.output.clone()))); + + // Screencopy is serviced *after* the on-screen frame is submitted, + // into its own offscreen buffer - never by reading back the window + // surface. Reading the winit backend's EGL window surface (what an + // earlier version did) reliably killed the GL context: the first + // `grim` capture produced `eglSwapBuffers: BAD_SURFACE` followed by + // `BAD_ALLOC` and "context has been lost", confirmed by A/B-ing the + // same build with only the readback call removed. The offscreen + // detour costs a second scene render, but only on frames where a + // capture was actually requested. + // + // Deliberately placed after the locked-session early return above, + // so a capture requested while the screen is locked can never see + // client content. + let captures = std::mem::take(&mut self.state.screencopy_pending); + if !captures.is_empty() { + if let Err(e) = self.capture_offscreen(captures) { + log::warn!("screencopy: offscreen capture pass failed: {e}"); + } + } + Ok(()) + } + + /// Re-renders the current scene into an offscreen GLES renderbuffer and + /// serves the queued screencopy captures from it. See the call site for + /// why capture cannot read the window surface directly. + fn capture_offscreen(&mut self, captures: Vec<screencopy::PendingCapture>) -> PlatformResult<()> { + use smithay::backend::renderer::{Bind, Offscreen}; + + let size = self.output.current_mode().map(|m| m.size).unwrap_or_default(); + if size.w <= 0 || size.h <= 0 { + return Ok(()); + } + let renderer = self.backend.renderer(); + let mut target: smithay::backend::renderer::gles::GlesRenderbuffer = + renderer.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into()).map_err(err)?; + let mut framebuffer = renderer.bind(&mut target).map_err(err)?; + + let mut custom_elements: Vec<MemoryRenderBufferRenderElement<GlesRenderer>> = Vec::new(); + for (&id, deco) in self.state.decorations.iter() { + let Some(geom) = self.wm.borrow().window(id).map(|w| w.geometry) else { continue }; + if let Ok(elem) = MemoryRenderBufferRenderElement::from_buffer(renderer, (geom.x as f64, geom.y as f64), deco, None, None, None, Kind::Unspecified) { + custom_elements.push(elem); + } + } + + // A throwaway damage tracker, so this pass always draws the whole + // scene (age 0) and never perturbs the on-screen tracker's history. + let mut tracker = OutputDamageTracker::from_output(&self.output); + render_output( + &self.output, + renderer, + &mut framebuffer, + 1.0, + 0, + [&self.state.space], + &custom_elements, + &mut tracker, + [0.05, 0.05, 0.08, 1.0], + ) + .map_err(err)?; + + screencopy::service_pending(captures, renderer, &framebuffer); + Ok(()) + } +} + +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 }) => 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); + handle_pointer_position(state, pos, event.time_msec()); + } + WinitEvent::Input(InputEvent::PointerButton { event }) => { + let pos = last_pointer_pos(state); + let button = event.button_code(); + let pressed = event.state() == BackendButtonState::Pressed; + handle_pointer_button(state, pos, button, pressed, event.time_msec()); + } + WinitEvent::Resized { .. } => {} + _ => {} + } +} + +impl Platform for WaylandPlatform { + fn kind(&self) -> PlatformKind { + PlatformKind::Wayland + } + + fn poll_events(&mut self) -> PlatformResult<Vec<CoreEvent>> { + self.accept_clients()?; + let closed = self.pump_winit()?; + if closed { + return Err(PlatformError::Other("compositor window closed".into())); + } + self.display.dispatch_clients(&mut self.state).map_err(err)?; + self.display.flush_clients().map_err(err)?; + self.render_frame()?; + Ok(self.pending.borrow_mut().drain(..).collect()) + } + + fn monitors(&mut self) -> PlatformResult<Vec<srdwm_core::Monitor>> { + let size = self.backend.window_size(); + Ok(vec![{ + let mut m = srdwm_core::Monitor::new(0, "winit", srdwm_core::Rect::new(0, 0, size.w as u32, size.h as u32)); + m.primary = true; + m + }]) + } + + fn apply_geometry(&mut self, window: WindowId, geometry: srdwm_core::Rect) -> PlatformResult<()> { + let _ = geometry; + self.state.sync_geometry(window); + Ok(()) + } + + fn set_title(&mut self, _window: WindowId, _title: &str) -> PlatformResult<()> { + Ok(()) + } + + fn focus(&mut self, window: WindowId) -> PlatformResult<()> { + self.state.wm.borrow_mut().focus_window(window); + Ok(()) + } + + fn minimize(&mut self, window: 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: WindowId) -> PlatformResult<()> { + self.state.sync_geometry(window); + Ok(()) + } + + fn close(&mut self, window: 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: WindowId, _decorated: bool) -> PlatformResult<()> { + Ok(()) + } + + fn set_border_color(&mut self, _window: WindowId, _rgb: (u8, u8, u8)) -> PlatformResult<()> { + Ok(()) + } + + fn set_border_width(&mut self, _window: WindowId, _width: u32) -> PlatformResult<()> { + Ok(()) + } + + 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(()) + } + + 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 index ca54f87..ac71492 100644 --- a/crates/wayland/src/xwayland.rs +++ b/crates/wayland/src/xwayland.rs @@ -31,7 +31,7 @@ use smithay::{delegate_xwayland_shell, desktop::Window as DWindow}; use srdwm_core::{Event as CoreEvent, ResizeEdge, Window as CoreWindow, TITLEBAR_HEIGHT}; -use crate::CompState; +use crate::state::CompState; pub(crate) type X11Window = smithay::xwayland::xwm::X11Window; diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 354cd75..ae78cd0 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -99,6 +99,25 @@ This is the one piece with essentially no working prior art to port (see `docs/PRIOR_ART.md`): the legacy C++ never wired a single event listener. What's here is a genuine from-scratch `smithay`-based compositor, not a stub: +**Module layout.** `lib.rs` had grown to ~1260 lines holding state, every +protocol handler, input routing and rendering; it is now a 78-line shell +(module declarations plus `connect()`), with the rest split by +responsibility: + +| module | responsibility | +| --- | --- | +| `state.rs` | `CompState` (the `Display<D>` state everything hangs off), outputs, window bookkeeping | +| `protocols.rs` | smithay `*Handler` impls + `delegate_*!` macros - deliberately thin | +| `input.rs` | keyboard/pointer routing and what "focus" means | +| `lock.rs` | session lock as one feature: state, handler *and* its render helpers | +| `screencopy.rs` | hand-written `wlr-screencopy` (no smithay helper exists) | +| `winit.rs` / `udev.rs` | the two backends - all they differ in is how a frame reaches a screen | +| `decoration.rs` / `xwayland.rs` | titlebar rasterisation; XWayland bridge | + +`lock.rs` is grouped by *feature* rather than by kind on purpose: the +security-relevant invariant spans state, protocol handling and rendering at +once, so splitting it across three files would have hidden it. + - ✅ Runs via smithay's winit backend (nested window), initializes EGL/GLES, advertises a real Wayland socket, and was verified to start, initialize rendering, and run its event loop without crashing (log-verified; a @@ -203,6 +222,181 @@ What's here is a genuine from-scratch `smithay`-based compositor, not a stub: - Not implemented: selections/clipboard, XSETTINGS, RandR primary-output sync, override-redirect window geometry beyond initial placement (all have harmless no-op default `XwmHandler` methods). +- ✅ **`wlr-layer-shell-unstable-v1`** (`WlrLayerShellHandler`, `delegate_layer_shell!` + in `lib.rs`): layer surfaces are mapped into the output's + `smithay::desktop::LayerMap` (`layer_map_for_output`), which `render_output` + renders automatically - no rendering-path changes were needed, only state + wiring, initial-configure-on-commit, and pointer/keyboard routing + (`layer_surface_under` in `lib.rs`, checked ahead of our own decorations and + xdg-shell windows so bars/launchers/notifications/lock UIs sit properly on + top; `Exclusive`-interactivity surfaces grab keyboard focus on commit, + `OnDemand` ones on click). Background/bottom-layer pointer routing (e.g. a + wallpaper daemon wanting clicks) is out of scope - nothing needed for the + daily-driver gate requires it. + **Verified live** against two real, unmodified clients (waybar 0.x, wofi + 1.5.3) run as actual Wayland clients of a running `srdwm` (winit backend, + `WAYLAND_DEBUG=1` protocol tracing): waybar's Top-layer bar configured + correctly ("Bar configured (width: 934, height: 45) for output: + srdwm-wayland"); wofi's Exclusive-interactivity launcher surface was + created, sized, and configured with no crash. Getting there surfaced and + fixed three real bugs: + - `Output::change_current_state` was called unconditionally every render + frame (60/s), which - harmless with zero Wayland-native clients ever + connected before this - floods any client actually bound to `wl_output` + with duplicate `mode`/`done` events forever. Fixed by only calling it + (and re-`arrange()`ing the layer map) when the output size actually + changed. + - The very first `configure` sent to a newly-mapped layer surface used + stale geometry: `map_layer`'s own `arrange()` runs before the client's + `set_size`/`set_anchor`/etc. requests (and the commit applying them) have + even arrived, so the initial `send_configure()` was re-sending that + stale pre-request computation instead of recomputing from what the + client actually asked for (caught live: wofi's `set_size(420, 550)` was + silently ignored, and it configured stuck at the output/2 fallback + instead). Fixed by re-`arrange()`ing on every layer-surface commit + (`LayerMap::arrange` only ever sends a configure when something actually + changed, so this is a no-op on unrelated commits). + - The missing `zxdg_output_manager_v1` (xdg-output) global - a separate, + real gap of its own, see below - made wofi's own layer-shell setup code + call a Wayland request on a proxy that was never bound (it doesn't + null-check), **segfaulting the client**, not just failing gracefully. + Root-caused with `gdb` (crash was `wl_proxy_marshal_constructor(proxy=0x0, + opcode=1, ...)`, matching `zxdg_output_manager_v1.get_xdg_output`) and + confirmed by comparing a `WAYLAND_DEBUG=1` trace of the same `wofi` + binary against the user's real Hyprland session (which advertises + xdg-output and doesn't crash it) side by side with the trace against + `srdwm`. +- ✅ **xdg-output (`zxdg_output_manager_v1`)**: added via smithay's + `OutputManagerState::new_with_xdg_output`, piggybacking on the existing + `delegate_output!`/`OutputHandler` wiring (no new handler trait needed). + Not itself in the original "biggest blocker" list, but found to be a hard + requirement in practice while fixing layer-shell above - see the wofi + segfault account. +- ✅ **Clipboard**: `wl_data_device_manager`, `zwp_primary_selection_v1`, + and `zwlr_data_control_manager_v1`, all three sharing smithay's single + `SelectionHandler`. Data-control is the one that matters most for this + user's session: `wl-paste --watch cliphist store` (in their Hyprland + autostart) needs to read the selection *without* holding keyboard focus, + which the core data-device protocol cannot do. + The non-obvious wiring is that selection focus must follow keyboard focus + - `set_keyboard_focus` now also calls `set_data_device_focus` and + `set_primary_focus`, because the data-device protocols only offer the + selection to, and accept `set_selection` from, the focus-holding client. + **Verified live** against the user's own tools: `wl-copy`/`wl-paste` + round-tripped both clipboard and primary; `wl-paste --watch cliphist + store` captured three successive copies; and a real `wezterm` toplevel + was observed receiving `wl_data_device.data_offer` + `selection` over + `WAYLAND_DEBUG=1`, i.e. the core (non-data-control) path works too. + Drag-and-drop uses smithay's default `ClientDndGrabHandler`/ + `ServerDndGrabHandler` behaviour and has *not* been separately tested. + This surfaced a real pre-existing bug, fixed here: nothing ever gave a + **newly-created** window Wayland focus. `WindowManager::add_window` sets + its own `focused` field, but no code path turned that into a + `KeyboardHandle::set_focus`, so a freshly-opened app received no + keystrokes and could not paste until it was clicked. (Same class as the + click-to-focus bug fixed in the XWayland pass; this was the creation + path.) +- ✅ **`ext-session-lock-v1`** (screen locking): `SessionLockHandler` with + per-output lock surfaces. `locked` gates both rendering (only the lock + surface, over an opaque black clear - no windows, decorations, or layer + surfaces) and input (all keys go to the lock surface, and **no key is + treated as a WM binding**, since the shipped config binds + `Mod4+Return` to spawn a terminal and honouring that at a locked screen + would defeat the lock entirely). The lock is confirmed only *after* a + client-content-free frame has actually been presented, never at request + time, so the locker is never told "the screen is safe" while the user's + windows are still on screen. + **Verified live** with a purpose-written minimal `ext-session-lock` + client (no locker - hyprlock/swaylock/etc. - is installed on this + machine, so there was nothing else to test against; the user's + `~/.scripts/lock` currently falls through to `loginctl lock-session`): + lock → cleared frame → `locked` confirmation → lock surface configured to + the real output size → `unlock_and_destroy` → normal operation restored. + Three properties were checked by counting protocol events delivered to a + real `wezterm` launched at each point: + - unlocked: 1 `wl_keyboard.enter` (control); + - locked: 0 `wl_keyboard.enter`, 0 `wl_pointer.enter`; + - locker killed *without* unlocking: still 0 - the session correctly + stays locked when the screen locker crashes, as the protocol requires. + The locked-case count was **1, not 0, before a bug was found and fixed by + this exact test**: `new_managed_window` called `set_keyboard_focus` + unconditionally, so merely opening a window at a locked screen handed it + keyboard focus. The guard now lives in `set_keyboard_focus` itself, as + the single chokepoint every focus path goes through. +- ✅ **`wlr-screencopy-unstable-v1`** (`crates/wayland/src/screencopy.rs`): + what `grim` uses, and therefore what the user's `Print` / `Alt+Print` + binds (`grim`, `slurp | grim -g -`) and `wf-recorder` need. smithay 0.7 + ships **no** helper for this protocol, so the `GlobalDispatch`/`Dispatch` + plumbing is written out by hand against the raw `wayland-protocols-wlr` + server bindings (a new direct dependency, pinned to the version smithay + already uses so both see one set of types). Capture is deferred: a `copy` + request only queues the frame, and pixels are read back during the render + pass via `ExportMem::copy_framebuffer`. + **Verified live with real `grim`**: full-output capture, region capture + (`-g "0,0 420x110"`, confirmed by screenshotting a window and reading the + PNG back - correct offset, size, colours, and orientation, which is also + what establishes that no `y_invert` flag is needed), and an + out-of-bounds region (clamped, no crash). While the session is locked, + queued captures are rejected outright rather than served or left + queued - confirmed: `grim` fails fast with "failed to copy output" and + writes nothing. + One real bug was found and fixed by this testing: reading back the winit + backend's **EGL window surface** destroyed the GL context on the first + capture (`eglSwapBuffers: BAD_SURFACE` → `BAD_ALLOC` → "context has been + lost", taking the whole compositor down), root-caused by A/B-ing the + identical build with only the readback call removed. The winit path now + renders a second pass into an offscreen `GlesRenderbuffer` and reads + *that*, costing an extra scene render only on frames where a capture was + actually requested. The udev/pixman path is unaffected - its render + target is already a plain memory image, so reading it directly is safe. + Not implemented: `linux_dmabuf` capture (the manager is capped at + protocol version 2 for that reason) and cursor overlay + (`overlay_cursor` is accepted and ignored - this backend draws no + cursor of its own yet). +- ✅ **Multi-monitor** (udev/DRM backend). Every connected connector becomes + a `UdevHead` with its **own** scanout buffers, damage tracker and + page-flip state, laid out left-to-right in a shared global coordinate + space; the `PixmanRenderer` is shared, since they are one GPU. A head + whose flip is still in flight is skipped for that pass and resumes when + its own page-flip event arrives (matched by CRTC), so monitors at + different refresh rates each run at their own pace instead of the slowest + gating the rest. + Connector→CRTC assignment never reuses a CRTC, so a machine with more + monitors than CRTCs drives as many as the hardware allows and logs the + rest. Modes are chosen by the `PREFERRED` flag rather than list order. + The rest of the compositor reaches outputs through + `CompState::{primary_output, output_at, output_for_wl}` rather than a + single field, which is what kept the change small: layer surfaces map to + the output the client names, session lock creates **one lock surface per + output** (and only confirms the lock once *every* output has both a + surface and a presented frame - otherwise a second monitor could still + be showing the desktop when the locker is told the session is safe), + screencopy captures the output the client names, and the pointer is + clamped to the union of all heads so it can cross between them. + **Verified live in the QEMU VM** with a two-output `virtio-gpu` + (`max_outputs=2`, second connector forced on with `video=Virtual-2:...e`, + default VGA removed so the GPU choice is unambiguous): + - srdwm logged `2 connected output(s)` and built both heads + (`Virtual-1 1280x800 at x=0`, `Virtual-2 ... at x=1280`), and core saw + `2 monitor(s)`; + - QMP `screendump` of **both** heads returned each one's own resolution, + both filled with srdwm's exact clear colour `rgb(12,12,20)` + (= `[0.05, 0.05, 0.08]`) - i.e. both are really being rendered and + scanned out, not just enumerated; + - a window forced by `srd.rule` to **global** x=1500 appeared on head 1 at + head-local x=**220** (= 1500 − 1280, the exact translation) with its + srdwm titlebar, while head 0 stayed completely empty (0 of 64000 sampled + pixels differed from the clear colour). + The nested winit backend remains single-output by construction (it is one + window on a host compositor). + +**Known limitation of the nested (winit) backend**: it renders through the +host compositor's frame callbacks, so if the srdwm window is occluded or on +another workspace, the host stops scheduling it, `eglSwapBuffers` blocks, +and srdwm's whole main loop stalls - it stays alive but stops serving +clients until the window is visible again. Observed repeatedly while +testing. This affects only the nested development path; the udev/DRM +backend drives its own page flips and is unaffected. **Why the visual verification stopped short of a screenshot**: the winit window opens on the *host* compositor, and the only available display in @@ -274,24 +468,17 @@ built them: ## Not implemented anywhere yet -- **`wlr-layer-shell-unstable-v1`** - `CompState` only delegates - `compositor`/`xdg_shell`/`xdg_decoration`/`shm`/`seat`/`output` (plus - `xwayland_shell` for XWayland). No layer-shell support means bars, - launchers (`wofi`, `rofi`'s layer-shell modes), notification daemons, - and lock-screen UIs can't run under srdwm at all - they don't create - `xdg_toplevel`s, they create layer surfaces. This is the single biggest - blocker to using srdwm-wayland as a real daily-driver session (e.g. for - an `ags`/`wofi`-based desktop). -- **`wl_data_device_manager`** (clipboard/drag-and-drop) - no global at - all is created for it, so copy-paste between two Wayland clients - doesn't work under srdwm yet (tools like `wl-copy`/`cliphist` have - nothing to talk to). -- **`ext-session-lock-v1`** (screen locking) - not implemented; a - lock-screen client (or a lid-switch/idle-triggered lock) has no - protocol to actually lock input/display under srdwm. -- **Multi-monitor** for the udev/DRM backend - deliberately scoped to a - single connector/output for the first pass (see `udev.rs`'s module - docs); no hotplug, no independent per-output layout. +All three protocols originally identified as blocking srdwm-wayland from +being a real daily-driver session (bars/launchers/notifications/lock UIs, +clipboard, screen locking) are now implemented and verified - see the +Wayland backend section above. What is left: + +- **Connector hotplug** - connectors are probed once at startup, so + plugging a monitor in (or unplugging one) while srdwm is running is not + noticed. Needs a udev event source, which this backend does not register + yet. Multi-monitor itself *is* implemented (see above); only hotplug is + missing. +- **Multi-GPU** - only the primary GPU's connectors are driven. - Animations (`general.animations`/`animation_duration` config keys exist and are read into defaults, but nothing consumes them yet). - A native GUI settings app (the legacy project's `GUI_SETTINGS.md` was |