Skip to main content

par_term_render/cell_renderer/pane_render/
mod.rs

1// ARC-009 TODO: This file is 1035 lines (limit: 800). Extract the following into
2// sibling modules within this pane_render/ directory:
3//
4//   rle_merge.rs    — RLE background-color merge inner loop (currently inlined in
5//                     build_pane_instance_buffers). Extract helper:
6//                     `fn merge_rle_bg_spans(cells, ...) -> Vec<BackgroundInstance>`
7//
8//   powerline.rs    — Powerline fringe-extension logic (~80 lines). Extract helper:
9//                     `fn extend_powerline_fringes(spans, cell_w, cell_h) -> Vec<BackgroundInstance>`
10//
11// IMPORTANT invariants to preserve (see MEMORY.md and CLAUDE.md):
12//   • 3-phase draw ordering: bg instances → text instances → cursor overlays
13//   • `fill_default_bg_cells` controls default-bg skip in bg-image mode
14//   • `skip_solid_background` must NOT be used to gate default-bg rendering
15//
16// Tracking: Issue ARC-009 in AUDIT.md.
17
18use super::block_chars;
19use super::instance_buffers::{
20    CURSOR_BRIGHTNESS_THRESHOLD, STIPPLE_OFF_PX, STIPPLE_ON_PX, UNDERLINE_HEIGHT_RATIO,
21};
22use super::{BackgroundInstance, Cell, CellRenderer, PaneViewport, TextInstance};
23use anyhow::Result;
24use par_term_config::{SeparatorMark, color_u8x4_rgb_to_f32, color_u8x4_rgb_to_f32_a};
25mod cursor_overlays;
26mod separators;
27
28use cursor_overlays::CursorOverlayParams;
29
30/// Atlas texture size in pixels. Must match the value used at atlas creation time.
31/// See `PREFERRED_ATLAS_SIZE` in `pipeline.rs` and `atlas_size` on `CellRendererAtlas`.
32pub(crate) const ATLAS_SIZE: f32 = 2048.0;
33
34/// Parameters for rendering a single pane to a surface texture view.
35pub struct PaneRenderViewParams<'a> {
36    pub viewport: &'a PaneViewport,
37    pub cells: &'a [Cell],
38    pub cols: usize,
39    pub rows: usize,
40    pub cursor_pos: Option<(usize, usize)>,
41    pub cursor_opacity: f32,
42    pub show_scrollbar: bool,
43    pub clear_first: bool,
44    pub skip_background_image: bool,
45    /// When true, emit background quads for default-bg cells (fills gaps in background-image mode).
46    /// Set to false in custom shader mode so the shader output shows through.
47    pub fill_default_bg_cells: bool,
48    pub separator_marks: &'a [SeparatorMark],
49    pub pane_background: Option<&'a par_term_config::PaneBackground>,
50}
51
52/// Parameters for building GPU instance buffers for a pane.
53pub(super) struct PaneInstanceBuildParams<'a> {
54    pub viewport: &'a PaneViewport,
55    pub cells: &'a [Cell],
56    pub cols: usize,
57    pub rows: usize,
58    pub cursor_pos: Option<(usize, usize)>,
59    pub cursor_opacity: f32,
60    pub skip_solid_background: bool,
61    pub fill_default_bg_cells: bool,
62    pub separator_marks: &'a [SeparatorMark],
63}
64
65impl CellRenderer {
66    /// Render a single pane's content within a viewport to an existing surface texture
67    ///
68    /// This method renders cells to a specific region of the render target,
69    /// using a GPU scissor rect to clip to the pane bounds.
70    ///
71    /// # Arguments
72    /// * `surface_view` - The texture view to render to
73    /// * `viewport` - The pane's viewport (position, size, focus state, opacity)
74    /// * `cells` - The cells to render (should match viewport grid size)
75    /// * `cols` - Number of columns in the cell grid
76    /// * `rows` - Number of rows in the cell grid
77    /// * `cursor_pos` - Cursor position (col, row) within this pane, or None if no cursor
78    /// * `cursor_opacity` - Cursor opacity (0.0 = hidden, 1.0 = fully visible)
79    /// * `show_scrollbar` - Whether to render the scrollbar for this pane
80    /// * `clear_first` - If true, clears the viewport region before rendering
81    /// * `skip_background_image` - If true, skip rendering the background image. Use this
82    ///   when the background image has already been rendered full-screen (for split panes).
83    pub fn render_pane_to_view(
84        &mut self,
85        surface_view: &wgpu::TextureView,
86        p: PaneRenderViewParams<'_>,
87    ) -> Result<()> {
88        let PaneRenderViewParams {
89            viewport,
90            cells,
91            cols,
92            rows,
93            cursor_pos,
94            cursor_opacity,
95            show_scrollbar,
96            clear_first,
97            skip_background_image,
98            fill_default_bg_cells,
99            separator_marks,
100            pane_background,
101        } = p;
102        // Build instance buffers for this pane's cells.
103        // Returns cursor_overlay_start: the bg_instance index where cursor overlays begin.
104        // Used for 3-phase rendering (bgs → text → cursor overlays).
105        let cursor_overlay_start = self.build_pane_instance_buffers(PaneInstanceBuildParams {
106            viewport,
107            cells,
108            cols,
109            rows,
110            cursor_pos,
111            cursor_opacity,
112            skip_solid_background: skip_background_image,
113            fill_default_bg_cells,
114            separator_marks,
115        })?;
116
117        // Pre-update per-pane background uniform buffer and bind group if needed (must happen
118        // before the render pass). Buffers are allocated once and reused across frames.
119        // Per-pane backgrounds are explicit user overrides and always prepared, even when a
120        // custom shader or global background would normally be skipped.
121        let has_pane_bg = if let Some(pane_bg) = pane_background
122            && let Some(ref path) = pane_bg.image_path
123            && self.bg_state.pane_bg_cache.contains_key(path.as_str())
124        {
125            self.prepare_pane_bg_bind_group(
126                path.as_str(),
127                super::background::PaneBgBindGroupParams {
128                    pane_x: viewport.x,
129                    pane_y: viewport.y,
130                    pane_width: viewport.width,
131                    pane_height: viewport.height,
132                    mode: pane_bg.mode,
133                    opacity: pane_bg.opacity,
134                    darken: pane_bg.darken,
135                },
136            );
137            true
138        } else {
139            false
140        };
141
142        // Retrieve cached path for use in the render pass (must be done before borrow in pass).
143        let pane_bg_path: Option<String> = if has_pane_bg {
144            pane_background
145                .and_then(|pb| pb.image_path.as_ref())
146                .map(|p| p.to_string())
147        } else {
148            None
149        };
150
151        let mut encoder = self
152            .device
153            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
154                label: Some("pane render encoder"),
155            });
156
157        // Determine load operation and clear color
158        let load_op = if clear_first {
159            let clear_color = if self.bg_state.bg_is_solid_color {
160                wgpu::Color {
161                    r: self.bg_state.solid_bg_color[0] as f64
162                        * self.window_opacity as f64
163                        * viewport.opacity as f64,
164                    g: self.bg_state.solid_bg_color[1] as f64
165                        * self.window_opacity as f64
166                        * viewport.opacity as f64,
167                    b: self.bg_state.solid_bg_color[2] as f64
168                        * self.window_opacity as f64
169                        * viewport.opacity as f64,
170                    a: self.window_opacity as f64 * viewport.opacity as f64,
171                }
172            } else {
173                wgpu::Color {
174                    r: self.background_color[0] as f64
175                        * self.window_opacity as f64
176                        * viewport.opacity as f64,
177                    g: self.background_color[1] as f64
178                        * self.window_opacity as f64
179                        * viewport.opacity as f64,
180                    b: self.background_color[2] as f64
181                        * self.window_opacity as f64
182                        * viewport.opacity as f64,
183                    a: self.window_opacity as f64 * viewport.opacity as f64,
184                }
185            };
186            wgpu::LoadOp::Clear(clear_color)
187        } else {
188            wgpu::LoadOp::Load
189        };
190
191        {
192            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
193                label: Some("pane render pass"),
194                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
195                    view: surface_view,
196                    resolve_target: None,
197                    ops: wgpu::Operations {
198                        load: load_op,
199                        store: wgpu::StoreOp::Store,
200                    },
201                    depth_slice: None,
202                })],
203                depth_stencil_attachment: None,
204                timestamp_writes: None,
205                occlusion_query_set: None,
206            });
207
208            // Set scissor rect to clip rendering to pane bounds
209            let (sx, sy, sw, sh) = viewport.to_scissor_rect();
210            render_pass.set_scissor_rect(sx, sy, sw, sh);
211
212            // Render per-pane background image within scissor rect.
213            // Per-pane backgrounds are explicit user overrides and always render,
214            // even when a custom shader or global background is active.
215            if let Some(ref path) = pane_bg_path
216                && let Some(cached) = self.bg_state.pane_bg_uniform_cache.get(path.as_str())
217            {
218                render_pass.set_pipeline(&self.pipelines.bg_image_pipeline);
219                render_pass.set_bind_group(0, &cached.bind_group, &[]);
220                render_pass.set_vertex_buffer(0, self.buffers.vertex_buffer.slice(..));
221                render_pass.draw(0..4, 0..1);
222            }
223
224            self.emit_three_phase_draw_calls(
225                &mut render_pass,
226                cursor_overlay_start as u32,
227                self.buffers.actual_bg_instances as u32,
228            );
229
230            // Render scrollbar if requested (uses its own scissor rect internally)
231            if show_scrollbar {
232                // Reset scissor to full surface for scrollbar
233                render_pass.set_scissor_rect(0, 0, self.config.width, self.config.height);
234                self.scrollbar.render(&mut render_pass);
235            }
236        }
237
238        self.queue.submit(std::iter::once(encoder.finish()));
239        Ok(())
240    }
241
242    /// Build instance buffers for a pane's cells with viewport offset.
243    ///
244    /// Similar to `build_instance_buffers` but adjusts all positions to be relative to the
245    /// viewport origin. Also appends cursor overlay instances (beam bar and hollow borders)
246    /// after the cell background instances.
247    ///
248    /// Returns the index in `bg_instances` where cursor overlays begin (`cursor_overlay_start`).
249    /// The caller uses this for 3-phase rendering: cell bgs, text, then cursor overlays on top.
250    ///
251    /// `skip_solid_background`: if true, skip the solid background fill for the viewport
252    /// (use when a custom shader or background image was already rendered full-screen).
253    fn build_pane_instance_buffers(&mut self, p: PaneInstanceBuildParams<'_>) -> Result<usize> {
254        let PaneInstanceBuildParams {
255            viewport,
256            cells,
257            cols,
258            rows,
259            cursor_pos,
260            cursor_opacity,
261            skip_solid_background,
262            fill_default_bg_cells,
263            separator_marks,
264        } = p;
265        // Clear previous instance buffers
266        for instance in &mut self.bg_instances {
267            instance.size = [0.0, 0.0];
268            instance.color = [0.0, 0.0, 0.0, 0.0];
269        }
270
271        // Add a background rectangle covering the entire pane viewport (unless skipped)
272        // This ensures the pane has a proper background even when cells are skipped.
273        // Skip when a custom shader or background image was already rendered full-screen.
274        let bg_start_index = if !skip_solid_background && !self.bg_instances.is_empty() {
275            let bg_color = self.background_color;
276            let opacity = self.window_opacity * viewport.opacity;
277            let width_f = self.config.width as f32;
278            let height_f = self.config.height as f32;
279            self.bg_instances[0] = super::types::BackgroundInstance {
280                position: [
281                    viewport.x / width_f * 2.0 - 1.0,
282                    1.0 - (viewport.y / height_f * 2.0),
283                ],
284                size: [
285                    viewport.width / width_f * 2.0,
286                    viewport.height / height_f * 2.0,
287                ],
288                color: [
289                    bg_color[0] * opacity,
290                    bg_color[1] * opacity,
291                    bg_color[2] * opacity,
292                    opacity,
293                ],
294            };
295            1 // Start cell backgrounds at index 1
296        } else {
297            0 // Start cell backgrounds at index 0 (no viewport fill)
298        };
299
300        for instance in &mut self.text_instances {
301            instance.size = [0.0, 0.0];
302        }
303
304        // Start at bg_start_index (1 if viewport fill was added, 0 otherwise)
305        let mut bg_index = bg_start_index;
306        let mut text_index = 0;
307
308        // Content offset - positions are relative to content area (with padding applied)
309        let (content_x, content_y) = viewport.content_origin();
310        let opacity_multiplier = viewport.opacity;
311
312        for row in 0..rows {
313            let row_start = row * cols;
314            let row_end = (row + 1) * cols;
315            if row_start >= cells.len() {
316                break;
317            }
318            let row_cells = &cells[row_start..row_end.min(cells.len())];
319
320            // Background - use RLE to merge consecutive cells with same color
321            let mut col = 0;
322            while col < row_cells.len() {
323                let cell = &row_cells[col];
324                let bg_f = color_u8x4_rgb_to_f32(cell.bg_color);
325                let is_default_bg = (bg_f[0] - self.background_color[0]).abs() < 0.001
326                    && (bg_f[1] - self.background_color[1]).abs() < 0.001
327                    && (bg_f[2] - self.background_color[2]).abs() < 0.001;
328
329                // Check for cursor at this position (position check only, no opacity gate)
330                let cursor_at_cell = cursor_pos.is_some_and(|(cx, cy)| cx == col && cy == row)
331                    && !self.cursor.hidden_for_shader;
332                // Hollow cursor (unfocused + Hollow style) must show regardless of blink opacity
333                let render_hollow_here = cursor_at_cell
334                    && !self.is_focused
335                    && self.cursor.unfocused_style == par_term_config::UnfocusedCursorStyle::Hollow;
336                let has_cursor = (cursor_at_cell && cursor_opacity > 0.0) || render_hollow_here;
337
338                // Skip cells with half-block characters (▄/▀).
339                // These are rendered entirely through the text pipeline to avoid
340                // cross-pipeline coordinate seams that cause visible banding.
341                let is_half_block = {
342                    let mut chars = cell.grapheme.chars();
343                    matches!(chars.next(), Some('\u{2580}' | '\u{2584}')) && chars.next().is_none()
344                };
345
346                // Skip default-bg cells only when NOT in background-image/shader mode.
347                // When skip_solid_background is true (background image or custom shader active),
348                // no viewport fill is drawn, so default-bg cells between colored segments would
349                // show the background image through — causing visible gaps/lines in the tmux
350                // status bar. In that mode we render them with the theme background color instead.
351                // Skip default-bg cells unless fill_default_bg_cells is set (background-image mode).
352                // In normal mode: viewport fill quad covers them — no individual quad needed.
353                // In shader mode: shader output must show through — do not paint over it.
354                // In bg-image mode: fill_default_bg_cells=true — render with theme bg color to
355                // close gaps that would otherwise show the background image unexpectedly.
356                if is_half_block || (is_default_bg && !has_cursor && !fill_default_bg_cells) {
357                    col += 1;
358                    continue;
359                }
360
361                // Calculate background color with alpha and pane opacity
362                let bg_alpha =
363                    if self.transparency_affects_only_default_background && !is_default_bg {
364                        1.0
365                    } else {
366                        self.window_opacity
367                    };
368                let pane_alpha = bg_alpha * opacity_multiplier;
369                let mut bg_color = color_u8x4_rgb_to_f32_a(cell.bg_color, pane_alpha);
370
371                // TODO(QA-002/QA-008): extract into `render_cursor_cell()` helper.
372                // Signature would be:
373                //   fn render_cursor_cell(&mut self, col: usize, row: usize,
374                //       content_x: f32, content_y: f32, bg_color: [f32; 4],
375                //       cursor_opacity: f32, render_hollow_here: bool,
376                //       bg_index: &mut usize)
377                // Handle cursor at this position
378                if has_cursor {
379                    use par_term_emu_core_rust::cursor::CursorStyle;
380                    match self.cursor.style {
381                        CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock => {
382                            if !render_hollow_here {
383                                // Solid block cursor: blend cursor color into background
384                                for (bg, &cursor) in
385                                    bg_color.iter_mut().take(3).zip(&self.cursor.color)
386                                {
387                                    *bg = *bg * (1.0 - cursor_opacity) + cursor * cursor_opacity;
388                                }
389                                bg_color[3] = bg_color[3].max(cursor_opacity * opacity_multiplier);
390                            }
391                            // If hollow: keep original background color (outline added as overlay)
392                        }
393                        _ => {}
394                    }
395
396                    // Cursor cell can't be merged
397                    // Snap to pixel boundaries to match text pipeline alignment
398                    let x0 = (content_x + col as f32 * self.grid.cell_width).round();
399                    let x1 = (content_x + (col + 1) as f32 * self.grid.cell_width).round();
400                    let y0 = (content_y + row as f32 * self.grid.cell_height).round();
401                    let y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
402
403                    if bg_index < self.buffers.max_bg_instances {
404                        self.bg_instances[bg_index] = BackgroundInstance {
405                            position: [
406                                x0 / self.config.width as f32 * 2.0 - 1.0,
407                                1.0 - (y0 / self.config.height as f32 * 2.0),
408                            ],
409                            size: [
410                                (x1 - x0) / self.config.width as f32 * 2.0,
411                                (y1 - y0) / self.config.height as f32 * 2.0,
412                            ],
413                            color: bg_color,
414                        };
415                        bg_index += 1;
416                    }
417                    col += 1;
418                    continue;
419                }
420
421                // RLE: Find run of consecutive cells with same background color
422                let start_col = col;
423                let run_color = cell.bg_color;
424                col += 1;
425                while col < row_cells.len() {
426                    let next_cell = &row_cells[col];
427                    let next_cursor_at_cell = cursor_pos
428                        .is_some_and(|(cx, cy)| cx == col && cy == row)
429                        && !self.cursor.hidden_for_shader;
430                    let next_hollow = next_cursor_at_cell
431                        && !self.is_focused
432                        && self.cursor.unfocused_style
433                            == par_term_config::UnfocusedCursorStyle::Hollow;
434                    let next_has_cursor =
435                        (next_cursor_at_cell && cursor_opacity > 0.0) || next_hollow;
436                    let next_is_half_block = {
437                        let mut chars = next_cell.grapheme.chars();
438                        matches!(chars.next(), Some('\u{2580}' | '\u{2584}'))
439                            && chars.next().is_none()
440                    };
441                    if next_cell.bg_color != run_color || next_has_cursor || next_is_half_block {
442                        break;
443                    }
444                    col += 1;
445                }
446                let run_length = col - start_col;
447
448                // Create single quad spanning entire run.
449                // Snap all edges to pixel boundaries to match the text pipeline and
450                // eliminate sub-pixel gaps between adjacent differently-colored cell runs.
451                let x0 = (content_x + start_col as f32 * self.grid.cell_width).round();
452                let x1 =
453                    (content_x + (start_col + run_length) as f32 * self.grid.cell_width).round();
454                let y0 = (content_y + row as f32 * self.grid.cell_height).round();
455                let y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
456
457                // Extend the colored bg quad 1 px under adjacent powerline separator glyphs
458                // to eliminate the dark fringe at their anti-aliased edges.
459                //
460                // Powerline separators with default bg rely on the viewport fill (no BG quad
461                // in normal mode). Their anti-aliased corner/edge pixels blend:
462                //   fg * alpha + dark_fill * (1 - alpha)  →  visible dark fringe
463                // Extending the adjacent colored quad by 1 px underneath changes the blend to:
464                //   fg * alpha + colored * (1 - alpha)  →  seamless transition
465                // The 1 px is small enough to be hidden under the glyph itself.
466                let is_default_bg_cell = |bg: [u8; 4]| -> bool {
467                    let f = color_u8x4_rgb_to_f32(bg);
468                    (f[0] - self.background_color[0]).abs() < 0.001
469                        && (f[1] - self.background_color[1]).abs() < 0.001
470                        && (f[2] - self.background_color[2]).abs() < 0.001
471                };
472                // Extend right if the next cell is any powerline separator with default bg.
473                // Covers anti-aliased left edges and transparent left corners of left-pointing seps.
474                let x1 = if col < row_cells.len()
475                    && matches!(
476                        row_cells[col].grapheme.as_str(),
477                        "\u{E0B0}"
478                            | "\u{E0B1}"
479                            | "\u{E0B2}"
480                            | "\u{E0B3}"
481                            | "\u{E0B4}"
482                            | "\u{E0B5}"
483                            | "\u{E0B6}"
484                            | "\u{E0B7}"
485                    )
486                    && is_default_bg_cell(row_cells[col].bg_color)
487                {
488                    x1 + 1.0
489                } else {
490                    x1
491                };
492                // Extend left if the previous cell is any powerline separator with default bg.
493                // Covers anti-aliased right edges and transparent right corners of right-pointing seps.
494                let x0 = if start_col > 0
495                    && matches!(
496                        row_cells[start_col - 1].grapheme.as_str(),
497                        "\u{E0B0}"
498                            | "\u{E0B1}"
499                            | "\u{E0B2}"
500                            | "\u{E0B3}"
501                            | "\u{E0B4}"
502                            | "\u{E0B5}"
503                            | "\u{E0B6}"
504                            | "\u{E0B7}"
505                    )
506                    && is_default_bg_cell(row_cells[start_col - 1].bg_color)
507                {
508                    x0 - 1.0
509                } else {
510                    x0
511                };
512
513                // In background-image mode (skip_solid_background=true), right-pointing
514                // separator cells (E0B0/E0B1/E0B4/E0B5) are rendered in the RLE path and
515                // their BG quad is drawn AFTER the adjacent colored run's quad. This causes
516                // them to overwrite the 1px EXT-RIGHT extension from the colored run.
517                //
518                // Fix: when this cell IS a right-pointing separator with a colored left
519                // neighbor, trim our own BG quad x0 by 1px so the colored extension stays
520                // visible under the separator's left edge.
521                let x0 = if skip_solid_background
522                    && is_default_bg
523                    && matches!(
524                        row_cells[start_col].grapheme.as_str(),
525                        "\u{E0B0}" | "\u{E0B1}" | "\u{E0B4}" | "\u{E0B5}"
526                    )
527                    && start_col > 0
528                    && !is_default_bg_cell(row_cells[start_col - 1].bg_color)
529                {
530                    x0 + 1.0
531                } else {
532                    x0
533                };
534
535                if bg_index < self.buffers.max_bg_instances {
536                    self.bg_instances[bg_index] = BackgroundInstance {
537                        position: [
538                            x0 / self.config.width as f32 * 2.0 - 1.0,
539                            1.0 - (y0 / self.config.height as f32 * 2.0),
540                        ],
541                        size: [
542                            (x1 - x0) / self.config.width as f32 * 2.0,
543                            (y1 - y0) / self.config.height as f32 * 2.0,
544                        ],
545                        color: bg_color,
546                    };
547                    bg_index += 1;
548                }
549            }
550
551            // Text rendering
552            let natural_line_height =
553                self.font.font_ascent + self.font.font_descent + self.font.font_leading;
554            let vertical_padding = (self.grid.cell_height - natural_line_height).max(0.0) / 2.0;
555            let baseline_y = content_y
556                + (row as f32 * self.grid.cell_height)
557                + vertical_padding
558                + self.font.font_ascent;
559
560            // Compute text alpha - force opaque if keep_text_opaque is enabled
561            let text_alpha = if self.keep_text_opaque {
562                opacity_multiplier // Only apply pane dimming, not window transparency
563            } else {
564                self.window_opacity * opacity_multiplier
565            };
566
567            // Check if this row has the cursor and it's a visible block cursor
568            // (for cursor text color override in split-pane rendering)
569            let cursor_is_block_on_this_row = {
570                use par_term_emu_core_rust::cursor::CursorStyle;
571                cursor_pos.is_some_and(|(_, cy)| cy == row)
572                    && cursor_opacity > 0.0
573                    && !self.cursor.hidden_for_shader
574                    && matches!(
575                        self.cursor.style,
576                        CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock
577                    )
578                    && (self.is_focused
579                        || self.cursor.unfocused_style
580                            == par_term_config::UnfocusedCursorStyle::Same)
581            };
582
583            for (col_idx, cell) in row_cells.iter().enumerate() {
584                if cell.wide_char_spacer || cell.grapheme == " " {
585                    continue;
586                }
587
588                let chars: Vec<char> = cell.grapheme.chars().collect();
589                if chars.is_empty() {
590                    continue;
591                }
592
593                let ch = chars[0];
594
595                // Determine text color - apply cursor_text_color (or auto-contrast) when the
596                // block cursor is on this cell, otherwise use the cell's foreground color.
597                let render_fg_color: [f32; 4] = if cursor_is_block_on_this_row
598                    && cursor_pos.is_some_and(|(cx, _)| cx == col_idx)
599                {
600                    if let Some(cursor_text) = self.cursor.text_color {
601                        [cursor_text[0], cursor_text[1], cursor_text[2], text_alpha]
602                    } else {
603                        let cursor_brightness =
604                            (self.cursor.color[0] + self.cursor.color[1] + self.cursor.color[2])
605                                / 3.0;
606                        if cursor_brightness > CURSOR_BRIGHTNESS_THRESHOLD {
607                            [0.0, 0.0, 0.0, text_alpha]
608                        } else {
609                            [1.0, 1.0, 1.0, text_alpha]
610                        }
611                    }
612                } else {
613                    color_u8x4_rgb_to_f32_a(cell.fg_color, text_alpha)
614                };
615
616                // TODO(QA-002/QA-008): extract into `render_block_char()` helper.
617                // The block char path returns early via `continue` — the helper would
618                // return `bool` (true = rendered, caller should continue) and write
619                // directly into `self.text_instances[text_index]`.
620                // Check for block characters that should be rendered geometrically
621                let char_type = block_chars::classify_char(ch);
622                if chars.len() == 1 && block_chars::should_render_geometrically(char_type) {
623                    let char_w = if cell.wide_char {
624                        self.grid.cell_width * 2.0
625                    } else {
626                        self.grid.cell_width
627                    };
628                    let x0 = (content_x + col_idx as f32 * self.grid.cell_width).round();
629                    let y0 = (content_y + row as f32 * self.grid.cell_height).round();
630                    let y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
631                    let snapped_cell_height = y1 - y0;
632
633                    // Try box drawing geometry first
634                    let aspect_ratio = snapped_cell_height / char_w;
635                    if let Some(box_geo) = block_chars::get_box_drawing_geometry(ch, aspect_ratio) {
636                        for segment in &box_geo.segments {
637                            let rect = segment
638                                .to_pixel_rect(x0, y0, char_w, snapped_cell_height)
639                                .snap_to_pixels();
640
641                            // Extension for seamless lines
642                            let extension = 1.0;
643                            let ext_x = if segment.x <= 0.01 { extension } else { 0.0 };
644                            let ext_y = if segment.y <= 0.01 { extension } else { 0.0 };
645                            let ext_w = if segment.x + segment.width >= 0.99 {
646                                extension
647                            } else {
648                                0.0
649                            };
650                            let ext_h = if segment.y + segment.height >= 0.99 {
651                                extension
652                            } else {
653                                0.0
654                            };
655
656                            let final_x = rect.x - ext_x;
657                            let final_y = rect.y - ext_y;
658                            let final_w = rect.width + ext_x + ext_w;
659                            let final_h = rect.height + ext_y + ext_h;
660
661                            if text_index < self.buffers.max_text_instances {
662                                self.text_instances[text_index] = TextInstance {
663                                    position: [
664                                        final_x / self.config.width as f32 * 2.0 - 1.0,
665                                        1.0 - (final_y / self.config.height as f32 * 2.0),
666                                    ],
667                                    size: [
668                                        final_w / self.config.width as f32 * 2.0,
669                                        final_h / self.config.height as f32 * 2.0,
670                                    ],
671                                    tex_offset: [
672                                        self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
673                                        self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
674                                    ],
675                                    tex_size: [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE],
676                                    color: render_fg_color,
677                                    is_colored: 0,
678                                };
679                                text_index += 1;
680                            }
681                        }
682                        continue;
683                    }
684
685                    // Half-block characters (▄/▀): render BOTH halves through the
686                    // text pipeline to eliminate cross-pipeline coordinate seams.
687                    // Use snapped cell edges (no extensions) for seamless tiling.
688                    if ch == '\u{2584}' || ch == '\u{2580}' {
689                        let x1 = (content_x + (col_idx + 1) as f32 * self.grid.cell_width).round();
690                        let cell_w = x1 - x0;
691                        let y_mid = y0 + self.grid.cell_height / 2.0;
692
693                        let bg_half_color = color_u8x4_rgb_to_f32_a(cell.bg_color, text_alpha);
694                        let (top_color, bottom_color) = if ch == '\u{2584}' {
695                            (bg_half_color, render_fg_color) // ▄: top=bg, bottom=fg
696                        } else {
697                            (render_fg_color, bg_half_color) // ▀: top=fg, bottom=bg
698                        };
699
700                        let tex_offset = [
701                            self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
702                            self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
703                        ];
704                        let tex_size = [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE];
705
706                        // Top half: [y0, y_mid)
707                        if text_index < self.buffers.max_text_instances {
708                            self.text_instances[text_index] = TextInstance {
709                                position: [
710                                    x0 / self.config.width as f32 * 2.0 - 1.0,
711                                    1.0 - (y0 / self.config.height as f32 * 2.0),
712                                ],
713                                size: [
714                                    cell_w / self.config.width as f32 * 2.0,
715                                    (y_mid - y0) / self.config.height as f32 * 2.0,
716                                ],
717                                tex_offset,
718                                tex_size,
719                                color: top_color,
720                                is_colored: 0,
721                            };
722                            text_index += 1;
723                        }
724
725                        // Bottom half: [y_mid, y1)
726                        if text_index < self.buffers.max_text_instances {
727                            self.text_instances[text_index] = TextInstance {
728                                position: [
729                                    x0 / self.config.width as f32 * 2.0 - 1.0,
730                                    1.0 - (y_mid / self.config.height as f32 * 2.0),
731                                ],
732                                size: [
733                                    cell_w / self.config.width as f32 * 2.0,
734                                    (y1 - y_mid) / self.config.height as f32 * 2.0,
735                                ],
736                                tex_offset,
737                                tex_size,
738                                color: bottom_color,
739                                is_colored: 0,
740                            };
741                            text_index += 1;
742                        }
743                        continue;
744                    }
745
746                    // Try block element geometry
747                    if let Some(geo_block) = block_chars::get_geometric_block(ch) {
748                        let rect = geo_block.to_pixel_rect(x0, y0, char_w, self.grid.cell_height);
749
750                        // Add small extension to prevent gaps (1 pixel overlap).
751                        let extension = 1.0;
752                        let ext_x = if geo_block.x == 0.0 { extension } else { 0.0 };
753                        let ext_y = if geo_block.y == 0.0 { extension } else { 0.0 };
754                        let ext_w = if geo_block.x + geo_block.width >= 1.0 {
755                            extension
756                        } else {
757                            0.0
758                        };
759                        let ext_h = if geo_block.y + geo_block.height >= 1.0 {
760                            extension
761                        } else {
762                            0.0
763                        };
764
765                        let final_x = rect.x - ext_x;
766                        let final_y = rect.y - ext_y;
767                        let final_w = rect.width + ext_x + ext_w;
768                        let final_h = rect.height + ext_y + ext_h;
769
770                        if text_index < self.buffers.max_text_instances {
771                            self.text_instances[text_index] = TextInstance {
772                                position: [
773                                    final_x / self.config.width as f32 * 2.0 - 1.0,
774                                    1.0 - (final_y / self.config.height as f32 * 2.0),
775                                ],
776                                size: [
777                                    final_w / self.config.width as f32 * 2.0,
778                                    final_h / self.config.height as f32 * 2.0,
779                                ],
780                                tex_offset: [
781                                    self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
782                                    self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
783                                ],
784                                tex_size: [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE],
785                                color: render_fg_color,
786                                is_colored: 0,
787                            };
788                            text_index += 1;
789                        }
790                        continue;
791                    }
792                }
793
794                // Check if this character should be rendered as a monochrome symbol.
795                // Also handle symbol + VS16 (U+FE0F): strip VS16, render monochrome.
796                let (force_monochrome, base_char) = if chars.len() == 1 {
797                    (super::atlas::should_render_as_symbol(ch), ch)
798                } else if chars.len() == 2
799                    && chars[1] == '\u{FE0F}'
800                    && super::atlas::should_render_as_symbol(chars[0])
801                {
802                    (true, chars[0])
803                } else {
804                    (false, ch)
805                };
806
807                // TODO(QA-002/QA-008): extract into `resolve_glyph_with_fallback()` helper.
808                // The loop iterates over font fallbacks until a rasterizable glyph is found.
809                // Signature would be:
810                //   fn resolve_glyph_with_fallback(&mut self, cell: &Cell, base_char: char,
811                //       force_monochrome: bool) -> Option<GlyphInfo>
812                // Regular glyph rendering — use single-char lookup when force_monochrome
813                // strips VS16, otherwise grapheme-aware lookup for multi-char sequences.
814                let mut glyph_result = if force_monochrome || chars.len() == 1 {
815                    self.font_manager
816                        .find_glyph(base_char, cell.bold, cell.italic)
817                } else {
818                    self.font_manager
819                        .find_grapheme_glyph(&cell.grapheme, cell.bold, cell.italic)
820                };
821
822                // Try to find a renderable glyph with font fallback for failures.
823                let mut excluded_fonts: Vec<usize> = Vec::new();
824                let resolved_info = loop {
825                    match glyph_result {
826                        Some((font_idx, glyph_id)) => {
827                            let cache_key = ((font_idx as u64) << 32) | (glyph_id as u64);
828                            if let Some(info) = self.get_or_rasterize_glyph(
829                                font_idx,
830                                glyph_id,
831                                force_monochrome,
832                                cache_key,
833                            ) {
834                                break Some(info);
835                            }
836                            // Rasterization failed — try next font
837                            excluded_fonts.push(font_idx);
838                            glyph_result = self.font_manager.find_glyph_excluding(
839                                base_char,
840                                cell.bold,
841                                cell.italic,
842                                &excluded_fonts,
843                            );
844                        }
845                        None => break None,
846                    }
847                };
848
849                // Last resort: colored emoji when no font has vector outlines
850                let resolved_info = if resolved_info.is_none() && force_monochrome {
851                    let mut glyph_result2 =
852                        self.font_manager
853                            .find_glyph(base_char, cell.bold, cell.italic);
854                    loop {
855                        match glyph_result2 {
856                            Some((font_idx, glyph_id)) => {
857                                // Bit 63 distinguishes the colored-fallback cache entry.
858                                let cache_key =
859                                    ((font_idx as u64) << 32) | (glyph_id as u64) | (1u64 << 63);
860                                if let Some(info) = self
861                                    .get_or_rasterize_glyph(font_idx, glyph_id, false, cache_key)
862                                {
863                                    break Some(info);
864                                }
865                                glyph_result2 = self.font_manager.find_glyph_excluding(
866                                    base_char,
867                                    cell.bold,
868                                    cell.italic,
869                                    &[font_idx],
870                                );
871                            }
872                            None => break None,
873                        }
874                    }
875                } else {
876                    resolved_info
877                };
878
879                if let Some(info) = resolved_info {
880                    let char_w = if cell.wide_char {
881                        self.grid.cell_width * 2.0
882                    } else {
883                        self.grid.cell_width
884                    };
885                    let x0 = content_x + col_idx as f32 * self.grid.cell_width;
886                    let y0 = content_y + row as f32 * self.grid.cell_height;
887                    let x1 = x0 + char_w;
888                    let y1 = y0 + self.grid.cell_height;
889
890                    let cell_w = x1 - x0;
891                    let cell_h = y1 - y0;
892                    let scale_x = cell_w / char_w;
893                    let scale_y = cell_h / self.grid.cell_height;
894
895                    let baseline_offset =
896                        baseline_y - (content_y + row as f32 * self.grid.cell_height);
897                    let glyph_left = x0 + (info.bearing_x * scale_x).round();
898                    let baseline_in_cell = (baseline_offset * scale_y).round();
899                    let glyph_top = y0 + baseline_in_cell - info.bearing_y;
900
901                    let render_w = info.width as f32 * scale_x;
902                    let render_h = info.height as f32 * scale_y;
903
904                    let (final_left, final_top, final_w, final_h) =
905                        if chars.len() == 1 && block_chars::should_snap_to_boundaries(char_type) {
906                            block_chars::snap_glyph_to_cell(block_chars::SnapGlyphParams {
907                                glyph_left,
908                                glyph_top,
909                                render_w,
910                                render_h,
911                                cell_x0: x0,
912                                cell_y0: y0,
913                                cell_x1: x1,
914                                cell_y1: y1,
915                                snap_threshold: 3.0,
916                                extension: 0.5,
917                            })
918                        } else {
919                            (glyph_left, glyph_top, render_w, render_h)
920                        };
921
922                    if text_index < self.buffers.max_text_instances {
923                        self.text_instances[text_index] = TextInstance {
924                            position: [
925                                final_left / self.config.width as f32 * 2.0 - 1.0,
926                                1.0 - (final_top / self.config.height as f32 * 2.0),
927                            ],
928                            size: [
929                                final_w / self.config.width as f32 * 2.0,
930                                final_h / self.config.height as f32 * 2.0,
931                            ],
932                            tex_offset: [info.x as f32 / ATLAS_SIZE, info.y as f32 / ATLAS_SIZE],
933                            tex_size: [
934                                info.width as f32 / ATLAS_SIZE,
935                                info.height as f32 / ATLAS_SIZE,
936                            ],
937                            color: render_fg_color,
938                            is_colored: if info.is_colored { 1 } else { 0 },
939                        };
940                        text_index += 1;
941                    }
942                }
943            }
944
945            // Underlines: emit a thin rectangle at the bottom of each underlined cell.
946            // Mirrors the logic in text_instance_builder.rs but uses pane-local coordinates.
947            {
948                let underline_thickness = (self.grid.cell_height * UNDERLINE_HEIGHT_RATIO)
949                    .max(1.0)
950                    .round();
951                let tex_offset = [
952                    self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
953                    self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
954                ];
955                let tex_size = [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE];
956                let y0 = content_y + (row + 1) as f32 * self.grid.cell_height - underline_thickness;
957                let ndc_y = 1.0 - (y0 / self.config.height as f32 * 2.0);
958                let ndc_h = underline_thickness / self.config.height as f32 * 2.0;
959                let is_stipple =
960                    self.link_underline_style == par_term_config::LinkUnderlineStyle::Stipple;
961                let stipple_period = STIPPLE_ON_PX + STIPPLE_OFF_PX;
962
963                for col_idx in 0..cols {
964                    if row_start + col_idx >= cells.len() {
965                        break;
966                    }
967                    let cell = &cells[row_start + col_idx];
968                    if !cell.underline {
969                        continue;
970                    }
971                    let fg = color_u8x4_rgb_to_f32_a(cell.fg_color, text_alpha);
972                    let cell_x0 = content_x + col_idx as f32 * self.grid.cell_width;
973
974                    if is_stipple {
975                        let mut px = 0.0;
976                        while px < self.grid.cell_width
977                            && text_index < self.buffers.max_text_instances
978                        {
979                            let seg_w = STIPPLE_ON_PX.min(self.grid.cell_width - px);
980                            let x = cell_x0 + px;
981                            self.text_instances[text_index] = TextInstance {
982                                position: [x / self.config.width as f32 * 2.0 - 1.0, ndc_y],
983                                size: [seg_w / self.config.width as f32 * 2.0, ndc_h],
984                                tex_offset,
985                                tex_size,
986                                color: fg,
987                                is_colored: 0,
988                            };
989                            text_index += 1;
990                            px += stipple_period;
991                        }
992                    } else if text_index < self.buffers.max_text_instances {
993                        self.text_instances[text_index] = TextInstance {
994                            position: [cell_x0 / self.config.width as f32 * 2.0 - 1.0, ndc_y],
995                            size: [self.grid.cell_width / self.config.width as f32 * 2.0, ndc_h],
996                            tex_offset,
997                            tex_size,
998                            color: fg,
999                            is_colored: 0,
1000                        };
1001                        text_index += 1;
1002                    }
1003                }
1004            }
1005        }
1006
1007        // Inject command separator line instances — see separators.rs
1008        bg_index = self.emit_separator_instances(
1009            separator_marks,
1010            cols,
1011            rows,
1012            content_x,
1013            content_y,
1014            opacity_multiplier,
1015            bg_index,
1016        );
1017
1018        // --- Cursor overlays (beam/underline bar + hollow borders) ---
1019        // These are rendered in Phase 3 (on top of text) via the 3-phase draw in render_pane_to_view.
1020        // Record where cursor overlays start — everything after this index is an overlay.
1021        let cursor_overlay_start = bg_index;
1022
1023        if let Some((cursor_col, cursor_row)) = cursor_pos {
1024            let cursor_x0 = content_x + cursor_col as f32 * self.grid.cell_width;
1025            let cursor_x1 = cursor_x0 + self.grid.cell_width;
1026            let cursor_y0 = (content_y + cursor_row as f32 * self.grid.cell_height).round();
1027            let cursor_y1 = (content_y + (cursor_row + 1) as f32 * self.grid.cell_height).round();
1028
1029            // Emit guide, shadow, beam/underline bar, hollow outline — see cursor_overlays.rs
1030            bg_index = self.emit_cursor_overlays(
1031                CursorOverlayParams {
1032                    cursor_x0,
1033                    cursor_x1,
1034                    cursor_y0,
1035                    cursor_y1,
1036                    cols,
1037                    content_x,
1038                    cursor_opacity,
1039                },
1040                bg_index,
1041            );
1042        }
1043
1044        // Update actual instance counts for draw calls
1045        self.buffers.actual_bg_instances = bg_index;
1046        self.buffers.actual_text_instances = text_index;
1047
1048        // Upload instance buffers to GPU
1049        self.queue.write_buffer(
1050            &self.buffers.bg_instance_buffer,
1051            0,
1052            bytemuck::cast_slice(&self.bg_instances),
1053        );
1054        self.queue.write_buffer(
1055            &self.buffers.text_instance_buffer,
1056            0,
1057            bytemuck::cast_slice(&self.text_instances),
1058        );
1059
1060        Ok(cursor_overlay_start)
1061    }
1062}