Skip to main content

par_term_render/cell_renderer/pane_render/
mod.rs

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