//! Capturing windows and displays. //! //! One session per tile, all opened before a single roundtrip so their buffer //! constraints arrive together, and every first frame put in flight at once: the //! compositor is bandwidth-bound reading pixels back, so serialising the //! captures only adds latency. //! //! The pixels are never mapped into this process. A capture buffer goes straight //! to a subsurface for display, so the compositor writes those pages and samples //! them again itself. Live previews are always on. use std::error::Error; use std::os::fd::AsFd; use std::time::{Duration, Instant}; use crate::Target; use wayland_client::protocol::{ wl_buffer::{self, WlBuffer}, wl_callback, wl_output, wl_shm, wl_subsurface::WlSubsurface, wl_surface::WlSurface, }; use wayland_client::{Connection, Dispatch, QueueHandle, WEnum}; use wayland_protocols::ext::image_capture_source::v1::client::ext_image_capture_source_v1::ExtImageCaptureSourceV1; use wayland_protocols::ext::image_copy_capture::v1::client::{ ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1}, ext_image_copy_capture_manager_v1, ext_image_copy_capture_session_v1::{self, ExtImageCopyCaptureSessionV1}, }; use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; use crate::app::App; use crate::shm; use crate::target::Kind; /// One capture buffer. `busy` means the compositor still holds it - either it is /// on screen or a capture is writing into it - so we must not scribble over it. pub struct Slot { pub(crate) buffer: WlBuffer, pub(crate) busy: bool, } pub struct Tile { pub(crate) target: Target, pub(crate) session: Option, /// A capture in flight, and which slot it is filling. pub(crate) frame: Option, pub(crate) filling: Option, pub(crate) slots: Vec, /// The slot currently attached to the subsurface. pub(crate) showing: Option, pub(crate) formats: Vec, pub(crate) format: Option, /// Buffer size the session requires: the window's full resolution. pub(crate) size: (u32, u32), pub(crate) transform: wl_output::Transform, pub(crate) session_done: bool, pub(crate) ready: bool, pub(crate) settled: bool, /// When the last capture was asked for, for rate limiting, and how many /// frames this tile has produced. pub(crate) asked: Option, pub(crate) frames: u32, pub(crate) surface: Option, pub(crate) subsurface: Option, pub(crate) viewport: Option, } impl Tile { pub fn new(target: Target) -> Self { Self { target, session: None, frame: None, filling: None, slots: Vec::new(), showing: None, formats: Vec::new(), format: None, size: (0, 0), transform: wl_output::Transform::Normal, session_done: false, ready: false, settled: false, asked: None, frames: 0, surface: None, subsurface: None, viewport: None, } } pub fn bytes(&self) -> usize { self.size.0 as usize * 4 * self.size.1 as usize } /// Whether the buffer's contents are turned on their side relative to the /// window, which flips the aspect ratio we have to fit. pub fn rotated(&self) -> bool { use wl_output::Transform; matches!( self.transform, Transform::_90 | Transform::_270 | Transform::Flipped90 | Transform::Flipped270 ) } } impl App { /// Open one capture session per window whose toplevel we recognise. They are /// all opened before a single roundtrip, so every session's buffer /// constraints arrive together instead of costing a round trip each. pub fn open_sessions(&mut self, qh: &QueueHandle) { for (i, tile) in self.tiles.iter_mut().enumerate() { // A window's source comes from its toplevel handle, a display's from // its wl_output; everything after that is identical. let source: Option = match tile.target.kind { Kind::Window => self .toplevels .iter() .find(|(_, id)| !id.is_empty() && *id == tile.target.ft_id) .map(|(handle, _)| self.src_mgr.create_source(handle, qh, ())), Kind::Output => self .outputs .iter() .find(|(_, n)| *n == tile.target.id) .and_then(|(output, _)| { self.output_src_mgr .as_ref() .map(|mgr| mgr.create_source(output, qh, ())) }), }; let Some(source) = source else { // Nothing to capture from: the tile stays label-only, and must // not be waited on. tile.settled = true; continue; }; tile.session = Some(self.copy_mgr.create_session( &source, ext_image_copy_capture_manager_v1::Options::empty(), qh, i, )); source.destroy(); } } /// Allocate the capture buffers in one pool and put every first frame in /// flight at once: the compositor is bandwidth-bound reading pixels back, so /// serialising the captures only adds latency. /// /// Live mode gets two buffers per window. A capture may not write into the /// buffer the compositor is currently displaying, so the two alternate: /// fill B while A is on screen, swap, and wait for A's release before /// touching it again. pub fn start_captures(&mut self, qh: &QueueHandle) -> Result<(), Box> { const PAGE: usize = 4096; let mut total = 0usize; let mut offsets: Vec> = Vec::with_capacity(self.tiles.len()); for tile in &mut self.tiles { offsets.push(Vec::new()); if tile.session.is_none() { continue; } if !tile.session_done || tile.size.0 == 0 || tile.size.1 == 0 { tile.settled = true; continue; } // Any 32-bit format will do: we never read these pixels, we hand the // buffer straight back for display, so byte order stays the // compositor's business on both ends. tile.format = tile .formats .iter() .copied() .find(|f| matches!(f, wl_shm::Format::Xrgb8888 | wl_shm::Format::Argb8888)) .or_else(|| tile.formats.first().copied()); if tile.format.is_none() { tile.settled = true; continue; } // Always 2 buffers for live preview; display gets full-screen buffer. let slots = if tile.target.kind == Kind::Output { 1 } else { 2 }; let last = offsets.last_mut().expect("just pushed"); for _ in 0..slots { last.push(total); total += tile.bytes().div_ceil(PAGE) * PAGE; } } if total == 0 { return Ok(()); } self.stats.pool_bytes = total; // Note: no mmap. The compositor writes these pages and samples them // again for display; mapping them here would only cost us the faults. let file = shm::memfd("wl-tab-capture", total)?; let pool = self.shm.create_pool(file.as_fd(), total as i32, qh, ()); for (i, slot_offsets) in offsets.iter().enumerate() { let (w, h, format) = { let t = &self.tiles[i]; if t.settled || t.session.is_none() || t.format.is_none() { continue; } (t.size.0 as i32, t.size.1 as i32, t.format.unwrap()) }; for &offset in slot_offsets { let slot = self.tiles[i].slots.len(); let buffer = pool.create_buffer(offset as i32, w, h, w * 4, format, qh, (i, slot)); self.tiles[i].slots.push(Slot { buffer, busy: false, }); } self.request_capture(i, qh); } pool.destroy(); // the buffers keep the mapping alive Ok(()) } /// Ask the compositor for one frame of window `i`, into a free slot. /// /// After a session's first frame the compositor only answers once the window /// content changes, so a request left outstanding on an idle window costs /// nothing: this is damage-driven, and the rate limit only bites on windows /// that really are animating. fn request_capture(&mut self, i: usize, qh: &QueueHandle) -> bool { let t = &mut self.tiles[i]; if t.frame.is_some() || t.session.is_none() { return false; // already waiting on one } let Some(slot) = t.slots.iter().position(|s| !s.busy) else { self.stats.starved += 1; return false; // both buffers still held by the compositor }; let (w, h) = (t.size.0 as i32, t.size.1 as i32); let frame = t .session .as_ref() .expect("checked above") .create_frame(qh, i); frame.attach_buffer(&t.slots[slot].buffer); frame.damage_buffer(0, 0, w, h); frame.capture(); t.frame = Some(frame); t.filling = Some(slot); t.asked = Some(Instant::now()); true } /// A capture landed: show it, and let go of the slot it replaced. fn frame_ready(&mut self, i: usize) { let t = &mut self.tiles[i]; let Some(slot) = t.filling.take() else { return }; t.frames += 1; t.ready = true; t.settled = true; t.slots[slot].busy = true; // the compositor reads it until it releases it let previous = t.showing.replace(slot); // Before the overlay is mapped there is nothing to attach to yet; // place_tiles picks up `showing` instead. if let Some(surface) = t.surface.clone() { let (w, h) = (t.size.0 as i32, t.size.1 as i32); surface.attach(Some(&t.slots[slot].buffer), 0, 0); surface.damage_buffer(0, 0, w, h); surface.commit(); } else if let Some(prev) = previous { // Not on screen yet, so the old slot was never actually read. t.slots[prev].busy = false; } } /// Ask for the next frame callback. A commit is needed for the compositor to /// schedule one, and an empty commit is enough. pub fn arm_frame_callback(&mut self, qh: &QueueHandle) { if let Some(surface) = self.surface.clone() { surface.frame(qh, ()); surface.commit(); } } /// Re-capture whatever is due. Driven by frame callbacks, so it stops when /// the overlay is not being presented. pub fn tick(&mut self, qh: &QueueHandle) { self.stats.ticks += 1; let interval = Duration::from_secs_f64(1.0 / self.fps.max(1) as f64); let now = Instant::now(); for i in 0..self.tiles.len() { // A display tile shows this overlay, which shows the display tile: // refreshing it never settles and costs a whole screen per frame. if self.tiles[i].target.kind == Kind::Output { continue; } // Nor is there any point refreshing a tile that is scrolled out of // sight - that is a readback for pixels nobody sees. if self.layout.tile(i as i32, self.scroll).is_none() { continue; } let t = &self.tiles[i]; if t.slots.is_empty() || t.frame.is_some() { continue; } if t.asked.is_some_and(|a| now.duration_since(a) < interval) { continue; } self.request_capture(i, qh); } } } // --- event plumbing ------------------------------------------------------- impl Dispatch for App { fn event( app: &mut Self, _: &ExtImageCopyCaptureSessionV1, event: ext_image_copy_capture_session_v1::Event, &i: &usize, _: &Connection, _: &QueueHandle, ) { let Some(tile) = app.tiles.get_mut(i) else { return; }; match event { ext_image_copy_capture_session_v1::Event::BufferSize { width, height } => { tile.size = (width, height) } ext_image_copy_capture_session_v1::Event::ShmFormat { format: WEnum::Value(f), } => tile.formats.push(f), ext_image_copy_capture_session_v1::Event::Done => tile.session_done = true, ext_image_copy_capture_session_v1::Event::Stopped => tile.settled = true, _ => {} } } } impl Dispatch for App { fn event( app: &mut Self, _: &ExtImageCopyCaptureFrameV1, event: ext_image_copy_capture_frame_v1::Event, &i: &usize, _: &Connection, _: &QueueHandle, ) { let Some(tile) = app.tiles.get_mut(i) else { return; }; match event { ext_image_copy_capture_frame_v1::Event::Transform { transform: WEnum::Value(t), } => tile.transform = t, ext_image_copy_capture_frame_v1::Event::Ready => { // The protocol wants the frame destroyed once ready; the buffer // stays ours to display. if let Some(frame) = tile.frame.take() { frame.destroy(); } app.frame_ready(i); } ext_image_copy_capture_frame_v1::Event::Failed { reason } => { // Live mode retries on the next tick; only a failure with no // frame yet leaves the tile without a thumbnail. if tile.frames == 0 { eprintln!( "wl-tab: capture failed for {:?} ({reason:?})", tile.target.title ); } tile.settled = true; if let Some(slot) = tile.filling.take() { tile.slots[slot].busy = false; } if let Some(frame) = tile.frame.take() { frame.destroy(); } } _ => {} } } } /// A released capture buffer is a slot we may capture into again. /// /// Release is the whole contract: with wl_shm the compositor copies the pixels /// out at commit and hands the buffer straight back, so the slot currently on /// screen is usually free too. (Waiting for it to stop being the displayed slot /// instead would deadlock - that release never comes twice.) impl Dispatch for App { fn event( app: &mut Self, _: &WlBuffer, event: wl_buffer::Event, &(tile, slot): &(usize, usize), _: &Connection, _: &QueueHandle, ) { if let wl_buffer::Event::Release = event && let Some(t) = app.tiles.get_mut(tile) { t.slots[slot].busy = false; } } } /// Frame callbacks are the clock for live updates: they arrive as the compositor /// presents the overlay, so re-captures stop when it is not being shown. impl Dispatch for App { fn event( app: &mut Self, _: &wl_callback::WlCallback, event: wl_callback::Event, _: &(), _: &Connection, qh: &QueueHandle, ) { if let wl_callback::Event::Done { .. } = event { app.tick(qh); app.arm_frame_callback(qh); } } }