Files
wl-tab/src/capture.rs
T
2026-09-10 08:53:44 +02:00

432 lines
16 KiB
Rust

//! 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<ExtImageCopyCaptureSessionV1>,
/// A capture in flight, and which slot it is filling.
pub(crate) frame: Option<ExtImageCopyCaptureFrameV1>,
pub(crate) filling: Option<usize>,
pub(crate) slots: Vec<Slot>,
/// The slot currently attached to the subsurface.
pub(crate) showing: Option<usize>,
pub(crate) formats: Vec<wl_shm::Format>,
pub(crate) format: Option<wl_shm::Format>,
/// 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<Instant>,
pub(crate) frames: u32,
pub(crate) surface: Option<WlSurface>,
pub(crate) subsurface: Option<WlSubsurface>,
pub(crate) viewport: Option<WpViewport>,
}
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<Self>) {
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<ExtImageCaptureSourceV1> = 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<Self>) -> Result<(), Box<dyn Error>> {
const PAGE: usize = 4096;
let mut total = 0usize;
let mut offsets: Vec<Vec<usize>> = 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<Self>) -> 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<Self>) {
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>) {
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<ExtImageCopyCaptureSessionV1, usize> for App {
fn event(
app: &mut Self,
_: &ExtImageCopyCaptureSessionV1,
event: ext_image_copy_capture_session_v1::Event,
&i: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
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<ExtImageCopyCaptureFrameV1, usize> for App {
fn event(
app: &mut Self,
_: &ExtImageCopyCaptureFrameV1,
event: ext_image_copy_capture_frame_v1::Event,
&i: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
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<WlBuffer, (usize, usize)> for App {
fn event(
app: &mut Self,
_: &WlBuffer,
event: wl_buffer::Event,
&(tile, slot): &(usize, usize),
_: &Connection,
_: &QueueHandle<Self>,
) {
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<wl_callback::WlCallback, ()> for App {
fn event(
app: &mut Self,
_: &wl_callback::WlCallback,
event: wl_callback::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_callback::Event::Done { .. } = event {
app.tick(qh);
app.arm_frame_callback(qh);
}
}
}