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

src/server/screenshot.rs (22.3K)

  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 /// Convert read-back pixels to RGBA. Alpha is forced opaque — the X-variants
201 /// carry garbage alpha, the 24-bit formats carry none at all, and screenshots
202 /// should not be translucent.
203 pub(crate) fn to_rgba(mut pixels: Vec<u8>, format: u32) -> Option<Vec<u8>> {
204     match format {
205         DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => {
206             for px in pixels.chunks_exact_mut(4) {
207                 px.swap(0, 2);
208                 px[3] = 255;
209             }
210             Some(pixels)
211         }
212         DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => {
213             for px in pixels.chunks_exact_mut(4) {
214                 px[3] = 255;
215             }
216             Some(pixels)
217         }
218         // 24-bit: widen rather than swizzle in place.
219         DRM_FORMAT_BGR888 => Some(widen_24(&pixels, false)),
220         DRM_FORMAT_RGB888 => Some(widen_24(&pixels, true)),
221         _ => {
222             log::warn!("screenshot: unsupported read format {format:#x}");
223             None
224         }
225     }
226 }
227 
228 /// 3-byte pixels to RGBA with opaque alpha. `swap_rb` covers RGB888, whose
229 /// memory order is B,G,R; BGR888 is already R,G,B. (Reversed from how the
230 /// names read — verified against a known-colour desktop on NVIDIA, which
231 /// hands back BGR888: assuming the intuitive order swapped every capture's
232 /// red and blue.)
233 fn widen_24(pixels: &[u8], swap_rb: bool) -> Vec<u8> {
234     let mut out = Vec::with_capacity(pixels.len() / 3 * 4);
235     for px in pixels.chunks_exact(3) {
236         if swap_rb {
237             out.extend_from_slice(&[px[2], px[1], px[0], 255]);
238         } else {
239             out.extend_from_slice(&[px[0], px[1], px[2], 255]);
240         }
241     }
242     out
243 }
244 
245 fn crop_rgba(pixels: &[u8], w: i32, h: i32, region: ffi::wlr_box) -> Option<(Vec<u8>, i32, i32)> {
246     let x0 = region.x.clamp(0, w);
247     let y0 = region.y.clamp(0, h);
248     let x1 = (region.x + region.width).clamp(0, w);
249     let y1 = (region.y + region.height).clamp(0, h);
250     let (cw, ch) = (x1 - x0, y1 - y0);
251     if cw <= 0 || ch <= 0 {
252         return None;
253     }
254     let mut out = Vec::with_capacity((cw as usize) * (ch as usize) * 4);
255     for row in y0..y1 {
256         let start = ((row * w + x0) * 4) as usize;
257         out.extend_from_slice(&pixels[start..start + (cw as usize) * 4]);
258     }
259     Some((out, cw, ch))
260 }
261 
262 /// Full-output / region capture: called from `Output::render_and_commit`
263 /// after a successful commit, while the output state's buffer is still alive.
264 pub unsafe fn capture_state_buffer(
265     renderer: *mut ffi::wlr_renderer,
266     buffer: *mut ffi::wlr_buffer,
267     buf_w: i32,
268     buf_h: i32,
269     mut shot: PendingScreenshot,
270 ) {
271     let texture = ffi::wlr_texture_from_buffer(renderer, buffer);
272     if texture.is_null() {
273         log::warn!("screenshot: wlr_texture_from_buffer failed");
274         shot.reply_err("screenshot: wlr_texture_from_buffer failed");
275         return;
276     }
277     let read = read_texture(texture, buf_w, buf_h);
278     ffi::wlr_texture_destroy(texture);
279     let Some((pixels, format)) = read else {
280         log::warn!("screenshot: pixel readback failed");
281         shot.reply_err("screenshot: pixel readback failed");
282         return;
283     };
284     let Some(rgba) = to_rgba(pixels, format) else {
285         shot.reply_err(&format!("screenshot: unsupported read format {format:#x}"));
286         return;
287     };
288     let (rgba, out_w, out_h) = match shot.region {
289         Some(region) => match crop_rgba(&rgba, buf_w, buf_h, region) {
290             Some(cropped) => cropped,
291             None => {
292                 log::warn!("screenshot: region outside the output");
293                 shot.reply_err("screenshot: region outside the output");
294                 return;
295             }
296         },
297         None => (rgba, buf_w, buf_h),
298     };
299     shot.reply_ok();
300     spawn_encode(rgba, out_w as u32, out_h as u32, shot.path.clone());
301 }
302 
303 /// Window capture straight from the committed surface textures: the root
304 /// surface's buffer is the canvas, subsurfaces composite at their offsets.
305 /// Works for windows outside the visible viewport (their last committed
306 /// buffers persist), but needs the client to have committed at least once.
307 pub unsafe fn capture_window(window: *mut crate::window::Window, path: PathBuf) -> Result<String, String> {
308     let (canvas, bw, bh) = capture_window_rgba(window)?;
309     let reply = path.display().to_string();
310     spawn_encode(canvas, bw as u32, bh as u32, path);
311     Ok(reply)
312 }
313 
314 /// The readback+composite half of [`capture_window`], PNG-free: returns the
315 /// tightly packed RGBA canvas and its pixel dimensions. Also the frame source
316 /// for the window-stream server.
317 pub unsafe fn capture_window_rgba(window: *mut crate::window::Window) -> Result<(Vec<u8>, i32, i32), String> {
318     let root = (*window).root_surface();
319     if root.is_null() {
320         return Err("window has no surface".to_string());
321     }
322     let (mut bw, mut bh) = (0i32, 0i32);
323     ffi::river_wlr_surface_get_buffer_size(root, &mut bw, &mut bh);
324     if bw <= 0 || bh <= 0 {
325         return Err("window has no committed buffer".to_string());
326     }
327     // Subsurface offsets are surface-logical; buffers are physical pixels.
328     let logical_w = ffi::river_wlr_surface_get_width(root).max(1);
329     let scale = bw as f64 / logical_w as f64;
330 
331     struct Collect {
332         list: Vec<(*mut ffi::wlr_surface, i32, i32)>,
333     }
334     unsafe extern "C" fn collect_cb(
335         surface: *mut ffi::wlr_surface,
336         sx: std::os::raw::c_int,
337         sy: std::os::raw::c_int,
338         data: *mut std::ffi::c_void,
339     ) {
340         let collect = &mut *(data as *mut Collect);
341         collect.list.push((surface, sx, sy));
342     }
343     let mut collect = Collect { list: Vec::new() };
344     ffi::wlr_surface_for_each_surface(
345         root,
346         Some(collect_cb),
347         &mut collect as *mut Collect as *mut std::ffi::c_void,
348     );
349 
350     let mut canvas = vec![0u8; (bw as usize) * (bh as usize) * 4];
351     let mut composited = 0usize;
352     for (surface, sx, sy) in collect.list {
353         let texture = ffi::wlr_surface_get_texture(surface);
354         if texture.is_null() {
355             continue;
356         }
357         let (mut sw, mut sh) = (0i32, 0i32);
358         ffi::river_wlr_surface_get_buffer_size(surface, &mut sw, &mut sh);
359         let Some((pixels, format)) = read_texture(texture, sw, sh) else { continue };
360         let Some(rgba) = to_rgba(pixels, format) else { continue };
361         let dst_x = (sx as f64 * scale).round() as i32;
362         let dst_y = (sy as f64 * scale).round() as i32;
363         blit(&mut canvas, bw, bh, &rgba, sw, sh, dst_x, dst_y);
364         composited += 1;
365     }
366     if composited == 0 {
367         return Err("no readable surface content".to_string());
368     }
369     Ok((canvas, bw, bh))
370 }
371 
372 /// Copy `src` (sw×sh RGBA) into `dst` (dw×dh RGBA) at (dx, dy), clipped.
373 pub(crate) fn blit(dst: &mut [u8], dw: i32, dh: i32, src: &[u8], sw: i32, sh: i32, dx: i32, dy: i32) {
374     for sy in 0..sh {
375         let ty = dy + sy;
376         if ty < 0 || ty >= dh {
377             continue;
378         }
379         let sx0 = (-dx).clamp(0, sw);
380         let sx1 = (dw - dx).clamp(0, sw);
381         if sx0 >= sx1 {
382             continue;
383         }
384         let src_start = ((sy * sw + sx0) * 4) as usize;
385         let dst_start = ((ty * dw + dx + sx0) * 4) as usize;
386         let len = ((sx1 - sx0) * 4) as usize;
387         dst[dst_start..dst_start + len].copy_from_slice(&src[src_start..src_start + len]);
388     }
389 }
390 
391 /// PNG-encode and save off the main thread, then announce via the
392 /// notification daemon (unless disabled in config).
393 fn spawn_encode(rgba: Vec<u8>, w: u32, h: u32, path: PathBuf) {
394     std::thread::spawn(move || {
395         if let Some(parent) = path.parent() {
396             let _ = std::fs::create_dir_all(parent);
397         }
398         let file = match std::fs::File::create(&path) {
399             Ok(f) => f,
400             Err(e) => {
401                 log::warn!("screenshot: failed to create {}: {e}", path.display());
402                 return;
403             }
404         };
405         let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), w, h);
406         encoder.set_color(png::ColorType::Rgba);
407         encoder.set_depth(png::BitDepth::Eight);
408         let write = encoder
409             .write_header()
410             .and_then(|mut writer| writer.write_image_data(&rgba));
411         if let Err(e) = write {
412             log::warn!("screenshot: failed to encode {}: {e}", path.display());
413             return;
414         }
415         log::info!("screenshot saved to {}", path.display());
416 
417         if notifications_enabled() {
418             let path_str = path.display().to_string();
419             let _ = std::process::Command::new("notify-send")
420                 .arg("-a")
421                 .arg("cce")
422                 .arg("-h")
423                 .arg(format!("string:image-path:{path_str}"))
424                 .arg("Screenshot saved")
425                 .arg(&path_str)
426                 .spawn();
427         }
428     });
429 }
430 
431 /// `notifications { screenshots <bool> }` in the shared config.kdl. Absent
432 /// *key* in a readable config means enabled — that is the documented default.
433 /// An unreadable config is a different thing: we know nothing about the
434 /// user's wishes, and a session running against a config we cannot read is
435 /// typically an isolated one (a headless shadow session, say) whose toasts
436 /// would land on someone else's screen. Stay quiet there.
437 fn notifications_enabled() -> bool {
438     let Ok(content) = std::fs::read_to_string(cce_core::config::get_config_path()) else {
439         log::debug!("screenshot: config unreadable, staying quiet about the capture");
440         return false;
441     };
442     cce_core::config::parse_kdl_to_json(&content)
443         .pointer("/notifications/screenshots")
444         .and_then(|v| v.as_bool())
445         .unwrap_or(true)
446 }
447 
448 #[cfg(test)]
449 mod tests {
450     use super::*;
451 
452     #[test]
453     fn to_rgba_swizzles_bgra_and_forces_opaque() {
454         // One BGRA pixel: B=1 G=2 R=3 A=4 → RGBA 3,2,1,255.
455         let out = to_rgba(vec![1, 2, 3, 4], DRM_FORMAT_ARGB8888).unwrap();
456         assert_eq!(out, vec![3, 2, 1, 255]);
457         // RGBA passthrough, alpha forced opaque.
458         let out = to_rgba(vec![1, 2, 3, 4], DRM_FORMAT_ABGR8888).unwrap();
459         assert_eq!(out, vec![1, 2, 3, 255]);
460         // 24-bit, two pixels. The names read backwards from the memory
461         // order: BGR888 arrives R,G,B and widens as-is, RGB888 arrives
462         // B,G,R and needs the swap. Both gain an alpha they never carried.
463         let out = to_rgba(vec![1, 2, 3, 4, 5, 6], DRM_FORMAT_BGR888).unwrap();
464         assert_eq!(out, vec![1, 2, 3, 255, 4, 5, 6, 255]);
465         let out = to_rgba(vec![1, 2, 3, 4, 5, 6], DRM_FORMAT_RGB888).unwrap();
466         assert_eq!(out, vec![3, 2, 1, 255, 6, 5, 4, 255]);
467         assert!(to_rgba(vec![0; 4], 0x1234).is_none());
468     }
469 
470     #[test]
471     fn bytes_per_pixel_matches_what_to_rgba_accepts() {
472         // The readback allocates and strides by this, so a format to_rgba
473         // handles must have a size here and vice versa.
474         for (format, bpp) in [
475             (DRM_FORMAT_XRGB8888, 4usize),
476             (DRM_FORMAT_ARGB8888, 4),
477             (DRM_FORMAT_XBGR8888, 4),
478             (DRM_FORMAT_ABGR8888, 4),
479             (DRM_FORMAT_BGR888, 3),
480             (DRM_FORMAT_RGB888, 3),
481         ] {
482             assert_eq!(bytes_per_pixel(format), Some(bpp), "{format:#x}");
483             // One pixel's worth of bytes converts to exactly one RGBA pixel.
484             assert_eq!(to_rgba(vec![0; bpp], format).map(|p| p.len()), Some(4));
485         }
486         assert_eq!(bytes_per_pixel(0x1234), None);
487     }
488 
489     #[test]
490     fn unique_path_never_reuses_a_name() {
491         let dir = std::env::temp_dir().join(format!("cce-shot-test-{}", std::process::id()));
492         std::fs::create_dir_all(&dir).unwrap();
493         // Same stem twice: the second capture must not be handed the first
494         // one's path, even though the encode thread has created no file yet.
495         let a = unique_path(&dir, "screenshot-20260815-120000-000");
496         let b = unique_path(&dir, "screenshot-20260815-120000-000");
497         assert_ne!(a, b);
498         assert!(a.ends_with("screenshot-20260815-120000-000.png"));
499         assert!(b.ends_with("screenshot-20260815-120000-000-2.png"));
500         // A name already on disk is skipped too (a stem reused across runs).
501         std::fs::write(dir.join("screenshot-20260815-130000-000.png"), b"").unwrap();
502         let c = unique_path(&dir, "screenshot-20260815-130000-000");
503         assert!(c.ends_with("screenshot-20260815-130000-000-2.png"));
504         let _ = std::fs::remove_dir_all(&dir);
505     }
506 
507     #[test]
508     fn pending_screenshot_answers_exactly_once() {
509         let (tx, rx) = std::sync::mpsc::channel();
510         let mut shot = PendingScreenshot::new(
511             std::ptr::null_mut(),
512             None,
513             PathBuf::from("/tmp/shot.png"),
514             Some(tx),
515         );
516         shot.reply_ok();
517         assert_eq!(rx.recv().unwrap(), "ok /tmp/shot.png\n");
518         drop(shot); // already answered: Drop must not send a second verdict
519         assert!(rx.recv().is_err());
520 
521         // A capture discarded before it ran answers from Drop, so ccectl
522         // hears an error instead of sitting out its timeout.
523         let (tx, rx) = std::sync::mpsc::channel();
524         drop(PendingScreenshot::new(
525             std::ptr::null_mut(),
526             None,
527             PathBuf::from("/tmp/shot.png"),
528             Some(tx),
529         ));
530         assert!(rx.recv().unwrap().starts_with("error: "));
531     }
532 
533     #[test]
534     fn crop_clamps_to_bounds() {
535         // 2x2 image, pixels numbered 0..4 in the red channel.
536         let px: Vec<u8> = (0..4u8).flat_map(|i| [i, 0, 0, 255]).collect();
537         let region = ffi::wlr_box { x: 1, y: 0, width: 5, height: 5 };
538         let (out, w, h) = crop_rgba(&px, 2, 2, region).unwrap();
539         assert_eq!((w, h), (1, 2));
540         assert_eq!(out[0], 1);
541         assert_eq!(out[4], 3);
542         let empty = ffi::wlr_box { x: 5, y: 5, width: 1, height: 1 };
543         assert!(crop_rgba(&px, 2, 2, empty).is_none());
544     }
545 
546     #[test]
547     fn blit_clips_at_edges() {
548         let mut dst = vec![0u8; 2 * 2 * 4];
549         let src: Vec<u8> = vec![9; 2 * 2 * 4];
550         blit(&mut dst, 2, 2, &src, 2, 2, 1, 1); // only dst (1,1) covered
551         assert_eq!(dst[(1 * 2 + 1) * 4], 9);
552         assert_eq!(dst[0], 0);
553         blit(&mut dst, 2, 2, &src, 2, 2, -5, -5); // fully clipped: no panic
554     }
555 }