//! Converts output-buffer readback into upright image coordinates. use std::path::{Path, PathBuf}; use std::sync::mpsc::SyncSender as ReplySender; use std::time::{Duration, Instant}; use ferese_ipc::Response; use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Size, Transform}; const BYTES_PER_PIXEL: usize = 4; pub(crate) struct OutputFrame { pub(crate) preserve_alpha: bool, pub(crate) width: i32, pub(crate) height: i32, pub(crate) stride: usize, pub(crate) pixels: Vec, pub(crate) transform: Transform, pub(crate) location: (i32, i32), pub(crate) scale: f64, // The clipped logical extent this readback was taken for, in upright // orientation. Dividing the readback dimensions by `scale` would not // recover it exactly at fractional scales, so it is carried through // unchanged rather than recomputed on the worker. pub(crate) logical_width: i32, pub(crate) logical_height: i32, } pub(crate) struct Canvas { pub(crate) width: u32, pub(crate) height: u32, pub(crate) pixels: Vec, } const MAX_CANVAS_BYTES: usize = 128 * 1024 * 1024; impl Canvas { fn new(width: u32, height: u32) -> Option { let length = (width as usize).checked_mul(height as usize)?.checked_mul(4)?; if width == 0 || height == 0 || length > MAX_CANVAS_BYTES { return None; } Some(Self { width, height, pixels: vec![0; length], }) } fn blit(&mut self, source: &[u8], source_width: usize, source_height: usize, x: i32, y: i32) { let canvas_width = self.width as usize; let source_row_bytes = source_width.checked_mul(BYTES_PER_PIXEL); let (Some(source_row_bytes), Some(canvas_row_bytes)) = (source_row_bytes, canvas_width.checked_mul(BYTES_PER_PIXEL)) else { return; }; let first_row = y.clamp(0, self.height as i32) as usize; let last_row = (y + source_height as i32).clamp(0, self.height as i32) as usize; if first_row >= last_row { return; } let first_column = x.clamp(0, self.width as i32) as usize; let last_column = (x + source_width as i32).clamp(0, self.width as i32) as usize; if first_column >= last_column { return; } let width = (last_column - first_column) * BYTES_PER_PIXEL; let source_column_offset = (first_column as i32 - x) as usize * BYTES_PER_PIXEL; for row in first_row..last_row { let from = (row - y as usize) * source_row_bytes + source_column_offset; let to = row * canvas_row_bytes + first_column * BYTES_PER_PIXEL; if from + width <= source.len() && to + width <= self.pixels.len() { self.pixels[to..to + width].copy_from_slice(&source[from..from + width]); } } } } fn scale_and_convert(frame: &OutputFrame, target_width: usize, target_height: usize) -> Option> { let (upright_width, upright_height) = upright_size(frame); if upright_width <= 0 || upright_height <= 0 || target_width == 0 || target_height == 0 { return None; } let mut out = vec![0_u8; target_width.checked_mul(target_height)?.checked_mul(4)?]; let row_bytes = target_width.checked_mul(BYTES_PER_PIXEL)?; if upright_width as usize == target_width && upright_height as usize == target_height { for row in 0..target_height { for column in 0..target_width { let (x, y) = upright_source_pixel(column as i32, row as i32, frame.width, frame.height, frame.transform); let from = (y as usize) .checked_mul(frame.stride)? .checked_add(x as usize * BYTES_PER_PIXEL)?; if from + BYTES_PER_PIXEL > frame.pixels.len() { return None; } let to = row * row_bytes + column * BYTES_PER_PIXEL; let pixel = &frame.pixels[from..from + BYTES_PER_PIXEL]; out[to] = pixel[2]; out[to + 1] = pixel[1]; out[to + 2] = pixel[0]; out[to + 3] = if frame.preserve_alpha { pixel[3] } else { 255 }; } } return Some(out); } // Destination columns map to source columns, and only the row varies within // a destination row, so resolve each column once. let columns: Vec = (0..target_width) .map(|column| (column * upright_width as usize / target_width) as i32) .collect(); for row in 0..target_height { let source_row = row * upright_height as usize / target_height; for (column, source_column) in columns.iter().enumerate() { let (x, y) = upright_source_pixel( *source_column, source_row as i32, frame.width, frame.height, frame.transform, ); let from = (y as usize) .checked_mul(frame.stride)? .checked_add(x as usize * BYTES_PER_PIXEL)?; if from + BYTES_PER_PIXEL > frame.pixels.len() { return None; } let to = row * row_bytes + column * BYTES_PER_PIXEL; let pixel = &frame.pixels[from..from + BYTES_PER_PIXEL]; out[to] = pixel[2]; out[to + 1] = pixel[1]; out[to + 2] = pixel[0]; out[to + 3] = if frame.preserve_alpha { pixel[3] } else { 255 }; } } Some(out) } fn upright_source_pixel(x: i32, y: i32, width: i32, height: i32, transform: Transform) -> (i32, i32) { match transform { Transform::_90 => (y, height - 1 - x), Transform::_180 => (width - 1 - x, height - 1 - y), Transform::_270 => (width - 1 - y, x), Transform::Flipped => (width - 1 - x, y), Transform::Flipped90 => (y, x), Transform::Flipped180 => (x, height - 1 - y), Transform::Flipped270 => (width - 1 - y, height - 1 - x), Transform::Normal => (x, y), } } pub(crate) fn swaps_axes(transform: Transform) -> bool { matches!( transform, Transform::_90 | Transform::_270 | Transform::Flipped90 | Transform::Flipped270 ) } pub(crate) fn compose(frames: &[OutputFrame]) -> Result { for frame in frames { validate(frame)?; } let (left, top) = logical_origin(frames).ok_or("Screenshot region is empty")?; let scale = frames.iter().map(|frame| frame.scale).fold(0.0_f64, f64::max); if !(scale.is_finite() && scale > 0.0) { return Err("Screenshot outputs have an invalid scale".into()); } let mut placement = Vec::with_capacity(frames.len()); let mut width = 0_i32; let mut height = 0_i32; for frame in frames { // Placement is rounded from the canvas origin, and the far edge is // derived from the near edge plus the physical extent, so a frame at the // maximum scale keeps its exact pixel dimensions and adjacent frames // share their boundary. let x0 = ((frame.location.0 - left) as f64 * scale).round() as i32; let y0 = ((frame.location.1 - top) as f64 * scale).round() as i32; let target_width = canvas_extent(frame.logical_width, scale); let target_height = canvas_extent(frame.logical_height, scale); let x1 = x0.saturating_add(target_width); let y1 = y0.saturating_add(target_height); width = width.max(x1); height = height.max(y1); placement.push((x0, y0, target_width, target_height)); } if width <= 0 || height <= 0 { return Err("Screenshot region is empty".into()); } let mut canvas = Canvas::new(width as u32, height as u32).ok_or("Screenshot image is too large to encode")?; for (frame, (x0, y0, target_width, target_height)) in frames.iter().zip(placement) { if target_width == 0 || target_height == 0 { continue; } let scaled = scale_and_convert(frame, target_width as usize, target_height as usize).ok_or("Invalid capture size")?; canvas.blit(&scaled, target_width as usize, target_height as usize, x0, y0); } Ok(canvas) } fn canvas_extent(logical: i32, scale: f64) -> i32 { if logical <= 0 || !(scale.is_finite() && scale > 0.0) { return 0; } ((logical as f64 * scale).round() as i32).max(0) } fn validate(frame: &OutputFrame) -> Result<(), String> { let row = (frame.width as usize) .checked_mul(BYTES_PER_PIXEL) .ok_or("Capture dimensions overflow")?; if frame.width <= 0 || frame.height <= 0 || frame.logical_width <= 0 || frame.logical_height <= 0 || frame.stride < row || !(frame.scale.is_finite() && frame.scale > 0.0) { return Err("Capture has invalid dimensions or scale".into()); } let length = frame .stride .checked_mul(frame.height as usize) .ok_or("Capture dimensions overflow")?; if frame.pixels.len() < length { return Err("Capture buffer is shorter than its dimensions".into()); } Ok(()) } fn upright_size(frame: &OutputFrame) -> (i32, i32) { if swaps_axes(frame.transform) { (frame.height, frame.width) } else { (frame.width, frame.height) } } fn logical_origin(frames: &[OutputFrame]) -> Option<(i32, i32)> { let mut origin: Option<(i32, i32)> = None; for frame in frames { if frame.width <= 0 || frame.height <= 0 { continue; } origin = Some(match origin { None => (frame.location.0, frame.location.1), Some((x, y)) => (x.min(frame.location.0), y.min(frame.location.1)), }); } origin } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Geometry { All, Region { x: i32, y: i32, width: i32, height: i32 }, } impl Geometry { // IPC arguments arrive as JSON numbers, so this is the gate that keeps a // negative or zero extent from reaching rectangle arithmetic. pub(crate) fn new_region(x: i32, y: i32, width: i32, height: i32) -> Result { if width <= 0 || height <= 0 { return Err("Screenshot region must have a positive size".into()); } x.checked_add(width).ok_or("screenshot geometry overflows")?; y.checked_add(height).ok_or("screenshot geometry overflows")?; Ok(Geometry::Region { x, y, width, height }) } } pub(crate) fn parse_geometry(raw: &str) -> Result { let raw = raw.trim(); let invalid = || format!("screenshot geometry must be \"x,y WxH\": {raw:?}"); if raw.is_empty() { return Err(invalid()); } let (position, size) = raw.split_once(' ').ok_or_else(invalid)?; let (x, y) = position.split_once(',').ok_or_else(invalid)?; let (width, height) = size.split_once('x').ok_or_else(invalid)?; let x: i32 = x.trim().parse().map_err(|_| invalid())?; let y: i32 = y.trim().parse().map_err(|_| invalid())?; let width: i32 = width.trim().parse().map_err(|_| invalid())?; let height: i32 = height.trim().parse().map_err(|_| invalid())?; Geometry::new_region(x, y, width, height) } #[derive(Debug)] pub(crate) struct CaptureBuffer { pub(crate) width: i32, pub(crate) height: i32, pub(crate) stride: usize, pub(crate) pixels: Vec, } // A capture part completes exactly once, with either pixels or an error. The // compositor thread sends it over a channel that never fills while it is // rendering; a dropped part would leave its request waiting indefinitely. #[derive(Debug)] pub(crate) struct PartOutcome { pub(crate) request: u64, pub(crate) part: usize, pub(crate) result: Result, } // A calloop channel so publishing a part also wakes the event loop, whichever // backend produced the pixels. pub(crate) type PartSender = smithay::reexports::calloop::channel::Sender; // Geometry snapshotted when the request was admitted, so the encoding worker // never re-queries an output whose transform or scale has since changed. pub(crate) struct PartSpec { pub(crate) preserve_alpha: bool, pub(crate) transform: Transform, pub(crate) scale: f64, pub(crate) location: (i32, i32), pub(crate) logical_width: i32, pub(crate) logical_height: i32, pub(crate) buffer_width: i32, pub(crate) buffer_height: i32, } enum PartState { Pending, Ready(OutputFrame), } enum RequestState { Collecting, Encoding, } struct Request { response_id: u64, reply: ReplySender, state: RequestState, parts: Vec<(PartSpec, PartState)>, received: usize, deadline: Instant, } pub(crate) enum Action { None, Encode { request: u64, frames: Vec }, // The path was already sent to the caller; it must not be cleaned up. Delivered(PathBuf), // The request is gone, so this file is ours to remove. DiscardFile(PathBuf), } const MAX_OUTSTANDING: usize = 8; const MAX_PENDING_BYTES: usize = 256 * 1024 * 1024; // A request is answered or abandoned this long after it is admitted. Parts are // produced by the render path, so an output that is never repainted would // otherwise leave the request in Collecting forever, holding one of the few // outstanding slots against every later request. pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Default)] pub(crate) struct Coordinator { requests: std::collections::HashMap, next_id: u64, } impl Coordinator { pub(crate) fn new() -> Self { Self::default() } pub(crate) fn admit( &mut self, response_id: u64, reply: ReplySender, specs: Vec, ) -> Result { if specs.is_empty() { return Err("No outputs are enabled".into()); } if self.requests.len() >= MAX_OUTSTANDING { return Err("Too many screenshot requests are in flight".into()); } // Budget every participating readback before the first one allocates. let mut pending = 0_usize; for spec in &specs { if spec.buffer_width <= 0 || spec.buffer_height <= 0 { return Err("Screenshot region is too large".into()); } let Some(bytes) = (spec.buffer_width as usize) .checked_mul(BYTES_PER_PIXEL) .and_then(|row| row.checked_mul(spec.buffer_height as usize)) else { return Err("Screenshot region is too large".into()); }; pending = pending.saturating_add(bytes); } if pending > MAX_PENDING_BYTES { return Err("Screenshot region is too large".into()); } self.next_id = self.next_id.wrapping_add(1); let id = self.next_id; self.requests.insert( id, Request { response_id, reply, state: RequestState::Collecting, parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(), received: 0, deadline: Instant::now() + REQUEST_TIMEOUT, }, ); Ok(id) } // Used by the completion tests to assert that a request is still tracked. #[allow(dead_code)] pub(crate) fn is_live(&self, request: u64) -> bool { self.requests.contains_key(&request) } pub(crate) fn is_encoding(&self, request: u64) -> bool { matches!( self.requests.get(&request).map(|entry| &entry.state), Some(RequestState::Encoding) ) } pub(crate) fn on_part(&mut self, request: u64, part: usize, result: Result) -> Action { let Some(entry) = self.requests.get_mut(&request) else { return Action::None; }; if !matches!(entry.state, RequestState::Collecting) { return Action::None; } let Some((spec, slot)) = entry.parts.get_mut(part) else { return Action::None; }; if !matches!(slot, PartState::Pending) { return Action::None; } let frame = match result { Ok(ref buffer) => { let mut pixels = Vec::new(); if pixels.try_reserve_exact(buffer.pixels.len()).is_err() { None } else { pixels.extend_from_slice(&buffer.pixels); Some(OutputFrame { preserve_alpha: spec.preserve_alpha, width: buffer.width, height: buffer.height, stride: buffer.stride, pixels, transform: spec.transform, location: spec.location, scale: spec.scale, logical_width: spec.logical_width, logical_height: spec.logical_height, }) } } Err(_) => None, }; let Some(frame) = frame else { let error = match result { Err(error) => error, Ok(_) => "Screenshot capture could not be allocated".to_string(), }; self.reject_inner(request, &error); return Action::None; }; let Some(entry) = self.requests.get_mut(&request) else { return Action::None; }; entry.parts[part].1 = PartState::Ready(frame); entry.received += 1; if entry.received < entry.parts.len() { return Action::None; } let frames: Vec = std::mem::take(&mut entry.parts) .into_iter() .filter_map(|(_, state)| match state { PartState::Ready(frame) => Some(frame), PartState::Pending => None, }) .collect(); entry.state = RequestState::Encoding; Action::Encode { request, frames } } pub(crate) fn on_encoded(&mut self, request: u64, result: Result) -> Action { if !self.is_encoding(request) { // Terminated while encoding: the file is ours to clean up, and a // late result must not revive the request. return match result { Ok(path) => Action::DiscardFile(path), Err(_) => Action::None, }; } let entry = self.requests.remove(&request).expect("checked above"); match result { Ok(path) => { reply(&entry, Ok(&path)); Action::Delivered(path) } Err(error) => { reply(&entry, Err(&error)); Action::None } } } pub(crate) fn reject(&mut self, request: u64, error: &str) { self.reject_inner(request, error); } // Answers every request that outlived its deadline. The returned ids let the // caller drop readbacks that would otherwise publish into a request that no // longer exists. pub(crate) fn expire(&mut self) -> Vec { let now = Instant::now(); let expired: Vec = self .requests .iter() .filter(|(_, entry)| now >= entry.deadline) .map(|(id, _)| *id) .collect(); for id in &expired { self.reject_inner(*id, "Screenshot timed out: an output was not redrawn in time"); } expired } // The remaining time before the earliest deadline, so the caller can sleep // exactly that long instead of polling on a fixed tick. pub(crate) fn next_deadline(&self) -> Option { self.requests .values() .map(|entry| entry.deadline.saturating_duration_since(Instant::now())) .min() } pub(crate) fn terminate_all(&mut self) -> Vec { let ids: Vec = self.requests.keys().copied().collect(); for id in &ids { self.reject_inner(*id, "Screenshot cancelled: the session was locked"); } ids } fn reject_inner(&mut self, request: u64, error: &str) { if let Some(entry) = self.requests.remove(&request) { reply(&entry, Err(error)); } } } fn reply(entry: &Request, result: Result<&Path, &str>) { let response = match result { Ok(path) => Response::success(entry.response_id, serde_json::json!({ "path": path })), Err(error) => Response::error(entry.response_id, "screenshot_failed", error), }; // The IPC worker is already blocked in recv(), so this never blocks. A // disconnected client simply drops the receiver. let _ = entry.reply.try_send(response); } // The layout facts the planner needs, snapshotted from a live output. #[derive(Clone)] pub(crate) struct OutputLayout { pub(crate) mode_size: Size, pub(crate) scale: f64, pub(crate) transform: Transform, // Global logical position of the output, as the layout sees it. pub(crate) location: Point, } impl OutputLayout { fn logical_size(&self) -> Size { crate::handlers::screencopy::output_logical_size(self.mode_size, self.scale, self.transform) } } pub(crate) struct PlannedPart { // Index of the contributing output in the caller's list. pub(crate) index: usize, // The clipped global logical rectangle, in the upright orientation the // caller sees. Composition places by this, so it is never recovered from // the readback dimensions. pub(crate) logical: Rectangle, // The readback rectangle, relative to this output's buffer origin. pub(crate) buffer: Rectangle, } impl PlannedPart { pub(crate) fn spec(&self, output: &OutputLayout) -> PartSpec { PartSpec { preserve_alpha: false, transform: output.transform, scale: output.scale, location: (self.logical.loc.x, self.logical.loc.y), logical_width: self.logical.size.w, logical_height: self.logical.size.h, buffer_width: self.buffer.size.w, buffer_height: self.buffer.size.h, } } } // Turns a requested logical region into the per-output reads that cover it. // The logical-to-buffer conversion is shared with the screencopy path so both // agree on rounding at fractional scales. pub(crate) fn plan(geometry: &Geometry, outputs: &[OutputLayout]) -> Result, String> { if outputs.is_empty() { return Err("No outputs are enabled".into()); } // Validate before building any Rectangle: Size construction panics on a // negative extent, and this input arrives over IPC. if let Geometry::Region { width, height, .. } = geometry && (*width <= 0 || *height <= 0) { return Err("Screenshot region must have a positive size".into()); } let logical_sizes: Vec> = outputs.iter().map(OutputLayout::logical_size).collect(); let requested = match geometry { Geometry::All => { let mut union: Option> = None; for (index, output) in outputs.iter().enumerate() { let rect = Rectangle { loc: output.location, size: logical_sizes[index], }; union = Some(match union { Some(current) => { let top_left = Point::::new(current.loc.x.min(rect.loc.x), current.loc.y.min(rect.loc.y)); let bottom_right = Point::::new( (current.loc.x + current.size.w).max(rect.loc.x + rect.size.w), (current.loc.y + current.size.h).max(rect.loc.y + rect.size.h), ); Rectangle::from_extremities(top_left, bottom_right) } None => rect, }); } union.expect("outputs is not empty") } Geometry::Region { x, y, width, height } => Rectangle { loc: (*x, *y).into(), size: (*width, *height).into(), }, }; if requested.size.w <= 0 || requested.size.h <= 0 { return Err("Screenshot region must have a positive size".into()); } let mut parts = Vec::new(); for (index, output) in outputs.iter().enumerate() { if !(output.scale.is_finite() && output.scale > 0.0) { return Err("Output scale is not usable".into()); } let output_rect = Rectangle { loc: output.location, size: logical_sizes[index], }; let Some(clipped) = requested.intersection(output_rect) else { continue; }; // Smithay's intersection already excludes a merely adjacent output, but // a degenerate clip must never reach the hard failure below. if clipped.size.w <= 0 || clipped.size.h <= 0 { continue; } // capture_region_for_geometry works in output-local logical space. let local = Rectangle { loc: (clipped.loc.x - output.location.x, clipped.loc.y - output.location.y).into(), size: clipped.size, }; // The clip above already intersected against this same logical rect, so // a failure here means the two paths disagree. Report it rather than // quietly returning a smaller image than was asked for. let Some(buffer) = crate::handlers::screencopy::capture_region_for_geometry( output.mode_size, output.scale, output.transform, Some(local), ) else { return Err(format!( "Could not compute a capture region for the output at {}x{}", output.location.x, output.location.y )); }; parts.push(PlannedPart { index, logical: clipped, buffer, }); } if parts.is_empty() { return Err("The requested region does not intersect any enabled output".into()); } Ok(parts) } #[cfg(test)] mod planning { use super::*; fn output(x: i32, y: i32, width: i32, height: i32, scale: f64) -> OutputLayout { let mode = Size::::new( (f64::from(width) * scale).round() as i32, (f64::from(height) * scale).round() as i32, ); OutputLayout { mode_size: mode, scale, transform: Transform::Normal, location: Point::new(x, y), } } fn region(x: i32, y: i32, width: i32, height: i32) -> Geometry { Geometry::Region { x, y, width, height } } #[test] fn a_crop_crossing_a_seam_splits_into_one_part_per_output() { let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)]; let parts = plan(®ion(1800, 0, 200, 100), &outputs).unwrap(); assert_eq!(parts.len(), 2); assert_eq!(parts[0].index, 0); assert_eq!((parts[0].logical.loc.x, parts[0].logical.size.w), (1800, 120)); assert_eq!( (parts[1].index, parts[1].logical.loc.x, parts[1].logical.size.w), (1, 1920, 80) ); // The clipped widths must add back up to the request. assert_eq!(parts[0].logical.size.w + parts[1].logical.size.w, 200); // Readbacks are rebased onto each output's own buffer origin. assert_eq!((parts[0].buffer.loc.x, parts[0].buffer.size.w), (1800, 120)); assert_eq!((parts[1].buffer.loc.x, parts[1].buffer.size.w), (0, 80)); } #[test] fn a_spec_carries_the_clipped_logical_rect_not_the_readback() { // A fractional-scale output readbacks more pixels than logical*scale // exactly, so the spec must not be rebuilt from the buffer size. let outputs = [output(0, 0, 3, 3, 1.5)]; let parts = plan(®ion(0, 0, 3, 3), &outputs).unwrap(); let spec = parts[0].spec(&outputs[0]); assert_eq!((spec.logical_width, spec.logical_height), (3, 3)); assert_eq!(spec.location, (0, 0)); assert_eq!(spec.scale, 1.5); assert_ne!( (spec.buffer_width, spec.buffer_height), (spec.logical_width, spec.logical_height), "this fixture only makes sense if the readback is scaled up" ); } #[test] fn mixed_scale_keeps_both_clips_in_logical_space() { let outputs = [output(0, 0, 800, 600, 1.0), output(800, 0, 1600, 1200, 1.5)]; let parts = plan(®ion(600, 0, 400, 300), &outputs).unwrap(); assert_eq!(parts.len(), 2); assert_eq!((parts[0].logical.loc.x, parts[0].logical.size.w), (600, 200)); assert_eq!((parts[1].logical.loc.x, parts[1].logical.size.w), (800, 200)); // The 1.5 output reads back at 1.5x while still reporting logical 200. let spec = parts[1].spec(&outputs[1]); assert_eq!(spec.logical_width, 200); assert_eq!(spec.buffer_width, 300); assert_eq!(spec.buffer_height, 450); } #[test] fn all_geometry_unions_every_output() { let outputs = [output(0, 0, 1920, 1080, 1.0), output(-1280, -400, 1280, 800, 1.0)]; let parts = plan(&Geometry::All, &outputs).unwrap(); assert_eq!(parts.len(), 2); // Neither output is moved into a normalised origin at plan time. assert_eq!(parts[0].logical.loc.x, 0); assert_eq!(parts[1].logical.loc.x, -1280); assert_eq!(parts[1].logical.loc.y, -400); } #[test] fn a_crop_misses_outputs_that_do_not_intersect() { let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)]; let parts = plan(®ion(4000, 0, 100, 100), &outputs); assert!(parts.is_err(), "a region off the desktop is reported"); } #[test] fn an_output_adjacent_to_the_region_is_skipped_not_fatal() { // The second output starts exactly where the region ends. It contributes // nothing, so the capture must still succeed with just the first output // rather than failing on a region it does not overlap. let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)]; let parts = plan(®ion(100, 100, 1820, 980), &outputs).expect("captures the overlap"); assert_eq!(parts.len(), 1, "only the overlapping output is read"); assert_eq!(parts[0].index, 0); assert_eq!((parts[0].logical.size.w, parts[0].logical.size.h), (1820, 980)); } #[test] fn a_rotated_output_reads_back_with_swapped_axes() { let mut rotated = output(0, 0, 1920, 1080, 1.0); rotated.transform = Transform::_90; // A 90-degree transform turns the 1920x1080 mode into a 1080x1920 // logical area, and the readback follows the same rotation. let parts = plan(®ion(0, 0, 1080, 400), std::slice::from_ref(&rotated)).unwrap(); assert_eq!((parts[0].logical.size.w, parts[0].logical.size.h), (1080, 400)); let spec = parts[0].spec(&rotated); assert_eq!(spec.transform, Transform::_90); assert!(spec.buffer_height > spec.buffer_width, "buffer axes are swapped"); } #[test] fn the_region_constructor_validates_instead_of_panicking() { // Size construction panics on a negative extent, so this must never // reach rectangle arithmetic. assert!(Geometry::new_region(0, 0, 100, -5).is_err()); assert!(Geometry::new_region(0, 0, 0, 100).is_err()); assert!(Geometry::new_region(i32::MAX, 0, 2, 2).is_err()); assert!(Geometry::new_region(0, i32::MAX, 2, 2).is_err()); assert!( Geometry::new_region(0, i32::MIN, 2, 2).is_ok(), "a large negative origin is representable" ); let ok = Geometry::new_region(-5, -7, 10, 20).expect("negative origins are fine"); assert_eq!( ok, Geometry::Region { x: -5, y: -7, width: 10, height: 20 } ); // The parser goes through the same gate. assert!(parse_geometry("0,0 100x-5").is_err()); } #[test] fn a_non_positive_or_empty_request_is_rejected() { let outputs = [output(0, 0, 1920, 1080, 1.0)]; assert!(plan(®ion(0, 0, 0, 100), &outputs).is_err()); assert!(plan(®ion(0, 0, 100, -5), &outputs).is_err()); assert!(plan(&Geometry::All, &[]).is_err()); } } #[cfg(test)] mod completion { use std::sync::mpsc::sync_channel; use smithay::reexports::calloop::channel::channel as loop_channel; use super::*; use crate::handlers::screencopy::CaptureSink; fn spec(width: i32, height: i32) -> PartSpec { PartSpec { preserve_alpha: false, transform: Transform::Normal, scale: 1.0, location: (0, 0), logical_width: width, logical_height: height, buffer_width: width, buffer_height: height, } } fn buffer(width: i32, height: i32) -> CaptureBuffer { let stride = width as usize * BYTES_PER_PIXEL; CaptureBuffer { width, height, stride, pixels: vec![0u8; stride * height as usize], } } struct Harness { coordinator: Coordinator, received: std::sync::mpsc::Receiver, } impl Harness { fn new(parts: usize) -> Self { let (reply, received) = sync_channel(8); let mut coordinator = Coordinator::new(); coordinator .admit(42, reply.clone(), (0..parts).map(|_| spec(2, 2)).collect()) .expect("admitted"); Self { coordinator, received } } fn response(&self) -> Option { self.received.try_recv().ok() } } #[test] fn part_failure_terminates_the_request_with_an_error() { let mut harness = Harness::new(2); let id = harness.coordinator.next_id; assert!(matches!( harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))), Action::None )); assert!(harness.coordinator.is_live(id), "still collecting after part 0"); assert!(matches!( harness.coordinator.on_part(id, 1, Err("readback failed".into())), Action::None )); assert!(!harness.coordinator.is_live(id), "one failed part ends the request"); let response = harness.response().expect("caller is always answered"); assert_eq!(response.id, 42); assert!(response.result.is_none()); assert_eq!(response.error.unwrap().code, "screenshot_failed"); } #[test] fn a_disappearing_output_fails_the_request_and_discards_late_results() { let mut harness = Harness::new(2); let id = harness.coordinator.next_id; harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); // Output 1 is removed from the layout, so its pending capture is failed. harness.coordinator.on_part(id, 1, Err("output was removed".into())); assert!(!harness.coordinator.is_live(id)); assert!(harness.response().unwrap().error.is_some()); // A late readback for the sibling must not revive the request. assert!(matches!( harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))), Action::None )); assert!(harness.response().is_none(), "no second response is sent"); } #[test] fn a_part_completes_at_most_once() { let mut harness = Harness::new(2); let id = harness.coordinator.next_id; harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); // A duplicate publication for the same part is ignored rather than // overwriting pixels that were already accepted. assert!(matches!( harness.coordinator.on_part(id, 0, Ok(buffer(64, 64))), Action::None )); assert!(matches!( harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))), Action::Encode { .. } )); // An unknown part index is ignored entirely. assert!(matches!( harness.coordinator.on_part(id, 99, Ok(buffer(2, 2))), Action::None )); } #[test] fn all_parts_ready_hands_frames_to_the_encoder() { let mut harness = Harness::new(2); let id = harness.coordinator.next_id; assert!(matches!( harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))), Action::None )); let action = harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))); let Action::Encode { request, frames } = action else { panic!("expected the request to move to encoding"); }; assert_eq!(request, id); assert_eq!(frames.len(), 2); assert!(harness.coordinator.is_encoding(id)); assert!(harness.response().is_none(), "no reply until encoding ends"); } #[test] fn lock_during_encoding_cancels_and_the_late_file_is_removed() { let mut harness = Harness::new(1); let id = harness.coordinator.next_id; harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); assert!(harness.coordinator.is_encoding(id)); // The session locks while the worker is still encoding. let terminated = harness.coordinator.terminate_all(); assert_eq!(terminated, vec![id]); let response = harness.response().expect("cancellation is answered"); assert!(response.error.is_some()); // Unlocking does not resurrect the request; the file the worker // eventually produces is ours to delete. assert!(!harness.coordinator.is_live(id)); let action = harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/late.png"))); assert!(matches!(action, Action::DiscardFile(ref path) if path.ends_with("late.png"))); assert!(harness.response().is_none(), "no late success is delivered"); } #[test] fn encoded_result_is_delivered_once() { let mut harness = Harness::new(1); let id = harness.coordinator.next_id; harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/shot.png"))); let response = harness.response().expect("success is answered"); assert_eq!(response.id, 42); assert!(response.error.is_none()); assert_eq!(response.result.unwrap()["path"], "/tmp/shot.png"); // A duplicate completion for the same request has nowhere to go, and // its file is an orphan rather than a second delivery. let action = harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/again.png"))); assert!(matches!(action, Action::DiscardFile(_))); // A delivered path is reported separately so the caller cannot // mistake it for a file to delete. let mut other = Harness::new(1); let other_id = other.coordinator.next_id; other.coordinator.on_part(other_id, 0, Ok(buffer(2, 2))); assert!(matches!( other .coordinator .on_encoded(other_id, Ok(PathBuf::from("/tmp/live.png"))), Action::Delivered(ref path) if path.ends_with("live.png") )); assert!(harness.response().is_none()); } #[test] fn encoder_failure_is_reported_to_the_caller() { let mut harness = Harness::new(1); let id = harness.coordinator.next_id; harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))); harness.coordinator.on_encoded(id, Err("encoding failed".into())); let response = harness.response().expect("failure is answered"); assert_eq!(response.error.unwrap().message, "encoding failed"); assert!(!harness.coordinator.is_live(id)); } #[test] fn a_request_whose_output_is_never_redrawn_is_abandoned() { // Nothing ever calls on_part, which is what an unrendered output does. // Without a deadline the request would hold its slot forever and the // client would never be answered. let mut harness = Harness::new(2); let id = harness.coordinator.next_id; harness.coordinator.requests.get_mut(&id).expect("tracked").deadline = Instant::now() - Duration::from_secs(1); assert_eq!(harness.coordinator.expire(), vec![id]); let response = harness.response().expect("the caller is answered"); assert_eq!(response.error.as_ref().unwrap().code, "screenshot_failed"); assert!( response.error.as_ref().unwrap().message.contains("timed out"), "the failure explains itself: {:?}", response.error.as_ref().unwrap().message ); assert!(!harness.coordinator.is_live(id)); // A late part for the abandoned request is discarded, not revived. assert!(matches!( harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))), Action::None )); assert!(harness.response().is_none()); } #[test] fn a_live_request_is_not_expired_early() { let mut harness = Harness::new(1); let id = harness.coordinator.next_id; assert!(harness.coordinator.expire().is_empty(), "not yet due"); assert!(harness.coordinator.is_live(id)); assert!(harness.response().is_none(), "still unanswered, not failed"); let remaining = harness.coordinator.next_deadline().expect("still armed"); assert!( remaining <= REQUEST_TIMEOUT && remaining > REQUEST_TIMEOUT - Duration::from_secs(1), "the timer re-arms to roughly the full timeout, got {remaining:?}" ); } #[test] fn expiring_frees_the_outstanding_slot() { let (reply, _received) = sync_channel(1); let mut coordinator = Coordinator::new(); for _ in 0..MAX_OUTSTANDING { coordinator .admit(42, reply.clone(), vec![spec(2, 2)]) .expect("within the request cap"); } for entry in coordinator.requests.values_mut() { entry.deadline = Instant::now() - Duration::from_secs(1); } assert_eq!(coordinator.expire().len(), MAX_OUTSTANDING); assert_eq!(coordinator.next_deadline(), None); // The cap is usable again rather than permanently degraded. coordinator .admit(42, reply, vec![spec(2, 2)]) .expect("a slot was released"); } #[test] fn admission_is_bounded_by_requests_and_bytes() { let (reply, _received) = sync_channel(1); let mut coordinator = Coordinator::new(); for _ in 0..MAX_OUTSTANDING { coordinator .admit(42, reply.clone(), vec![spec(2, 2)]) .expect("within the request cap"); } let overflow = coordinator.admit(42, reply.clone(), vec![spec(2, 2)]); assert!(overflow.is_err(), "outstanding requests are bounded"); let huge = PartSpec { preserve_alpha: false, buffer_width: 1 << 20, buffer_height: 1 << 20, ..spec(2, 2) }; let mut fresh = Coordinator::new(); assert!( fresh.admit(42, reply.clone(), vec![huge]).is_err(), "the byte budget is checked before any readback allocates" ); } #[test] fn a_sink_publishes_once_and_then_rejects_duplicates() { let (sender, receiver) = loop_channel(); let sink = CaptureSink::owned(7, 1, PartSender::clone(&sender)); assert!(sink.publish(buffer(2, 2))); // A second publication for the same part is refused, so the first // buffer that was handed off is never replaced. assert!(!sink.publish(buffer(64, 64))); let PartOutcome { request, part, result } = receiver.try_recv().expect("published once"); assert_eq!((request, part), (7, 1)); assert_eq!(result.unwrap().width, 2); assert!(receiver.try_recv().is_err(), "no second buffer was sent"); } #[test] fn failing_an_owned_sink_answers_the_part_with_an_error() { let (sender, receiver) = loop_channel(); let sink = CaptureSink::owned(3, 0, PartSender::clone(&sender)); sink.fail(); // A capture that later completes cannot publish after the terminal // failure has already been reported. assert!(!sink.publish(buffer(2, 2))); let PartOutcome { request, part, result } = receiver.try_recv().expect("answered"); assert_eq!((request, part), (3, 0)); assert!(result.is_err()); assert!(receiver.try_recv().is_err()); } #[test] fn completion_channel_keeps_more_parts_than_the_old_capacity() { let (sender, receiver) = loop_channel(); for part in 0..65 { let sink = CaptureSink::owned(1, part, sender.clone()); assert!(sink.publish(buffer(2, 2))); } for part in 0..65 { let outcome = receiver.try_recv().expect("every part was queued"); assert_eq!((outcome.request, outcome.part), (1, part)); } assert!(receiver.try_recv().is_err()); } } #[cfg(test)] mod tests { use super::*; fn labelled_frame(transform: Transform, width: i32, height: i32) -> OutputFrame { let mut pixels = Vec::new(); for index in 0..(width * height) { // First byte of each pixel is a unique row-major index. pixels.extend_from_slice(&[b'a' + index as u8, 0, 0, 255]); } OutputFrame { preserve_alpha: false, width, height, stride: width as usize * BYTES_PER_PIXEL, pixels, transform, location: (0, 0), scale: 1.0, logical_width: width, logical_height: height, } } fn decode(frame: &OutputFrame, from: usize) -> &'static str { const CELLS: [&str; 6] = ["a", "b", "c", "d", "e", "f"]; let index = (frame.pixels[from] - b'a') as usize; CELLS.get(index).copied().unwrap_or("?") } fn frame(transform: Transform, width: i32, height: i32) -> OutputFrame { // Row-major fill with a distinct byte per pixel; the fixture is // asymmetric so a missing rotation cannot cancel out. let mut pixels = Vec::new(); for index in 0..(width * height) { pixels.extend_from_slice(&[(index % 251) as u8, 0, 0, 255]); } OutputFrame { preserve_alpha: false, width, height, stride: width as usize * BYTES_PER_PIXEL, pixels, transform, location: (0, 0), scale: 1.0, logical_width: width, logical_height: height, } } #[test] fn upright_orientation_matches_hand_derived_layouts() { let cases: [(Transform, Vec<&str>); 8] = [ (Transform::Normal, vec!["a", "b", "c", "d", "e", "f"]), (Transform::_90, vec!["d", "a", "e", "b", "f", "c"]), (Transform::_180, vec!["f", "e", "d", "c", "b", "a"]), (Transform::_270, vec!["c", "f", "b", "e", "a", "d"]), (Transform::Flipped, vec!["c", "b", "a", "f", "e", "d"]), (Transform::Flipped90, vec!["a", "d", "b", "e", "c", "f"]), (Transform::Flipped180, vec!["d", "e", "f", "a", "b", "c"]), (Transform::Flipped270, vec!["f", "c", "e", "b", "d", "a"]), ]; for (transform, expected) in cases { let f = labelled_frame(transform, 3, 2); let (upright_width, upright_height) = upright_size(&f); let mut got = vec![""; (upright_width * upright_height) as usize]; for y in 0..upright_height { for x in 0..upright_width { let (sx, sy) = upright_source_pixel(x, y, f.width, f.height, f.transform); assert!( (0..f.width).contains(&sx) && (0..f.height).contains(&sy), "{transform:?} maps ({x},{y}) outside the source" ); let from = (sy * f.width + sx) as usize * BYTES_PER_PIXEL; got[(y * upright_width + x) as usize] = decode(&f, from); } } assert_eq!(got, expected, "wrong upright layout for {transform:?}"); } } #[test] fn upright_orientation_holds_for_the_transposed_fixture() { // The 2x3 fixture distinguishes a rotation from a transposition, which // the 3x2 fixture cannot. let cases: [(Transform, Vec<&str>); 8] = [ (Transform::Normal, vec!["a", "b", "c", "d", "e", "f"]), (Transform::_90, vec!["e", "c", "a", "f", "d", "b"]), (Transform::_180, vec!["f", "e", "d", "c", "b", "a"]), (Transform::_270, vec!["b", "d", "f", "a", "c", "e"]), (Transform::Flipped, vec!["b", "a", "d", "c", "f", "e"]), (Transform::Flipped90, vec!["a", "c", "e", "b", "d", "f"]), (Transform::Flipped180, vec!["e", "f", "c", "d", "a", "b"]), (Transform::Flipped270, vec!["f", "d", "b", "e", "c", "a"]), ]; for (transform, expected) in cases { let f = labelled_frame(transform, 2, 3); let (upright_width, upright_height) = upright_size(&f); let mut got = vec![""; (upright_width * upright_height) as usize]; for y in 0..upright_height { for x in 0..upright_width { let (sx, sy) = upright_source_pixel(x, y, f.width, f.height, f.transform); assert!( (0..f.width).contains(&sx) && (0..f.height).contains(&sy), "{transform:?} maps ({x},{y}) outside the source" ); let from = (sy * f.width + sx) as usize * BYTES_PER_PIXEL; got[(y * upright_width + x) as usize] = decode(&f, from); } } assert_eq!(got, expected, "wrong upright layout for {transform:?}"); } } #[test] fn every_transform_is_a_bijection_over_the_source() { for transform in [ Transform::Normal, Transform::_90, Transform::_180, Transform::_270, Transform::Flipped, Transform::Flipped90, Transform::Flipped180, Transform::Flipped270, ] { let f = frame(transform, 3, 2); let (upright_width, upright_height) = upright_size(&f); let mut seen = vec![false; (f.width * f.height) as usize]; for y in 0..upright_height { for x in 0..upright_width { let (sx, sy) = upright_source_pixel(x, y, f.width, f.height, f.transform); let index = (sy * f.width + sx) as usize; assert!(!seen[index], "{transform:?} reused source pixel {index}"); seen[index] = true; } } assert!(seen.iter().all(|hit| *hit), "{transform:?} dropped pixels"); } } #[test] fn rotated_output_swaps_axis_but_keeps_size_shape() { let f = frame(Transform::_90, 3, 2); assert_eq!(upright_size(&f), (2, 3)); } #[test] fn conversion_swaps_red_and_blue_and_forces_opaque_alpha() { let mut f = frame(Transform::Normal, 1, 1); f.pixels = vec![0x11, 0x22, 0x33, 0x00]; // B, G, R, A let out = scale_and_convert(&f, 1, 1).unwrap(); assert_eq!(out, vec![0x33, 0x22, 0x11, 255]); } #[test] fn conversion_does_not_flip_vertically() { // Top row red, bottom row blue. An extra Y flip would swap them. let mut f = frame(Transform::Normal, 1, 2); f.pixels = vec![0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0x00]; let out = scale_and_convert(&f, 1, 2).unwrap(); assert_eq!(&out[0..4], &[0xFF, 0x00, 0x00, 255], "top row"); assert_eq!(&out[4..8], &[0x00, 0x00, 0xFF, 255], "bottom row"); } #[test] fn conversion_scales_to_target_and_handles_stride() { let mut f = frame(Transform::Normal, 2, 2); // Padded stride, as an owned buffer would carry. f.stride = 2 * BYTES_PER_PIXEL + 4; let mut padded = Vec::new(); for row in 0..2 { for column in 0..2 { let value = (row * 2 + column) as u8; padded.extend_from_slice(&[value, value, value, 255]); } padded.extend_from_slice(&[0xEE; 4]); } f.pixels = padded; let out = scale_and_convert(&f, 4, 4).unwrap(); assert_eq!(out.len(), 4 * 4 * 4); // Upscaling 2x must not pick up the padding bytes; source indices are // 0,1,2,3 in row-major order and the first byte of each pixel is red. for row in 0..4 { for column in 0..4 { let source_row = row / 2; let source_column = column / 2; let expected = (source_row * 2 + source_column) as u8; assert_eq!( out[(row * 4 + column) * 4], expected, "row {row} column {column} should sample source index {}", expected ); } } } #[test] fn negative_origin_places_output_at_origin_of_canvas() { let mut f = frame(Transform::Normal, 2, 2); f.location = (-10, -20); let canvas = compose(&[f]).unwrap(); assert_eq!((canvas.width, canvas.height), (2, 2)); } #[test] fn mixed_scale_uses_highest_scale_and_preserves_logical_layout() { // 200x100 logical at scale 1, then 200x200 logical at scale 2. let mut left = frame(Transform::Normal, 200, 100); left.location = (0, 0); left.scale = 1.0; let mut right = frame(Transform::Normal, 400, 400); right.location = (200, 0); right.scale = 2.0; right.logical_width = 200; right.logical_height = 200; let canvas = compose(&[left, right]).unwrap(); // Logical extent is 400x200; the highest participating scale is 2.0. assert_eq!((canvas.width, canvas.height), (800, 400)); } #[test] fn adjacent_outputs_leave_no_seam() { // Two 1x1 logical outputs side by side at scale 1, with a gap in the // canvas only if rounding is done independently per output. let mut left = frame(Transform::Normal, 1, 1); left.location = (0, 0); left.scale = 1.0; let mut right = frame(Transform::Normal, 1, 1); right.location = (1, 0); right.scale = 1.0; let canvas = compose(&[left, right]).unwrap(); assert_eq!((canvas.width, canvas.height), (2, 1)); assert_eq!(canvas.pixels[3], 255, "left pixel opaque"); assert_eq!(canvas.pixels[7], 255, "right pixel opaque"); } #[test] fn fractional_scale_leaves_no_gaps_between_outputs() { // 3 + 2 logical pixels at scale 1.5 must total 8 device pixels exactly // when boundaries are rounded from a shared origin. // 2x1 and 2x1 logical, side by side, at scale 1.5. let mut left = frame(Transform::Normal, 3, 2); left.location = (0, 0); left.scale = 1.5; left.logical_width = 2; left.logical_height = 1; let mut right = frame(Transform::Normal, 3, 2); right.location = (2, 0); right.scale = 1.5; right.logical_width = 2; right.logical_height = 1; let canvas = compose(&[left, right]).unwrap(); assert_eq!((canvas.width, canvas.height), (6, 2), "4x1 logical at 1.5"); // Every pixel in the top row is covered by some output. for column in 0..canvas.width as usize { let index = column * 4 + 3; assert_eq!(canvas.pixels[index], 255, "column {column} has a seam"); } } #[test] fn rejects_canvas_beyond_the_allocation_cap() { assert!(Canvas::new(20_000, 20_000).is_none()); assert!(Canvas::new(0, 100).is_none()); assert!(Canvas::new(100, 0).is_none()); } #[test] fn rejects_frames_whose_buffer_is_shorter_than_their_dimensions() { let mut short = frame(Transform::Normal, 4, 4); short.pixels.truncate(short.pixels.len() - 4); assert!(compose(&[short]).is_err()); let mut narrow = frame(Transform::Normal, 4, 4); narrow.stride = 4 * BYTES_PER_PIXEL - 1; assert!(compose(&[narrow]).is_err()); let mut zero = frame(Transform::Normal, 4, 4); zero.width = 0; assert!(compose(&[zero]).is_err()); let mut bad_scale = frame(Transform::Normal, 4, 4); bad_scale.scale = 0.0; assert!(compose(&[bad_scale]).is_err()); let mut nan_scale = frame(Transform::Normal, 4, 4); nan_scale.scale = f64::NAN; assert!(compose(&[nan_scale]).is_err()); } #[test] fn fractional_scale_keeps_physical_dimensions_exact() { // A single output at the maximum scale must keep its pixel dimensions. // 2560x1440 at 1.25 is exactly 2048x1152 logical. let mut f = frame(Transform::Normal, 2560, 1440); f.scale = 1.25; f.logical_width = 2048; f.logical_height = 1152; let canvas = compose(&[f]).unwrap(); assert_eq!((canvas.width, canvas.height), (2560, 1440)); } #[test] fn fractional_scale_upsamples_a_lower_scale_output() { // 1600x900 physical at scale 1.0 is 1600x900 logical, beside // 3200x1800 physical at scale 2.0 which is also 1600x900 logical. let mut low = frame(Transform::Normal, 1600, 900); low.location = (0, 0); low.scale = 1.0; let mut high = frame(Transform::Normal, 3200, 1800); high.location = (1600, 0); high.scale = 2.0; high.logical_width = 1600; high.logical_height = 900; let canvas = compose(&[low, high]).unwrap(); // 3200x900 logical composed at the highest participating scale of 2.0. assert_eq!((canvas.width, canvas.height), (6400, 1800)); } #[test] fn fractional_scale_crop_uses_the_original_logical_rect() { // 3 logical pixels at scale 1.5 read back as 5 physical pixels, so // recovering the logical extent by dividing would give 5/1.5 = 3.33 and // then 3.33*2.0 = 6.67 -> 7 on a 2.0 canvas, instead of 6. let mut low = frame(Transform::Normal, 5, 5); low.location = (0, 0); low.scale = 1.5; low.logical_width = 3; low.logical_height = 3; let mut high = frame(Transform::Normal, 4, 4); high.location = (3, 0); high.scale = 2.0; high.logical_width = 2; high.logical_height = 2; let canvas = compose(&[low, high]).unwrap(); // 3 + 2 = 5 logical, composed at the highest participating scale of 2.0. assert_eq!((canvas.width, canvas.height), (10, 6)); } #[test] fn adjacent_outputs_do_not_drift_across_repeated_rounding() { // Three fractional-scale outputs side by side. Boundaries are derived // from the logical rectangles, so accumulated rounding cannot open a // gap or overlap between them. let mut frames = Vec::new(); for index in 0..3 { let mut f = frame(Transform::Normal, 5, 4); f.location = (index * 3, 0); f.scale = 1.5; f.logical_width = 3; f.logical_height = 3; frames.push(f); } let canvas = compose(&frames).unwrap(); // 9 logical at scale 1.5. assert_eq!((canvas.width, canvas.height), (14, 5)); for column in 0..canvas.width as usize { assert_eq!(canvas.pixels[column * 4 + 3], 255, "column {column} has a gap"); } } #[test] fn outputs_below_negative_locations_share_a_seam() { let mut left = frame(Transform::Normal, 2, 2); left.location = (-4, -4); left.scale = 1.0; let mut right = frame(Transform::Normal, 2, 2); right.location = (-2, -4); right.scale = 1.0; let canvas = compose(&[left, right]).unwrap(); assert_eq!((canvas.width, canvas.height), (4, 2)); for column in 0..canvas.width as usize { assert_eq!(canvas.pixels[column * 4 + 3], 255, "column {column} seam"); } } #[test] fn gap_between_outputs_stays_transparent() { // Two outputs with a one-pixel logical gap between them. let mut left = frame(Transform::Normal, 2, 2); left.location = (0, 0); left.scale = 1.0; let mut right = frame(Transform::Normal, 2, 2); right.location = (3, 0); right.scale = 1.0; let canvas = compose(&[left, right]).unwrap(); assert_eq!(canvas.width, 5); assert_eq!(&canvas.pixels[2 * 4..3 * 4], &[0, 0, 0, 0], "gap is transparent"); assert_eq!(canvas.pixels[3], 255); assert_eq!(canvas.pixels[3 * 4 + 3], 255); } #[test] fn parses_slurp_geometry_with_negative_origin() { assert_eq!( parse_geometry("100,200 800x600").unwrap(), Geometry::Region { x: 100, y: 200, width: 800, height: 600 } ); assert_eq!( parse_geometry("-1920,-40 1280x1024").unwrap(), Geometry::Region { x: -1920, y: -40, width: 1280, height: 1024 } ); } #[test] fn geometry_tolerates_surrounding_whitespace_from_slurp() { for raw in ["100,200 800x600\n", " 100,200 800x600 ", "100,200 800x600\r\n"] { assert_eq!( parse_geometry(raw).unwrap(), Geometry::Region { x: 100, y: 200, width: 800, height: 600 }, "should accept {raw:?}" ); } } #[test] fn rejects_empty_and_malformed_geometry() { for raw in [ "", " ", "\n", "100,200", "100 200 300", "100,200 800", "100,200 800x", "x,200 800x600", "100,y 800x600", "100,200 WxH", "100,200 800xH", "100.5,200 800x600", "1e3,200 800x600", "100,200,300 800x600", ] { assert!(parse_geometry(raw).is_err(), "should reject {raw:?}"); } } #[test] fn rejects_non_positive_and_overflowing_geometry() { for raw in [ "0,0 0x100", "0,0 100x0", "0,0 -100x100", "0,0 100x-1", "2147483647,0 2x100", "0,2147483647 100x2", ] { assert!(parse_geometry(raw).is_err(), "should reject {raw:?}"); } } #[test] fn rejects_empty_frame_set() { assert!(compose(&[]).is_err()); } }