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            // Update scrollbar state before rendering panes to intermediate
150            for pane in panes.iter() {
151                if pane.viewport.focused && pane.show_scrollbar {
152                    let total_lines = pane.scrollback_len + pane.grid_size.1;
153                    let new_state = (
154                        pane.scroll_offset,
155                        pane.grid_size.1,
156                        total_lines,
157                        pane.marks.len(),
158                        self.cell_renderer.config.width,
159                        self.cell_renderer.config.height,
160                        pane.viewport.x.to_bits(),
161                        pane.viewport.y.to_bits(),
162                        pane.viewport.width.to_bits(),
163                        pane.viewport.height.to_bits(),
164                    );
165                    if new_state != self.last_scrollbar_state {
166                        self.last_scrollbar_state = new_state;
167                        self.cell_renderer.update_scrollbar_for_pane(
168                            pane.scroll_offset,
169                            pane.grid_size.1,
170                            total_lines,
171                            &pane.marks,
172                            &pane.viewport,
173                        );
174                    }
175                    break;
176                }
177            }
178
179            // Render each pane's content to the intermediate texture.
180            // `scratch` is declared outside the loop so its capacity is preserved
181            // across iterations, avoiding a per-pane heap allocation.
182            let mut scratch: Vec<SeparatorMark> = Vec::new();
183            for pane in panes.iter() {
184                fill_visible_separator_marks(
185                    &mut scratch,
186                    &pane.marks,
187                    pane.scrollback_len,
188                    pane.scroll_offset,
189                    pane.grid_size.1,
190                );
191                self.cell_renderer.render_pane_to_view(
192                    &intermediate_view,
193                    crate::cell_renderer::PaneRenderViewParams {
194                        viewport: &pane.viewport,
195                        cells: pane.cells,
196                        cols: pane.grid_size.0,
197                        rows: pane.grid_size.1,
198                        cursor_pos: pane.cursor_pos,
199                        cursor_opacity: pane.cursor_opacity,
200                        show_scrollbar: pane.show_scrollbar,
201                        clear_first: false,
202                        skip_background_image: true, // Shader handles background
203                        fill_default_bg_cells: false, // Shader shows through default-bg cells
204                        separator_marks: &scratch,
205                        pane_background: pane.background.as_ref(),
206                    },
207                )?;
208            }
209
210            // Render inline graphics to intermediate so shader can process them
211            for pane in panes.iter() {
212                if !pane.graphics.is_empty() {
213                    self.render_pane_sixel_graphics(
214                        &intermediate_view,
215                        &pane.viewport,
216                        &pane.graphics,
217                        pane.scroll_offset,
218                        pane.scrollback_len,
219                        pane.grid_size.1,
220                    )?;
221                }
222            }
223        }
224
225        // If custom shader is enabled, render it to the content target
226        // (the shader's render pass will handle clearing the target)
227        if let Some(ref mut custom_shader) = self.custom_shader_renderer {
228            if !full_content_mode {
229                // Background-only mode: clear intermediate texture (shader doesn't
230                // need terminal content, panes will be rendered on top)
231                custom_shader.clear_intermediate_texture(
232                    self.cell_renderer.device(),
233                    self.cell_renderer.queue(),
234                );
235            }
236
237            // Render shader effect. When cursor shader is chained, render to cursor
238            // shader's intermediate without applying opacity (cursor shader will do it).
239            // When no cursor shader, render directly to surface with opacity applied.
240            custom_shader.render_with_clear_color(
241                self.cell_renderer.device(),
242                self.cell_renderer.queue(),
243                content_view,
244                !use_cursor_shader, // Apply opacity only when not chaining to cursor shader
245                clear_color,
246            )?;
247        } else {
248            // No custom shader - just clear the content target with background color
249            let mut encoder = self.cell_renderer.device().create_command_encoder(
250                &wgpu::CommandEncoderDescriptor {
251                    label: Some("split pane clear encoder"),
252                },
253            );
254
255            {
256                let _clear_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
257                    label: Some("surface clear pass"),
258                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
259                        view: content_view,
260                        resolve_target: None,
261                        ops: wgpu::Operations {
262                            load: wgpu::LoadOp::Clear(clear_color),
263                            store: wgpu::StoreOp::Store,
264                        },
265                        depth_slice: None,
266                    })],
267                    depth_stencil_attachment: None,
268                    timestamp_writes: None,
269                    occlusion_query_set: None,
270                });
271            }
272
273            self.cell_renderer
274                .queue()
275                .submit(std::iter::once(encoder.finish()));
276        }
277
278        // Render background image (full-screen, after shader but before panes)
279        // Skip if custom shader is handling the background.
280        // Also skip if any pane has a per-pane background configured -
281        // per-pane backgrounds are rendered individually in render_pane_to_view.
282        let any_pane_has_background = panes.iter().any(|p| p.background.is_some());
283        let has_background_image = if !has_custom_shader && !any_pane_has_background {
284            self.cell_renderer
285                .render_background_only(content_view, false)?
286        } else {
287            false
288        };
289
290        // In full content mode, panes were already rendered to the shader's intermediate
291        // texture and the shader output includes the processed terminal content.
292        // Skip re-rendering panes to the content view.
293        if !full_content_mode {
294            // Update scrollbar state for the focused pane before rendering.
295            // In single-pane mode this is done in the main render loop; in split mode
296            // we must do it here, constrained to the pane's pixel bounds, so the
297            // track and thumb appear inside the focused pane rather than spanning
298            // the full window height/width.
299            for pane in panes.iter() {
300                if pane.viewport.focused && pane.show_scrollbar {
301                    let total_lines = pane.scrollback_len + pane.grid_size.1;
302                    let new_state = (
303                        pane.scroll_offset,
304                        pane.grid_size.1,
305                        total_lines,
306                        pane.marks.len(),
307                        self.cell_renderer.config.width,
308                        self.cell_renderer.config.height,
309                        // Include pane viewport bounds so splits/resizes trigger
310                        // a scrollbar geometry update (viewport changes position/size).
311                        pane.viewport.x.to_bits(),
312                        pane.viewport.y.to_bits(),
313                        pane.viewport.width.to_bits(),
314                        pane.viewport.height.to_bits(),
315                    );
316                    if new_state != self.last_scrollbar_state {
317                        self.last_scrollbar_state = new_state;
318                        self.cell_renderer.update_scrollbar_for_pane(
319                            pane.scroll_offset,
320                            pane.grid_size.1,
321                            total_lines,
322                            &pane.marks,
323                            &pane.viewport,
324                        );
325                    }
326                    break;
327                }
328            }
329
330            // Render each pane's content (skip background image since we rendered it full-screen).
331            // `scratch` is declared outside the loop so its capacity is preserved
332            // across iterations, avoiding a per-pane heap allocation.
333            let mut scratch: Vec<SeparatorMark> = Vec::new();
334            for pane in panes {
335                fill_visible_separator_marks(
336                    &mut scratch,
337                    &pane.marks,
338                    pane.scrollback_len,
339                    pane.scroll_offset,
340                    pane.grid_size.1,
341                );
342                self.cell_renderer.render_pane_to_view(
343                    content_view,
344                    crate::cell_renderer::PaneRenderViewParams {
345                        viewport: &pane.viewport,
346                        cells: pane.cells,
347                        cols: pane.grid_size.0,
348                        rows: pane.grid_size.1,
349                        cursor_pos: pane.cursor_pos,
350                        cursor_opacity: pane.cursor_opacity,
351                        show_scrollbar: pane.show_scrollbar,
352                        clear_first: false, // Don't clear - we already cleared the surface
353                        skip_background_image: has_background_image || has_custom_shader,
354                        fill_default_bg_cells: has_background_image, // Only fill gaps in bg-image mode; shader shows through
355                        separator_marks: &scratch,
356                        pane_background: pane.background.as_ref(),
357                    },
358                )?;
359            }
360
361            // Render inline graphics (Sixel/iTerm2/Kitty) for each pane, clipped to its bounds
362            for pane in panes {
363                if !pane.graphics.is_empty() {
364                    self.render_pane_sixel_graphics(
365                        content_view,
366                        &pane.viewport,
367                        &pane.graphics,
368                        pane.scroll_offset,
369                        pane.scrollback_len,
370                        pane.grid_size.1,
371                    )?;
372                }
373            }
374        }
375
376        // Render dividers between panes
377        if !dividers.is_empty() {
378            self.render_dividers(content_view, dividers, divider_settings)?;
379        }
380
381        // Render pane title bars (background + text)
382        if !pane_titles.is_empty() {
383            self.render_pane_titles(content_view, pane_titles)?;
384        }
385
386        // Render visual bell overlay (fullscreen flash)
387        if self.cell_renderer.visual_bell_intensity > 0.0 {
388            let uniforms: [f32; 8] = [
389                -1.0,                                     // position.x (NDC left)
390                -1.0,                                     // position.y (NDC bottom)
391                2.0,                                      // size.x (full width in NDC)
392                2.0,                                      // size.y (full height in NDC)
393                self.cell_renderer.visual_bell_color[0],  // color.r
394                self.cell_renderer.visual_bell_color[1],  // color.g
395                self.cell_renderer.visual_bell_color[2],  // color.b
396                self.cell_renderer.visual_bell_intensity, // color.a (intensity)
397            ];
398            self.cell_renderer.queue().write_buffer(
399                &self.cell_renderer.buffers.visual_bell_uniform_buffer,
400                0,
401                bytemuck::cast_slice(&uniforms),
402            );
403
404            let mut encoder = self.cell_renderer.device().create_command_encoder(
405                &wgpu::CommandEncoderDescriptor {
406                    label: Some("visual bell encoder"),
407                },
408            );
409            {
410                let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
411                    label: Some("visual bell pass"),
412                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
413                        view: content_view,
414                        resolve_target: None,
415                        ops: wgpu::Operations {
416                            load: wgpu::LoadOp::Load,
417                            store: wgpu::StoreOp::Store,
418                        },
419                        depth_slice: None,
420                    })],
421                    depth_stencil_attachment: None,
422                    timestamp_writes: None,
423                    occlusion_query_set: None,
424                });
425                render_pass.set_pipeline(&self.cell_renderer.pipelines.visual_bell_pipeline);
426                render_pass.set_bind_group(
427                    0,
428                    &self.cell_renderer.pipelines.visual_bell_bind_group,
429                    &[],
430                );
431                render_pass.draw(0..4, 0..1); // 4 vertices = triangle strip quad
432            }
433            self.cell_renderer
434                .queue()
435                .submit(std::iter::once(encoder.finish()));
436        }
437
438        // Render focus indicator around focused pane (only if multiple panes)
439        if panes.len() > 1
440            && let Some(viewport) = focused_viewport
441        {
442            self.render_focus_indicator(content_view, viewport, divider_settings)?;
443        }
444
445        // Apply cursor shader if active: composite content to surface
446        if use_cursor_shader {
447            self.cursor_shader_renderer
448                .as_mut()
449                .expect("cursor_shader_renderer must be Some when use_cursor_shader is true")
450                .render(
451                    self.cell_renderer.device(),
452                    self.cell_renderer.queue(),
453                    &surface_view,
454                    true, // Apply opacity - final render to surface
455                )?;
456        }
457
458        // Render egui overlay if provided
459        if let Some((egui_output, egui_ctx)) = egui_data {
460            self.render_egui(&surface_texture, egui_output, egui_ctx, force_egui_opaque)?;
461        }
462
463        // Ensure opaque surface when window_opacity == 1.0 (skipped for transparent windows)
464        self.cell_renderer.render_opaque_alpha(&surface_texture)?;
465
466        // Present the surface
467        surface_texture.present();
468
469        self.dirty = false;
470        Ok(true)
471    }
472
473    /// Take a screenshot of the current terminal content
474    /// Returns an RGBA image that can be saved to disk
475    ///
476    /// This captures the fully composited output including shader effects.
477    pub fn take_screenshot(&mut self) -> Result<image::RgbaImage, crate::error::RenderError> {
478        log::info!(
479            "take_screenshot: Starting screenshot capture ({}x{})",
480            self.size.width,
481            self.size.height
482        );
483
484        let width = self.size.width;
485        let height = self.size.height;
486        // Use the same format as the surface to match pipeline expectations
487        let format = self.cell_renderer.surface_format();
488        log::info!("take_screenshot: Using texture format {:?}", format);
489
490        // Create a texture to render the final composited output to (with COPY_SRC for reading back)
491        let screenshot_texture =
492            self.cell_renderer
493                .device()
494                .create_texture(&wgpu::TextureDescriptor {
495                    label: Some("screenshot texture"),
496                    size: wgpu::Extent3d {
497                        width,
498                        height,
499                        depth_or_array_layers: 1,
500                    },
501                    mip_level_count: 1,
502                    sample_count: 1,
503                    dimension: wgpu::TextureDimension::D2,
504                    format,
505                    usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
506                    view_formats: &[],
507                });
508
509        let screenshot_view =
510            screenshot_texture.create_view(&wgpu::TextureViewDescriptor::default());
511
512        // Render the full composited frame (cells + shaders + overlays)
513        log::info!("take_screenshot: Rendering composited frame...");
514
515        // Check if shaders are enabled
516        let has_custom_shader = self.custom_shader_renderer.is_some();
517        let use_cursor_shader =
518            self.cursor_shader_renderer.is_some() && !self.cursor_shader_disabled_for_alt_screen;
519
520        if has_custom_shader {
521            // Render cells to the custom shader's intermediate texture
522            let intermediate_view = self
523                .custom_shader_renderer
524                .as_ref()
525                .expect("Custom shader renderer must be Some when has_custom_shader is true")
526                .intermediate_texture_view()
527                .clone();
528            self.cell_renderer
529                .render_to_texture(&intermediate_view, true)
530                .map_err(|e| {
531                    crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
532                })?;
533
534            if use_cursor_shader {
535                // Background shader renders to cursor shader's intermediate texture
536                let cursor_intermediate = self
537                    .cursor_shader_renderer
538                    .as_ref()
539                    .expect("Cursor shader renderer must be Some when use_cursor_shader is true")
540                    .intermediate_texture_view()
541                    .clone();
542                self.custom_shader_renderer
543                    .as_mut()
544                    .expect("Custom shader renderer must be Some when has_custom_shader is true")
545                    .render(
546                        self.cell_renderer.device(),
547                        self.cell_renderer.queue(),
548                        &cursor_intermediate,
549                        false,
550                    )
551                    .map_err(|e| {
552                        crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
553                    })?;
554                // Cursor shader renders to screenshot texture
555                self.cursor_shader_renderer
556                    .as_mut()
557                    .expect("Cursor shader renderer must be Some when use_cursor_shader is true")
558                    .render(
559                        self.cell_renderer.device(),
560                        self.cell_renderer.queue(),
561                        &screenshot_view,
562                        true,
563                    )
564                    .map_err(|e| {
565                        crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
566                    })?;
567            } else {
568                // Background shader renders directly to screenshot texture
569                self.custom_shader_renderer
570                    .as_mut()
571                    .expect("Custom shader renderer must be Some when has_custom_shader is true")
572                    .render(
573                        self.cell_renderer.device(),
574                        self.cell_renderer.queue(),
575                        &screenshot_view,
576                        true,
577                    )
578                    .map_err(|e| {
579                        crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
580                    })?;
581            }
582        } else if use_cursor_shader {
583            // Render cells to cursor shader's intermediate texture
584            let cursor_intermediate = self
585                .cursor_shader_renderer
586                .as_ref()
587                .expect("Cursor shader renderer must be Some when use_cursor_shader is true")
588                .intermediate_texture_view()
589                .clone();
590            self.cell_renderer
591                .render_to_texture(&cursor_intermediate, true)
592                .map_err(|e| {
593                    crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
594                })?;
595            // Cursor shader renders to screenshot texture
596            self.cursor_shader_renderer
597                .as_mut()
598                .expect("Cursor shader renderer must be Some when use_cursor_shader is true")
599                .render(
600                    self.cell_renderer.device(),
601                    self.cell_renderer.queue(),
602                    &screenshot_view,
603                    true,
604                )
605                .map_err(|e| {
606                    crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
607                })?;
608        } else {
609            // No shaders - render directly to screenshot texture
610            self.cell_renderer
611                .render_to_view(&screenshot_view)
612                .map_err(|e| {
613                    crate::error::RenderError::ScreenshotMap(format!("Render failed: {:#}", e))
614                })?;
615        }
616
617        log::info!("take_screenshot: Render complete");
618
619        // Get device and queue references for buffer operations
620        let device = self.cell_renderer.device();
621        let queue = self.cell_renderer.queue();
622
623        // Create buffer for reading back the texture
624        let bytes_per_pixel = 4u32;
625        let unpadded_bytes_per_row = width * bytes_per_pixel;
626        // wgpu requires rows to be aligned to 256 bytes
627        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
628        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
629        let buffer_size = (padded_bytes_per_row * height) as u64;
630
631        let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
632            label: Some("screenshot buffer"),
633            size: buffer_size,
634            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
635            mapped_at_creation: false,
636        });
637
638        // Copy texture to buffer
639        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
640            label: Some("screenshot encoder"),
641        });
642
643        encoder.copy_texture_to_buffer(
644            wgpu::TexelCopyTextureInfo {
645                texture: &screenshot_texture,
646                mip_level: 0,
647                origin: wgpu::Origin3d::ZERO,
648                aspect: wgpu::TextureAspect::All,
649            },
650            wgpu::TexelCopyBufferInfo {
651                buffer: &output_buffer,
652                layout: wgpu::TexelCopyBufferLayout {
653                    offset: 0,
654                    bytes_per_row: Some(padded_bytes_per_row),
655                    rows_per_image: Some(height),
656                },
657            },
658            wgpu::Extent3d {
659                width,
660                height,
661                depth_or_array_layers: 1,
662            },
663        );
664
665        queue.submit(std::iter::once(encoder.finish()));
666        log::info!("take_screenshot: Texture copy submitted");
667
668        // Map the buffer and read the data
669        let buffer_slice = output_buffer.slice(..);
670        let (tx, rx) = std::sync::mpsc::channel();
671        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
672            let _ = tx.send(result);
673        });
674
675        // Wait for GPU to finish
676        log::info!("take_screenshot: Waiting for GPU...");
677        if let Err(e) = device.poll(wgpu::PollType::wait_indefinitely()) {
678            log::warn!("take_screenshot: GPU poll returned error: {:?}", e);
679        }
680        log::info!("take_screenshot: GPU poll complete, waiting for buffer map...");
681        rx.recv()
682            .map_err(|e| {
683                crate::error::RenderError::ScreenshotMap(format!(
684                    "Failed to receive map result: {}",
685                    e
686                ))
687            })?
688            .map_err(|e| {
689                crate::error::RenderError::ScreenshotMap(format!("Failed to map buffer: {:?}", e))
690            })?;
691        log::info!("take_screenshot: Buffer mapped successfully");
692
693        // Read the data
694        let data = buffer_slice.get_mapped_range();
695        let mut pixels = Vec::with_capacity((width * height * 4) as usize);
696
697        // Check if format is BGRA (needs swizzle) or RGBA (direct copy)
698        let is_bgra = matches!(
699            format,
700            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
701        );
702
703        // Copy data row by row (to handle padding)
704        for y in 0..height {
705            let row_start = (y * padded_bytes_per_row) as usize;
706            let row_end = row_start + (width * bytes_per_pixel) as usize;
707            let row = &data[row_start..row_end];
708
709            if is_bgra {
710                // Convert BGRA to RGBA
711                for chunk in row.chunks(4) {
712                    pixels.push(chunk[2]); // R (was B)
713                    pixels.push(chunk[1]); // G
714                    pixels.push(chunk[0]); // B (was R)
715                    pixels.push(chunk[3]); // A
716                }
717            } else {
718                // Already RGBA, direct copy
719                pixels.extend_from_slice(row);
720            }
721        }
722
723        drop(data);
724        output_buffer.unmap();
725
726        // Create image
727        image::RgbaImage::from_raw(width, height, pixels)
728            .ok_or(crate::error::RenderError::ScreenshotImageAssembly)
729    }
730}