git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

src/server/screenshot.rs (23.4K)

  1 //! Native screenshots.
  2 //!
  3 //! Two capture paths, both finishing (PNG encode + notification) on a worker
  4 //! thread, and both answering the waiting `ccectl` only once the readback has
  5 //! actually produced pixels:
  6 //!
  7 //! - Full-output / region: `process_ipc_command` parks a [`PendingScreenshot`]
  8 //!   on the window manager and schedules a frame; `Output::render_and_commit`
  9 //!   picks it up right after `wlr_scene_output_build_state` renders the frame
 10 //!   into the output state's buffer, and reads that buffer back
 11 //!   (`wlr_texture_from_buffer` + `wlr_texture_read_pixels`). Regions are
 12 //!   cropped CPU-side in buffer pixels. Because that happens a frame later,
 13 //!   the IPC reply travels with the parked capture (see [`PendingScreenshot`])
 14 //!   instead of being answered optimistically at park time.
 15 //! - Window: the window's committed surface textures are read back directly
 16 //!   (root surface + subsurfaces composited by their offsets), so it works
 17 //!   even when the window is panned outside the visible viewport — the
 18 //!   client's last committed buffers still exist regardless of culling.
 19 //!
 20 //! Completion is announced through the freedesktop notification daemon
 21 //! (`notify-send` with the standard `image-path` hint, which cce-notifier
 22 //! renders as a thumbnail). The `notifications { screenshots }` key in
 23 //! config.kdl disables the announcement (default enabled, but silent when
 24 //! the config itself cannot be read); it is re-read per screenshot on the
 25 //! worker thread, so edits take effect immediately.
 26 //!
 27 //! Destination names carry milliseconds and are uniquified before being
 28 //! handed out — a second is long enough for two captures, and the loser used
 29 //! to overwrite the winner.
 30 
 31 use std::path::{Path, PathBuf};
 32 use std::sync::mpsc::Sender;
 33 
 34 use crate::ffi;
 35 
 36 // DRM fourcc codes wlr_texture_preferred_read_format may hand us; all are
 37 // 8-bit-per-channel, little-endian packed. The fourcc name lists channels
 38 // most-significant first, so the memory order is that name *reversed*:
 39 // ARGB8888 is B,G,R,A in memory, and BGR888 — despite the name — is R,G,B.
 40 const DRM_FORMAT_XRGB8888: u32 = 0x34325258;
 41 const DRM_FORMAT_ARGB8888: u32 = 0x34325241;
 42 const DRM_FORMAT_XBGR8888: u32 = 0x34324258;
 43 const DRM_FORMAT_ABGR8888: u32 = 0x34324241;
 44 // The 24-bit pair: 3 bytes/px, no alpha channel at all. NVIDIA's GLES
 45 // renderer hands these back where Intel's offers a 32-bit format, so a
 46 // compositor that only knows the 8888 formats cannot screenshot on it.
 47 const DRM_FORMAT_BGR888: u32 = 0x34324742;
 48 const DRM_FORMAT_RGB888: u32 = 0x34324752;
 49 
 50 /// A full-output / region capture waiting for the next composited frame.
 51 pub struct PendingScreenshot {
 52     /// The output whose next frame is captured.
 53     pub output: *mut crate::output::Output,
 54     /// Crop in output-buffer pixels; `None` captures the whole output.
 55     pub region: Option<ffi::wlr_box>,
 56     pub path: PathBuf,
 57     /// The `ccectl` connection still waiting to hear how this went. The
 58     /// capture only runs on the next composited frame, so replying `ok
 59     /// <path>` at park time reported success before anything had been read
 60     /// back — an unsupported read format then left the user holding a path
 61     /// to a file that never appeared. Answered by [`Self::reply_ok`] /
 62     /// [`Self::reply_err`], or from `Drop` for the paths that discard a
 63     /// parked capture (failed commit, WM reset, output teardown).
 64     reply: Option<Sender<String>>,
 65 }
 66 
 67 impl PendingScreenshot {
 68     pub fn new(
 69         output: *mut crate::output::Output,
 70         region: Option<ffi::wlr_box>,
 71         path: PathBuf,
 72         reply: Option<Sender<String>>,
 73     ) -> Self {
 74         Self { output, region, path, reply }
 75     }
 76 
 77     /// Report the capture as landed. Sent once the pixels are in hand and the
 78     /// path is settled — the PNG write itself still happens on the encode
 79     /// thread and only logs, since holding the reply until a large output is
 80     /// compressed would push it past the IPC client's timeout.
 81     pub fn reply_ok(&mut self) {
 82         let msg = format!("ok {}\n", self.path.display());
 83         self.answer(msg);
 84     }
 85 
 86     pub fn reply_err(&mut self, msg: &str) {
 87         self.answer(format!("error: {msg}\n"));
 88     }
 89 
 90     fn answer(&mut self, msg: String) {
 91         if let Some(tx) = self.reply.take() {
 92             let _ = tx.send(msg);
 93         }
 94     }
 95 }
 96 
 97 impl Drop for PendingScreenshot {
 98     fn drop(&mut self) {
 99         // Anything that drops a parked capture without capturing is still an
100         // outcome someone is blocked on; answer rather than let ccectl sit
101         // out its timeout.
102         self.answer("error: screenshot: capture dropped before a frame rendered\n".to_string());
103     }
104 }
105 
106 /// `~/Pictures/screenshots/screenshot-YYYYMMDD-HHMMSS-mmm.png` (the directory
107 /// is created by the encode thread).
108 pub fn default_path() -> PathBuf {
109     let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
110     let dir = PathBuf::from(home).join("Pictures").join("screenshots");
111     unique_path(&dir, &timestamp_stem())
112 }
113 
114 /// Millisecond-resolution stem. Seconds were not enough: `ccectl screenshot`
115 /// followed by `ccectl screenshot window` lands inside one second, and the
116 /// second capture silently overwrote the first.
117 fn timestamp_stem() -> String {
118     let mut ts: libc::timespec = unsafe { std::mem::zeroed() };
119     unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut ts) };
120     let mut tm: libc::tm = unsafe { std::mem::zeroed() };
121     unsafe { libc::localtime_r(&ts.tv_sec, &mut tm) };
122     format!(
123         "screenshot-{:04}{:02}{:02}-{:02}{:02}{:02}-{:03}",
124         tm.tm_year + 1900,
125         tm.tm_mon + 1,
126         tm.tm_mday,
127         tm.tm_hour,
128         tm.tm_min,
129         tm.tm_sec,
130         ts.tv_nsec / 1_000_000,
131     )
132 }
133 
134 /// `<stem>.png`, then `<stem>-2.png`, … until the name is free.
135 ///
136 /// Checking the filesystem alone is not enough: the file is created by the
137 /// encode thread well after the path is handed out, so two captures in the
138 /// same millisecond would both see an empty directory. Hence the set of names
139 /// already issued this run (one `PathBuf` per capture, never reclaimed —
140 /// bounded by how many screenshots a session takes).
141 fn unique_path(dir: &Path, stem: &str) -> PathBuf {
142     static ISSUED: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<PathBuf>>> =
143         std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
144     let mut issued = ISSUED.lock().unwrap_or_else(|e| e.into_inner());
145     let mut n = 1u32;
146     loop {
147         let path = dir.join(match n {
148             1 => format!("{stem}.png"),
149             n => format!("{stem}-{n}.png"),
150         });
151         if !issued.contains(&path) && !path.exists() {
152             issued.insert(path.clone());
153             return path;
154         }
155         n += 1;
156     }
157 }
158 
159 /// Bytes per pixel of the formats [`to_rgba`] can convert; `None` for
160 /// anything else, which is the one place that knowledge is written down.
161 fn bytes_per_pixel(format: u32) -> Option<usize> {
162     match format {
163         DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 | DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => {
164             Some(4)
165         }
166         DRM_FORMAT_BGR888 | DRM_FORMAT_RGB888 => Some(3),
167         _ => None,
168     }
169 }
170 
171 /// Read a texture's full contents into a tightly packed `w*h*bpp` byte buffer.
172 /// Returns the bytes plus the DRM format they are in.
173 unsafe fn read_texture(texture: *mut ffi::wlr_texture, w: i32, h: i32) -> Option<(Vec<u8>, u32)> {
174     if texture.is_null() || w <= 0 || h <= 0 {
175         return None;
176     }
177     let format = ffi::wlr_texture_preferred_read_format(texture);
178     // Both the buffer and the stride must be sized for the format the
179     // renderer is about to write: a 24-bit format read into a 4-byte-strided
180     // buffer would leave every row short and shifted.
181     let Some(bpp) = bytes_per_pixel(format) else {
182         log::warn!("screenshot: unsupported read format {format:#x}");
183         return None;
184     };
185     let mut data = vec![0u8; (w as usize) * (h as usize) * bpp];
186     let options = ffi::wlr_texture_read_pixels_options {
187         data: data.as_mut_ptr() as *mut std::ffi::c_void,
188         format,
189         stride: (w as u32) * bpp as u32,
190         dst_x: 0,
191         dst_y: 0,
192         src_box: std::mem::zeroed(), // empty = full texture
193     };
194     if !ffi::wlr_texture_read_pixels(texture, &options) {
195         return None;
196     }
197     Some((data, format))
198 }
199 
200 /// Read back only `src` (in buffer px) of a texture, into a `w`×`h` buffer.
201 ///
202 /// The full-texture [`read_texture`] is fine for a screenshot, which wants
203 /// every pixel anyway; it is not fine for the backdrop sampler, which wants a
204 /// bar-height strip out of a window that may be 4K — 33MB copied per sample to
205 /// look at 0.3% of it.
206 pub(crate) unsafe fn read_texture_region(
207     texture: *mut ffi::wlr_texture,
208     src: ffi::wlr_box,
209     w: i32,
210     h: i32,
211 ) -> Option<(Vec<u8>, u32)> {
212     if texture.is_null() || w <= 0 || h <= 0 || src.width <= 0 || src.height <= 0 {
213         return None;
214     }
215     let format = ffi::wlr_texture_preferred_read_format(texture);
216     let bpp = bytes_per_pixel(format)?;
217     let mut data = vec![0u8; (w as usize) * (h as usize) * bpp];
218     let options = ffi::wlr_texture_read_pixels_options {
219         data: data.as_mut_ptr() as *mut std::ffi::c_void,
220         format,
221         stride: (w as u32) * bpp as u32,
222         dst_x: 0,
223         dst_y: 0,
224         src_box: src,
225     };
226     if !ffi::wlr_texture_read_pixels(texture, &options) {
227         return None;
228     }
229     Some((data, format))
230 }
231 
232 /// Convert read-back pixels to RGBA. Alpha is forced opaque — the X-variants
233 /// carry garbage alpha, the 24-bit formats carry none at all, and screenshots
234 /// should not be translucent.
235 pub(crate) fn to_rgba(mut pixels: Vec<u8>, format: u32) -> Option<Vec<u8>> {
236     match format {
237         DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => {
238             for px in pixels.chunks_exact_mut(4) {
239                 px.swap(0, 2);
240                 px[3] = 255;
241             }
242             Some(pixels)
243         }
244         DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => {
245             for px in pixels.chunks_exact_mut(4) {
246                 px[3] = 255;
247             }
248             Some(pixels)
249         }
250         // 24-bit: widen rather than swizzle in place.
251         DRM_FORMAT_BGR888 => Some(widen_24(&pixels, false)),
252         DRM_FORMAT_RGB888 => Some(widen_24(&pixels, true)),
253         _ => {
254             log::warn!("screenshot: unsupported read format {format:#x}");
255             None
256         }
257     }
258 }
259 
260 /// 3-byte pixels to RGBA with opaque alpha. `swap_rb` covers RGB888, whose
261 /// memory order is B,G,R; BGR888 is already R,G,B. (Reversed from how the
262 /// names read — verified against a known-colour desktop on NVIDIA, which
263 /// hands back BGR888: assuming the intuitive order swapped every capture's
264 /// red and blue.)
265 fn widen_24(pixels: &[u8], swap_rb: bool) -> Vec<u8> {
266     let mut out = Vec::with_capacity(pixels.len() / 3 * 4);
267     for px in pixels.chunks_exact(3) {
268         if swap_rb {
269             out.extend_from_slice(&[px[2], px[1], px[0], 255]);
270         } else {
271             out.extend_from_slice(&[px[0], px[1], px[2], 255]);
272         }
273     }
274     out
275 }
276 
277 fn crop_rgba(pixels: &[u8], w: i32, h: i32, region: ffi::wlr_box) -> Option<(Vec<u8>, i32, i32)> {
278     let x0 = region.x.clamp(0, w);
279     let y0 = region.y.clamp(0, h);
280     let x1 = (region.x + region.width).clamp(0, w);
281     let y1 = (region.y + region.height).clamp(0, h);
282     let (cw, ch) = (x1 - x0, y1 - y0);
283     if cw <= 0 || ch <= 0 {
284         return None;
285     }
286     let mut out = Vec::with_capacity((cw as usize) * (ch as usize) * 4);
287     for row in y0..y1 {
288         let start = ((row * w + x0) * 4) as usize;
289         out.extend_from_slice(&pixels[start..start + (cw as usize) * 4]);
290     }
291     Some((out, cw, ch))
292 }
293 
294 /// Full-output / region capture: called from `Output::render_and_commit`
295 /// after a successful commit, while the output state's buffer is still alive.
296 pub unsafe fn capture_state_buffer(
297     renderer: *mut ffi::wlr_renderer,
298     buffer: *mut ffi::wlr_buffer,
299     buf_w: i32,
300     buf_h: i32,
301     mut shot: PendingScreenshot,
302 ) {
303     let texture = ffi::wlr_texture_from_buffer(renderer, buffer);
304     if texture.is_null() {
305         log::warn!("screenshot: wlr_texture_from_buffer failed");
306         shot.reply_err("screenshot: wlr_texture_from_buffer failed");
307         return;
308     }
309     let read = read_texture(texture, buf_w, buf_h);
310     ffi::wlr_texture_destroy(texture);
311     let Some((pixels, format)) = read else {
312         log::warn!("screenshot: pixel readback failed");
313         shot.reply_err("screenshot: pixel readback failed");
314         return;
315     };
316     let Some(rgba) = to_rgba(pixels, format) else {
317         shot.reply_err(&format!("screenshot: unsupported read format {format:#x}"));
318         return;
319     };
320     let (rgba, out_w, out_h) = match shot.region {
321         Some(region) => match crop_rgba(&rgba, buf_w, buf_h, region) {
322             Some(cropped) => cropped,
323             None => {
324                 log::warn!("screenshot: region outside the output");
325                 shot.reply_err("screenshot: region outside the output");
326                 return;
327             }
328         },
329         None => (rgba, buf_w, buf_h),
330     };
331     shot.reply_ok();
332     spawn_encode(rgba, out_w as u32, out_h as u32, shot.path.clone());
333 }
334 
335 /// Window capture straight from the committed surface textures: the root
336 /// surface's buffer is the canvas, subsurfaces composite at their offsets.
337 /// Works for windows outside the visible viewport (their last committed
338 /// buffers persist), but needs the client to have committed at least once.
339 pub unsafe fn capture_window(window: *mut crate::window::Window, path: PathBuf) -> Result<String, String> {
340     let (canvas, bw, bh) = capture_window_rgba(window)?;
341     let reply = path.display().to_string();
342     spawn_encode(canvas, bw as u32, bh as u32, path);
343     Ok(reply)
344 }
345 
346 /// The readback+composite half of [`capture_window`], PNG-free: returns the
347 /// tightly packed RGBA canvas and its pixel dimensions. Also the frame source
348 /// for the window-stream server.
349 pub unsafe fn capture_window_rgba(window: *mut crate::window::Window) -> Result<(Vec<u8>, i32, i32), String> {
350     let root = (*window).root_surface();
351     if root.is_null() {
352         return Err("window has no surface".to_string());
353     }
354     let (mut bw, mut bh) = (0i32, 0i32);
355     ffi::river_wlr_surface_get_buffer_size(root, &mut bw, &mut bh);
356     if bw <= 0 || bh <= 0 {
357         return Err("window has no committed buffer".to_string());
358     }
359     // Subsurface offsets are surface-logical; buffers are physical pixels.
360     let logical_w = ffi::river_wlr_surface_get_width(root).max(1);
361     let scale = bw as f64 / logical_w as f64;
362 
363     struct Collect {
364         list: Vec<(*mut ffi::wlr_surface, i32, i32)>,
365     }
366     unsafe extern "C" fn collect_cb(
367         surface: *mut ffi::wlr_surface,
368         sx: std::os::raw::c_int,
369         sy: std::os::raw::c_int,
370         data: *mut std::ffi::c_void,
371     ) {
372         let collect = &mut *(data as *mut Collect);
373         collect.list.push((surface, sx, sy));
374     }
375     let mut collect = Collect { list: Vec::new() };
376     ffi::wlr_surface_for_each_surface(
377         root,
378         Some(collect_cb),
379         &mut collect as *mut Collect as *mut std::ffi::c_void,
380     );
381 
382     let mut canvas = vec![0u8; (bw as usize) * (bh as usize) * 4];
383     let mut composited = 0usize;
384     for (surface, sx, sy) in collect.list {
385         let texture = ffi::wlr_surface_get_texture(surface);
386         if texture.is_null() {
387             continue;
388         }
389         let (mut sw, mut sh) = (0i32, 0i32);
390         ffi::river_wlr_surface_get_buffer_size(surface, &mut sw, &mut sh);
391         let Some((pixels, format)) = read_texture(texture, sw, sh) else { continue };
392         let Some(rgba) = to_rgba(pixels, format) else { continue };
393         let dst_x = (sx as f64 * scale).round() as i32;
394         let dst_y = (sy as f64 * scale).round() as i32;
395         blit(&mut canvas, bw, bh, &rgba, sw, sh, dst_x, dst_y);
396         composited += 1;
397     }
398     if composited == 0 {
399         return Err("no readable surface content".to_string());
400     }
401     Ok((canvas, bw, bh))
402 }
403 
404 /// Copy `src` (sw×sh RGBA) into `dst` (dw×dh RGBA) at (dx, dy), clipped.
405 pub(crate) fn blit(dst: &mut [u8], dw: i32, dh: i32, src: &[u8], sw: i32, sh: i32, dx: i32, dy: i32) {
406     for sy in 0..sh {
407         let ty = dy + sy;
408         if ty < 0 || ty >= dh {
409             continue;
410         }
411         let sx0 = (-dx).clamp(0, sw);
412         let sx1 = (dw - dx).clamp(0, sw);
413         if sx0 >= sx1 {
414             continue;
415         }
416         let src_start = ((sy * sw + sx0) * 4) as usize;
417         let dst_start = ((ty * dw + dx + sx0) * 4) as usize;
418         let len = ((sx1 - sx0) * 4) as usize;
419         dst[dst_start..dst_start + len].copy_from_slice(&src[src_start..src_start + len]);
420     }
421 }
422 
423 /// PNG-encode and save off the main thread, then announce via the
424 /// notification daemon (unless disabled in config).
425 fn spawn_encode(rgba: Vec<u8>, w: u32, h: u32, path: PathBuf) {
426     std::thread::spawn(move || {
427         if let Some(parent) = path.parent() {
428             let _ = std::fs::create_dir_all(parent);
429         }
430         let file = match std::fs::File::create(&path) {
431             Ok(f) => f,
432             Err(e) => {
433                 log::warn!("screenshot: failed to create {}: {e}", path.display());
434                 return;
435             }
436         };
437         let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), w, h);
438         encoder.set_color(png::ColorType::Rgba);
439         encoder.set_depth(png::BitDepth::Eight);
440         let write = encoder
441             .write_header()
442             .and_then(|mut writer| writer.write_image_data(&rgba));
443         if let Err(e) = write {
444             log::warn!("screenshot: failed to encode {}: {e}", path.display());
445             return;
446         }
447         log::info!("screenshot saved to {}", path.display());
448 
449         if notifications_enabled() {
450             let path_str = path.display().to_string();
451             let _ = std::process::Command::new("notify-send")
452                 .arg("-a")
453                 .arg("cce")
454                 .arg("-h")
455                 .arg(format!("string:image-path:{path_str}"))
456                 .arg("Screenshot saved")
457                 .arg(&path_str)
458                 .spawn();
459         }
460     });
461 }
462 
463 /// `notifications { screenshots <bool> }` in the shared config.kdl. Absent
464 /// *key* in a readable config means enabled — that is the documented default.
465 /// An unreadable config is a different thing: we know nothing about the
466 /// user's wishes, and a session running against a config we cannot read is
467 /// typically an isolated one (a headless shadow session, say) whose toasts
468 /// would land on someone else's screen. Stay quiet there.
469 fn notifications_enabled() -> bool {
470     let Ok(content) = std::fs::read_to_string(cce_ui::config::get_config_path()) else {
471         log::debug!("screenshot: config unreadable, staying quiet about the capture");
472         return false;
473     };
474     cce_ui::config::parse_kdl_to_json(&content)
475         .pointer("/notifications/screenshots")
476         .and_then(|v| v.as_bool())
477         .unwrap_or(true)
478 }
479 
480 #[cfg(test)]
481 mod tests {
482     use super::*;
483 
484     #[test]
485     fn to_rgba_swizzles_bgra_and_forces_opaque() {
486         // One BGRA pixel: B=1 G=2 R=3 A=4 → RGBA 3,2,1,255.
487         let out = to_rgba(vec![1, 2, 3, 4], DRM_FORMAT_ARGB8888).unwrap();
488         assert_eq!(out, vec![3, 2, 1, 255]);
489         // RGBA passthrough, alpha forced opaque.
490         let out = to_rgba(vec![1, 2, 3, 4], DRM_FORMAT_ABGR8888).unwrap();
491         assert_eq!(out, vec![1, 2, 3, 255]);
492         // 24-bit, two pixels. The names read backwards from the memory
493         // order: BGR888 arrives R,G,B and widens as-is, RGB888 arrives
494         // B,G,R and needs the swap. Both gain an alpha they never carried.
495         let out = to_rgba(vec![1, 2, 3, 4, 5, 6], DRM_FORMAT_BGR888).unwrap();
496         assert_eq!(out, vec![1, 2, 3, 255, 4, 5, 6, 255]);
497         let out = to_rgba(vec![1, 2, 3, 4, 5, 6], DRM_FORMAT_RGB888).unwrap();
498         assert_eq!(out, vec![3, 2, 1, 255, 6, 5, 4, 255]);
499         assert!(to_rgba(vec![0; 4], 0x1234).is_none());
500     }
501 
502     #[test]
503     fn bytes_per_pixel_matches_what_to_rgba_accepts() {
504         // The readback allocates and strides by this, so a format to_rgba
505         // handles must have a size here and vice versa.
506         for (format, bpp) in [
507             (DRM_FORMAT_XRGB8888, 4usize),
508             (DRM_FORMAT_ARGB8888, 4),
509             (DRM_FORMAT_XBGR8888, 4),
510             (DRM_FORMAT_ABGR8888, 4),
511             (DRM_FORMAT_BGR888, 3),
512             (DRM_FORMAT_RGB888, 3),
513         ] {
514             assert_eq!(bytes_per_pixel(format), Some(bpp), "{format:#x}");
515             // One pixel's worth of bytes converts to exactly one RGBA pixel.
516             assert_eq!(to_rgba(vec![0; bpp], format).map(|p| p.len()), Some(4));
517         }
518         assert_eq!(bytes_per_pixel(0x1234), None);
519     }
520 
521     #[test]
522     fn unique_path_never_reuses_a_name() {
523         let dir = std::env::temp_dir().join(format!("cce-shot-test-{}", std::process::id()));
524         std::fs::create_dir_all(&dir).unwrap();
525         // Same stem twice: the second capture must not be handed the first
526         // one's path, even though the encode thread has created no file yet.
527         let a = unique_path(&dir, "screenshot-20260815-120000-000");
528         let b = unique_path(&dir, "screenshot-20260815-120000-000");
529         assert_ne!(a, b);
530         assert!(a.ends_with("screenshot-20260815-120000-000.png"));
531         assert!(b.ends_with("screenshot-20260815-120000-000-2.png"));
532         // A name already on disk is skipped too (a stem reused across runs).
533         std::fs::write(dir.join("screenshot-20260815-130000-000.png"), b"").unwrap();
534         let c = unique_path(&dir, "screenshot-20260815-130000-000");
535         assert!(c.ends_with("screenshot-20260815-130000-000-2.png"));
536         let _ = std::fs::remove_dir_all(&dir);
537     }
538 
539     #[test]
540     fn pending_screenshot_answers_exactly_once() {
541         let (tx, rx) = std::sync::mpsc::channel();
542         let mut shot = PendingScreenshot::new(
543             std::ptr::null_mut(),
544             None,
545             PathBuf::from("/tmp/shot.png"),
546             Some(tx),
547         );
548         shot.reply_ok();
549         assert_eq!(rx.recv().unwrap(), "ok /tmp/shot.png\n");
550         drop(shot); // already answered: Drop must not send a second verdict
551         assert!(rx.recv().is_err());
552 
553         // A capture discarded before it ran answers from Drop, so ccectl
554         // hears an error instead of sitting out its timeout.
555         let (tx, rx) = std::sync::mpsc::channel();
556         drop(PendingScreenshot::new(
557             std::ptr::null_mut(),
558             None,
559             PathBuf::from("/tmp/shot.png"),
560             Some(tx),
561         ));
562         assert!(rx.recv().unwrap().starts_with("error: "));
563     }
564 
565     #[test]
566     fn crop_clamps_to_bounds() {
567         // 2x2 image, pixels numbered 0..4 in the red channel.
568         let px: Vec<u8> = (0..4u8).flat_map(|i| [i, 0, 0, 255]).collect();
569         let region = ffi::wlr_box { x: 1, y: 0, width: 5, height: 5 };
570         let (out, w, h) = crop_rgba(&px, 2, 2, region).unwrap();
571         assert_eq!((w, h), (1, 2));
572         assert_eq!(out[0], 1);
573         assert_eq!(out[4], 3);
574         let empty = ffi::wlr_box { x: 5, y: 5, width: 1, height: 1 };
575         assert!(crop_rgba(&px, 2, 2, empty).is_none());
576     }
577 
578     #[test]
579     fn blit_clips_at_edges() {
580         let mut dst = vec![0u8; 2 * 2 * 4];
581         let src: Vec<u8> = vec![9; 2 * 2 * 4];
582         blit(&mut dst, 2, 2, &src, 2, 2, 1, 1); // only dst (1,1) covered
583         assert_eq!(dst[(1 * 2 + 1) * 4], 9);
584         assert_eq!(dst[0], 0);
585         blit(&mut dst, 2, 2, &src, 2, 2, -5, -5); // fully clipped: no panic
586     }
587 }