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, ×tamp_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 }