Skip to main content

agg_gui/
screenshot.rs

1//! Screenshot capture handle for agg-gui apps.
2//!
3//! The GL rendering harness (`GlGfxCtx::read_screenshot` on the desktop GL
4//! path + the equivalent WebGL2 read-back in the WASM harness) produces a
5//! top-down RGBA8 buffer of the current back buffer.  This module supplies
6//! the small shared-state handle that a button or hotkey uses to
7//! **request** a capture and that a widget uses to **display** the result.
8//!
9//! # Threading / ownership
10//!
11//! All fields are `Rc<...>` — single-threaded, cheap to clone.  Never
12//! transfer a [`ScreenshotHandle`] across threads.
13//!
14//! # Wiring on native (winit + glow)
15//!
16//! ```ignore
17//! let shot = agg_gui::ScreenshotHandle::new();
18//!
19//! // In a button's on_click:
20//! let req = shot.request.clone();
21//! Button::new("📷 Capture", font).on_click(move || req.set(true))
22//!
23//! // In the event loop, AFTER render_frame but BEFORE swap_buffers:
24//! if shot.request.get() {
25//!     let (rgba, w, h) = gl_ctx.read_screenshot();
26//!     *shot.image.borrow_mut() = Some((rgba, w, h));
27//!     shot.request.set(false);
28//! }
29//!
30//! // Display: pass `shot.image` to `ImageView`.
31//! ```
32//!
33//! # Wiring on WASM
34//!
35//! Same Rust-side flow — the browser's WebGL2 context still provides
36//! `glReadPixels`, so `GlGfxCtx::read_screenshot()` works unchanged.  The
37//! JS side needs no special code beyond driving the animation loop:
38//!
39//! ```ignore
40//! // In the WASM render export (called from JS requestAnimationFrame):
41//! if shot.request.get() {
42//!     let (rgba, w, h) = gl_ctx.read_screenshot();  // must be BEFORE presenting
43//!     *shot.image.borrow_mut() = Some((rgba, w, h));
44//!     shot.request.set(false);
45//! }
46//! ```
47//!
48//! Note for the LLM / future dev: on WASM, `read_screenshot` MUST be called
49//! before the browser composites the canvas (i.e. within the same rAF
50//! tick, before yielding).  Because WebGL uses a preserved-drawing-buffer
51//! only when explicitly requested, calling it outside that window yields
52//! a blank image.  The natural "after paint, before yield" position in the
53//! render function is correct.
54//!
55//! If the app wants to TRIGGER a browser download instead of displaying
56//! in-canvas, export a WASM function that calls `read_screenshot`, encode
57//! with the `png` crate via `agg_gui::encode_png_rgba` (if available in
58//! the surrounding app), and pass the bytes to a JS helper that creates a
59//! `Blob` + `URL.createObjectURL` + synthetic `<a download>` click.
60
61use std::cell::{Cell, RefCell};
62use std::fmt;
63use std::rc::Rc;
64
65/// Shared capture state.  Clone freely; all inner fields are `Rc<...>`.
66#[derive(Clone)]
67pub struct ScreenshotHandle {
68    /// Set to `true` to request a capture on the next rendered frame.  The
69    /// platform harness reads this cell after painting, captures the
70    /// framebuffer into `image`, and clears the flag.
71    pub request: Rc<Cell<bool>>,
72    /// Most recent captured image — top-down RGBA8, plus `(width, height)`.
73    /// `None` until the first capture completes.
74    pub image: Rc<RefCell<Option<(Vec<u8>, u32, u32)>>>,
75}
76
77impl ScreenshotHandle {
78    pub fn new() -> Self {
79        Self {
80            request: Rc::new(Cell::new(false)),
81            image: Rc::new(RefCell::new(None)),
82        }
83    }
84
85    /// Convenience: request a capture.  Equivalent to `self.request.set(true)`.
86    pub fn take(&self) {
87        self.request.set(true);
88    }
89
90    /// `true` while the latest request has not yet been fulfilled.
91    pub fn pending(&self) -> bool {
92        self.request.get()
93    }
94
95    /// Access the most recent capture without consuming it.
96    pub fn has_image(&self) -> bool {
97        self.image.borrow().is_some()
98    }
99}
100
101impl Default for ScreenshotHandle {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107// ─── Export helpers ───────────────────────────────────────────────────────
108
109/// Result of a platform screenshot export operation.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum ScreenshotExportOutcome {
112    /// Native targets write the PNG to disk and return the saved path.
113    Saved(std::path::PathBuf),
114    /// Browser targets hand the operation to the DOM and return immediately.
115    Started,
116}
117
118/// Error returned by screenshot export helpers.
119#[derive(Debug)]
120pub enum ScreenshotExportError {
121    InvalidBuffer { expected: usize, actual: usize },
122    Encode(String),
123    Io(std::io::Error),
124    Clipboard(String),
125    Unsupported(&'static str),
126}
127
128impl fmt::Display for ScreenshotExportError {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            Self::InvalidBuffer { expected, actual } => {
132                write!(
133                    f,
134                    "invalid RGBA buffer: expected {expected} bytes, got {actual}"
135                )
136            }
137            Self::Encode(msg) => write!(f, "PNG encode failed: {msg}"),
138            Self::Io(err) => write!(f, "I/O failed: {err}"),
139            Self::Clipboard(msg) => write!(f, "clipboard failed: {msg}"),
140            Self::Unsupported(msg) => write!(f, "unsupported screenshot export: {msg}"),
141        }
142    }
143}
144
145impl std::error::Error for ScreenshotExportError {}
146
147impl From<std::io::Error> for ScreenshotExportError {
148    fn from(err: std::io::Error) -> Self {
149        Self::Io(err)
150    }
151}
152
153fn validate_rgba_len(rgba: &[u8], width: u32, height: u32) -> Result<(), ScreenshotExportError> {
154    let expected = (width as usize)
155        .checked_mul(height as usize)
156        .and_then(|px| px.checked_mul(4))
157        .ok_or_else(|| ScreenshotExportError::Encode("image dimensions overflow".to_string()))?;
158    if rgba.len() != expected {
159        return Err(ScreenshotExportError::InvalidBuffer {
160            expected,
161            actual: rgba.len(),
162        });
163    }
164    Ok(())
165}
166
167/// Encode a top-down RGBA8 image as a PNG.
168pub fn encode_png_rgba(
169    rgba: &[u8],
170    width: u32,
171    height: u32,
172) -> Result<Vec<u8>, ScreenshotExportError> {
173    validate_rgba_len(rgba, width, height)?;
174
175    let mut out = Vec::with_capacity(rgba.len() / 2);
176    {
177        let mut encoder = png::Encoder::new(&mut out, width, height);
178        encoder.set_color(png::ColorType::Rgba);
179        encoder.set_depth(png::BitDepth::Eight);
180        let mut writer = encoder
181            .write_header()
182            .map_err(|e| ScreenshotExportError::Encode(e.to_string()))?;
183        writer
184            .write_image_data(rgba)
185            .map_err(|e| ScreenshotExportError::Encode(e.to_string()))?;
186    }
187    Ok(out)
188}
189
190/// Download or save a top-down RGBA8 screenshot as a PNG.
191pub fn download_rgba_as_png(
192    rgba: &[u8],
193    width: u32,
194    height: u32,
195    filename: &str,
196) -> Result<ScreenshotExportOutcome, ScreenshotExportError> {
197    let png = encode_png_rgba(rgba, width, height)?;
198    download_png(filename, &png)
199}
200
201/// Copy a top-down RGBA8 screenshot to the system clipboard.
202pub fn copy_rgba_to_clipboard(
203    rgba: &[u8],
204    width: u32,
205    height: u32,
206) -> Result<ScreenshotExportOutcome, ScreenshotExportError> {
207    validate_rgba_len(rgba, width, height)?;
208    copy_rgba_to_clipboard_impl(rgba, width, height)
209}
210
211#[cfg(not(target_arch = "wasm32"))]
212fn download_png(
213    filename: &str,
214    png: &[u8],
215) -> Result<ScreenshotExportOutcome, ScreenshotExportError> {
216    let dir = downloads_dir();
217    std::fs::create_dir_all(&dir)?;
218    let path = unique_download_path(&dir, filename);
219    std::fs::write(&path, png)?;
220    Ok(ScreenshotExportOutcome::Saved(path))
221}
222
223#[cfg(not(target_arch = "wasm32"))]
224fn downloads_dir() -> std::path::PathBuf {
225    #[cfg(target_os = "windows")]
226    {
227        if let Some(profile) = std::env::var_os("USERPROFILE") {
228            return std::path::PathBuf::from(profile).join("Downloads");
229        }
230    }
231    #[cfg(not(target_os = "windows"))]
232    {
233        if let Some(home) = std::env::var_os("HOME") {
234            return std::path::PathBuf::from(home).join("Downloads");
235        }
236    }
237    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
238}
239
240#[cfg(not(target_arch = "wasm32"))]
241fn unique_download_path(dir: &std::path::Path, filename: &str) -> std::path::PathBuf {
242    let candidate = dir.join(filename);
243    if !candidate.exists() {
244        return candidate;
245    }
246
247    let path = std::path::Path::new(filename);
248    let stem = path
249        .file_stem()
250        .and_then(|s| s.to_str())
251        .unwrap_or("screenshot");
252    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("png");
253    for i in 1.. {
254        let name = format!("{stem}-{i}.{ext}");
255        let candidate = dir.join(name);
256        if !candidate.exists() {
257            return candidate;
258        }
259    }
260    unreachable!("unbounded integer iterator should always produce a path")
261}
262
263#[cfg(all(not(target_arch = "wasm32"), feature = "clipboard"))]
264fn copy_rgba_to_clipboard_impl(
265    rgba: &[u8],
266    width: u32,
267    height: u32,
268) -> Result<ScreenshotExportOutcome, ScreenshotExportError> {
269    let image = arboard::ImageData {
270        width: width as usize,
271        height: height as usize,
272        bytes: std::borrow::Cow::Borrowed(rgba),
273    };
274    arboard::Clipboard::new()
275        .and_then(|mut clipboard| clipboard.set_image(image))
276        .map_err(|e| ScreenshotExportError::Clipboard(e.to_string()))?;
277    Ok(ScreenshotExportOutcome::Started)
278}
279
280#[cfg(all(not(target_arch = "wasm32"), not(feature = "clipboard")))]
281fn copy_rgba_to_clipboard_impl(
282    _: &[u8],
283    _: u32,
284    _: u32,
285) -> Result<ScreenshotExportOutcome, ScreenshotExportError> {
286    Err(ScreenshotExportError::Unsupported(
287        "enable the `clipboard` feature for native image clipboard support",
288    ))
289}
290
291#[cfg(target_arch = "wasm32")]
292fn download_png(
293    filename: &str,
294    png: &[u8],
295) -> Result<ScreenshotExportOutcome, ScreenshotExportError> {
296    if wasm_download_png(filename, png) {
297        Ok(ScreenshotExportOutcome::Started)
298    } else {
299        Err(ScreenshotExportError::Unsupported(
300            "browser download API is unavailable",
301        ))
302    }
303}
304
305#[cfg(target_arch = "wasm32")]
306fn copy_rgba_to_clipboard_impl(
307    rgba: &[u8],
308    width: u32,
309    height: u32,
310) -> Result<ScreenshotExportOutcome, ScreenshotExportError> {
311    let png = encode_png_rgba(rgba, width, height)?;
312    if wasm_copy_png_to_clipboard(&png) {
313        Ok(ScreenshotExportOutcome::Started)
314    } else {
315        Err(ScreenshotExportError::Unsupported(
316            "browser image clipboard API is unavailable",
317        ))
318    }
319}
320
321#[cfg(target_arch = "wasm32")]
322#[wasm_bindgen::prelude::wasm_bindgen(inline_js = r#"
323export function wasm_download_png(filename, bytes) {
324    try {
325        const blob = new Blob([bytes], { type: "image/png" });
326        const url = URL.createObjectURL(blob);
327        const a = document.createElement("a");
328        a.href = url;
329        a.download = filename || "agg-gui-screenshot.png";
330        a.style.display = "none";
331        document.body.appendChild(a);
332        a.click();
333        a.remove();
334        URL.revokeObjectURL(url);
335        return true;
336    } catch (err) {
337        console.error("agg-gui screenshot download failed", err);
338        return false;
339    }
340}
341
342export function wasm_copy_png_to_clipboard(bytes) {
343    try {
344        if (!navigator.clipboard || typeof ClipboardItem === "undefined") {
345            return false;
346        }
347        const blob = new Blob([bytes], { type: "image/png" });
348        navigator.clipboard
349            .write([new ClipboardItem({ "image/png": blob })])
350            .catch(err => console.error("agg-gui screenshot clipboard failed", err));
351        return true;
352    } catch (err) {
353        console.error("agg-gui screenshot clipboard failed", err);
354        return false;
355    }
356}
357"#)]
358extern "C" {
359    fn wasm_download_png(filename: &str, bytes: &[u8]) -> bool;
360    fn wasm_copy_png_to_clipboard(bytes: &[u8]) -> bool;
361}
362
363// ─── Capture-aware render orchestration ─────────────────────────────────
364//
365// Both the native (winit/glutin) and wasm (rAF/WebGL2) harnesses need the
366// same "screenshot capture" flow around their per-frame render:
367//
368//   1. If a capture was requested:
369//        a. Flip `capturing` to true so the Screenshot preview pane
370//           paints empty (so captured pixels don't include last frame's
371//           preview — the hall-of-mirrors bug).
372//        b. Render the frame (platform-specific: clear + paint widgets).
373//        c. `glReadPixels` the back buffer (platform-specific).
374//        d. Publish the bytes into `image` and clear both flags.
375//        e. Render again — this time the preview pane reveals the
376//           freshly-captured image.
377//   2. Otherwise: render once.
378//
379// The orchestration (flag flipping, double-render, Arc wrap) is
380// platform-agnostic and belongs here; each host supplies two closures:
381//  - `render_fn()`          : clear the framebuffer and paint the widget
382//                             tree once (the host's existing frame path).
383//  - `read_back_buffer()`   : glReadPixels the current framebuffer and
384//                             return `(rgba, width, height)`.
385
386/// Run one frame through the screenshot capture flow.
387///
388/// Call this instead of invoking the per-frame render directly.  It runs
389/// the single-render path in the common case and the double-render
390/// capture path when `request` is set.
391///
392/// `ctx` is the host's rendering context (e.g. the GL `GlGfxCtx`) —
393/// passed in once and handed through to each closure so the two
394/// closures don't both borrow it from their capture environment (which
395/// the borrow checker can't reconcile statically even though the
396/// closures are invoked sequentially).
397///
398/// The `image` field uses the `Arc<Vec<u8>>` form so the GL back-end's
399/// texture cache can key on the Arc's pointer identity — see
400/// `gfx_ctx::draw_image_rgba_arc`.
401pub fn run_frame_with_capture<C>(
402    request: &Rc<Cell<bool>>,
403    capturing: &Rc<Cell<bool>>,
404    image: &Rc<RefCell<Option<(std::sync::Arc<Vec<u8>>, u32, u32)>>>,
405    ctx: &mut C,
406    mut render_fn: impl FnMut(&mut C),
407    read_back_buffer: impl FnOnce(&mut C) -> (Vec<u8>, u32, u32),
408) {
409    if !request.get() {
410        render_fn(ctx);
411        return;
412    }
413    capturing.set(true);
414    render_fn(ctx);
415    let (rgba, w, h) = read_back_buffer(ctx);
416    *image.borrow_mut() = Some((std::sync::Arc::new(rgba), w, h));
417    capturing.set(false);
418    request.set(false);
419    render_fn(ctx);
420}