diff options
| author | srdusr <[email protected]> | 2024-04-08 23:54:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-04-08 23:54:00 +0200 |
| commit | 709333908d5b7d3157165d1811c8bc1795ab0028 (patch) | |
| tree | 92d5616519f63c1e4743add0294ce552e239681c /crates/wayland/src/state.rs | |
| parent | 592bd3716c0aa5a47a5eb43f45f55844de9db382 (diff) | |
| download | srdwm-709333908d5b7d3157165d1811c8bc1795ab0028.tar.gz srdwm-709333908d5b7d3157165d1811c8bc1795ab0028.zip | |
Wayland: clipboard, session lock, screencopy, multi-monitor; modularize
Implements the protocols that were blocking srdwm-wayland from being a real
session, plus multi-monitor, and splits the backend into modules. Everything
here was verified by running it against real clients, not just compiled.
Protocols
- wlr-layer-shell + xdg-output: bars/launchers/notifications. xdg-output is
not optional in practice - without it wofi segfaults rather than
degrading, since it calls get_xdg_output without null-checking.
- Clipboard: wl_data_device_manager, primary selection, and wlr-data-control.
Data-control is what `wl-paste --watch cliphist store` needs, as it reads
the selection without holding focus. Selection focus now follows keyboard
focus, without which a focused window can neither copy nor paste.
- ext-session-lock: `locked` gates rendering and input. No key is treated as
a WM binding while locked - the config binds Mod4+Return to spawn a
terminal, so honouring bindings at a locked screen would defeat the lock.
The lock is confirmed only after a client-content-free frame has actually
been presented, never at request time.
- wlr-screencopy (hand-written; smithay ships no helper) for grim/slurp.
Multi-monitor
Every connected connector becomes a head with its own buffers, damage
tracker and page-flip state, matched by CRTC so differing refresh rates
don't gate each other. Outputs are reached through primary_output/output_at/
output_for_wl rather than a single field, which kept the change to ~11 call
sites. Session lock creates one lock surface per output and waits for all of
them, so a second monitor can't still show the desktop when the locker is
told the session is safe. Modes are picked by the PREFERRED flag, not list
order, and CRTCs are never double-assigned.
Bugs found by testing, not review
- Nothing gave a newly-created window Wayland focus: a freshly-opened app
received no keystrokes and could not paste until clicked.
- Opening a window at a locked screen stole keyboard focus. Caught by
counting wl_keyboard.enter delivered to a client launched while locked:
1 before the guard, 0 after. A killed locker correctly leaves it locked.
- Reading back the winit EGL window surface destroyed the GL context on the
first screencopy capture, taking the compositor down. Root-caused by
A/B-ing the same build with only the readback removed; capture now renders
an offscreen pass.
- Output mode was resent every frame at 60Hz, flooding any client bound to
wl_output with duplicate mode/done events.
- general.default_layout was defaulted and validated but never read, so
setting it did nothing. srdwm is dynamic-first (Windows/macOS style, with
drag-to-edge snapping); tiling is one opt-in layout, and that stays true.
- .gitignore's unanchored `srdwm` matched any path component of that name,
silently excluding the whole crates/srdwm source crate - the binary crate
the workspace lists as a member, so a fresh clone could not build.
Modularization
lib.rs went from ~1260 lines to 78: state, protocols, input, lock,
screencopy, winit and udev now each own one responsibility. lock is grouped
by feature rather than kind on purpose, since its security invariant spans
state, protocol handling and rendering at once.
Multi-monitor was verified in the QEMU VM with a two-output virtio-gpu: both
heads screendumped at their own resolution showing srdwm's clear colour, and
a window forced to global x=1500 landed on head 1 at head-local x=220 while
head 0 stayed empty. Known limitation, now documented: the nested winit
backend stalls while its window is occluded, because the host stops
scheduling frames and eglSwapBuffers blocks.
Diffstat (limited to 'crates/wayland/src/state.rs')
| -rw-r--r-- | crates/wayland/src/state.rs | 356 |
1 files changed, 356 insertions, 0 deletions
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()) + }) +} |