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/udev.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/udev.rs')
| -rw-r--r-- | crates/wayland/src/udev.rs | 508 |
1 files changed, 364 insertions, 144 deletions
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<()> { |