Skip to main content

par_term_render/cell_renderer/
render.rs

1use super::block_chars;
2use super::{BackgroundInstance, Cell, CellRenderer, PaneViewport, RowCacheEntry, TextInstance};
3use anyhow::Result;
4use par_term_config::SeparatorMark;
5use par_term_fonts::text_shaper::ShapingOptions;
6
7impl CellRenderer {
8    pub fn render(
9        &mut self,
10        _show_scrollbar: bool,
11        pane_background: Option<&par_term_config::PaneBackground>,
12    ) -> Result<wgpu::SurfaceTexture> {
13        let output = self.surface.get_current_texture()?;
14        let view = output
15            .texture
16            .create_view(&wgpu::TextureViewDescriptor::default());
17        self.build_instance_buffers()?;
18
19        // Pre-create per-pane background bind group if needed (must happen before render pass)
20        // This supports pane 0 background in single-pane (no splits) mode.
21        let pane_bg_resources = if !self.bg_is_solid_color {
22            if let Some(pane_bg) = pane_background {
23                if let Some(ref path) = pane_bg.image_path {
24                    self.pane_bg_cache.get(path.as_str()).map(|entry| {
25                        self.create_pane_bg_bind_group(
26                            entry,
27                            0.0, // pane_x: full window starts at 0
28                            0.0, // pane_y: full window starts at 0
29                            self.config.width as f32,
30                            self.config.height as f32,
31                            pane_bg.mode,
32                            pane_bg.opacity,
33                            pane_bg.darken,
34                        )
35                    })
36                } else {
37                    None
38                }
39            } else {
40                None
41            }
42        } else {
43            None
44        };
45
46        let mut encoder = self
47            .device
48            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
49                label: Some("render encoder"),
50            });
51
52        // Determine clear color and whether to use bg_image pipeline:
53        // - Solid color mode: use clear color directly (same as Default mode for proper transparency)
54        // - Image mode: use TRANSPARENT clear, let bg_image_pipeline handle background
55        // - Default mode: use theme background with window_opacity
56        // - Per-pane bg: use TRANSPARENT clear, render pane bg before global bg
57        let has_pane_bg = pane_bg_resources.is_some();
58        let (clear_color, use_bg_image_pipeline) = if has_pane_bg {
59            // Per-pane background: use transparent clear, pane bg will be rendered first
60            (wgpu::Color::TRANSPARENT, false)
61        } else if self.bg_is_solid_color {
62            // Solid color mode: use clear color directly for proper window transparency
63            // This works the same as Default mode - LoadOp::Clear sets alpha correctly
64            log::info!(
65                "[BACKGROUND] Solid color mode: RGB({:.3}, {:.3}, {:.3}) * opacity {:.3}",
66                self.solid_bg_color[0],
67                self.solid_bg_color[1],
68                self.solid_bg_color[2],
69                self.window_opacity
70            );
71            (
72                wgpu::Color {
73                    r: self.solid_bg_color[0] as f64 * self.window_opacity as f64,
74                    g: self.solid_bg_color[1] as f64 * self.window_opacity as f64,
75                    b: self.solid_bg_color[2] as f64 * self.window_opacity as f64,
76                    a: self.window_opacity as f64,
77                },
78                false,
79            )
80        } else if self.bg_image_bind_group.is_some() {
81            // Image mode: use TRANSPARENT, let bg_image_pipeline handle background
82            (wgpu::Color::TRANSPARENT, true)
83        } else {
84            // Default mode: use theme background with window_opacity
85            (
86                wgpu::Color {
87                    r: self.background_color[0] as f64 * self.window_opacity as f64,
88                    g: self.background_color[1] as f64 * self.window_opacity as f64,
89                    b: self.background_color[2] as f64 * self.window_opacity as f64,
90                    a: self.window_opacity as f64,
91                },
92                false,
93            )
94        };
95
96        {
97            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
98                label: Some("render pass"),
99                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
100                    view: &view,
101                    resolve_target: None,
102                    ops: wgpu::Operations {
103                        load: wgpu::LoadOp::Clear(clear_color),
104                        store: wgpu::StoreOp::Store,
105                    },
106                    depth_slice: None,
107                })],
108                depth_stencil_attachment: None,
109                timestamp_writes: None,
110                occlusion_query_set: None,
111            });
112
113            // Render per-pane background for single-pane mode (pane 0)
114            if let Some((ref bind_group, _)) = pane_bg_resources {
115                render_pass.set_pipeline(&self.bg_image_pipeline);
116                render_pass.set_bind_group(0, bind_group, &[]);
117                render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
118                render_pass.draw(0..4, 0..1);
119            }
120
121            // Render global background image if present (not used for solid color or pane bg mode)
122            if use_bg_image_pipeline && let Some(ref bg_bind_group) = self.bg_image_bind_group {
123                render_pass.set_pipeline(&self.bg_image_pipeline);
124                render_pass.set_bind_group(0, bg_bind_group, &[]);
125                render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
126                render_pass.draw(0..4, 0..1);
127            }
128
129            render_pass.set_pipeline(&self.bg_pipeline);
130            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
131            render_pass.set_vertex_buffer(1, self.bg_instance_buffer.slice(..));
132            render_pass.draw(0..4, 0..self.max_bg_instances as u32);
133
134            render_pass.set_pipeline(&self.text_pipeline);
135            render_pass.set_bind_group(0, &self.text_bind_group, &[]);
136            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
137            render_pass.set_vertex_buffer(1, self.text_instance_buffer.slice(..));
138            render_pass.draw(0..4, 0..self.max_text_instances as u32);
139        }
140
141        self.queue.submit(std::iter::once(encoder.finish()));
142        Ok(output)
143    }
144
145    /// Render terminal content to an intermediate texture for shader processing.
146    ///
147    /// # Arguments
148    /// * `target_view` - The texture view to render to
149    /// * `skip_background_image` - If true, skip rendering the background image. Use this when
150    ///   a custom shader will handle the background image via iChannel0 instead.
151    ///
152    /// Note: Solid color backgrounds are NOT rendered here. For cursor shaders, the solid color
153    /// is passed to the shader's render function as the clear color instead.
154    pub fn render_to_texture(
155        &mut self,
156        target_view: &wgpu::TextureView,
157        skip_background_image: bool,
158    ) -> Result<wgpu::SurfaceTexture> {
159        let output = self.surface.get_current_texture()?;
160        self.build_instance_buffers()?;
161
162        // Only render background IMAGE to intermediate texture (not solid color).
163        // Solid colors are handled by the shader's clear color for proper compositing.
164        let render_background_image =
165            !skip_background_image && !self.bg_is_solid_color && self.bg_image_bind_group.is_some();
166        let saved_window_opacity = self.window_opacity;
167
168        if render_background_image {
169            // Temporarily set window_opacity to 1.0 for the background render
170            // The shader wrapper will apply window_opacity at the end
171            self.window_opacity = 1.0;
172            self.update_bg_image_uniforms();
173        }
174
175        let mut encoder = self
176            .device
177            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
178                label: Some("render to texture encoder"),
179            });
180
181        // Always clear with TRANSPARENT for intermediate textures
182        let clear_color = wgpu::Color::TRANSPARENT;
183
184        {
185            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
186                label: Some("render pass"),
187                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
188                    view: target_view,
189                    resolve_target: None,
190                    ops: wgpu::Operations {
191                        load: wgpu::LoadOp::Clear(clear_color),
192                        store: wgpu::StoreOp::Store,
193                    },
194                    depth_slice: None,
195                })],
196                depth_stencil_attachment: None,
197                timestamp_writes: None,
198                occlusion_query_set: None,
199            });
200
201            // Render background IMAGE (not solid color) via bg_image_pipeline at full opacity
202            if render_background_image && let Some(ref bg_bind_group) = self.bg_image_bind_group {
203                log::info!(
204                    "[BACKGROUND] render_to_texture: bg_image_pipeline (image, window_opacity={:.3} applied by shader)",
205                    saved_window_opacity
206                );
207                render_pass.set_pipeline(&self.bg_image_pipeline);
208                render_pass.set_bind_group(0, bg_bind_group, &[]);
209                render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
210                render_pass.draw(0..4, 0..1);
211            }
212
213            render_pass.set_pipeline(&self.bg_pipeline);
214            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
215            render_pass.set_vertex_buffer(1, self.bg_instance_buffer.slice(..));
216            render_pass.draw(0..4, 0..self.max_bg_instances as u32);
217
218            render_pass.set_pipeline(&self.text_pipeline);
219            render_pass.set_bind_group(0, &self.text_bind_group, &[]);
220            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
221            render_pass.set_vertex_buffer(1, self.text_instance_buffer.slice(..));
222            render_pass.draw(0..4, 0..self.max_text_instances as u32);
223        }
224
225        self.queue.submit(std::iter::once(encoder.finish()));
226
227        // Restore window_opacity and update uniforms
228        if render_background_image {
229            self.window_opacity = saved_window_opacity;
230            self.update_bg_image_uniforms();
231        }
232
233        Ok(output)
234    }
235
236    /// Render only the background (image or solid color) to a view.
237    ///
238    /// This is useful for split pane rendering where the background should be
239    /// rendered once full-screen before rendering each pane's cells on top.
240    ///
241    /// # Arguments
242    /// * `target_view` - The texture view to render to
243    /// * `clear_first` - If true, clear the surface before rendering
244    ///
245    /// # Returns
246    /// `true` if a background image was rendered, `false` if only clear color was used
247    pub fn render_background_only(
248        &self,
249        target_view: &wgpu::TextureView,
250        clear_first: bool,
251    ) -> Result<bool> {
252        let mut encoder = self
253            .device
254            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
255                label: Some("background only encoder"),
256            });
257
258        // Determine clear color and whether to use bg_image pipeline
259        let (clear_color, use_bg_image_pipeline) = if self.bg_is_solid_color {
260            (
261                wgpu::Color {
262                    r: self.solid_bg_color[0] as f64 * self.window_opacity as f64,
263                    g: self.solid_bg_color[1] as f64 * self.window_opacity as f64,
264                    b: self.solid_bg_color[2] as f64 * self.window_opacity as f64,
265                    a: self.window_opacity as f64,
266                },
267                false,
268            )
269        } else if self.bg_image_bind_group.is_some() {
270            (wgpu::Color::TRANSPARENT, true)
271        } else {
272            (
273                wgpu::Color {
274                    r: self.background_color[0] as f64 * self.window_opacity as f64,
275                    g: self.background_color[1] as f64 * self.window_opacity as f64,
276                    b: self.background_color[2] as f64 * self.window_opacity as f64,
277                    a: self.window_opacity as f64,
278                },
279                false,
280            )
281        };
282
283        let load_op = if clear_first {
284            wgpu::LoadOp::Clear(clear_color)
285        } else {
286            wgpu::LoadOp::Load
287        };
288
289        {
290            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
291                label: Some("background only render pass"),
292                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
293                    view: target_view,
294                    resolve_target: None,
295                    ops: wgpu::Operations {
296                        load: load_op,
297                        store: wgpu::StoreOp::Store,
298                    },
299                    depth_slice: None,
300                })],
301                depth_stencil_attachment: None,
302                timestamp_writes: None,
303                occlusion_query_set: None,
304            });
305
306            // Render background image if present
307            if use_bg_image_pipeline && let Some(ref bg_bind_group) = self.bg_image_bind_group {
308                render_pass.set_pipeline(&self.bg_image_pipeline);
309                render_pass.set_bind_group(0, bg_bind_group, &[]);
310                render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
311                render_pass.draw(0..4, 0..1);
312            }
313        }
314
315        self.queue.submit(std::iter::once(encoder.finish()));
316        Ok(use_bg_image_pipeline)
317    }
318
319    /// Render terminal content to a view for screenshots.
320    /// This renders without requiring the surface texture.
321    pub fn render_to_view(&self, target_view: &wgpu::TextureView) -> Result<()> {
322        // Note: We don't rebuild instance buffers here since this is typically called
323        // right after a normal render, and the buffers should already be up to date.
324
325        let mut encoder = self
326            .device
327            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
328                label: Some("screenshot render encoder"),
329            });
330
331        // Determine clear color and whether to use bg_image pipeline
332        let (clear_color, use_bg_image_pipeline) = if self.bg_is_solid_color {
333            (
334                wgpu::Color {
335                    r: self.solid_bg_color[0] as f64 * self.window_opacity as f64,
336                    g: self.solid_bg_color[1] as f64 * self.window_opacity as f64,
337                    b: self.solid_bg_color[2] as f64 * self.window_opacity as f64,
338                    a: self.window_opacity as f64,
339                },
340                false,
341            )
342        } else if self.bg_image_bind_group.is_some() {
343            (wgpu::Color::TRANSPARENT, true)
344        } else {
345            (
346                wgpu::Color {
347                    r: self.background_color[0] as f64 * self.window_opacity as f64,
348                    g: self.background_color[1] as f64 * self.window_opacity as f64,
349                    b: self.background_color[2] as f64 * self.window_opacity as f64,
350                    a: self.window_opacity as f64,
351                },
352                false,
353            )
354        };
355
356        {
357            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
358                label: Some("screenshot render pass"),
359                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
360                    view: target_view,
361                    resolve_target: None,
362                    ops: wgpu::Operations {
363                        load: wgpu::LoadOp::Clear(clear_color),
364                        store: wgpu::StoreOp::Store,
365                    },
366                    depth_slice: None,
367                })],
368                depth_stencil_attachment: None,
369                timestamp_writes: None,
370                occlusion_query_set: None,
371            });
372
373            // Render background image if present
374            if use_bg_image_pipeline && let Some(ref bg_bind_group) = self.bg_image_bind_group {
375                render_pass.set_pipeline(&self.bg_image_pipeline);
376                render_pass.set_bind_group(0, bg_bind_group, &[]);
377                render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
378                render_pass.draw(0..4, 0..1);
379            }
380
381            render_pass.set_pipeline(&self.bg_pipeline);
382            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
383            render_pass.set_vertex_buffer(1, self.bg_instance_buffer.slice(..));
384            render_pass.draw(0..4, 0..self.max_bg_instances as u32);
385
386            render_pass.set_pipeline(&self.text_pipeline);
387            render_pass.set_bind_group(0, &self.text_bind_group, &[]);
388            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
389            render_pass.set_vertex_buffer(1, self.text_instance_buffer.slice(..));
390            render_pass.draw(0..4, 0..self.max_text_instances as u32);
391
392            // Render scrollbar
393            self.scrollbar.render(&mut render_pass);
394        }
395
396        self.queue.submit(std::iter::once(encoder.finish()));
397        Ok(())
398    }
399
400    pub fn render_overlays(
401        &mut self,
402        surface_texture: &wgpu::SurfaceTexture,
403        show_scrollbar: bool,
404    ) -> Result<()> {
405        let view = surface_texture
406            .texture
407            .create_view(&wgpu::TextureViewDescriptor::default());
408        let mut encoder = self
409            .device
410            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
411                label: Some("overlay encoder"),
412            });
413
414        {
415            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
416                label: Some("overlay pass"),
417                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
418                    view: &view,
419                    resolve_target: None,
420                    ops: wgpu::Operations {
421                        load: wgpu::LoadOp::Load,
422                        store: wgpu::StoreOp::Store,
423                    },
424                    depth_slice: None,
425                })],
426                depth_stencil_attachment: None,
427                timestamp_writes: None,
428                occlusion_query_set: None,
429            });
430
431            if show_scrollbar {
432                self.scrollbar.render(&mut render_pass);
433            }
434
435            if self.visual_bell_intensity > 0.0 {
436                // Visual bell logic
437            }
438        }
439
440        self.queue.submit(std::iter::once(encoder.finish()));
441        Ok(())
442    }
443
444    pub(crate) fn build_instance_buffers(&mut self) -> Result<()> {
445        let _shaping_options = ShapingOptions {
446            enable_ligatures: self.enable_ligatures,
447            enable_kerning: self.enable_kerning,
448            ..Default::default()
449        };
450
451        for row in 0..self.rows {
452            if self.dirty_rows[row] || self.row_cache[row].is_none() {
453                let start = row * self.cols;
454                let end = (row + 1) * self.cols;
455                let row_cells = &self.cells[start..end];
456
457                let mut row_bg = Vec::with_capacity(self.cols);
458                let mut row_text = Vec::with_capacity(self.cols);
459
460                // Background - use RLE to merge consecutive cells with same color (like iTerm2)
461                // This eliminates seams between adjacent same-colored cells
462                let mut col = 0;
463                while col < row_cells.len() {
464                    let cell = &row_cells[col];
465                    let is_default_bg =
466                        (cell.bg_color[0] as f32 / 255.0 - self.background_color[0]).abs() < 0.001
467                            && (cell.bg_color[1] as f32 / 255.0 - self.background_color[1]).abs()
468                                < 0.001
469                            && (cell.bg_color[2] as f32 / 255.0 - self.background_color[2]).abs()
470                                < 0.001;
471
472                    // Check for cursor at this position, accounting for unfocused state
473                    let cursor_visible = self.cursor_opacity > 0.0
474                        && !self.cursor_hidden_for_shader
475                        && self.cursor_pos.1 == row
476                        && self.cursor_pos.0 == col;
477
478                    // Handle unfocused cursor visibility
479                    let has_cursor = if cursor_visible && !self.is_focused {
480                        match self.unfocused_cursor_style {
481                            par_term_config::UnfocusedCursorStyle::Hidden => false,
482                            par_term_config::UnfocusedCursorStyle::Hollow
483                            | par_term_config::UnfocusedCursorStyle::Same => true,
484                        }
485                    } else {
486                        cursor_visible
487                    };
488
489                    if is_default_bg && !has_cursor {
490                        col += 1;
491                        continue;
492                    }
493
494                    // Calculate background color with alpha
495                    let bg_alpha =
496                        if self.transparency_affects_only_default_background && !is_default_bg {
497                            1.0
498                        } else {
499                            self.window_opacity
500                        };
501                    let mut bg_color = [
502                        cell.bg_color[0] as f32 / 255.0,
503                        cell.bg_color[1] as f32 / 255.0,
504                        cell.bg_color[2] as f32 / 255.0,
505                        bg_alpha,
506                    ];
507
508                    // Handle cursor at this position
509                    if has_cursor && self.cursor_opacity > 0.0 {
510                        use par_term_emu_core_rust::cursor::CursorStyle;
511
512                        // Check if we should render hollow cursor (unfocused hollow style)
513                        let render_hollow = !self.is_focused
514                            && self.unfocused_cursor_style
515                                == par_term_config::UnfocusedCursorStyle::Hollow;
516
517                        match self.cursor_style {
518                            CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock => {
519                                if render_hollow {
520                                    // Hollow cursor: don't fill the cell, outline will be added later
521                                    // Keep original background color
522                                } else {
523                                    // Solid block cursor
524                                    for (bg, &cursor) in
525                                        bg_color.iter_mut().take(3).zip(&self.cursor_color)
526                                    {
527                                        *bg = *bg * (1.0 - self.cursor_opacity)
528                                            + cursor * self.cursor_opacity;
529                                    }
530                                    bg_color[3] = bg_color[3].max(self.cursor_opacity);
531                                }
532                            }
533                            _ => {}
534                        }
535                        // Cursor cell can't be merged, render it alone
536                        let x0 = self.window_padding
537                            + self.content_offset_x
538                            + col as f32 * self.cell_width;
539                        let x1 = self.window_padding
540                            + self.content_offset_x
541                            + (col + 1) as f32 * self.cell_width;
542                        let y0 = self.window_padding
543                            + self.content_offset_y
544                            + row as f32 * self.cell_height;
545                        let y1 = y0 + self.cell_height;
546                        row_bg.push(BackgroundInstance {
547                            position: [
548                                x0 / self.config.width as f32 * 2.0 - 1.0,
549                                1.0 - (y0 / self.config.height as f32 * 2.0),
550                            ],
551                            size: [
552                                (x1 - x0) / self.config.width as f32 * 2.0,
553                                (y1 - y0) / self.config.height as f32 * 2.0,
554                            ],
555                            color: bg_color,
556                        });
557                        col += 1;
558                        continue;
559                    }
560
561                    // RLE: Find run of consecutive cells with same background color
562                    let start_col = col;
563                    let run_color = cell.bg_color;
564                    col += 1;
565                    while col < row_cells.len() {
566                        let next_cell = &row_cells[col];
567                        let next_has_cursor = self.cursor_opacity > 0.0
568                            && !self.cursor_hidden_for_shader
569                            && self.cursor_pos.1 == row
570                            && self.cursor_pos.0 == col;
571                        // Stop run if color differs or cursor is here
572                        if next_cell.bg_color != run_color || next_has_cursor {
573                            break;
574                        }
575                        col += 1;
576                    }
577                    let run_length = col - start_col;
578
579                    // Create single quad spanning entire run (no per-cell rounding)
580                    let x0 = self.window_padding
581                        + self.content_offset_x
582                        + start_col as f32 * self.cell_width;
583                    let x1 = self.window_padding
584                        + self.content_offset_x
585                        + (start_col + run_length) as f32 * self.cell_width;
586                    let y0 =
587                        self.window_padding + self.content_offset_y + row as f32 * self.cell_height;
588                    let y1 = y0 + self.cell_height;
589
590                    row_bg.push(BackgroundInstance {
591                        position: [
592                            x0 / self.config.width as f32 * 2.0 - 1.0,
593                            1.0 - (y0 / self.config.height as f32 * 2.0),
594                        ],
595                        size: [
596                            (x1 - x0) / self.config.width as f32 * 2.0,
597                            (y1 - y0) / self.config.height as f32 * 2.0,
598                        ],
599                        color: bg_color,
600                    });
601                }
602
603                // Pad row_bg to expected size with empty instances
604                // (RLE creates fewer instances than cells, but buffer expects cols entries)
605                while row_bg.len() < self.cols {
606                    row_bg.push(BackgroundInstance {
607                        position: [0.0, 0.0],
608                        size: [0.0, 0.0],
609                        color: [0.0, 0.0, 0.0, 0.0],
610                    });
611                }
612
613                // Text
614                let mut x_offset = 0.0;
615                #[allow(clippy::type_complexity)]
616                let cell_data: Vec<(
617                    String,
618                    bool,
619                    bool,
620                    [u8; 4],
621                    [u8; 4],
622                    bool,
623                    bool,
624                )> = row_cells
625                    .iter()
626                    .map(|c| {
627                        (
628                            c.grapheme.clone(),
629                            c.bold,
630                            c.italic,
631                            c.fg_color,
632                            c.bg_color,
633                            c.wide_char_spacer,
634                            c.wide_char,
635                        )
636                    })
637                    .collect();
638
639                // Dynamic baseline calculation based on font metrics
640                let natural_line_height = self.font_ascent + self.font_descent + self.font_leading;
641                let vertical_padding = (self.cell_height - natural_line_height).max(0.0) / 2.0;
642                let baseline_y_unrounded = self.window_padding
643                    + self.content_offset_y
644                    + (row as f32 * self.cell_height)
645                    + vertical_padding
646                    + self.font_ascent;
647
648                // Check if this row has the cursor and it's a visible block cursor
649                // (for cursor text color override)
650                let cursor_is_block_on_this_row = {
651                    use par_term_emu_core_rust::cursor::CursorStyle;
652                    self.cursor_pos.1 == row
653                        && self.cursor_opacity > 0.0
654                        && !self.cursor_hidden_for_shader
655                        && matches!(
656                            self.cursor_style,
657                            CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock
658                        )
659                        && (self.is_focused
660                            || self.unfocused_cursor_style
661                                == par_term_config::UnfocusedCursorStyle::Same)
662                };
663
664                let mut current_col = 0usize;
665                for (grapheme, bold, italic, fg_color, bg_color, is_spacer, is_wide) in cell_data {
666                    if is_spacer || grapheme == " " {
667                        x_offset += self.cell_width;
668                        current_col += 1;
669                        continue;
670                    }
671
672                    // Compute text alpha - force opaque if keep_text_opaque is enabled,
673                    // otherwise use window opacity so text becomes transparent with the window
674                    let text_alpha = if self.keep_text_opaque {
675                        1.0
676                    } else {
677                        self.window_opacity
678                    };
679
680                    // Determine text color - use cursor_text_color if this is the cursor position
681                    // with a block cursor, otherwise use the cell's foreground color
682                    let render_fg_color: [f32; 4] =
683                        if cursor_is_block_on_this_row && current_col == self.cursor_pos.0 {
684                            if let Some(cursor_text) = self.cursor_text_color {
685                                [cursor_text[0], cursor_text[1], cursor_text[2], text_alpha]
686                            } else {
687                                // Auto-contrast: use cursor color as a starting point
688                                // Simple inversion: if cursor is bright, use dark text; if dark, use bright
689                                let cursor_brightness = (self.cursor_color[0]
690                                    + self.cursor_color[1]
691                                    + self.cursor_color[2])
692                                    / 3.0;
693                                if cursor_brightness > 0.5 {
694                                    [0.0, 0.0, 0.0, text_alpha] // Dark text on bright cursor
695                                } else {
696                                    [1.0, 1.0, 1.0, text_alpha] // Bright text on dark cursor
697                                }
698                            }
699                        } else {
700                            // Determine the effective background color for contrast calculation
701                            // If the cell has a non-default bg, use that; otherwise use terminal background
702                            let effective_bg = if bg_color[3] > 0 {
703                                // Cell has explicit background
704                                [
705                                    bg_color[0] as f32 / 255.0,
706                                    bg_color[1] as f32 / 255.0,
707                                    bg_color[2] as f32 / 255.0,
708                                    1.0,
709                                ]
710                            } else {
711                                // Use terminal default background
712                                [
713                                    self.background_color[0],
714                                    self.background_color[1],
715                                    self.background_color[2],
716                                    1.0,
717                                ]
718                            };
719
720                            let base_fg = [
721                                fg_color[0] as f32 / 255.0,
722                                fg_color[1] as f32 / 255.0,
723                                fg_color[2] as f32 / 255.0,
724                                text_alpha,
725                            ];
726
727                            // Apply minimum contrast adjustment if enabled
728                            self.ensure_minimum_contrast(base_fg, effective_bg)
729                        };
730
731                    let chars: Vec<char> = grapheme.chars().collect();
732                    #[allow(clippy::collapsible_if)]
733                    if let Some(ch) = chars.first() {
734                        // Classify the character for rendering optimization
735                        // Only classify based on first char for block drawing detection
736                        let char_type = block_chars::classify_char(*ch);
737
738                        // Check if we should render this character geometrically
739                        // (only for single-char graphemes that are block drawing chars)
740                        if chars.len() == 1 && block_chars::should_render_geometrically(char_type) {
741                            let char_w = if is_wide {
742                                self.cell_width * 2.0
743                            } else {
744                                self.cell_width
745                            };
746                            let x0 =
747                                (self.window_padding + self.content_offset_x + x_offset).round();
748                            let y0 = (self.window_padding
749                                + self.content_offset_y
750                                + row as f32 * self.cell_height)
751                                .round();
752
753                            // Try box drawing geometry first (for lines, corners, junctions)
754                            // Pass aspect ratio so vertical lines have same visual thickness as horizontal
755                            let aspect_ratio = self.cell_height / char_w;
756                            if let Some(box_geo) =
757                                block_chars::get_box_drawing_geometry(*ch, aspect_ratio)
758                            {
759                                for segment in &box_geo.segments {
760                                    let rect =
761                                        segment.to_pixel_rect(x0, y0, char_w, self.cell_height);
762
763                                    // Extend segments that touch cell edges
764                                    let extension = 1.0;
765                                    let ext_x = if segment.x <= 0.01 { extension } else { 0.0 };
766                                    let ext_y = if segment.y <= 0.01 { extension } else { 0.0 };
767                                    let ext_w = if segment.x + segment.width >= 0.99 {
768                                        extension
769                                    } else {
770                                        0.0
771                                    };
772                                    let ext_h = if segment.y + segment.height >= 0.99 {
773                                        extension
774                                    } else {
775                                        0.0
776                                    };
777
778                                    let final_x = rect.x - ext_x;
779                                    let final_y = rect.y - ext_y;
780                                    let final_w = rect.width + ext_x + ext_w;
781                                    let final_h = rect.height + ext_y + ext_h;
782
783                                    row_text.push(TextInstance {
784                                        position: [
785                                            final_x / self.config.width as f32 * 2.0 - 1.0,
786                                            1.0 - (final_y / self.config.height as f32 * 2.0),
787                                        ],
788                                        size: [
789                                            final_w / self.config.width as f32 * 2.0,
790                                            final_h / self.config.height as f32 * 2.0,
791                                        ],
792                                        tex_offset: [
793                                            self.solid_pixel_offset.0 as f32 / 2048.0,
794                                            self.solid_pixel_offset.1 as f32 / 2048.0,
795                                        ],
796                                        tex_size: [1.0 / 2048.0, 1.0 / 2048.0],
797                                        color: render_fg_color,
798                                        is_colored: 0,
799                                    });
800                                }
801                                x_offset += self.cell_width;
802                                current_col += 1;
803                                continue;
804                            }
805
806                            // Try block element geometry (for solid blocks, half blocks, etc.)
807                            if let Some(geo_block) = block_chars::get_geometric_block(*ch) {
808                                let rect =
809                                    geo_block.to_pixel_rect(x0, y0, char_w, self.cell_height);
810
811                                // Add small extension to prevent gaps (1 pixel overlap)
812                                let extension = 1.0;
813                                let ext_x = if geo_block.x == 0.0 { extension } else { 0.0 };
814                                let ext_y = if geo_block.y == 0.0 { extension } else { 0.0 };
815                                let ext_w = if geo_block.x + geo_block.width >= 1.0 {
816                                    extension
817                                } else {
818                                    0.0
819                                };
820                                let ext_h = if geo_block.y + geo_block.height >= 1.0 {
821                                    extension
822                                } else {
823                                    0.0
824                                };
825
826                                let final_x = rect.x - ext_x;
827                                let final_y = rect.y - ext_y;
828                                let final_w = rect.width + ext_x + ext_w;
829                                let final_h = rect.height + ext_y + ext_h;
830
831                                // Render as a colored rectangle using the solid white pixel in atlas
832                                // This goes through the text pipeline with foreground color
833                                row_text.push(TextInstance {
834                                    position: [
835                                        final_x / self.config.width as f32 * 2.0 - 1.0,
836                                        1.0 - (final_y / self.config.height as f32 * 2.0),
837                                    ],
838                                    size: [
839                                        final_w / self.config.width as f32 * 2.0,
840                                        final_h / self.config.height as f32 * 2.0,
841                                    ],
842                                    // Use solid white pixel from atlas
843                                    tex_offset: [
844                                        self.solid_pixel_offset.0 as f32 / 2048.0,
845                                        self.solid_pixel_offset.1 as f32 / 2048.0,
846                                    ],
847                                    tex_size: [1.0 / 2048.0, 1.0 / 2048.0],
848                                    color: render_fg_color,
849                                    is_colored: 0,
850                                });
851
852                                x_offset += self.cell_width;
853                                current_col += 1;
854                                continue;
855                            }
856
857                            // Try geometric shape (aspect-ratio-aware squares, rectangles)
858                            if let Some(rect) = block_chars::get_geometric_shape_rect(
859                                *ch,
860                                x0,
861                                y0,
862                                char_w,
863                                self.cell_height,
864                            ) {
865                                row_text.push(TextInstance {
866                                    position: [
867                                        rect.x / self.config.width as f32 * 2.0 - 1.0,
868                                        1.0 - (rect.y / self.config.height as f32 * 2.0),
869                                    ],
870                                    size: [
871                                        rect.width / self.config.width as f32 * 2.0,
872                                        rect.height / self.config.height as f32 * 2.0,
873                                    ],
874                                    tex_offset: [
875                                        self.solid_pixel_offset.0 as f32 / 2048.0,
876                                        self.solid_pixel_offset.1 as f32 / 2048.0,
877                                    ],
878                                    tex_size: [1.0 / 2048.0, 1.0 / 2048.0],
879                                    color: render_fg_color,
880                                    is_colored: 0,
881                                });
882
883                                x_offset += self.cell_width;
884                                current_col += 1;
885                                continue;
886                            }
887                        }
888
889                        // Check if this character should be rendered as a monochrome symbol
890                        // (dingbats, etc.) rather than colorful emoji.
891                        // Also handle symbol + VS16 (U+FE0F emoji presentation selector):
892                        // in terminal contexts, symbols should remain monochrome even with VS16.
893                        let (force_monochrome, base_char) = if chars.len() == 1 {
894                            (super::atlas::should_render_as_symbol(*ch), *ch)
895                        } else if chars.len() == 2
896                            && chars[1] == '\u{FE0F}'
897                            && super::atlas::should_render_as_symbol(chars[0])
898                        {
899                            // Symbol + VS16: strip VS16 and render base char as monochrome
900                            (true, chars[0])
901                        } else {
902                            (false, *ch)
903                        };
904
905                        // Use grapheme-aware glyph lookup for multi-character sequences
906                        // (flags, emoji with skin tones, ZWJ sequences, combining chars).
907                        // When force_monochrome strips VS16, use single-char lookup instead.
908                        let mut glyph_result = if force_monochrome || chars.len() == 1 {
909                            self.font_manager.find_glyph(base_char, bold, italic)
910                        } else {
911                            self.font_manager
912                                .find_grapheme_glyph(&grapheme, bold, italic)
913                        };
914
915                        // Try to find a renderable glyph. Some fonts (e.g., Apple Color
916                        // Emoji) have charmap entries for characters but produce empty
917                        // outlines. When rasterization fails, retry with alternative fonts.
918                        let mut excluded_fonts: Vec<usize> = Vec::new();
919                        let resolved_info = loop {
920                            match glyph_result {
921                                Some((font_idx, glyph_id)) => {
922                                    let cache_key = ((font_idx as u64) << 32) | (glyph_id as u64);
923                                    if self.glyph_cache.contains_key(&cache_key) {
924                                        self.lru_remove(cache_key);
925                                        self.lru_push_front(cache_key);
926                                        break Some(
927                                            self.glyph_cache.get(&cache_key).unwrap().clone(),
928                                        );
929                                    } else if let Some(raster) =
930                                        self.rasterize_glyph(font_idx, glyph_id, force_monochrome)
931                                    {
932                                        let info = self.upload_glyph(cache_key, &raster);
933                                        self.glyph_cache.insert(cache_key, info.clone());
934                                        self.lru_push_front(cache_key);
935                                        break Some(info);
936                                    } else {
937                                        // Rasterization failed — try next font
938                                        excluded_fonts.push(font_idx);
939                                        glyph_result = self.font_manager.find_glyph_excluding(
940                                            base_char,
941                                            bold,
942                                            italic,
943                                            &excluded_fonts,
944                                        );
945                                        continue;
946                                    }
947                                }
948                                None => break None,
949                            }
950                        };
951
952                        // Last resort: if monochrome rendering failed across all fonts
953                        // (no font has vector outlines for this character), retry with
954                        // colored emoji rendering. Characters like ✨ only exist in
955                        // Apple Color Emoji — rendering them colored is better than
956                        // rendering nothing.
957                        let resolved_info = if resolved_info.is_none() && force_monochrome {
958                            let mut glyph_result2 =
959                                self.font_manager.find_glyph(base_char, bold, italic);
960                            loop {
961                                match glyph_result2 {
962                                    Some((font_idx, glyph_id)) => {
963                                        let cache_key = ((font_idx as u64) << 32)
964                                            | (glyph_id as u64)
965                                            | (1u64 << 63); // different cache key for colored
966                                        if let Some(raster) =
967                                            self.rasterize_glyph(font_idx, glyph_id, false)
968                                        {
969                                            let info = self.upload_glyph(cache_key, &raster);
970                                            self.glyph_cache.insert(cache_key, info.clone());
971                                            self.lru_push_front(cache_key);
972                                            break Some(info);
973                                        } else {
974                                            glyph_result2 = self.font_manager.find_glyph_excluding(
975                                                base_char,
976                                                bold,
977                                                italic,
978                                                &[font_idx],
979                                            );
980                                            continue;
981                                        }
982                                    }
983                                    None => break None,
984                                }
985                            }
986                        } else {
987                            resolved_info
988                        };
989
990                        let info = match resolved_info {
991                            Some(info) => info,
992                            None => {
993                                x_offset += self.cell_width;
994                                continue;
995                            }
996                        };
997
998                        let char_w = if is_wide {
999                            self.cell_width * 2.0
1000                        } else {
1001                            self.cell_width
1002                        };
1003                        let x0 = (self.window_padding + self.content_offset_x + x_offset).round();
1004                        let x1 = (self.window_padding + self.content_offset_x + x_offset + char_w)
1005                            .round();
1006                        let y0 = (self.window_padding
1007                            + self.content_offset_y
1008                            + row as f32 * self.cell_height)
1009                            .round();
1010                        let y1 = (self.window_padding
1011                            + self.content_offset_y
1012                            + (row + 1) as f32 * self.cell_height)
1013                            .round();
1014
1015                        let cell_w = x1 - x0;
1016                        let cell_h = y1 - y0;
1017
1018                        let scale_x = cell_w / char_w;
1019                        let scale_y = cell_h / self.cell_height;
1020
1021                        // Position glyph relative to snapped cell top-left.
1022                        // Round the scaled baseline position once, then subtract
1023                        // the integer bearing_y. This ensures all glyphs on a row
1024                        // share the same rounded baseline, with bearing offsets
1025                        // applied exactly (no scale_y on bearing avoids rounding
1026                        // artifacts between glyphs with different bearings).
1027                        let baseline_offset = baseline_y_unrounded
1028                            - (self.window_padding
1029                                + self.content_offset_y
1030                                + row as f32 * self.cell_height);
1031                        let glyph_left = x0 + (info.bearing_x * scale_x).round();
1032                        let baseline_in_cell = (baseline_offset * scale_y).round();
1033                        let glyph_top = y0 + baseline_in_cell - info.bearing_y;
1034
1035                        let render_w = info.width as f32 * scale_x;
1036                        let render_h = info.height as f32 * scale_y;
1037
1038                        // For block characters that need font rendering (box drawing, etc.),
1039                        // apply snapping to cell boundaries with sub-pixel extension.
1040                        // Only apply to single-char graphemes (multi-char are never block chars)
1041                        let (final_left, final_top, final_w, final_h) = if chars.len() == 1
1042                            && block_chars::should_snap_to_boundaries(char_type)
1043                        {
1044                            // Snap threshold of 3 pixels, extension of 0.5 pixels
1045                            block_chars::snap_glyph_to_cell(
1046                                glyph_left, glyph_top, render_w, render_h, x0, y0, x1, y1, 3.0, 0.5,
1047                            )
1048                        } else {
1049                            (glyph_left, glyph_top, render_w, render_h)
1050                        };
1051
1052                        row_text.push(TextInstance {
1053                            position: [
1054                                final_left / self.config.width as f32 * 2.0 - 1.0,
1055                                1.0 - (final_top / self.config.height as f32 * 2.0),
1056                            ],
1057                            size: [
1058                                final_w / self.config.width as f32 * 2.0,
1059                                final_h / self.config.height as f32 * 2.0,
1060                            ],
1061                            tex_offset: [info.x as f32 / 2048.0, info.y as f32 / 2048.0],
1062                            tex_size: [info.width as f32 / 2048.0, info.height as f32 / 2048.0],
1063                            color: render_fg_color,
1064                            is_colored: if info.is_colored { 1 } else { 0 },
1065                        });
1066                    }
1067                    x_offset += self.cell_width;
1068                    current_col += 1;
1069                }
1070
1071                // Underlines: emit thin rectangle(s) at the bottom of each underlined cell
1072                {
1073                    let underline_thickness = (self.cell_height * 0.07).max(1.0).round();
1074                    let tex_offset = [
1075                        self.solid_pixel_offset.0 as f32 / 2048.0,
1076                        self.solid_pixel_offset.1 as f32 / 2048.0,
1077                    ];
1078                    let tex_size = [1.0 / 2048.0, 1.0 / 2048.0];
1079                    let y0 = self.window_padding
1080                        + self.content_offset_y
1081                        + (row + 1) as f32 * self.cell_height
1082                        - underline_thickness;
1083                    let ndc_y = 1.0 - (y0 / self.config.height as f32 * 2.0);
1084                    let ndc_h = underline_thickness / self.config.height as f32 * 2.0;
1085                    let is_stipple =
1086                        self.link_underline_style == par_term_config::LinkUnderlineStyle::Stipple;
1087                    // Stipple: 2px on, 2px off pattern
1088                    let stipple_on = 2.0_f32;
1089                    let stipple_off = 2.0_f32;
1090                    let stipple_period = stipple_on + stipple_off;
1091
1092                    for col_idx in 0..self.cols {
1093                        let cell = &self.cells[start + col_idx];
1094                        if !cell.underline || row_text.len() >= self.cols * 2 {
1095                            continue;
1096                        }
1097                        let text_alpha = if self.keep_text_opaque {
1098                            1.0
1099                        } else {
1100                            self.window_opacity
1101                        };
1102                        let fg = [
1103                            cell.fg_color[0] as f32 / 255.0,
1104                            cell.fg_color[1] as f32 / 255.0,
1105                            cell.fg_color[2] as f32 / 255.0,
1106                            text_alpha,
1107                        ];
1108                        let cell_x0 = self.window_padding
1109                            + self.content_offset_x
1110                            + col_idx as f32 * self.cell_width;
1111
1112                        if is_stipple {
1113                            // Emit alternating dot segments across the cell width
1114                            let mut px = 0.0;
1115                            while px < self.cell_width && row_text.len() < self.cols * 2 {
1116                                let seg_w = stipple_on.min(self.cell_width - px);
1117                                let x = cell_x0 + px;
1118                                row_text.push(TextInstance {
1119                                    position: [x / self.config.width as f32 * 2.0 - 1.0, ndc_y],
1120                                    size: [seg_w / self.config.width as f32 * 2.0, ndc_h],
1121                                    tex_offset,
1122                                    tex_size,
1123                                    color: fg,
1124                                    is_colored: 0,
1125                                });
1126                                px += stipple_period;
1127                            }
1128                        } else {
1129                            row_text.push(TextInstance {
1130                                position: [cell_x0 / self.config.width as f32 * 2.0 - 1.0, ndc_y],
1131                                size: [self.cell_width / self.config.width as f32 * 2.0, ndc_h],
1132                                tex_offset,
1133                                tex_size,
1134                                color: fg,
1135                                is_colored: 0,
1136                            });
1137                        }
1138                    }
1139                }
1140
1141                // Update CPU-side buffers
1142                let bg_start = row * self.cols;
1143                self.bg_instances[bg_start..bg_start + self.cols].copy_from_slice(&row_bg);
1144
1145                let text_start = row * self.cols * 2;
1146                // Clear row text segment first
1147                for i in 0..(self.cols * 2) {
1148                    self.text_instances[text_start + i].size = [0.0, 0.0];
1149                }
1150                // Copy new text instances
1151                let text_count = row_text.len().min(self.cols * 2);
1152                self.text_instances[text_start..text_start + text_count]
1153                    .copy_from_slice(&row_text[..text_count]);
1154
1155                // Update GPU-side buffers incrementally
1156                self.queue.write_buffer(
1157                    &self.bg_instance_buffer,
1158                    (bg_start * std::mem::size_of::<BackgroundInstance>()) as u64,
1159                    bytemuck::cast_slice(&row_bg),
1160                );
1161                self.queue.write_buffer(
1162                    &self.text_instance_buffer,
1163                    (text_start * std::mem::size_of::<TextInstance>()) as u64,
1164                    bytemuck::cast_slice(
1165                        &self.text_instances[text_start..text_start + self.cols * 2],
1166                    ),
1167                );
1168
1169                self.row_cache[row] = Some(RowCacheEntry {});
1170                self.dirty_rows[row] = false;
1171            }
1172        }
1173
1174        // Write cursor-related overlays to extra slots at the end of bg_instances
1175        // Slot layout: [0] cursor overlay (beam/underline), [1] guide, [2] shadow, [3-6] boost glow, [7-10] hollow outline
1176        let base_overlay_index = self.cols * self.rows;
1177        let mut overlay_instances = vec![
1178            BackgroundInstance {
1179                position: [0.0, 0.0],
1180                size: [0.0, 0.0],
1181                color: [0.0, 0.0, 0.0, 0.0],
1182            };
1183            10
1184        ];
1185
1186        // Check if cursor should be visible
1187        let cursor_visible = self.cursor_opacity > 0.0
1188            && !self.cursor_hidden_for_shader
1189            && (self.is_focused
1190                || self.unfocused_cursor_style != par_term_config::UnfocusedCursorStyle::Hidden);
1191
1192        // Calculate cursor pixel positions
1193        let cursor_col = self.cursor_pos.0;
1194        let cursor_row = self.cursor_pos.1;
1195        let cursor_x0 =
1196            self.window_padding + self.content_offset_x + cursor_col as f32 * self.cell_width;
1197        let cursor_x1 = cursor_x0 + self.cell_width;
1198        let cursor_y0 =
1199            self.window_padding + self.content_offset_y + cursor_row as f32 * self.cell_height;
1200        let cursor_y1 = cursor_y0 + self.cell_height;
1201
1202        // Slot 0: Cursor overlay (beam/underline) - handled by existing cursor_overlay
1203        overlay_instances[0] = self.cursor_overlay.unwrap_or(BackgroundInstance {
1204            position: [0.0, 0.0],
1205            size: [0.0, 0.0],
1206            color: [0.0, 0.0, 0.0, 0.0],
1207        });
1208
1209        // Slot 1: Cursor guide (horizontal line spanning full width at cursor row)
1210        if cursor_visible && self.cursor_guide_enabled {
1211            let guide_x0 = self.window_padding + self.content_offset_x;
1212            let guide_x1 =
1213                self.config.width as f32 - self.window_padding - self.content_inset_right;
1214            overlay_instances[1] = BackgroundInstance {
1215                position: [
1216                    guide_x0 / self.config.width as f32 * 2.0 - 1.0,
1217                    1.0 - (cursor_y0 / self.config.height as f32 * 2.0),
1218                ],
1219                size: [
1220                    (guide_x1 - guide_x0) / self.config.width as f32 * 2.0,
1221                    (cursor_y1 - cursor_y0) / self.config.height as f32 * 2.0,
1222                ],
1223                color: self.cursor_guide_color,
1224            };
1225        }
1226
1227        // Slot 2: Cursor shadow (offset rectangle behind cursor)
1228        if cursor_visible && self.cursor_shadow_enabled {
1229            let shadow_x0 = cursor_x0 + self.cursor_shadow_offset[0];
1230            let shadow_y0 = cursor_y0 + self.cursor_shadow_offset[1];
1231            overlay_instances[2] = BackgroundInstance {
1232                position: [
1233                    shadow_x0 / self.config.width as f32 * 2.0 - 1.0,
1234                    1.0 - (shadow_y0 / self.config.height as f32 * 2.0),
1235                ],
1236                size: [
1237                    self.cell_width / self.config.width as f32 * 2.0,
1238                    self.cell_height / self.config.height as f32 * 2.0,
1239                ],
1240                color: self.cursor_shadow_color,
1241            };
1242        }
1243
1244        // Slot 3: Cursor boost glow (larger rectangle around cursor with low opacity)
1245        if cursor_visible && self.cursor_boost > 0.0 {
1246            let glow_expand = 4.0 * self.scale_factor * self.cursor_boost; // Expand by up to 4 logical pixels
1247            let glow_x0 = cursor_x0 - glow_expand;
1248            let glow_y0 = cursor_y0 - glow_expand;
1249            let glow_w = self.cell_width + glow_expand * 2.0;
1250            let glow_h = self.cell_height + glow_expand * 2.0;
1251            overlay_instances[3] = BackgroundInstance {
1252                position: [
1253                    glow_x0 / self.config.width as f32 * 2.0 - 1.0,
1254                    1.0 - (glow_y0 / self.config.height as f32 * 2.0),
1255                ],
1256                size: [
1257                    glow_w / self.config.width as f32 * 2.0,
1258                    glow_h / self.config.height as f32 * 2.0,
1259                ],
1260                color: [
1261                    self.cursor_boost_color[0],
1262                    self.cursor_boost_color[1],
1263                    self.cursor_boost_color[2],
1264                    self.cursor_boost * 0.3 * self.cursor_opacity, // Max 30% alpha
1265                ],
1266            };
1267        }
1268
1269        // Slots 4-7: Hollow cursor outline (4 thin rectangles forming a border)
1270        // Rendered when unfocused with hollow style and block cursor
1271        let render_hollow = cursor_visible
1272            && !self.is_focused
1273            && self.unfocused_cursor_style == par_term_config::UnfocusedCursorStyle::Hollow;
1274
1275        if render_hollow {
1276            use par_term_emu_core_rust::cursor::CursorStyle;
1277            let is_block = matches!(
1278                self.cursor_style,
1279                CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock
1280            );
1281
1282            if is_block {
1283                let border_width = 2.0; // 2 pixel border
1284                let color = [
1285                    self.cursor_color[0],
1286                    self.cursor_color[1],
1287                    self.cursor_color[2],
1288                    self.cursor_opacity,
1289                ];
1290
1291                // Top border
1292                overlay_instances[4] = BackgroundInstance {
1293                    position: [
1294                        cursor_x0 / self.config.width as f32 * 2.0 - 1.0,
1295                        1.0 - (cursor_y0 / self.config.height as f32 * 2.0),
1296                    ],
1297                    size: [
1298                        self.cell_width / self.config.width as f32 * 2.0,
1299                        border_width / self.config.height as f32 * 2.0,
1300                    ],
1301                    color,
1302                };
1303
1304                // Bottom border
1305                overlay_instances[5] = BackgroundInstance {
1306                    position: [
1307                        cursor_x0 / self.config.width as f32 * 2.0 - 1.0,
1308                        1.0 - ((cursor_y1 - border_width) / self.config.height as f32 * 2.0),
1309                    ],
1310                    size: [
1311                        self.cell_width / self.config.width as f32 * 2.0,
1312                        border_width / self.config.height as f32 * 2.0,
1313                    ],
1314                    color,
1315                };
1316
1317                // Left border
1318                overlay_instances[6] = BackgroundInstance {
1319                    position: [
1320                        cursor_x0 / self.config.width as f32 * 2.0 - 1.0,
1321                        1.0 - ((cursor_y0 + border_width) / self.config.height as f32 * 2.0),
1322                    ],
1323                    size: [
1324                        border_width / self.config.width as f32 * 2.0,
1325                        (self.cell_height - border_width * 2.0) / self.config.height as f32 * 2.0,
1326                    ],
1327                    color,
1328                };
1329
1330                // Right border
1331                overlay_instances[7] = BackgroundInstance {
1332                    position: [
1333                        (cursor_x1 - border_width) / self.config.width as f32 * 2.0 - 1.0,
1334                        1.0 - ((cursor_y0 + border_width) / self.config.height as f32 * 2.0),
1335                    ],
1336                    size: [
1337                        border_width / self.config.width as f32 * 2.0,
1338                        (self.cell_height - border_width * 2.0) / self.config.height as f32 * 2.0,
1339                    ],
1340                    color,
1341                };
1342            }
1343        }
1344
1345        // Write all overlay instances to GPU buffer
1346        for (i, instance) in overlay_instances.iter().enumerate() {
1347            self.bg_instances[base_overlay_index + i] = *instance;
1348        }
1349        self.queue.write_buffer(
1350            &self.bg_instance_buffer,
1351            (base_overlay_index * std::mem::size_of::<BackgroundInstance>()) as u64,
1352            bytemuck::cast_slice(&overlay_instances),
1353        );
1354
1355        // Write command separator line instances after cursor overlay slots
1356        let separator_base = self.cols * self.rows + 10;
1357        let mut separator_instances = vec![
1358            BackgroundInstance {
1359                position: [0.0, 0.0],
1360                size: [0.0, 0.0],
1361                color: [0.0, 0.0, 0.0, 0.0],
1362            };
1363            self.rows
1364        ];
1365
1366        if self.command_separator_enabled {
1367            let width_f = self.config.width as f32;
1368            let height_f = self.config.height as f32;
1369            for &(screen_row, exit_code, custom_color) in &self.visible_separator_marks {
1370                if screen_row < self.rows {
1371                    let x0 = self.window_padding + self.content_offset_x;
1372                    let x1 = width_f - self.window_padding - self.content_inset_right;
1373                    let y0 = self.window_padding
1374                        + self.content_offset_y
1375                        + screen_row as f32 * self.cell_height;
1376                    let color = self.separator_color(exit_code, custom_color, 1.0);
1377                    separator_instances[screen_row] = BackgroundInstance {
1378                        position: [x0 / width_f * 2.0 - 1.0, 1.0 - (y0 / height_f * 2.0)],
1379                        size: [
1380                            (x1 - x0) / width_f * 2.0,
1381                            self.command_separator_thickness / height_f * 2.0,
1382                        ],
1383                        color,
1384                    };
1385                }
1386            }
1387        }
1388
1389        for (i, instance) in separator_instances.iter().enumerate() {
1390            if separator_base + i < self.max_bg_instances {
1391                self.bg_instances[separator_base + i] = *instance;
1392            }
1393        }
1394        let separator_byte_offset = separator_base * std::mem::size_of::<BackgroundInstance>();
1395        let separator_byte_count =
1396            separator_instances.len() * std::mem::size_of::<BackgroundInstance>();
1397        if separator_byte_offset + separator_byte_count
1398            <= self.max_bg_instances * std::mem::size_of::<BackgroundInstance>()
1399        {
1400            self.queue.write_buffer(
1401                &self.bg_instance_buffer,
1402                separator_byte_offset as u64,
1403                bytemuck::cast_slice(&separator_instances),
1404            );
1405        }
1406
1407        // Write gutter indicator background instances after separator slots
1408        let gutter_base = separator_base + self.rows;
1409        let mut gutter_instances = vec![
1410            BackgroundInstance {
1411                position: [0.0, 0.0],
1412                size: [0.0, 0.0],
1413                color: [0.0, 0.0, 0.0, 0.0],
1414            };
1415            self.rows
1416        ];
1417
1418        if !self.gutter_indicators.is_empty() {
1419            let width_f = self.config.width as f32;
1420            let height_f = self.config.height as f32;
1421            for &(screen_row, color) in &self.gutter_indicators {
1422                if screen_row < self.rows {
1423                    let x0 = self.window_padding + self.content_offset_x;
1424                    let x1 = x0 + 2.0 * self.cell_width; // gutter_width = 2 columns
1425                    let y0 = self.window_padding
1426                        + self.content_offset_y
1427                        + screen_row as f32 * self.cell_height;
1428                    gutter_instances[screen_row] = BackgroundInstance {
1429                        position: [x0 / width_f * 2.0 - 1.0, 1.0 - (y0 / height_f * 2.0)],
1430                        size: [(x1 - x0) / width_f * 2.0, self.cell_height / height_f * 2.0],
1431                        color,
1432                    };
1433                }
1434            }
1435        }
1436
1437        for (i, instance) in gutter_instances.iter().enumerate() {
1438            if gutter_base + i < self.max_bg_instances {
1439                self.bg_instances[gutter_base + i] = *instance;
1440            }
1441        }
1442        let gutter_byte_offset = gutter_base * std::mem::size_of::<BackgroundInstance>();
1443        let gutter_byte_count = gutter_instances.len() * std::mem::size_of::<BackgroundInstance>();
1444        if gutter_byte_offset + gutter_byte_count
1445            <= self.max_bg_instances * std::mem::size_of::<BackgroundInstance>()
1446        {
1447            self.queue.write_buffer(
1448                &self.bg_instance_buffer,
1449                gutter_byte_offset as u64,
1450                bytemuck::cast_slice(&gutter_instances),
1451            );
1452        }
1453
1454        Ok(())
1455    }
1456
1457    /// Render a single pane's content within a viewport to an existing surface texture
1458    ///
1459    /// This method renders cells to a specific region of the render target,
1460    /// using a GPU scissor rect to clip to the pane bounds.
1461    ///
1462    /// # Arguments
1463    /// * `surface_view` - The texture view to render to
1464    /// * `viewport` - The pane's viewport (position, size, focus state, opacity)
1465    /// * `cells` - The cells to render (should match viewport grid size)
1466    /// * `cols` - Number of columns in the cell grid
1467    /// * `rows` - Number of rows in the cell grid
1468    /// * `cursor_pos` - Cursor position (col, row) within this pane, or None if no cursor
1469    /// * `cursor_opacity` - Cursor opacity (0.0 = hidden, 1.0 = fully visible)
1470    /// * `show_scrollbar` - Whether to render the scrollbar for this pane
1471    /// * `clear_first` - If true, clears the viewport region before rendering
1472    /// * `skip_background_image` - If true, skip rendering the background image. Use this
1473    ///   when the background image has already been rendered full-screen (for split panes).
1474    #[allow(dead_code, clippy::too_many_arguments)]
1475    pub fn render_pane_to_view(
1476        &mut self,
1477        surface_view: &wgpu::TextureView,
1478        viewport: &PaneViewport,
1479        cells: &[Cell],
1480        cols: usize,
1481        rows: usize,
1482        cursor_pos: Option<(usize, usize)>,
1483        cursor_opacity: f32,
1484        show_scrollbar: bool,
1485        clear_first: bool,
1486        skip_background_image: bool,
1487        separator_marks: &[SeparatorMark],
1488        pane_background: Option<&par_term_config::PaneBackground>,
1489    ) -> Result<()> {
1490        // Build instance buffers for this pane's cells
1491        // Skip solid background fill if background (shader/image) was already rendered full-screen
1492        self.build_pane_instance_buffers(
1493            viewport,
1494            cells,
1495            cols,
1496            rows,
1497            cursor_pos,
1498            cursor_opacity,
1499            skip_background_image,
1500            separator_marks,
1501        )?;
1502
1503        // Pre-create per-pane background bind group if needed (must happen before render pass).
1504        // Per-pane backgrounds are explicit user overrides and always created,
1505        // even when a custom shader or global background would normally be skipped.
1506        let pane_bg_resources = if let Some(pane_bg) = pane_background
1507            && let Some(ref path) = pane_bg.image_path
1508        {
1509            self.pane_bg_cache.get(path.as_str()).map(|entry| {
1510                self.create_pane_bg_bind_group(
1511                    entry,
1512                    viewport.x,
1513                    viewport.y,
1514                    viewport.width,
1515                    viewport.height,
1516                    pane_bg.mode,
1517                    pane_bg.opacity,
1518                    pane_bg.darken,
1519                )
1520            })
1521        } else {
1522            None
1523        };
1524
1525        let mut encoder = self
1526            .device
1527            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1528                label: Some("pane render encoder"),
1529            });
1530
1531        // Determine load operation and clear color
1532        let load_op = if clear_first {
1533            let clear_color = if self.bg_is_solid_color {
1534                wgpu::Color {
1535                    r: self.solid_bg_color[0] as f64
1536                        * self.window_opacity as f64
1537                        * viewport.opacity as f64,
1538                    g: self.solid_bg_color[1] as f64
1539                        * self.window_opacity as f64
1540                        * viewport.opacity as f64,
1541                    b: self.solid_bg_color[2] as f64
1542                        * self.window_opacity as f64
1543                        * viewport.opacity as f64,
1544                    a: self.window_opacity as f64 * viewport.opacity as f64,
1545                }
1546            } else {
1547                wgpu::Color {
1548                    r: self.background_color[0] as f64
1549                        * self.window_opacity as f64
1550                        * viewport.opacity as f64,
1551                    g: self.background_color[1] as f64
1552                        * self.window_opacity as f64
1553                        * viewport.opacity as f64,
1554                    b: self.background_color[2] as f64
1555                        * self.window_opacity as f64
1556                        * viewport.opacity as f64,
1557                    a: self.window_opacity as f64 * viewport.opacity as f64,
1558                }
1559            };
1560            wgpu::LoadOp::Clear(clear_color)
1561        } else {
1562            wgpu::LoadOp::Load
1563        };
1564
1565        {
1566            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1567                label: Some("pane render pass"),
1568                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1569                    view: surface_view,
1570                    resolve_target: None,
1571                    ops: wgpu::Operations {
1572                        load: load_op,
1573                        store: wgpu::StoreOp::Store,
1574                    },
1575                    depth_slice: None,
1576                })],
1577                depth_stencil_attachment: None,
1578                timestamp_writes: None,
1579                occlusion_query_set: None,
1580            });
1581
1582            // Set scissor rect to clip rendering to pane bounds
1583            let (sx, sy, sw, sh) = viewport.to_scissor_rect();
1584            render_pass.set_scissor_rect(sx, sy, sw, sh);
1585
1586            // Render per-pane background image within scissor rect.
1587            // Per-pane backgrounds are explicit user overrides and always render,
1588            // even when a custom shader or global background is active.
1589            if let Some((ref bind_group, ref _buf)) = pane_bg_resources {
1590                render_pass.set_pipeline(&self.bg_image_pipeline);
1591                render_pass.set_bind_group(0, bind_group, &[]);
1592                render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
1593                render_pass.draw(0..4, 0..1);
1594            }
1595
1596            // Render cell backgrounds
1597            render_pass.set_pipeline(&self.bg_pipeline);
1598            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
1599            render_pass.set_vertex_buffer(1, self.bg_instance_buffer.slice(..));
1600            render_pass.draw(0..4, 0..self.max_bg_instances as u32);
1601
1602            // Render text
1603            render_pass.set_pipeline(&self.text_pipeline);
1604            render_pass.set_bind_group(0, &self.text_bind_group, &[]);
1605            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
1606            render_pass.set_vertex_buffer(1, self.text_instance_buffer.slice(..));
1607            render_pass.draw(0..4, 0..self.max_text_instances as u32);
1608
1609            // Render scrollbar if requested (uses its own scissor rect internally)
1610            if show_scrollbar {
1611                // Reset scissor to full surface for scrollbar
1612                render_pass.set_scissor_rect(0, 0, self.config.width, self.config.height);
1613                self.scrollbar.render(&mut render_pass);
1614            }
1615        }
1616
1617        self.queue.submit(std::iter::once(encoder.finish()));
1618        Ok(())
1619    }
1620
1621    /// Build instance buffers for a pane's cells with viewport offset
1622    ///
1623    /// This is similar to `build_instance_buffers` but adjusts all positions
1624    /// to be relative to the viewport origin.
1625    ///
1626    /// # Arguments
1627    /// * `skip_solid_background` - If true, skip adding a solid background fill for the viewport.
1628    ///   Use when a custom shader or background image was already rendered full-screen.
1629    #[allow(clippy::too_many_arguments)]
1630    fn build_pane_instance_buffers(
1631        &mut self,
1632        viewport: &PaneViewport,
1633        cells: &[Cell],
1634        cols: usize,
1635        rows: usize,
1636        cursor_pos: Option<(usize, usize)>,
1637        cursor_opacity: f32,
1638        skip_solid_background: bool,
1639        separator_marks: &[SeparatorMark],
1640    ) -> Result<()> {
1641        let _shaping_options = ShapingOptions {
1642            enable_ligatures: self.enable_ligatures,
1643            enable_kerning: self.enable_kerning,
1644            ..Default::default()
1645        };
1646
1647        // Clear previous instance buffers
1648        for instance in &mut self.bg_instances {
1649            instance.size = [0.0, 0.0];
1650            instance.color = [0.0, 0.0, 0.0, 0.0];
1651        }
1652
1653        // Add a background rectangle covering the entire pane viewport (unless skipped)
1654        // This ensures the pane has a proper background even when cells are skipped.
1655        // Skip when a custom shader or background image was already rendered full-screen.
1656        let bg_start_index = if !skip_solid_background && !self.bg_instances.is_empty() {
1657            let bg_color = self.background_color;
1658            let opacity = self.window_opacity * viewport.opacity;
1659            self.bg_instances[0] = super::types::BackgroundInstance {
1660                position: [viewport.x, viewport.y],
1661                size: [viewport.width, viewport.height],
1662                color: [
1663                    bg_color[0] * opacity,
1664                    bg_color[1] * opacity,
1665                    bg_color[2] * opacity,
1666                    opacity,
1667                ],
1668            };
1669            1 // Start cell backgrounds at index 1
1670        } else {
1671            0 // Start cell backgrounds at index 0 (no viewport fill)
1672        };
1673
1674        for instance in &mut self.text_instances {
1675            instance.size = [0.0, 0.0];
1676        }
1677
1678        // Start at bg_start_index (1 if viewport fill was added, 0 otherwise)
1679        let mut bg_index = bg_start_index;
1680        let mut text_index = 0;
1681
1682        // Content offset - positions are relative to content area (with padding applied)
1683        let (content_x, content_y) = viewport.content_origin();
1684        let opacity_multiplier = viewport.opacity;
1685
1686        for row in 0..rows {
1687            let row_start = row * cols;
1688            let row_end = (row + 1) * cols;
1689            if row_start >= cells.len() {
1690                break;
1691            }
1692            let row_cells = &cells[row_start..row_end.min(cells.len())];
1693
1694            // Background - use RLE to merge consecutive cells with same color
1695            let mut col = 0;
1696            while col < row_cells.len() {
1697                let cell = &row_cells[col];
1698                let is_default_bg = (cell.bg_color[0] as f32 / 255.0 - self.background_color[0])
1699                    .abs()
1700                    < 0.001
1701                    && (cell.bg_color[1] as f32 / 255.0 - self.background_color[1]).abs() < 0.001
1702                    && (cell.bg_color[2] as f32 / 255.0 - self.background_color[2]).abs() < 0.001;
1703
1704                // Check for cursor at this position
1705                let has_cursor = cursor_pos.is_some_and(|(cx, cy)| cx == col && cy == row)
1706                    && cursor_opacity > 0.0
1707                    && !self.cursor_hidden_for_shader;
1708
1709                if is_default_bg && !has_cursor {
1710                    col += 1;
1711                    continue;
1712                }
1713
1714                // Calculate background color with alpha and pane opacity
1715                let bg_alpha =
1716                    if self.transparency_affects_only_default_background && !is_default_bg {
1717                        1.0
1718                    } else {
1719                        self.window_opacity
1720                    };
1721                let pane_alpha = bg_alpha * opacity_multiplier;
1722                let mut bg_color = [
1723                    cell.bg_color[0] as f32 / 255.0,
1724                    cell.bg_color[1] as f32 / 255.0,
1725                    cell.bg_color[2] as f32 / 255.0,
1726                    pane_alpha,
1727                ];
1728
1729                // Handle cursor at this position
1730                if has_cursor {
1731                    use par_term_emu_core_rust::cursor::CursorStyle;
1732                    match self.cursor_style {
1733                        CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock => {
1734                            for (bg, &cursor) in bg_color.iter_mut().take(3).zip(&self.cursor_color)
1735                            {
1736                                *bg = *bg * (1.0 - cursor_opacity) + cursor * cursor_opacity;
1737                            }
1738                            bg_color[3] = bg_color[3].max(cursor_opacity * opacity_multiplier);
1739                        }
1740                        _ => {}
1741                    }
1742
1743                    // Cursor cell can't be merged
1744                    let x0 = content_x + col as f32 * self.cell_width;
1745                    let y0 = content_y + row as f32 * self.cell_height;
1746                    let x1 = x0 + self.cell_width;
1747                    let y1 = y0 + self.cell_height;
1748
1749                    if bg_index < self.max_bg_instances {
1750                        self.bg_instances[bg_index] = BackgroundInstance {
1751                            position: [
1752                                x0 / self.config.width as f32 * 2.0 - 1.0,
1753                                1.0 - (y0 / self.config.height as f32 * 2.0),
1754                            ],
1755                            size: [
1756                                (x1 - x0) / self.config.width as f32 * 2.0,
1757                                (y1 - y0) / self.config.height as f32 * 2.0,
1758                            ],
1759                            color: bg_color,
1760                        };
1761                        bg_index += 1;
1762                    }
1763                    col += 1;
1764                    continue;
1765                }
1766
1767                // RLE: Find run of consecutive cells with same background color
1768                let start_col = col;
1769                let run_color = cell.bg_color;
1770                col += 1;
1771                while col < row_cells.len() {
1772                    let next_cell = &row_cells[col];
1773                    let next_has_cursor = cursor_pos.is_some_and(|(cx, cy)| cx == col && cy == row)
1774                        && cursor_opacity > 0.0;
1775                    if next_cell.bg_color != run_color || next_has_cursor {
1776                        break;
1777                    }
1778                    col += 1;
1779                }
1780                let run_length = col - start_col;
1781
1782                // Create single quad spanning entire run
1783                let x0 = content_x + start_col as f32 * self.cell_width;
1784                let x1 = content_x + (start_col + run_length) as f32 * self.cell_width;
1785                let y0 = content_y + row as f32 * self.cell_height;
1786                let y1 = y0 + self.cell_height;
1787
1788                if bg_index < self.max_bg_instances {
1789                    self.bg_instances[bg_index] = BackgroundInstance {
1790                        position: [
1791                            x0 / self.config.width as f32 * 2.0 - 1.0,
1792                            1.0 - (y0 / self.config.height as f32 * 2.0),
1793                        ],
1794                        size: [
1795                            (x1 - x0) / self.config.width as f32 * 2.0,
1796                            (y1 - y0) / self.config.height as f32 * 2.0,
1797                        ],
1798                        color: bg_color,
1799                    };
1800                    bg_index += 1;
1801                }
1802            }
1803
1804            // Text rendering
1805            let natural_line_height = self.font_ascent + self.font_descent + self.font_leading;
1806            let vertical_padding = (self.cell_height - natural_line_height).max(0.0) / 2.0;
1807            let baseline_y =
1808                content_y + (row as f32 * self.cell_height) + vertical_padding + self.font_ascent;
1809
1810            // Compute text alpha - force opaque if keep_text_opaque is enabled
1811            let text_alpha = if self.keep_text_opaque {
1812                opacity_multiplier // Only apply pane dimming, not window transparency
1813            } else {
1814                self.window_opacity * opacity_multiplier
1815            };
1816
1817            for (col_idx, cell) in row_cells.iter().enumerate() {
1818                if cell.wide_char_spacer || cell.grapheme == " " {
1819                    continue;
1820                }
1821
1822                let chars: Vec<char> = cell.grapheme.chars().collect();
1823                if chars.is_empty() {
1824                    continue;
1825                }
1826
1827                let ch = chars[0];
1828
1829                // Check for block characters that should be rendered geometrically
1830                let char_type = block_chars::classify_char(ch);
1831                if chars.len() == 1 && block_chars::should_render_geometrically(char_type) {
1832                    let char_w = if cell.wide_char {
1833                        self.cell_width * 2.0
1834                    } else {
1835                        self.cell_width
1836                    };
1837                    let x0 = content_x + col_idx as f32 * self.cell_width;
1838                    let y0 = content_y + row as f32 * self.cell_height;
1839
1840                    let fg_color = [
1841                        cell.fg_color[0] as f32 / 255.0,
1842                        cell.fg_color[1] as f32 / 255.0,
1843                        cell.fg_color[2] as f32 / 255.0,
1844                        text_alpha,
1845                    ];
1846
1847                    // Try box drawing geometry first
1848                    let aspect_ratio = self.cell_height / char_w;
1849                    if let Some(box_geo) = block_chars::get_box_drawing_geometry(ch, aspect_ratio) {
1850                        for segment in &box_geo.segments {
1851                            let rect = segment.to_pixel_rect(x0, y0, char_w, self.cell_height);
1852
1853                            // Extension for seamless lines
1854                            let extension = 1.0;
1855                            let ext_x = if segment.x <= 0.01 { extension } else { 0.0 };
1856                            let ext_y = if segment.y <= 0.01 { extension } else { 0.0 };
1857                            let ext_w = if segment.x + segment.width >= 0.99 {
1858                                extension
1859                            } else {
1860                                0.0
1861                            };
1862                            let ext_h = if segment.y + segment.height >= 0.99 {
1863                                extension
1864                            } else {
1865                                0.0
1866                            };
1867
1868                            let final_x = rect.x - ext_x;
1869                            let final_y = rect.y - ext_y;
1870                            let final_w = rect.width + ext_x + ext_w;
1871                            let final_h = rect.height + ext_y + ext_h;
1872
1873                            if text_index < self.max_text_instances {
1874                                self.text_instances[text_index] = TextInstance {
1875                                    position: [
1876                                        final_x / self.config.width as f32 * 2.0 - 1.0,
1877                                        1.0 - (final_y / self.config.height as f32 * 2.0),
1878                                    ],
1879                                    size: [
1880                                        final_w / self.config.width as f32 * 2.0,
1881                                        final_h / self.config.height as f32 * 2.0,
1882                                    ],
1883                                    tex_offset: [
1884                                        self.solid_pixel_offset.0 as f32 / 2048.0,
1885                                        self.solid_pixel_offset.1 as f32 / 2048.0,
1886                                    ],
1887                                    tex_size: [1.0 / 2048.0, 1.0 / 2048.0],
1888                                    color: fg_color,
1889                                    is_colored: 0,
1890                                };
1891                                text_index += 1;
1892                            }
1893                        }
1894                        continue;
1895                    }
1896
1897                    // Try block element geometry
1898                    if let Some(geo_block) = block_chars::get_geometric_block(ch) {
1899                        let rect = geo_block.to_pixel_rect(x0, y0, char_w, self.cell_height);
1900
1901                        // Extension for seamless blocks
1902                        let extension = 1.0;
1903                        let ext_x = if geo_block.x == 0.0 { extension } else { 0.0 };
1904                        let ext_y = if geo_block.y == 0.0 { extension } else { 0.0 };
1905                        let ext_w = if geo_block.x + geo_block.width >= 1.0 {
1906                            extension
1907                        } else {
1908                            0.0
1909                        };
1910                        let ext_h = if geo_block.y + geo_block.height >= 1.0 {
1911                            extension
1912                        } else {
1913                            0.0
1914                        };
1915
1916                        let final_x = rect.x - ext_x;
1917                        let final_y = rect.y - ext_y;
1918                        let final_w = rect.width + ext_x + ext_w;
1919                        let final_h = rect.height + ext_y + ext_h;
1920
1921                        if text_index < self.max_text_instances {
1922                            self.text_instances[text_index] = TextInstance {
1923                                position: [
1924                                    final_x / self.config.width as f32 * 2.0 - 1.0,
1925                                    1.0 - (final_y / self.config.height as f32 * 2.0),
1926                                ],
1927                                size: [
1928                                    final_w / self.config.width as f32 * 2.0,
1929                                    final_h / self.config.height as f32 * 2.0,
1930                                ],
1931                                tex_offset: [
1932                                    self.solid_pixel_offset.0 as f32 / 2048.0,
1933                                    self.solid_pixel_offset.1 as f32 / 2048.0,
1934                                ],
1935                                tex_size: [1.0 / 2048.0, 1.0 / 2048.0],
1936                                color: fg_color,
1937                                is_colored: 0,
1938                            };
1939                            text_index += 1;
1940                        }
1941                        continue;
1942                    }
1943                }
1944
1945                // Check if this character should be rendered as a monochrome symbol.
1946                // Also handle symbol + VS16 (U+FE0F): strip VS16, render monochrome.
1947                let (force_monochrome, base_char) = if chars.len() == 1 {
1948                    (super::atlas::should_render_as_symbol(ch), ch)
1949                } else if chars.len() == 2
1950                    && chars[1] == '\u{FE0F}'
1951                    && super::atlas::should_render_as_symbol(chars[0])
1952                {
1953                    (true, chars[0])
1954                } else {
1955                    (false, ch)
1956                };
1957
1958                // Regular glyph rendering — use single-char lookup when force_monochrome
1959                // strips VS16, otherwise grapheme-aware lookup for multi-char sequences.
1960                let mut glyph_result = if force_monochrome || chars.len() == 1 {
1961                    self.font_manager
1962                        .find_glyph(base_char, cell.bold, cell.italic)
1963                } else {
1964                    self.font_manager
1965                        .find_grapheme_glyph(&cell.grapheme, cell.bold, cell.italic)
1966                };
1967
1968                // Try to find a renderable glyph with font fallback for failures.
1969                let mut excluded_fonts: Vec<usize> = Vec::new();
1970                let resolved_info = loop {
1971                    match glyph_result {
1972                        Some((font_idx, glyph_id)) => {
1973                            let cache_key = ((font_idx as u64) << 32) | (glyph_id as u64);
1974                            if self.glyph_cache.contains_key(&cache_key) {
1975                                self.lru_remove(cache_key);
1976                                self.lru_push_front(cache_key);
1977                                break Some(self.glyph_cache.get(&cache_key).unwrap().clone());
1978                            } else if let Some(raster) =
1979                                self.rasterize_glyph(font_idx, glyph_id, force_monochrome)
1980                            {
1981                                let info = self.upload_glyph(cache_key, &raster);
1982                                self.glyph_cache.insert(cache_key, info.clone());
1983                                self.lru_push_front(cache_key);
1984                                break Some(info);
1985                            } else {
1986                                // Rasterization failed — try next font
1987                                excluded_fonts.push(font_idx);
1988                                glyph_result = self.font_manager.find_glyph_excluding(
1989                                    base_char,
1990                                    cell.bold,
1991                                    cell.italic,
1992                                    &excluded_fonts,
1993                                );
1994                                continue;
1995                            }
1996                        }
1997                        None => break None,
1998                    }
1999                };
2000
2001                // Last resort: colored emoji when no font has vector outlines
2002                let resolved_info = if resolved_info.is_none() && force_monochrome {
2003                    let mut glyph_result2 =
2004                        self.font_manager
2005                            .find_glyph(base_char, cell.bold, cell.italic);
2006                    loop {
2007                        match glyph_result2 {
2008                            Some((font_idx, glyph_id)) => {
2009                                let cache_key =
2010                                    ((font_idx as u64) << 32) | (glyph_id as u64) | (1u64 << 63);
2011                                if let Some(raster) =
2012                                    self.rasterize_glyph(font_idx, glyph_id, false)
2013                                {
2014                                    let info = self.upload_glyph(cache_key, &raster);
2015                                    self.glyph_cache.insert(cache_key, info.clone());
2016                                    self.lru_push_front(cache_key);
2017                                    break Some(info);
2018                                } else {
2019                                    glyph_result2 = self.font_manager.find_glyph_excluding(
2020                                        base_char,
2021                                        cell.bold,
2022                                        cell.italic,
2023                                        &[font_idx],
2024                                    );
2025                                    continue;
2026                                }
2027                            }
2028                            None => break None,
2029                        }
2030                    }
2031                } else {
2032                    resolved_info
2033                };
2034
2035                if let Some(info) = resolved_info {
2036                    let char_w = if cell.wide_char {
2037                        self.cell_width * 2.0
2038                    } else {
2039                        self.cell_width
2040                    };
2041                    let x0 = content_x + col_idx as f32 * self.cell_width;
2042                    let y0 = content_y + row as f32 * self.cell_height;
2043                    let x1 = x0 + char_w;
2044                    let y1 = y0 + self.cell_height;
2045
2046                    let cell_w = x1 - x0;
2047                    let cell_h = y1 - y0;
2048                    let scale_x = cell_w / char_w;
2049                    let scale_y = cell_h / self.cell_height;
2050
2051                    let baseline_offset = baseline_y - (content_y + row as f32 * self.cell_height);
2052                    let glyph_left = x0 + (info.bearing_x * scale_x).round();
2053                    let baseline_in_cell = (baseline_offset * scale_y).round();
2054                    let glyph_top = y0 + baseline_in_cell - info.bearing_y;
2055
2056                    let render_w = info.width as f32 * scale_x;
2057                    let render_h = info.height as f32 * scale_y;
2058
2059                    let (final_left, final_top, final_w, final_h) =
2060                        if chars.len() == 1 && block_chars::should_snap_to_boundaries(char_type) {
2061                            block_chars::snap_glyph_to_cell(
2062                                glyph_left, glyph_top, render_w, render_h, x0, y0, x1, y1, 3.0, 0.5,
2063                            )
2064                        } else {
2065                            (glyph_left, glyph_top, render_w, render_h)
2066                        };
2067
2068                    let fg_color = [
2069                        cell.fg_color[0] as f32 / 255.0,
2070                        cell.fg_color[1] as f32 / 255.0,
2071                        cell.fg_color[2] as f32 / 255.0,
2072                        text_alpha,
2073                    ];
2074
2075                    if text_index < self.max_text_instances {
2076                        self.text_instances[text_index] = TextInstance {
2077                            position: [
2078                                final_left / self.config.width as f32 * 2.0 - 1.0,
2079                                1.0 - (final_top / self.config.height as f32 * 2.0),
2080                            ],
2081                            size: [
2082                                final_w / self.config.width as f32 * 2.0,
2083                                final_h / self.config.height as f32 * 2.0,
2084                            ],
2085                            tex_offset: [info.x as f32 / 2048.0, info.y as f32 / 2048.0],
2086                            tex_size: [info.width as f32 / 2048.0, info.height as f32 / 2048.0],
2087                            color: fg_color,
2088                            is_colored: if info.is_colored { 1 } else { 0 },
2089                        };
2090                        text_index += 1;
2091                    }
2092                }
2093            }
2094        }
2095
2096        // Inject command separator line instances for split panes
2097        if self.command_separator_enabled && !separator_marks.is_empty() {
2098            let width_f = self.config.width as f32;
2099            let height_f = self.config.height as f32;
2100            let opacity_multiplier = viewport.opacity;
2101            for &(screen_row, exit_code, custom_color) in separator_marks {
2102                if screen_row < rows && bg_index < self.max_bg_instances {
2103                    let x0 = content_x;
2104                    let x1 = content_x + cols as f32 * self.cell_width;
2105                    let y0 = content_y + screen_row as f32 * self.cell_height;
2106                    let color = self.separator_color(exit_code, custom_color, opacity_multiplier);
2107                    self.bg_instances[bg_index] = BackgroundInstance {
2108                        position: [x0 / width_f * 2.0 - 1.0, 1.0 - (y0 / height_f * 2.0)],
2109                        size: [
2110                            (x1 - x0) / width_f * 2.0,
2111                            self.command_separator_thickness / height_f * 2.0,
2112                        ],
2113                        color,
2114                    };
2115                    bg_index += 1;
2116                }
2117            }
2118        }
2119        let _ = bg_index; // suppress unused warning
2120
2121        // Upload instance buffers to GPU
2122        self.queue.write_buffer(
2123            &self.bg_instance_buffer,
2124            0,
2125            bytemuck::cast_slice(&self.bg_instances),
2126        );
2127        self.queue.write_buffer(
2128            &self.text_instance_buffer,
2129            0,
2130            bytemuck::cast_slice(&self.text_instances),
2131        );
2132
2133        Ok(())
2134    }
2135}