Skip to main content

par_term_render/renderer/
screenshot.rs

1//! Offscreen frame capture.
2//!
3//! `take_screenshot` renders a composited frame into a `COPY_SRC` texture and reads
4//! it back as an `RgbaImage`.
5//!
6//! QA-011 (resolved): this used to render from the `CellRenderer`'s single-grid
7//! state (`self.cells`) rather than the live split-pane layout, so with more than
8//! one pane the capture showed the single-grid content, not what was on screen. It
9//! now runs the same `composite_panes` the live frame does, against an offscreen
10//! target instead of the surface, so the capture is the screen.
11//!
12//! Still absent from the image: the egui overlay (tab bar, settings window, menus).
13//! `render_egui` consumes an `egui::FullOutput` produced once per frame by the live
14//! egui pass; a capture taken between frames has none, so `PaneCaptureParams`
15//! carries no egui data by construction. This is unchanged from the old path.
16
17use super::{PaneCaptureParams, Renderer};
18
19impl Renderer {
20    /// Take a screenshot of the current terminal content.
21    /// Returns an RGBA image that can be saved to disk.
22    ///
23    /// This captures the fully composited output including shader effects, every
24    /// pane of a split, dividers, pane titles and the focus indicator — the same
25    /// draw sequence `render_split_panes` puts on the surface, minus the egui
26    /// overlay (see the module docs).
27    ///
28    /// `params` is the same pane data the live frame renders; the caller gathers
29    /// it exactly as it would for `render_split_panes`. Rendering is unconditional:
30    /// unlike the live path this does not consult or clear `dirty`.
31    pub fn take_screenshot(
32        &mut self,
33        params: PaneCaptureParams<'_>,
34    ) -> Result<image::RgbaImage, crate::error::RenderError> {
35        log::info!(
36            "take_screenshot: Starting screenshot capture ({}x{})",
37            self.size.width,
38            self.size.height
39        );
40
41        let width = self.size.width;
42        let height = self.size.height;
43        // Use the same format as the surface to match pipeline expectations
44        let format = self.cell_renderer.surface_format();
45        log::info!("take_screenshot: Using texture format {:?}", format);
46
47        // Create a texture to render the final composited output to (with COPY_SRC for reading back)
48        let screenshot_texture =
49            self.cell_renderer
50                .device()
51                .create_texture(&wgpu::TextureDescriptor {
52                    label: Some("screenshot texture"),
53                    size: wgpu::Extent3d {
54                        width,
55                        height,
56                        depth_or_array_layers: 1,
57                    },
58                    mip_level_count: 1,
59                    sample_count: 1,
60                    dimension: wgpu::TextureDimension::D2,
61                    format,
62                    usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
63                    view_formats: &[],
64                });
65
66        let screenshot_view =
67            screenshot_texture.create_view(&wgpu::TextureViewDescriptor::default());
68
69        // Render the full composited frame through the live pane path (QA-011).
70        log::info!("take_screenshot: Rendering composited frame...");
71        self.composite_panes_offscreen(params, &screenshot_view)
72            .map_err(|e| {
73                crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
74            })?;
75        // Match the surface path's alpha stamp so an opaque window reads back opaque.
76        self.cell_renderer
77            .render_opaque_alpha_to_view(&screenshot_view)
78            .map_err(|e| {
79                crate::error::RenderError::ScreenshotMap(format!("Alpha stamp failed: {:#}", e))
80            })?;
81
82        log::info!("take_screenshot: Render complete");
83
84        // Get device and queue references for buffer operations
85        let device = self.cell_renderer.device();
86        let queue = self.cell_renderer.queue();
87
88        // Create buffer for reading back the texture
89        let bytes_per_pixel = 4u32;
90        let unpadded_bytes_per_row = width * bytes_per_pixel;
91        // wgpu requires rows to be aligned to 256 bytes
92        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
93        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
94        let buffer_size = (padded_bytes_per_row * height) as u64;
95
96        let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
97            label: Some("screenshot buffer"),
98            size: buffer_size,
99            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
100            mapped_at_creation: false,
101        });
102
103        // Copy texture to buffer
104        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
105            label: Some("screenshot encoder"),
106        });
107
108        encoder.copy_texture_to_buffer(
109            wgpu::TexelCopyTextureInfo {
110                texture: &screenshot_texture,
111                mip_level: 0,
112                origin: wgpu::Origin3d::ZERO,
113                aspect: wgpu::TextureAspect::All,
114            },
115            wgpu::TexelCopyBufferInfo {
116                buffer: &output_buffer,
117                layout: wgpu::TexelCopyBufferLayout {
118                    offset: 0,
119                    bytes_per_row: Some(padded_bytes_per_row),
120                    rows_per_image: Some(height),
121                },
122            },
123            wgpu::Extent3d {
124                width,
125                height,
126                depth_or_array_layers: 1,
127            },
128        );
129
130        queue.submit(std::iter::once(encoder.finish()));
131        log::info!("take_screenshot: Texture copy submitted");
132
133        // Map the buffer and read the data
134        let buffer_slice = output_buffer.slice(..);
135        let (tx, rx) = std::sync::mpsc::channel();
136        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
137            let _ = tx.send(result);
138        });
139
140        // Wait for the GPU to finish — bounded so a stalled GPU can't freeze the
141        // event loop indefinitely. This readback runs on the main thread when an
142        // MCP screenshot is requested; a healthy GPU completes in milliseconds,
143        // but `wait_indefinitely` could hang the loop if the device is lost.
144        let gpu_timeout = std::time::Duration::from_secs(5);
145        log::info!("take_screenshot: Waiting for GPU...");
146        if let Err(e) = device.poll(wgpu::PollType::Wait {
147            submission_index: None,
148            timeout: Some(gpu_timeout),
149        }) {
150            log::warn!("take_screenshot: GPU poll returned error: {:?}", e);
151        }
152        log::info!("take_screenshot: GPU poll complete, waiting for buffer map...");
153        rx.recv_timeout(gpu_timeout)
154            .map_err(|e| {
155                crate::error::RenderError::ScreenshotMap(format!(
156                    "Timed out or failed to receive map result: {}",
157                    e
158                ))
159            })?
160            .map_err(|e| {
161                crate::error::RenderError::ScreenshotMap(format!("Failed to map buffer: {:?}", e))
162            })?;
163        log::info!("take_screenshot: Buffer mapped successfully");
164
165        // Read the data
166        let data = buffer_slice.get_mapped_range();
167        let mut pixels = Vec::with_capacity((width * height * 4) as usize);
168
169        // Check if format is BGRA (needs swizzle) or RGBA (direct copy)
170        let is_bgra = matches!(
171            format,
172            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
173        );
174
175        // Copy data row by row (to handle padding)
176        for y in 0..height {
177            let row_start = (y * padded_bytes_per_row) as usize;
178            let row_end = row_start + (width * bytes_per_pixel) as usize;
179            let row = &data[row_start..row_end];
180
181            if is_bgra {
182                // Convert BGRA to RGBA
183                for chunk in row.chunks(4) {
184                    pixels.push(chunk[2]); // R (was B)
185                    pixels.push(chunk[1]); // G
186                    pixels.push(chunk[0]); // B (was R)
187                    pixels.push(chunk[3]); // A
188                }
189            } else {
190                // Already RGBA, direct copy
191                pixels.extend_from_slice(row);
192            }
193        }
194
195        drop(data);
196        output_buffer.unmap();
197
198        // Create image
199        image::RgbaImage::from_raw(width, height, pixels)
200            .ok_or(crate::error::RenderError::ScreenshotImageAssembly)
201    }
202}