srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore8
-rw-r--r--Cargo.lock1
-rw-r--r--crates/srdwm/Cargo.toml27
-rw-r--r--crates/srdwm/src/main.rs149
-rw-r--r--crates/wayland/Cargo.toml4
-rw-r--r--crates/wayland/src/input.rs277
-rw-r--r--crates/wayland/src/lib.rs702
-rw-r--r--crates/wayland/src/lock.rs171
-rw-r--r--crates/wayland/src/protocols.rs237
-rw-r--r--crates/wayland/src/screencopy.rs302
-rw-r--r--crates/wayland/src/state.rs356
-rw-r--r--crates/wayland/src/udev.rs508
-rw-r--r--crates/wayland/src/winit.rs412
-rw-r--r--crates/wayland/src/xwayland.rs2
-rw-r--r--docs/IMPLEMENTATION_STATUS.md223
15 files changed, 2524 insertions, 855 deletions
diff --git a/.gitignore b/.gitignore
index e73ef55..9ab9c2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/Cargo.lock b/Cargo.lock
index 5484ebc..cfead32 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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