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