Skip to main content

par_term_render/renderer/
rendering.rs

1// ARC-009 TODO: This file is 705 lines (limit: 800 — approaching threshold). When it
2// exceeds 800 lines, extract into renderer/ siblings:
3//
4//   split_layout.rs  — Split-pane geometry calculations (render_split_panes_with_data)
5//   separator_draw.rs — compute_visible_separator_marks + draw calls (see also QA-001,
6//                       QA-008 which affect this area)
7//
8// Tracking: Issue ARC-009 in AUDIT.md.
9
10use crate::cell_renderer::PaneViewport;
11use anyhow::Result;
12
13use super::{
14    DividerRenderInfo, PaneDividerSettings, PaneRenderInfo, PaneTitleInfo, Renderer, SeparatorMark,
15    fill_visible_separator_marks,
16};
17
18// This file contains the multi-pane frame-level helper `render_split_panes` and `take_screenshot`.
19
20/// Parameters for [`Renderer::render_split_panes`].
21pub struct SplitPanesRenderParams<'a> {
22    pub panes: &'a [PaneRenderInfo<'a>],
23    pub dividers: &'a [DividerRenderInfo],
24    pub pane_titles: &'a [PaneTitleInfo],
25    pub focused_viewport: Option<&'a PaneViewport>,
26    pub divider_settings: &'a PaneDividerSettings,
27    pub egui_data: Option<(egui::FullOutput, &'a egui::Context)>,
28    pub force_egui_opaque: bool,
29}
30
31impl Renderer {
32    /// Render split panes with dividers and focus indicator
33    ///
34    /// This is the main entry point for rendering a split pane layout.
35    /// It handles:
36    /// 1. Clearing the surface
37    /// 2. Rendering each pane's content
38    /// 3. Rendering dividers between panes
39    /// 4. Rendering focus indicator around the focused pane
40    /// 5. Rendering egui overlay if provided
41    /// 6. Presenting the surface
42    ///
43    /// # Arguments
44    /// * `panes` - List of panes to render with their viewport info
45    /// * `dividers` - List of dividers between panes with hover state
46    /// * `focused_viewport` - Viewport of the focused pane (for focus indicator)
47    /// * `divider_settings` - Settings for divider and focus indicator appearance
48    /// * `egui_data` - Optional egui overlay data
49    /// * `force_egui_opaque` - Force egui to render at full opacity
50    ///
51    /// # Returns
52    /// `true` if rendering was performed, `false` if skipped
53    pub fn render_split_panes(&mut self, params: SplitPanesRenderParams<'_>) -> Result<bool> {
54        let SplitPanesRenderParams {
55            panes,
56            dividers,
57            pane_titles,
58            focused_viewport,
59            divider_settings,
60            egui_data,
61            force_egui_opaque,
62        } = params;
63        // Check if we need to render
64        let force_render = self.needs_continuous_render();
65        if !self.dirty && !force_render && egui_data.is_none() {
66            return Ok(false);
67        }
68
69        let has_custom_shader = self.custom_shader_renderer.is_some();
70        // Only use cursor shader if it's enabled and not disabled for alt screen
71        let use_cursor_shader =
72            self.cursor_shader_renderer.is_some() && !self.cursor_shader_disabled_for_alt_screen;
73
74        // Pre-load any per-pane background textures that aren't cached yet
75        for pane in panes.iter() {
76            if let Some(ref bg) = pane.background
77                && let Some(ref path) = bg.image_path
78                && let Err(e) = self.cell_renderer.load_pane_background(path)
79            {
80                log::error!("Failed to load pane background '{}': {}", path, e);
81            }
82        }
83
84        // Get the surface texture
85        let surface_texture = self.cell_renderer.surface.get_current_texture()?;
86        let surface_view = surface_texture
87            .texture
88            .create_view(&wgpu::TextureViewDescriptor::default());
89
90        // When cursor shader is active, render all content to its intermediate texture.
91        // The cursor shader will then composite the result onto the surface.
92        let cursor_intermediate: Option<wgpu::TextureView> = if use_cursor_shader {
93            Some(
94                self.cursor_shader_renderer
95                    .as_ref()
96                    .expect("cursor_shader_renderer must be Some when use_cursor_shader is true")
97                    .intermediate_texture_view()
98                    .clone(),
99            )
100        } else {
101            None
102        };
103        // Content render target: cursor shader intermediate (if active) or surface directly
104        let content_view = cursor_intermediate.as_ref().unwrap_or(&surface_view);
105
106        // Clear color for content rendering. When cursor shader will apply opacity,
107        // use non-premultiplied color so opacity isn't applied twice.
108        let opacity = self.cell_renderer.window_opacity as f64;
109        let clear_color = if self.cell_renderer.pipelines.bg_image_bind_group.is_some() {
110            wgpu::Color::TRANSPARENT
111        } else if use_cursor_shader {
112            // Cursor shader applies opacity — use full-opacity background
113            wgpu::Color {
114                r: self.cell_renderer.background_color[0] as f64,
115                g: self.cell_renderer.background_color[1] as f64,
116                b: self.cell_renderer.background_color[2] as f64,
117                a: 1.0,
118            }
119        } else {
120            wgpu::Color {
121                r: self.cell_renderer.background_color[0] as f64 * opacity,
122                g: self.cell_renderer.background_color[1] as f64 * opacity,
123                b: self.cell_renderer.background_color[2] as f64 * opacity,
124                a: opacity,
125            }
126        };
127
128        // Determine if custom shader uses full content mode (shader processes terminal content)
129        let full_content_mode = self
130            .custom_shader_renderer
131            .as_ref()
132            .is_some_and(|s| s.full_content_mode());
133
134        // Full content mode: render pane content to the shader's intermediate texture
135        // BEFORE running the shader, so it can process terminal content via iChannel4.
136        // This must happen outside the `custom_shader_renderer` mutable borrow scope
137        // because rendering panes requires `&mut self`.
138        if full_content_mode {
139            let custom_shader = self
140                .custom_shader_renderer
141                .as_mut()
142                .expect("custom_shader_renderer must be Some when full_content_mode is true");
143            custom_shader.clear_intermediate_texture(
144                self.cell_renderer.device(),
145                self.cell_renderer.queue(),
146            );
147            let intermediate_view = custom_shader.intermediate_texture_view().clone();
148
149            // Render each pane's content to the intermediate texture.
150            // Scrollbar geometry is updated per-pane before each render call so
151            // unfocused panes can also show their own scrollbar.
152            // `scratch` is declared outside the loop so its capacity is preserved
153            // across iterations, avoiding a per-pane heap allocation.
154            let mut scratch: Vec<SeparatorMark> = Vec::new();
155            for pane in panes.iter() {
156                if pane.show_scrollbar {
157                    let total_lines = pane.scrollback_len + pane.grid_size.1;
158                    self.cell_renderer.update_scrollbar_for_pane(
159                        pane.scroll_offset,
160                        pane.grid_size.1,
161                        total_lines,
162                        &pane.marks,
163                        &pane.viewport,
164                    );
165                }
166                fill_visible_separator_marks(
167                    &mut scratch,
168                    &pane.marks,
169                    pane.scrollback_len,
170                    pane.scroll_offset,
171                    pane.grid_size.1,
172                );
173                self.cell_renderer.render_pane_to_view(
174                    &intermediate_view,
175                    crate::cell_renderer::PaneRenderViewParams {
176                        viewport: &pane.viewport,
177                        cells: pane.cells,
178                        cols: pane.grid_size.0,
179                        rows: pane.grid_size.1,
180                        cursor_pos: pane.cursor_pos,
181                        cursor_opacity: pane.cursor_opacity,
182                        show_scrollbar: pane.show_scrollbar,
183                        clear_first: false,
184                        skip_background_image: true, // Shader handles background
185                        fill_default_bg_cells: false, // Shader shows through default-bg cells
186                        separator_marks: &scratch,
187                        pane_background: pane.background.as_ref(),
188                    },
189                )?;
190            }
191
192            // Render inline graphics to intermediate so shader can process them
193            for pane in panes.iter() {
194                if !pane.graphics.is_empty() {
195                    self.render_pane_sixel_graphics(
196                        &intermediate_view,
197                        &pane.viewport,
198                        &pane.graphics,
199                        pane.scroll_offset,
200                        pane.scrollback_len,
201                        pane.grid_size.1,
202                    )?;
203                }
204            }
205        }
206
207        // If custom shader is enabled, render it to the content target
208        // (the shader's render pass will handle clearing the target)
209        if let Some(ref mut custom_shader) = self.custom_shader_renderer {
210            if !full_content_mode {
211                // Background-only mode: clear intermediate texture (shader doesn't
212                // need terminal content, panes will be rendered on top)
213                custom_shader.clear_intermediate_texture(
214                    self.cell_renderer.device(),
215                    self.cell_renderer.queue(),
216                );
217            }
218
219            // Render shader effect. When cursor shader is chained, render to cursor
220            // shader's intermediate without applying opacity (cursor shader will do it).
221            // When no cursor shader, render directly to surface with opacity applied.
222            custom_shader.render_with_clear_color(
223                self.cell_renderer.device(),
224                self.cell_renderer.queue(),
225                content_view,
226                !use_cursor_shader, // Apply opacity only when not chaining to cursor shader
227                clear_color,
228            )?;
229        } else {
230            // No custom shader - just clear the content target with background color
231            let mut encoder = self.cell_renderer.device().create_command_encoder(
232                &wgpu::CommandEncoderDescriptor {
233                    label: Some("split pane clear encoder"),
234                },
235            );
236
237            {
238                let _clear_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
239                    label: Some("surface clear pass"),
240                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
241                        view: content_view,
242                        resolve_target: None,
243                        ops: wgpu::Operations {
244                            load: wgpu::LoadOp::Clear(clear_color),
245                            store: wgpu::StoreOp::Store,
246                        },
247                        depth_slice: None,
248                    })],
249                    depth_stencil_attachment: None,
250                    timestamp_writes: None,
251                    occlusion_query_set: None,
252                });
253            }
254
255            self.cell_renderer
256                .queue()
257                .submit(std::iter::once(encoder.finish()));
258        }
259
260        // Render background image (full-screen, after shader but before panes)
261        // Skip if custom shader is handling the background.
262        // Also skip if any pane has a per-pane background configured -
263        // per-pane backgrounds are rendered individually in render_pane_to_view.
264        let any_pane_has_background = panes.iter().any(|p| p.background.is_some());
265        let has_background_image = if !has_custom_shader && !any_pane_has_background {
266            self.cell_renderer
267                .render_background_only(content_view, false)?
268        } else {
269            false
270        };
271
272        // In full content mode, panes were already rendered to the shader's intermediate
273        // texture and the shader output includes the processed terminal content.
274        // Skip re-rendering panes to the content view.
275        if !full_content_mode {
276            // Render each pane's content (skip background image since we rendered it full-screen).
277            // Scrollbar geometry is updated per-pane before each render call so
278            // unfocused panes can also show their own scrollbar.
279            // `scratch` is declared outside the loop so its capacity is preserved
280            // across iterations, avoiding a per-pane heap allocation.
281            let mut scratch: Vec<SeparatorMark> = Vec::new();
282            for pane in panes {
283                if pane.show_scrollbar {
284                    let total_lines = pane.scrollback_len + pane.grid_size.1;
285                    self.cell_renderer.update_scrollbar_for_pane(
286                        pane.scroll_offset,
287                        pane.grid_size.1,
288                        total_lines,
289                        &pane.marks,
290                        &pane.viewport,
291                    );
292                }
293                fill_visible_separator_marks(
294                    &mut scratch,
295                    &pane.marks,
296                    pane.scrollback_len,
297                    pane.scroll_offset,
298                    pane.grid_size.1,
299                );
300                self.cell_renderer.render_pane_to_view(
301                    content_view,
302                    crate::cell_renderer::PaneRenderViewParams {
303                        viewport: &pane.viewport,
304                        cells: pane.cells,
305                        cols: pane.grid_size.0,
306                        rows: pane.grid_size.1,
307                        cursor_pos: pane.cursor_pos,
308                        cursor_opacity: pane.cursor_opacity,
309                        show_scrollbar: pane.show_scrollbar,
310                        clear_first: false, // Don't clear - we already cleared the surface
311                        skip_background_image: has_background_image || has_custom_shader,
312                        fill_default_bg_cells: has_background_image, // Only fill gaps in bg-image mode; shader shows through
313                        separator_marks: &scratch,
314                        pane_background: pane.background.as_ref(),
315                    },
316                )?;
317            }
318
319            // Render inline graphics (Sixel/iTerm2/Kitty) for each pane, clipped to its bounds
320            for pane in panes {
321                if !pane.graphics.is_empty() {
322                    self.render_pane_sixel_graphics(
323                        content_view,
324                        &pane.viewport,
325                        &pane.graphics,
326                        pane.scroll_offset,
327                        pane.scrollback_len,
328                        pane.grid_size.1,
329                    )?;
330                }
331            }
332        }
333
334        // Render dividers between panes
335        if !dividers.is_empty() {
336            self.render_dividers(content_view, dividers, divider_settings)?;
337        }
338
339        // Render pane title bars (background + text)
340        if !pane_titles.is_empty() {
341            self.render_pane_titles(content_view, pane_titles)?;
342        }
343
344        // Render visual bell overlay (fullscreen flash)
345        if self.cell_renderer.visual_bell_intensity > 0.0 {
346            let uniforms: [f32; 8] = [
347                -1.0,                                     // position.x (NDC left)
348                -1.0,                                     // position.y (NDC bottom)
349                2.0,                                      // size.x (full width in NDC)
350                2.0,                                      // size.y (full height in NDC)
351                self.cell_renderer.visual_bell_color[0],  // color.r
352                self.cell_renderer.visual_bell_color[1],  // color.g
353                self.cell_renderer.visual_bell_color[2],  // color.b
354                self.cell_renderer.visual_bell_intensity, // color.a (intensity)
355            ];
356            self.cell_renderer.queue().write_buffer(
357                &self.cell_renderer.buffers.visual_bell_uniform_buffer,
358                0,
359                bytemuck::cast_slice(&uniforms),
360            );
361
362            let mut encoder = self.cell_renderer.device().create_command_encoder(
363                &wgpu::CommandEncoderDescriptor {
364                    label: Some("visual bell encoder"),
365                },
366            );
367            {
368                let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
369                    label: Some("visual bell pass"),
370                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
371                        view: content_view,
372                        resolve_target: None,
373                        ops: wgpu::Operations {
374                            load: wgpu::LoadOp::Load,
375                            store: wgpu::StoreOp::Store,
376                        },
377                        depth_slice: None,
378                    })],
379                    depth_stencil_attachment: None,
380                    timestamp_writes: None,
381                    occlusion_query_set: None,
382                });
383                render_pass.set_pipeline(&self.cell_renderer.pipelines.visual_bell_pipeline);
384                render_pass.set_bind_group(
385                    0,
386                    &self.cell_renderer.pipelines.visual_bell_bind_group,
387                    &[],
388                );
389                render_pass.draw(0..4, 0..1); // 4 vertices = triangle strip quad
390            }
391            self.cell_renderer
392                .queue()
393                .submit(std::iter::once(encoder.finish()));
394        }
395
396        // Render focus indicator around focused pane (only if multiple panes)
397        if panes.len() > 1
398            && let Some(viewport) = focused_viewport
399        {
400            self.render_focus_indicator(content_view, viewport, divider_settings)?;
401        }
402
403        // Apply cursor shader if active: composite content to surface
404        if use_cursor_shader {
405            self.cursor_shader_renderer
406                .as_mut()
407                .expect("cursor_shader_renderer must be Some when use_cursor_shader is true")
408                .render(
409                    self.cell_renderer.device(),
410                    self.cell_renderer.queue(),
411                    &surface_view,
412                    true, // Apply opacity - final render to surface
413                )?;
414        }
415
416        // Render egui overlay if provided
417        if let Some((egui_output, egui_ctx)) = egui_data {
418            self.render_egui(&surface_texture, egui_output, egui_ctx, force_egui_opaque)?;
419        }
420
421        // Ensure opaque surface when window_opacity == 1.0 (skipped for transparent windows)
422        self.cell_renderer.render_opaque_alpha(&surface_texture)?;
423
424        // Present the surface
425        surface_texture.present();
426
427        self.dirty = false;
428        Ok(true)
429    }
430
431    /// Take a screenshot of the current terminal content
432    /// Returns an RGBA image that can be saved to disk
433    ///
434    /// This captures the fully composited output including shader effects.
435    pub fn take_screenshot(&mut self) -> Result<image::RgbaImage, crate::error::RenderError> {
436        log::info!(
437            "take_screenshot: Starting screenshot capture ({}x{})",
438            self.size.width,
439            self.size.height
440        );
441
442        let width = self.size.width;
443        let height = self.size.height;
444        // Use the same format as the surface to match pipeline expectations
445        let format = self.cell_renderer.surface_format();
446        log::info!("take_screenshot: Using texture format {:?}", format);
447
448        // Create a texture to render the final composited output to (with COPY_SRC for reading back)
449        let screenshot_texture =
450            self.cell_renderer
451                .device()
452                .create_texture(&wgpu::TextureDescriptor {
453                    label: Some("screenshot texture"),
454                    size: wgpu::Extent3d {
455                        width,
456                        height,
457                        depth_or_array_layers: 1,
458                    },
459                    mip_level_count: 1,
460                    sample_count: 1,
461                    dimension: wgpu::TextureDimension::D2,
462                    format,
463                    usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
464                    view_formats: &[],
465                });
466
467        let screenshot_view =
468            screenshot_texture.create_view(&wgpu::TextureViewDescriptor::default());
469
470        // Render the full composited frame (cells + shaders + overlays)
471        log::info!("take_screenshot: Rendering composited frame...");
472
473        // Check if shaders are enabled
474        let has_custom_shader = self.custom_shader_renderer.is_some();
475        let use_cursor_shader =
476            self.cursor_shader_renderer.is_some() && !self.cursor_shader_disabled_for_alt_screen;
477
478        if has_custom_shader {
479            // Render cells to the custom shader's intermediate texture
480            let intermediate_view = self
481                .custom_shader_renderer
482                .as_ref()
483                .expect("Custom shader renderer must be Some when has_custom_shader is true")
484                .intermediate_texture_view()
485                .clone();
486            self.cell_renderer
487                .render_to_texture(&intermediate_view, true)
488                .map_err(|e| {
489                    crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
490                })?;
491
492            if use_cursor_shader {
493                // Background shader renders to cursor shader's intermediate texture
494                let cursor_intermediate = self
495                    .cursor_shader_renderer
496                    .as_ref()
497                    .expect("Cursor shader renderer must be Some when use_cursor_shader is true")
498                    .intermediate_texture_view()
499                    .clone();
500                self.custom_shader_renderer
501                    .as_mut()
502                    .expect("Custom shader renderer must be Some when has_custom_shader is true")
503                    .render(
504                        self.cell_renderer.device(),
505                        self.cell_renderer.queue(),
506                        &cursor_intermediate,
507                        false,
508                    )
509                    .map_err(|e| {
510                        crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
511                    })?;
512                // Cursor shader renders to screenshot texture
513                self.cursor_shader_renderer
514                    .as_mut()
515                    .expect("Cursor shader renderer must be Some when use_cursor_shader is true")
516                    .render(
517                        self.cell_renderer.device(),
518                        self.cell_renderer.queue(),
519                        &screenshot_view,
520                        true,
521                    )
522                    .map_err(|e| {
523                        crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
524                    })?;
525            } else {
526                // Background shader renders directly to screenshot texture
527                self.custom_shader_renderer
528                    .as_mut()
529                    .expect("Custom shader renderer must be Some when has_custom_shader is true")
530                    .render(
531                        self.cell_renderer.device(),
532                        self.cell_renderer.queue(),
533                        &screenshot_view,
534                        true,
535                    )
536                    .map_err(|e| {
537                        crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
538                    })?;
539            }
540        } else if use_cursor_shader {
541            // Render cells to cursor shader's intermediate texture
542            let cursor_intermediate = self
543                .cursor_shader_renderer
544                .as_ref()
545                .expect("Cursor shader renderer must be Some when use_cursor_shader is true")
546                .intermediate_texture_view()
547                .clone();
548            self.cell_renderer
549                .render_to_texture(&cursor_intermediate, true)
550                .map_err(|e| {
551                    crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
552                })?;
553            // Cursor shader renders to screenshot texture
554            self.cursor_shader_renderer
555                .as_mut()
556                .expect("Cursor shader renderer must be Some when use_cursor_shader is true")
557                .render(
558                    self.cell_renderer.device(),
559                    self.cell_renderer.queue(),
560                    &screenshot_view,
561                    true,
562                )
563                .map_err(|e| {
564                    crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
565                })?;
566        } else {
567            // No shaders - render directly to screenshot texture
568            self.cell_renderer
569                .render_to_view(&screenshot_view)
570                .map_err(|e| {
571                    crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
572                })?;
573        }
574
575        log::info!("take_screenshot: Render complete");
576
577        // Get device and queue references for buffer operations
578        let device = self.cell_renderer.device();
579        let queue = self.cell_renderer.queue();
580
581        // Create buffer for reading back the texture
582        let bytes_per_pixel = 4u32;
583        let unpadded_bytes_per_row = width * bytes_per_pixel;
584        // wgpu requires rows to be aligned to 256 bytes
585        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
586        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
587        let buffer_size = (padded_bytes_per_row * height) as u64;
588
589        let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
590            label: Some("screenshot buffer"),
591            size: buffer_size,
592            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
593            mapped_at_creation: false,
594        });
595
596        // Copy texture to buffer
597        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
598            label: Some("screenshot encoder"),
599        });
600
601        encoder.copy_texture_to_buffer(
602            wgpu::TexelCopyTextureInfo {
603                texture: &screenshot_texture,
604                mip_level: 0,
605                origin: wgpu::Origin3d::ZERO,
606                aspect: wgpu::TextureAspect::All,
607            },
608            wgpu::TexelCopyBufferInfo {
609                buffer: &output_buffer,
610                layout: wgpu::TexelCopyBufferLayout {
611                    offset: 0,
612                    bytes_per_row: Some(padded_bytes_per_row),
613                    rows_per_image: Some(height),
614                },
615            },
616            wgpu::Extent3d {
617                width,
618                height,
619                depth_or_array_layers: 1,
620            },
621        );
622
623        queue.submit(std::iter::once(encoder.finish()));
624        log::info!("take_screenshot: Texture copy submitted");
625
626        // Map the buffer and read the data
627        let buffer_slice = output_buffer.slice(..);
628        let (tx, rx) = std::sync::mpsc::channel();
629        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
630            let _ = tx.send(result);
631        });
632
633        // Wait for GPU to finish
634        log::info!("take_screenshot: Waiting for GPU...");
635        if let Err(e) = device.poll(wgpu::PollType::wait_indefinitely()) {
636            log::warn!("take_screenshot: GPU poll returned error: {:?}", e);
637        }
638        log::info!("take_screenshot: GPU poll complete, waiting for buffer map...");
639        rx.recv()
640            .map_err(|e| {
641                crate::error::RenderError::ScreenshotMap(format!(
642                    "Failed to receive map result: {}",
643                    e
644                ))
645            })?
646            .map_err(|e| {
647                crate::error::RenderError::ScreenshotMap(format!("Failed to map buffer: {:?}", e))
648            })?;
649        log::info!("take_screenshot: Buffer mapped successfully");
650
651        // Read the data
652        let data = buffer_slice.get_mapped_range();
653        let mut pixels = Vec::with_capacity((width * height * 4) as usize);
654
655        // Check if format is BGRA (needs swizzle) or RGBA (direct copy)
656        let is_bgra = matches!(
657            format,
658            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
659        );
660
661        // Copy data row by row (to handle padding)
662        for y in 0..height {
663            let row_start = (y * padded_bytes_per_row) as usize;
664            let row_end = row_start + (width * bytes_per_pixel) as usize;
665            let row = &data[row_start..row_end];
666
667            if is_bgra {
668                // Convert BGRA to RGBA
669                for chunk in row.chunks(4) {
670                    pixels.push(chunk[2]); // R (was B)
671                    pixels.push(chunk[1]); // G
672                    pixels.push(chunk[0]); // B (was R)
673                    pixels.push(chunk[3]); // A
674                }
675            } else {
676                // Already RGBA, direct copy
677                pixels.extend_from_slice(row);
678            }
679        }
680
681        drop(data);
682        output_buffer.unmap();
683
684        // Create image
685        image::RgbaImage::from_raw(width, height, pixels)
686            .ok_or(crate::error::RenderError::ScreenshotImageAssembly)
687    }
688}