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