Skip to main content

par_term_render/cell_renderer/pane_render/
mod.rs

1// ARC-005 completed: file reduced from ~1004 lines to ~791 lines (target: <800).
2//
3// Extracted submodules:
4//   powerline.rs        — Powerline fringe-extension logic (pure fn, no self access).
5//                         `extend_powerline_fringes()` adjusts bg-quad x0/x1 to eliminate
6//                         anti-aliased dark fringes at separator boundaries.
7//   block_char_render.rs — Geometric rendering of block/box-drawing characters via the text
8//                          pipeline. `render_block_char_geometrically()` returns Some(new_idx)
9//                          when rendered; caller continues the per-cell loop.
10//
11// Note: The glyph font-fallback loop was extracted to `CellRenderer::resolve_glyph_with_fallback()`
12// in `atlas.rs` (ARC-004 / QA-003). The RLE bg-instance merge inner loop remains inlined
13// as it mutates self.bg_instances in place with no clean free-function boundary.
14//
15// IMPORTANT invariants to preserve (see MEMORY.md and CLAUDE.md):
16//   • 3-phase draw ordering: bg instances → text instances → cursor overlays
17//   • `fill_default_bg_cells` controls default-bg skip in bg-image mode
18//   • `skip_solid_background` must NOT be used to gate default-bg rendering
19//
20// Tracking: Issues ARC-005 and ARC-009 in AUDIT.md.
21
22use super::block_chars;
23use super::instance_buffers::{
24    STIPPLE_OFF_PX, STIPPLE_ON_PX, UNDERLINE_HEIGHT_RATIO, compute_cursor_text_color,
25};
26use super::{BackgroundInstance, Cell, CellRenderer, PaneViewport, TextInstance};
27use anyhow::Result;
28use par_term_config::{SeparatorMark, color_u8x4_rgb_to_f32, color_u8x4_rgb_to_f32_a};
29mod block_char_render;
30mod cursor_overlays;
31mod powerline;
32mod separators;
33
34use block_char_render::BlockCharRenderParams;
35
36use cursor_overlays::CursorOverlayParams;
37
38/// Atlas texture size in pixels. Must match the value used at atlas creation time.
39/// See `PREFERRED_ATLAS_SIZE` in `pipeline.rs` and `atlas_size` on `CellRendererAtlas`.
40pub(crate) const ATLAS_SIZE: f32 = 2048.0;
41
42/// Parameters for rendering a single pane to a surface texture view.
43pub struct PaneRenderViewParams<'a> {
44    pub viewport: &'a PaneViewport,
45    pub cells: &'a [Cell],
46    pub cols: usize,
47    pub rows: usize,
48    pub cursor_pos: Option<(usize, usize)>,
49    pub cursor_opacity: f32,
50    pub show_scrollbar: bool,
51    pub clear_first: bool,
52    pub skip_background_image: bool,
53    /// When true, emit background quads for default-bg cells (fills gaps in background-image mode).
54    /// Set to false in custom shader mode so the shader output shows through.
55    pub fill_default_bg_cells: bool,
56    pub separator_marks: &'a [SeparatorMark],
57    pub pane_background: Option<&'a par_term_config::PaneBackground>,
58}
59
60/// Parameters for building GPU instance buffers for a pane.
61pub(super) struct PaneInstanceBuildParams<'a> {
62    pub viewport: &'a PaneViewport,
63    pub cells: &'a [Cell],
64    pub cols: usize,
65    pub rows: usize,
66    pub cursor_pos: Option<(usize, usize)>,
67    pub cursor_opacity: f32,
68    pub skip_solid_background: bool,
69    pub fill_default_bg_cells: bool,
70    pub separator_marks: &'a [SeparatorMark],
71}
72
73/// Parameters for emitting a cursor cell background instance (QA-006 extraction).
74struct CursorCellBgParams {
75    col: usize,
76    row: usize,
77    content_x: f32,
78    content_y: f32,
79    bg_color: [f32; 4],
80    cursor_opacity: f32,
81    render_hollow_here: bool,
82    opacity_multiplier: f32,
83    bg_index: usize,
84}
85
86impl CellRenderer {
87    /// Render a single pane's content within a viewport to an existing surface texture
88    ///
89    /// This method renders cells to a specific region of the render target,
90    /// using a GPU scissor rect to clip to the pane bounds.
91    ///
92    /// # Arguments
93    /// * `surface_view` - The texture view to render to
94    /// * `viewport` - The pane's viewport (position, size, focus state, opacity)
95    /// * `cells` - The cells to render (should match viewport grid size)
96    /// * `cols` - Number of columns in the cell grid
97    /// * `rows` - Number of rows in the cell grid
98    /// * `cursor_pos` - Cursor position (col, row) within this pane, or None if no cursor
99    /// * `cursor_opacity` - Cursor opacity (0.0 = hidden, 1.0 = fully visible)
100    /// * `show_scrollbar` - Whether to render the scrollbar for this pane
101    /// * `clear_first` - If true, clears the viewport region before rendering
102    /// * `skip_background_image` - If true, skip rendering the background image. Use this
103    ///   when the background image has already been rendered full-screen (for split panes).
104    pub fn render_pane_to_view(
105        &mut self,
106        surface_view: &wgpu::TextureView,
107        p: PaneRenderViewParams<'_>,
108    ) -> Result<()> {
109        let PaneRenderViewParams {
110            viewport,
111            cells,
112            cols,
113            rows,
114            cursor_pos,
115            cursor_opacity,
116            show_scrollbar,
117            clear_first,
118            skip_background_image,
119            fill_default_bg_cells,
120            separator_marks,
121            pane_background,
122        } = p;
123        // Build instance buffers for this pane's cells.
124        // Returns cursor_overlay_start: the bg_instance index where cursor overlays begin.
125        // Used for 3-phase rendering (bgs → text → cursor overlays).
126        let cursor_overlay_start = self.build_pane_instance_buffers(PaneInstanceBuildParams {
127            viewport,
128            cells,
129            cols,
130            rows,
131            cursor_pos,
132            cursor_opacity,
133            skip_solid_background: skip_background_image,
134            fill_default_bg_cells,
135            separator_marks,
136        })?;
137
138        // Pre-update per-pane background uniform buffer and bind group if needed (must happen
139        // before the render pass). Buffers are allocated once and reused across frames.
140        // Per-pane backgrounds are explicit user overrides and always prepared, even when a
141        // custom shader or global background would normally be skipped.
142        let has_pane_bg = if let Some(pane_bg) = pane_background
143            && let Some(ref path) = pane_bg.image_path
144            && self.bg_state.pane_bg_cache.contains_key(path.as_str())
145        {
146            self.prepare_pane_bg_bind_group(
147                path.as_str(),
148                super::background::PaneBgBindGroupParams {
149                    pane_x: viewport.x,
150                    pane_y: viewport.y,
151                    pane_width: viewport.width,
152                    pane_height: viewport.height,
153                    mode: pane_bg.mode,
154                    opacity: pane_bg.opacity,
155                    darken: pane_bg.darken,
156                },
157            );
158            true
159        } else {
160            false
161        };
162
163        // Retrieve cached path for use in the render pass (must be done before borrow in pass).
164        let pane_bg_path: Option<String> = if has_pane_bg {
165            pane_background
166                .and_then(|pb| pb.image_path.as_ref())
167                .map(|p| p.to_string())
168        } else {
169            None
170        };
171
172        let mut encoder = self
173            .device
174            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
175                label: Some("pane render encoder"),
176            });
177
178        // Determine load operation and clear color
179        let load_op = if clear_first {
180            let clear_color = if self.bg_state.bg_is_solid_color {
181                wgpu::Color {
182                    r: self.bg_state.solid_bg_color[0] as f64
183                        * self.window_opacity as f64
184                        * viewport.opacity as f64,
185                    g: self.bg_state.solid_bg_color[1] as f64
186                        * self.window_opacity as f64
187                        * viewport.opacity as f64,
188                    b: self.bg_state.solid_bg_color[2] as f64
189                        * self.window_opacity as f64
190                        * viewport.opacity as f64,
191                    a: self.window_opacity as f64 * viewport.opacity as f64,
192                }
193            } else {
194                wgpu::Color {
195                    r: self.background_color[0] as f64
196                        * self.window_opacity as f64
197                        * viewport.opacity as f64,
198                    g: self.background_color[1] as f64
199                        * self.window_opacity as f64
200                        * viewport.opacity as f64,
201                    b: self.background_color[2] as f64
202                        * self.window_opacity as f64
203                        * viewport.opacity as f64,
204                    a: self.window_opacity as f64 * viewport.opacity as f64,
205                }
206            };
207            wgpu::LoadOp::Clear(clear_color)
208        } else {
209            wgpu::LoadOp::Load
210        };
211
212        {
213            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
214                label: Some("pane render pass"),
215                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
216                    view: surface_view,
217                    resolve_target: None,
218                    ops: wgpu::Operations {
219                        load: load_op,
220                        store: wgpu::StoreOp::Store,
221                    },
222                    depth_slice: None,
223                })],
224                depth_stencil_attachment: None,
225                timestamp_writes: None,
226                occlusion_query_set: None,
227            });
228
229            // Set scissor rect to clip rendering to pane bounds
230            let (sx, sy, sw, sh) = viewport.to_scissor_rect();
231            render_pass.set_scissor_rect(sx, sy, sw, sh);
232
233            // Render per-pane background image within scissor rect.
234            // Per-pane backgrounds are explicit user overrides and always render,
235            // even when a custom shader or global background is active.
236            if let Some(ref path) = pane_bg_path
237                && let Some(cached) = self.bg_state.pane_bg_uniform_cache.get(path.as_str())
238            {
239                render_pass.set_pipeline(&self.pipelines.bg_image_pipeline);
240                render_pass.set_bind_group(0, &cached.bind_group, &[]);
241                render_pass.set_vertex_buffer(0, self.buffers.vertex_buffer.slice(..));
242                render_pass.draw(0..4, 0..1);
243            }
244
245            self.emit_three_phase_draw_calls(
246                &mut render_pass,
247                cursor_overlay_start as u32,
248                self.buffers.actual_bg_instances as u32,
249            );
250
251            // Render scrollbar if requested (uses its own scissor rect internally)
252            if show_scrollbar {
253                // Reset scissor to full surface for scrollbar
254                render_pass.set_scissor_rect(0, 0, self.config.width, self.config.height);
255                self.scrollbar.render(&mut render_pass);
256            }
257        }
258
259        self.queue.submit(std::iter::once(encoder.finish()));
260        Ok(())
261    }
262
263    /// Build instance buffers for a pane's cells with viewport offset.
264    ///
265    /// Similar to `build_instance_buffers` but adjusts all positions to be relative to the
266    /// viewport origin. Also appends cursor overlay instances (beam bar and hollow borders)
267    /// after the cell background instances.
268    ///
269    /// Returns the index in `bg_instances` where cursor overlays begin (`cursor_overlay_start`).
270    /// The caller uses this for 3-phase rendering: cell bgs, text, then cursor overlays on top.
271    ///
272    /// `skip_solid_background`: if true, skip the solid background fill for the viewport
273    /// (use when a custom shader or background image was already rendered full-screen).
274    fn build_pane_instance_buffers(&mut self, p: PaneInstanceBuildParams<'_>) -> Result<usize> {
275        let PaneInstanceBuildParams {
276            viewport,
277            cells,
278            cols,
279            rows,
280            cursor_pos,
281            cursor_opacity,
282            skip_solid_background,
283            fill_default_bg_cells,
284            separator_marks,
285        } = p;
286        // Clear previous instance buffers
287        for instance in &mut self.bg_instances {
288            instance.size = [0.0, 0.0];
289            instance.color = [0.0, 0.0, 0.0, 0.0];
290        }
291
292        // Add a background rectangle covering the entire pane viewport (unless skipped)
293        // This ensures the pane has a proper background even when cells are skipped.
294        // Skip when a custom shader or background image was already rendered full-screen.
295        let bg_start_index = if !skip_solid_background && !self.bg_instances.is_empty() {
296            let bg_color = self.background_color;
297            let opacity = self.window_opacity * viewport.opacity;
298            let width_f = self.config.width as f32;
299            let height_f = self.config.height as f32;
300            self.bg_instances[0] = super::types::BackgroundInstance {
301                position: [
302                    viewport.x / width_f * 2.0 - 1.0,
303                    1.0 - (viewport.y / height_f * 2.0),
304                ],
305                size: [
306                    viewport.width / width_f * 2.0,
307                    viewport.height / height_f * 2.0,
308                ],
309                color: [
310                    bg_color[0] * opacity,
311                    bg_color[1] * opacity,
312                    bg_color[2] * opacity,
313                    opacity,
314                ],
315            };
316            1 // Start cell backgrounds at index 1
317        } else {
318            0 // Start cell backgrounds at index 0 (no viewport fill)
319        };
320
321        for instance in &mut self.text_instances {
322            instance.size = [0.0, 0.0];
323        }
324
325        // Start at bg_start_index (1 if viewport fill was added, 0 otherwise)
326        let mut bg_index = bg_start_index;
327        let mut text_index = 0;
328
329        // Content offset - positions are relative to content area (with padding applied)
330        let (content_x, content_y) = viewport.content_origin();
331        let opacity_multiplier = viewport.opacity;
332
333        for row in 0..rows {
334            let row_start = row * cols;
335            let row_end = (row + 1) * cols;
336            if row_start >= cells.len() {
337                break;
338            }
339            let row_cells = &cells[row_start..row_end.min(cells.len())];
340
341            // Background - use RLE to merge consecutive cells with same color
342            let mut col = 0;
343            while col < row_cells.len() {
344                let cell = &row_cells[col];
345                let bg_f = color_u8x4_rgb_to_f32(cell.bg_color);
346                let is_default_bg = (bg_f[0] - self.background_color[0]).abs() < 0.001
347                    && (bg_f[1] - self.background_color[1]).abs() < 0.001
348                    && (bg_f[2] - self.background_color[2]).abs() < 0.001;
349
350                // Check for cursor at this position (position check only, no opacity gate)
351                let cursor_at_cell = cursor_pos.is_some_and(|(cx, cy)| cx == col && cy == row)
352                    && !self.cursor.hidden_for_shader;
353                // Hollow cursor (unfocused + Hollow style) must show regardless of blink opacity
354                let render_hollow_here = cursor_at_cell
355                    && !self.is_focused
356                    && self.cursor.unfocused_style == par_term_config::UnfocusedCursorStyle::Hollow;
357                let has_cursor = (cursor_at_cell && cursor_opacity > 0.0) || render_hollow_here;
358
359                // Skip cells with half-block characters (▄/▀).
360                // These are rendered entirely through the text pipeline to avoid
361                // cross-pipeline coordinate seams that cause visible banding.
362                let is_half_block = {
363                    let mut chars = cell.grapheme.chars();
364                    matches!(chars.next(), Some('\u{2580}' | '\u{2584}')) && chars.next().is_none()
365                };
366
367                // Skip default-bg cells only when NOT in background-image/shader mode.
368                // When skip_solid_background is true (background image or custom shader active),
369                // no viewport fill is drawn, so default-bg cells between colored segments would
370                // show the background image through — causing visible gaps/lines in the tmux
371                // status bar. In that mode we render them with the theme background color instead.
372                // Skip default-bg cells unless fill_default_bg_cells is set (background-image mode).
373                // In normal mode: viewport fill quad covers them — no individual quad needed.
374                // In shader mode: shader output must show through — do not paint over it.
375                // In bg-image mode: fill_default_bg_cells=true — render with theme bg color to
376                // close gaps that would otherwise show the background image unexpectedly.
377                if is_half_block || (is_default_bg && !has_cursor && !fill_default_bg_cells) {
378                    col += 1;
379                    continue;
380                }
381
382                // Calculate background color with alpha and pane opacity
383                let bg_alpha =
384                    if self.transparency_affects_only_default_background && !is_default_bg {
385                        1.0
386                    } else {
387                        self.window_opacity
388                    };
389                let pane_alpha = bg_alpha * opacity_multiplier;
390                let bg_color = color_u8x4_rgb_to_f32_a(cell.bg_color, pane_alpha);
391
392                // Handle cursor at this position (QA-006: extracted to helper for readability)
393                if has_cursor {
394                    let emitted = self.emit_cursor_cell_bg(CursorCellBgParams {
395                        col,
396                        row,
397                        content_x,
398                        content_y,
399                        bg_color,
400                        cursor_opacity,
401                        render_hollow_here,
402                        opacity_multiplier,
403                        bg_index,
404                    });
405                    if emitted {
406                        bg_index += 1;
407                    }
408                    col += 1;
409                    continue;
410                }
411
412                // RLE: Find run of consecutive cells with same background color
413                let start_col = col;
414                let run_color = cell.bg_color;
415                col += 1;
416                while col < row_cells.len() {
417                    let next_cell = &row_cells[col];
418                    let next_cursor_at_cell = cursor_pos
419                        .is_some_and(|(cx, cy)| cx == col && cy == row)
420                        && !self.cursor.hidden_for_shader;
421                    let next_hollow = next_cursor_at_cell
422                        && !self.is_focused
423                        && self.cursor.unfocused_style
424                            == par_term_config::UnfocusedCursorStyle::Hollow;
425                    let next_has_cursor =
426                        (next_cursor_at_cell && cursor_opacity > 0.0) || next_hollow;
427                    let next_is_half_block = {
428                        let mut chars = next_cell.grapheme.chars();
429                        matches!(chars.next(), Some('\u{2580}' | '\u{2584}'))
430                            && chars.next().is_none()
431                    };
432                    if next_cell.bg_color != run_color || next_has_cursor || next_is_half_block {
433                        break;
434                    }
435                    col += 1;
436                }
437                let run_length = col - start_col;
438
439                // Create single quad spanning entire run.
440                // Snap all edges to pixel boundaries to match the text pipeline and
441                // eliminate sub-pixel gaps between adjacent differently-colored cell runs.
442                let x0 = (content_x + start_col as f32 * self.grid.cell_width).round();
443                let x1 =
444                    (content_x + (start_col + run_length) as f32 * self.grid.cell_width).round();
445                let y0 = (content_y + row as f32 * self.grid.cell_height).round();
446                let y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
447
448                // Extend the colored bg quad 1 px under adjacent powerline separator glyphs
449                // to eliminate the dark fringe at their anti-aliased edges.
450                // See powerline.rs for full rationale.
451                let (x0, x1) =
452                    powerline::extend_powerline_fringes(powerline::PowerlineFringeParams {
453                        row_cells,
454                        start_col,
455                        col,
456                        x0,
457                        x1,
458                        skip_solid_background,
459                        is_default_bg,
460                        background_color: self.background_color,
461                    });
462
463                if bg_index < self.buffers.max_bg_instances {
464                    self.bg_instances[bg_index] = BackgroundInstance {
465                        position: [
466                            x0 / self.config.width as f32 * 2.0 - 1.0,
467                            1.0 - (y0 / self.config.height as f32 * 2.0),
468                        ],
469                        size: [
470                            (x1 - x0) / self.config.width as f32 * 2.0,
471                            (y1 - y0) / self.config.height as f32 * 2.0,
472                        ],
473                        color: bg_color,
474                    };
475                    bg_index += 1;
476                }
477            }
478
479            // Text rendering
480            let natural_line_height =
481                self.font.font_ascent + self.font.font_descent + self.font.font_leading;
482            let vertical_padding = (self.grid.cell_height - natural_line_height).max(0.0) / 2.0;
483            let baseline_y = content_y
484                + (row as f32 * self.grid.cell_height)
485                + vertical_padding
486                + self.font.font_ascent;
487
488            // Compute text alpha - force opaque if keep_text_opaque is enabled
489            let text_alpha = if self.keep_text_opaque {
490                opacity_multiplier // Only apply pane dimming, not window transparency
491            } else {
492                self.window_opacity * opacity_multiplier
493            };
494
495            // Check if this row has the cursor and it's a visible block cursor
496            // (for cursor text color override in split-pane rendering)
497            let cursor_is_block_on_this_row = {
498                use par_term_emu_core_rust::cursor::CursorStyle;
499                cursor_pos.is_some_and(|(_, cy)| cy == row)
500                    && cursor_opacity > 0.0
501                    && !self.cursor.hidden_for_shader
502                    && matches!(
503                        self.cursor.style,
504                        CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock
505                    )
506                    && (self.is_focused
507                        || self.cursor.unfocused_style
508                            == par_term_config::UnfocusedCursorStyle::Same)
509            };
510
511            for (col_idx, cell) in row_cells.iter().enumerate() {
512                if cell.wide_char_spacer || cell.grapheme == " " {
513                    continue;
514                }
515
516                // Avoid Vec<char> allocation: use iterator-based char access.
517                let Some(ch) = cell.grapheme.chars().next() else {
518                    continue;
519                };
520
521                // Kitty Unicode placeholder cells render as images (handled by
522                // the graphics path), not as glyphs. Most fonts have no glyph
523                // for U+10EEEE so falling through here would draw tofu where
524                // an image is supposed to appear.
525                if ch == '\u{10EEEE}' {
526                    continue;
527                }
528                let second_char = cell.grapheme.chars().nth(1);
529                // grapheme_len is 1, 2, or "more than 2" — we stop counting at 3.
530                let grapheme_len = match second_char {
531                    None => 1usize,
532                    Some(_) => {
533                        if cell.grapheme.chars().nth(2).is_none() {
534                            2
535                        } else {
536                            3
537                        }
538                    }
539                };
540
541                // Determine text color - apply cursor_text_color (or auto-contrast) when the
542                // block cursor is on this cell, otherwise use the cell's foreground color.
543                let render_fg_color: [f32; 4] = if cursor_is_block_on_this_row
544                    && cursor_pos.is_some_and(|(cx, _)| cx == col_idx)
545                {
546                    compute_cursor_text_color(self.cursor.color, self.cursor.text_color, text_alpha)
547                } else {
548                    color_u8x4_rgb_to_f32_a(cell.fg_color, text_alpha)
549                };
550
551                // Classify character for block/box-drawing detection and glyph snapping.
552                let char_type = block_chars::classify_char(ch);
553
554                // Attempt geometric rendering for block/box-drawing characters.
555                // See block_char_render.rs for the full implementation.
556                let block_x0 = (content_x + col_idx as f32 * self.grid.cell_width).round();
557                let block_y0 = (content_y + row as f32 * self.grid.cell_height).round();
558                let block_y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
559                if let Some(new_idx) = self.render_block_char_geometrically(BlockCharRenderParams {
560                    cell,
561                    ch,
562                    grapheme_len,
563                    x0_pixel: block_x0,
564                    y0_pixel: block_y0,
565                    y1_pixel: block_y1,
566                    render_fg_color,
567                    text_alpha,
568                    text_index,
569                }) {
570                    text_index = new_idx;
571                    continue;
572                }
573
574                // Check if this character should be rendered as a monochrome symbol.
575                // Also handle symbol + VS16 (U+FE0F): strip VS16, render monochrome.
576                let (force_monochrome, base_char) = if grapheme_len == 1 {
577                    (super::atlas::should_render_as_symbol(ch), ch)
578                } else if grapheme_len == 2
579                    && second_char == Some('\u{FE0F}')
580                    && super::atlas::should_render_as_symbol(ch)
581                {
582                    // Symbol + VS16: strip VS16 and render base char as monochrome
583                    (true, ch)
584                } else {
585                    (false, ch)
586                };
587
588                // Resolve a renderable glyph via the shared font-fallback helper (ARC-004 / QA-003).
589                // This replaces the duplicated excluded_fonts/get_or_rasterize_glyph loop
590                // that previously existed in both pane_render/mod.rs and text_instance_builder.rs.
591                let resolved_info = self.resolve_glyph_with_fallback(
592                    base_char,
593                    &cell.grapheme,
594                    cell.bold,
595                    cell.italic,
596                    force_monochrome,
597                );
598
599                if let Some(info) = resolved_info {
600                    let char_w = if cell.wide_char {
601                        self.grid.cell_width * 2.0
602                    } else {
603                        self.grid.cell_width
604                    };
605                    let x0 = content_x + col_idx as f32 * self.grid.cell_width;
606                    let y0 = content_y + row as f32 * self.grid.cell_height;
607                    let x1 = x0 + char_w;
608                    let y1 = y0 + self.grid.cell_height;
609
610                    let cell_w = x1 - x0;
611                    let cell_h = y1 - y0;
612                    let scale_x = cell_w / char_w;
613                    let scale_y = cell_h / self.grid.cell_height;
614
615                    let baseline_offset =
616                        baseline_y - (content_y + row as f32 * self.grid.cell_height);
617                    let glyph_left = x0 + (info.bearing_x * scale_x).round();
618                    let baseline_in_cell = (baseline_offset * scale_y).round();
619                    let glyph_top = y0 + baseline_in_cell - info.bearing_y;
620
621                    let render_w = info.width as f32 * scale_x;
622                    let render_h = info.height as f32 * scale_y;
623
624                    let (final_left, final_top, final_w, final_h) = if grapheme_len == 1
625                        && matches!(
626                            char_type,
627                            block_chars::BlockCharType::Symbol
628                                | block_chars::BlockCharType::Geometric
629                        ) {
630                        let height_scale = cell_h / render_h;
631                        let width_scale = cell_w / render_w;
632                        let symbol_scale = height_scale.min(width_scale).max(1.0);
633                        let final_w = render_w * symbol_scale;
634                        let final_h = render_h * symbol_scale;
635                        (
636                            x0 + (cell_w - final_w) / 2.0,
637                            y0 + (cell_h - final_h) / 2.0,
638                            final_w,
639                            final_h,
640                        )
641                    } else if grapheme_len == 1 && block_chars::should_snap_to_boundaries(char_type)
642                    {
643                        block_chars::snap_glyph_to_cell(block_chars::SnapGlyphParams {
644                            glyph_left,
645                            glyph_top,
646                            render_w,
647                            render_h,
648                            cell_x0: x0,
649                            cell_y0: y0,
650                            cell_x1: x1,
651                            cell_y1: y1,
652                            snap_threshold: 3.0,
653                            extension: 0.5,
654                        })
655                    } else {
656                        (glyph_left, glyph_top, render_w, render_h)
657                    };
658
659                    if text_index < self.buffers.max_text_instances {
660                        self.text_instances[text_index] = TextInstance {
661                            position: [
662                                final_left / self.config.width as f32 * 2.0 - 1.0,
663                                1.0 - (final_top / self.config.height as f32 * 2.0),
664                            ],
665                            size: [
666                                final_w / self.config.width as f32 * 2.0,
667                                final_h / self.config.height as f32 * 2.0,
668                            ],
669                            tex_offset: [info.x as f32 / ATLAS_SIZE, info.y as f32 / ATLAS_SIZE],
670                            tex_size: [
671                                info.width as f32 / ATLAS_SIZE,
672                                info.height as f32 / ATLAS_SIZE,
673                            ],
674                            color: render_fg_color,
675                            is_colored: if info.is_colored { 1 } else { 0 },
676                        };
677                        text_index += 1;
678                    }
679                }
680            }
681
682            // Underlines: emit a thin rectangle at the bottom of each underlined cell.
683            // Mirrors the logic in text_instance_builder.rs but uses pane-local coordinates.
684            {
685                let underline_thickness = (self.grid.cell_height * UNDERLINE_HEIGHT_RATIO)
686                    .max(1.0)
687                    .round();
688                let tex_offset = [
689                    self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
690                    self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
691                ];
692                let tex_size = [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE];
693                let y0 = content_y + (row + 1) as f32 * self.grid.cell_height - underline_thickness;
694                let ndc_y = 1.0 - (y0 / self.config.height as f32 * 2.0);
695                let ndc_h = underline_thickness / self.config.height as f32 * 2.0;
696                let is_stipple =
697                    self.link_underline_style == par_term_config::LinkUnderlineStyle::Stipple;
698                let stipple_period = STIPPLE_ON_PX + STIPPLE_OFF_PX;
699
700                for col_idx in 0..cols {
701                    if row_start + col_idx >= cells.len() {
702                        break;
703                    }
704                    let cell = &cells[row_start + col_idx];
705                    if !cell.underline {
706                        continue;
707                    }
708                    let fg = color_u8x4_rgb_to_f32_a(cell.fg_color, text_alpha);
709                    let cell_x0 = content_x + col_idx as f32 * self.grid.cell_width;
710
711                    if is_stipple {
712                        let mut px = 0.0;
713                        while px < self.grid.cell_width
714                            && text_index < self.buffers.max_text_instances
715                        {
716                            let seg_w = STIPPLE_ON_PX.min(self.grid.cell_width - px);
717                            let x = cell_x0 + px;
718                            self.text_instances[text_index] = TextInstance {
719                                position: [x / self.config.width as f32 * 2.0 - 1.0, ndc_y],
720                                size: [seg_w / self.config.width as f32 * 2.0, ndc_h],
721                                tex_offset,
722                                tex_size,
723                                color: fg,
724                                is_colored: 0,
725                            };
726                            text_index += 1;
727                            px += stipple_period;
728                        }
729                    } else if text_index < self.buffers.max_text_instances {
730                        self.text_instances[text_index] = TextInstance {
731                            position: [cell_x0 / self.config.width as f32 * 2.0 - 1.0, ndc_y],
732                            size: [self.grid.cell_width / self.config.width as f32 * 2.0, ndc_h],
733                            tex_offset,
734                            tex_size,
735                            color: fg,
736                            is_colored: 0,
737                        };
738                        text_index += 1;
739                    }
740                }
741            }
742        }
743
744        // Inject command separator line instances — see separators.rs
745        bg_index = self.emit_separator_instances(
746            separator_marks,
747            cols,
748            rows,
749            content_x,
750            content_y,
751            opacity_multiplier,
752            bg_index,
753        );
754
755        // --- Cursor overlays (beam/underline bar + hollow borders) ---
756        // These are rendered in Phase 3 (on top of text) via the 3-phase draw in render_pane_to_view.
757        // Record where cursor overlays start — everything after this index is an overlay.
758        let cursor_overlay_start = bg_index;
759
760        if let Some((cursor_col, cursor_row)) = cursor_pos {
761            let cursor_x0 = content_x + cursor_col as f32 * self.grid.cell_width;
762            let cursor_x1 = cursor_x0 + self.grid.cell_width;
763            let cursor_y0 = (content_y + cursor_row as f32 * self.grid.cell_height).round();
764            let cursor_y1 = (content_y + (cursor_row + 1) as f32 * self.grid.cell_height).round();
765
766            // Emit guide, shadow, beam/underline bar, hollow outline — see cursor_overlays.rs
767            bg_index = self.emit_cursor_overlays(
768                CursorOverlayParams {
769                    cursor_x0,
770                    cursor_x1,
771                    cursor_y0,
772                    cursor_y1,
773                    cols,
774                    content_x,
775                    cursor_opacity,
776                },
777                bg_index,
778            );
779        }
780
781        // Update actual instance counts for draw calls
782        self.buffers.actual_bg_instances = bg_index;
783        self.buffers.actual_text_instances = text_index;
784
785        // Upload only the used portion of instance buffers to GPU.
786        // Each pane typically uses a fraction of the full-window buffer, so uploading
787        // only [0..count] instead of the entire array significantly reduces per-pane
788        // staging bandwidth — critical when rendering many split panes per frame.
789        if bg_index > 0 {
790            self.queue.write_buffer(
791                &self.buffers.bg_instance_buffer,
792                0,
793                bytemuck::cast_slice(&self.bg_instances[..bg_index]),
794            );
795        }
796        if text_index > 0 {
797            self.queue.write_buffer(
798                &self.buffers.text_instance_buffer,
799                0,
800                bytemuck::cast_slice(&self.text_instances[..text_index]),
801            );
802        }
803
804        Ok(cursor_overlay_start)
805    }
806
807    /// Emit a background instance for a cell containing the cursor.
808    ///
809    /// QA-006: Extracted from the RLE background loop in `build_pane_instance_buffers`.
810    /// Handles block-cursor color blending and pixel-snapped background quad placement.
811    /// Returns `true` if an instance was emitted (caller increments bg_index).
812    fn emit_cursor_cell_bg(&mut self, p: CursorCellBgParams) -> bool {
813        let CursorCellBgParams {
814            col,
815            row,
816            content_x,
817            content_y,
818            mut bg_color,
819            cursor_opacity,
820            render_hollow_here,
821            opacity_multiplier,
822            bg_index,
823        } = p;
824
825        use par_term_emu_core_rust::cursor::CursorStyle;
826        match self.cursor.style {
827            CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock => {
828                if !render_hollow_here {
829                    // Solid block cursor: blend cursor color into background
830                    for (bg, &cursor) in bg_color.iter_mut().take(3).zip(&self.cursor.color) {
831                        *bg = *bg * (1.0 - cursor_opacity) + cursor * cursor_opacity;
832                    }
833                    bg_color[3] = bg_color[3].max(cursor_opacity * opacity_multiplier);
834                }
835                // If hollow: keep original background color (outline added as overlay)
836            }
837            _ => {}
838        }
839
840        // Cursor cell can't be merged
841        // Snap to pixel boundaries to match text pipeline alignment
842        let x0 = (content_x + col as f32 * self.grid.cell_width).round();
843        let x1 = (content_x + (col + 1) as f32 * self.grid.cell_width).round();
844        let y0 = (content_y + row as f32 * self.grid.cell_height).round();
845        let y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
846
847        if bg_index < self.buffers.max_bg_instances {
848            self.bg_instances[bg_index] = BackgroundInstance {
849                position: [
850                    x0 / self.config.width as f32 * 2.0 - 1.0,
851                    1.0 - (y0 / self.config.height as f32 * 2.0),
852                ],
853                size: [
854                    (x1 - x0) / self.config.width as f32 * 2.0,
855                    (y1 - y0) / self.config.height as f32 * 2.0,
856                ],
857                color: bg_color,
858            };
859            true
860        } else {
861            false
862        }
863    }
864}