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