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                        // Use grapheme-aware glyph lookup for multi-character sequences
890                        // (flags, emoji with skin tones, ZWJ sequences, combining chars)
891                        let glyph_result = if chars.len() > 1 {
892                            self.font_manager
893                                .find_grapheme_glyph(&grapheme, bold, italic)
894                        } else {
895                            self.font_manager.find_glyph(*ch, bold, italic)
896                        };
897
898                        if let Some((font_idx, glyph_id)) = glyph_result {
899                            let cache_key = ((font_idx as u64) << 32) | (glyph_id as u64);
900                            // Check if this character should be rendered as a monochrome symbol
901                            // (dingbats, etc.) rather than colorful emoji
902                            let force_monochrome =
903                                chars.len() == 1 && super::atlas::should_render_as_symbol(*ch);
904                            let info = if self.glyph_cache.contains_key(&cache_key) {
905                                // Move to front of LRU
906                                self.lru_remove(cache_key);
907                                self.lru_push_front(cache_key);
908                                self.glyph_cache.get(&cache_key).unwrap().clone()
909                            } else if let Some(raster) =
910                                self.rasterize_glyph(font_idx, glyph_id, force_monochrome)
911                            {
912                                let info = self.upload_glyph(cache_key, &raster);
913                                self.glyph_cache.insert(cache_key, info.clone());
914                                self.lru_push_front(cache_key);
915                                info
916                            } else {
917                                x_offset += self.cell_width;
918                                continue;
919                            };
920
921                            let char_w = if is_wide {
922                                self.cell_width * 2.0
923                            } else {
924                                self.cell_width
925                            };
926                            let x0 =
927                                (self.window_padding + self.content_offset_x + x_offset).round();
928                            let x1 =
929                                (self.window_padding + self.content_offset_x + x_offset + char_w)
930                                    .round();
931                            let y0 = (self.window_padding
932                                + self.content_offset_y
933                                + row as f32 * self.cell_height)
934                                .round();
935                            let y1 = (self.window_padding
936                                + self.content_offset_y
937                                + (row + 1) as f32 * self.cell_height)
938                                .round();
939
940                            let cell_w = x1 - x0;
941                            let cell_h = y1 - y0;
942
943                            let scale_x = cell_w / char_w;
944                            let scale_y = cell_h / self.cell_height;
945
946                            // Position glyph relative to snapped cell top-left.
947                            // Round the scaled baseline position once, then subtract
948                            // the integer bearing_y. This ensures all glyphs on a row
949                            // share the same rounded baseline, with bearing offsets
950                            // applied exactly (no scale_y on bearing avoids rounding
951                            // artifacts between glyphs with different bearings).
952                            let baseline_offset = baseline_y_unrounded
953                                - (self.window_padding
954                                    + self.content_offset_y
955                                    + row as f32 * self.cell_height);
956                            let glyph_left = x0 + (info.bearing_x * scale_x).round();
957                            let baseline_in_cell = (baseline_offset * scale_y).round();
958                            let glyph_top = y0 + baseline_in_cell - info.bearing_y;
959
960                            let render_w = info.width as f32 * scale_x;
961                            let render_h = info.height as f32 * scale_y;
962
963                            // For block characters that need font rendering (box drawing, etc.),
964                            // apply snapping to cell boundaries with sub-pixel extension.
965                            // Only apply to single-char graphemes (multi-char are never block chars)
966                            let (final_left, final_top, final_w, final_h) = if chars.len() == 1
967                                && block_chars::should_snap_to_boundaries(char_type)
968                            {
969                                // Snap threshold of 3 pixels, extension of 0.5 pixels
970                                block_chars::snap_glyph_to_cell(
971                                    glyph_left, glyph_top, render_w, render_h, x0, y0, x1, y1, 3.0,
972                                    0.5,
973                                )
974                            } else {
975                                (glyph_left, glyph_top, render_w, render_h)
976                            };
977
978                            row_text.push(TextInstance {
979                                position: [
980                                    final_left / self.config.width as f32 * 2.0 - 1.0,
981                                    1.0 - (final_top / self.config.height as f32 * 2.0),
982                                ],
983                                size: [
984                                    final_w / self.config.width as f32 * 2.0,
985                                    final_h / self.config.height as f32 * 2.0,
986                                ],
987                                tex_offset: [info.x as f32 / 2048.0, info.y as f32 / 2048.0],
988                                tex_size: [info.width as f32 / 2048.0, info.height as f32 / 2048.0],
989                                color: render_fg_color,
990                                is_colored: if info.is_colored { 1 } else { 0 },
991                            });
992                        }
993                    }
994                    x_offset += self.cell_width;
995                    current_col += 1;
996                }
997
998                // Underlines: emit thin rectangle(s) at the bottom of each underlined cell
999                {
1000                    let underline_thickness = (self.cell_height * 0.07).max(1.0).round();
1001                    let tex_offset = [
1002                        self.solid_pixel_offset.0 as f32 / 2048.0,
1003                        self.solid_pixel_offset.1 as f32 / 2048.0,
1004                    ];
1005                    let tex_size = [1.0 / 2048.0, 1.0 / 2048.0];
1006                    let y0 = self.window_padding
1007                        + self.content_offset_y
1008                        + (row + 1) as f32 * self.cell_height
1009                        - underline_thickness;
1010                    let ndc_y = 1.0 - (y0 / self.config.height as f32 * 2.0);
1011                    let ndc_h = underline_thickness / self.config.height as f32 * 2.0;
1012                    let is_stipple =
1013                        self.link_underline_style == par_term_config::LinkUnderlineStyle::Stipple;
1014                    // Stipple: 2px on, 2px off pattern
1015                    let stipple_on = 2.0_f32;
1016                    let stipple_off = 2.0_f32;
1017                    let stipple_period = stipple_on + stipple_off;
1018
1019                    for col_idx in 0..self.cols {
1020                        let cell = &self.cells[start + col_idx];
1021                        if !cell.underline || row_text.len() >= self.cols * 2 {
1022                            continue;
1023                        }
1024                        let text_alpha = if self.keep_text_opaque {
1025                            1.0
1026                        } else {
1027                            self.window_opacity
1028                        };
1029                        let fg = [
1030                            cell.fg_color[0] as f32 / 255.0,
1031                            cell.fg_color[1] as f32 / 255.0,
1032                            cell.fg_color[2] as f32 / 255.0,
1033                            text_alpha,
1034                        ];
1035                        let cell_x0 = self.window_padding
1036                            + self.content_offset_x
1037                            + col_idx as f32 * self.cell_width;
1038
1039                        if is_stipple {
1040                            // Emit alternating dot segments across the cell width
1041                            let mut px = 0.0;
1042                            while px < self.cell_width && row_text.len() < self.cols * 2 {
1043                                let seg_w = stipple_on.min(self.cell_width - px);
1044                                let x = cell_x0 + px;
1045                                row_text.push(TextInstance {
1046                                    position: [x / self.config.width as f32 * 2.0 - 1.0, ndc_y],
1047                                    size: [seg_w / self.config.width as f32 * 2.0, ndc_h],
1048                                    tex_offset,
1049                                    tex_size,
1050                                    color: fg,
1051                                    is_colored: 0,
1052                                });
1053                                px += stipple_period;
1054                            }
1055                        } else {
1056                            row_text.push(TextInstance {
1057                                position: [cell_x0 / self.config.width as f32 * 2.0 - 1.0, ndc_y],
1058                                size: [self.cell_width / self.config.width as f32 * 2.0, ndc_h],
1059                                tex_offset,
1060                                tex_size,
1061                                color: fg,
1062                                is_colored: 0,
1063                            });
1064                        }
1065                    }
1066                }
1067
1068                // Update CPU-side buffers
1069                let bg_start = row * self.cols;
1070                self.bg_instances[bg_start..bg_start + self.cols].copy_from_slice(&row_bg);
1071
1072                let text_start = row * self.cols * 2;
1073                // Clear row text segment first
1074                for i in 0..(self.cols * 2) {
1075                    self.text_instances[text_start + i].size = [0.0, 0.0];
1076                }
1077                // Copy new text instances
1078                let text_count = row_text.len().min(self.cols * 2);
1079                self.text_instances[text_start..text_start + text_count]
1080                    .copy_from_slice(&row_text[..text_count]);
1081
1082                // Update GPU-side buffers incrementally
1083                self.queue.write_buffer(
1084                    &self.bg_instance_buffer,
1085                    (bg_start * std::mem::size_of::<BackgroundInstance>()) as u64,
1086                    bytemuck::cast_slice(&row_bg),
1087                );
1088                self.queue.write_buffer(
1089                    &self.text_instance_buffer,
1090                    (text_start * std::mem::size_of::<TextInstance>()) as u64,
1091                    bytemuck::cast_slice(
1092                        &self.text_instances[text_start..text_start + self.cols * 2],
1093                    ),
1094                );
1095
1096                self.row_cache[row] = Some(RowCacheEntry {});
1097                self.dirty_rows[row] = false;
1098            }
1099        }
1100
1101        // Write cursor-related overlays to extra slots at the end of bg_instances
1102        // Slot layout: [0] cursor overlay (beam/underline), [1] guide, [2] shadow, [3-6] boost glow, [7-10] hollow outline
1103        let base_overlay_index = self.cols * self.rows;
1104        let mut overlay_instances = vec![
1105            BackgroundInstance {
1106                position: [0.0, 0.0],
1107                size: [0.0, 0.0],
1108                color: [0.0, 0.0, 0.0, 0.0],
1109            };
1110            10
1111        ];
1112
1113        // Check if cursor should be visible
1114        let cursor_visible = self.cursor_opacity > 0.0
1115            && !self.cursor_hidden_for_shader
1116            && (self.is_focused
1117                || self.unfocused_cursor_style != par_term_config::UnfocusedCursorStyle::Hidden);
1118
1119        // Calculate cursor pixel positions
1120        let cursor_col = self.cursor_pos.0;
1121        let cursor_row = self.cursor_pos.1;
1122        let cursor_x0 =
1123            self.window_padding + self.content_offset_x + cursor_col as f32 * self.cell_width;
1124        let cursor_x1 = cursor_x0 + self.cell_width;
1125        let cursor_y0 =
1126            self.window_padding + self.content_offset_y + cursor_row as f32 * self.cell_height;
1127        let cursor_y1 = cursor_y0 + self.cell_height;
1128
1129        // Slot 0: Cursor overlay (beam/underline) - handled by existing cursor_overlay
1130        overlay_instances[0] = self.cursor_overlay.unwrap_or(BackgroundInstance {
1131            position: [0.0, 0.0],
1132            size: [0.0, 0.0],
1133            color: [0.0, 0.0, 0.0, 0.0],
1134        });
1135
1136        // Slot 1: Cursor guide (horizontal line spanning full width at cursor row)
1137        if cursor_visible && self.cursor_guide_enabled {
1138            let guide_x0 = self.window_padding + self.content_offset_x;
1139            let guide_x1 =
1140                self.config.width as f32 - self.window_padding - self.content_inset_right;
1141            overlay_instances[1] = BackgroundInstance {
1142                position: [
1143                    guide_x0 / self.config.width as f32 * 2.0 - 1.0,
1144                    1.0 - (cursor_y0 / self.config.height as f32 * 2.0),
1145                ],
1146                size: [
1147                    (guide_x1 - guide_x0) / self.config.width as f32 * 2.0,
1148                    (cursor_y1 - cursor_y0) / self.config.height as f32 * 2.0,
1149                ],
1150                color: self.cursor_guide_color,
1151            };
1152        }
1153
1154        // Slot 2: Cursor shadow (offset rectangle behind cursor)
1155        if cursor_visible && self.cursor_shadow_enabled {
1156            let shadow_x0 = cursor_x0 + self.cursor_shadow_offset[0];
1157            let shadow_y0 = cursor_y0 + self.cursor_shadow_offset[1];
1158            overlay_instances[2] = BackgroundInstance {
1159                position: [
1160                    shadow_x0 / self.config.width as f32 * 2.0 - 1.0,
1161                    1.0 - (shadow_y0 / self.config.height as f32 * 2.0),
1162                ],
1163                size: [
1164                    self.cell_width / self.config.width as f32 * 2.0,
1165                    self.cell_height / self.config.height as f32 * 2.0,
1166                ],
1167                color: self.cursor_shadow_color,
1168            };
1169        }
1170
1171        // Slot 3: Cursor boost glow (larger rectangle around cursor with low opacity)
1172        if cursor_visible && self.cursor_boost > 0.0 {
1173            let glow_expand = 4.0 * self.scale_factor * self.cursor_boost; // Expand by up to 4 logical pixels
1174            let glow_x0 = cursor_x0 - glow_expand;
1175            let glow_y0 = cursor_y0 - glow_expand;
1176            let glow_w = self.cell_width + glow_expand * 2.0;
1177            let glow_h = self.cell_height + glow_expand * 2.0;
1178            overlay_instances[3] = BackgroundInstance {
1179                position: [
1180                    glow_x0 / self.config.width as f32 * 2.0 - 1.0,
1181                    1.0 - (glow_y0 / self.config.height as f32 * 2.0),
1182                ],
1183                size: [
1184                    glow_w / self.config.width as f32 * 2.0,
1185                    glow_h / self.config.height as f32 * 2.0,
1186                ],
1187                color: [
1188                    self.cursor_boost_color[0],
1189                    self.cursor_boost_color[1],
1190                    self.cursor_boost_color[2],
1191                    self.cursor_boost * 0.3 * self.cursor_opacity, // Max 30% alpha
1192                ],
1193            };
1194        }
1195
1196        // Slots 4-7: Hollow cursor outline (4 thin rectangles forming a border)
1197        // Rendered when unfocused with hollow style and block cursor
1198        let render_hollow = cursor_visible
1199            && !self.is_focused
1200            && self.unfocused_cursor_style == par_term_config::UnfocusedCursorStyle::Hollow;
1201
1202        if render_hollow {
1203            use par_term_emu_core_rust::cursor::CursorStyle;
1204            let is_block = matches!(
1205                self.cursor_style,
1206                CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock
1207            );
1208
1209            if is_block {
1210                let border_width = 2.0; // 2 pixel border
1211                let color = [
1212                    self.cursor_color[0],
1213                    self.cursor_color[1],
1214                    self.cursor_color[2],
1215                    self.cursor_opacity,
1216                ];
1217
1218                // Top border
1219                overlay_instances[4] = BackgroundInstance {
1220                    position: [
1221                        cursor_x0 / self.config.width as f32 * 2.0 - 1.0,
1222                        1.0 - (cursor_y0 / self.config.height as f32 * 2.0),
1223                    ],
1224                    size: [
1225                        self.cell_width / self.config.width as f32 * 2.0,
1226                        border_width / self.config.height as f32 * 2.0,
1227                    ],
1228                    color,
1229                };
1230
1231                // Bottom border
1232                overlay_instances[5] = BackgroundInstance {
1233                    position: [
1234                        cursor_x0 / self.config.width as f32 * 2.0 - 1.0,
1235                        1.0 - ((cursor_y1 - border_width) / self.config.height as f32 * 2.0),
1236                    ],
1237                    size: [
1238                        self.cell_width / self.config.width as f32 * 2.0,
1239                        border_width / self.config.height as f32 * 2.0,
1240                    ],
1241                    color,
1242                };
1243
1244                // Left border
1245                overlay_instances[6] = BackgroundInstance {
1246                    position: [
1247                        cursor_x0 / self.config.width as f32 * 2.0 - 1.0,
1248                        1.0 - ((cursor_y0 + border_width) / self.config.height as f32 * 2.0),
1249                    ],
1250                    size: [
1251                        border_width / self.config.width as f32 * 2.0,
1252                        (self.cell_height - border_width * 2.0) / self.config.height as f32 * 2.0,
1253                    ],
1254                    color,
1255                };
1256
1257                // Right border
1258                overlay_instances[7] = BackgroundInstance {
1259                    position: [
1260                        (cursor_x1 - border_width) / self.config.width as f32 * 2.0 - 1.0,
1261                        1.0 - ((cursor_y0 + border_width) / self.config.height as f32 * 2.0),
1262                    ],
1263                    size: [
1264                        border_width / self.config.width as f32 * 2.0,
1265                        (self.cell_height - border_width * 2.0) / self.config.height as f32 * 2.0,
1266                    ],
1267                    color,
1268                };
1269            }
1270        }
1271
1272        // Write all overlay instances to GPU buffer
1273        for (i, instance) in overlay_instances.iter().enumerate() {
1274            self.bg_instances[base_overlay_index + i] = *instance;
1275        }
1276        self.queue.write_buffer(
1277            &self.bg_instance_buffer,
1278            (base_overlay_index * std::mem::size_of::<BackgroundInstance>()) as u64,
1279            bytemuck::cast_slice(&overlay_instances),
1280        );
1281
1282        // Write command separator line instances after cursor overlay slots
1283        let separator_base = self.cols * self.rows + 10;
1284        let mut separator_instances = vec![
1285            BackgroundInstance {
1286                position: [0.0, 0.0],
1287                size: [0.0, 0.0],
1288                color: [0.0, 0.0, 0.0, 0.0],
1289            };
1290            self.rows
1291        ];
1292
1293        if self.command_separator_enabled {
1294            let width_f = self.config.width as f32;
1295            let height_f = self.config.height as f32;
1296            for &(screen_row, exit_code, custom_color) in &self.visible_separator_marks {
1297                if screen_row < self.rows {
1298                    let x0 = self.window_padding + self.content_offset_x;
1299                    let x1 = width_f - self.window_padding - self.content_inset_right;
1300                    let y0 = self.window_padding
1301                        + self.content_offset_y
1302                        + screen_row as f32 * self.cell_height;
1303                    let color = self.separator_color(exit_code, custom_color, 1.0);
1304                    separator_instances[screen_row] = BackgroundInstance {
1305                        position: [x0 / width_f * 2.0 - 1.0, 1.0 - (y0 / height_f * 2.0)],
1306                        size: [
1307                            (x1 - x0) / width_f * 2.0,
1308                            self.command_separator_thickness / height_f * 2.0,
1309                        ],
1310                        color,
1311                    };
1312                }
1313            }
1314        }
1315
1316        for (i, instance) in separator_instances.iter().enumerate() {
1317            if separator_base + i < self.max_bg_instances {
1318                self.bg_instances[separator_base + i] = *instance;
1319            }
1320        }
1321        let separator_byte_offset = separator_base * std::mem::size_of::<BackgroundInstance>();
1322        let separator_byte_count =
1323            separator_instances.len() * std::mem::size_of::<BackgroundInstance>();
1324        if separator_byte_offset + separator_byte_count
1325            <= self.max_bg_instances * std::mem::size_of::<BackgroundInstance>()
1326        {
1327            self.queue.write_buffer(
1328                &self.bg_instance_buffer,
1329                separator_byte_offset as u64,
1330                bytemuck::cast_slice(&separator_instances),
1331            );
1332        }
1333
1334        Ok(())
1335    }
1336
1337    /// Render a single pane's content within a viewport to an existing surface texture
1338    ///
1339    /// This method renders cells to a specific region of the render target,
1340    /// using a GPU scissor rect to clip to the pane bounds.
1341    ///
1342    /// # Arguments
1343    /// * `surface_view` - The texture view to render to
1344    /// * `viewport` - The pane's viewport (position, size, focus state, opacity)
1345    /// * `cells` - The cells to render (should match viewport grid size)
1346    /// * `cols` - Number of columns in the cell grid
1347    /// * `rows` - Number of rows in the cell grid
1348    /// * `cursor_pos` - Cursor position (col, row) within this pane, or None if no cursor
1349    /// * `cursor_opacity` - Cursor opacity (0.0 = hidden, 1.0 = fully visible)
1350    /// * `show_scrollbar` - Whether to render the scrollbar for this pane
1351    /// * `clear_first` - If true, clears the viewport region before rendering
1352    /// * `skip_background_image` - If true, skip rendering the background image. Use this
1353    ///   when the background image has already been rendered full-screen (for split panes).
1354    #[allow(dead_code, clippy::too_many_arguments)]
1355    pub fn render_pane_to_view(
1356        &mut self,
1357        surface_view: &wgpu::TextureView,
1358        viewport: &PaneViewport,
1359        cells: &[Cell],
1360        cols: usize,
1361        rows: usize,
1362        cursor_pos: Option<(usize, usize)>,
1363        cursor_opacity: f32,
1364        show_scrollbar: bool,
1365        clear_first: bool,
1366        skip_background_image: bool,
1367        separator_marks: &[SeparatorMark],
1368        pane_background: Option<&par_term_config::PaneBackground>,
1369    ) -> Result<()> {
1370        // Build instance buffers for this pane's cells
1371        // Skip solid background fill if background (shader/image) was already rendered full-screen
1372        self.build_pane_instance_buffers(
1373            viewport,
1374            cells,
1375            cols,
1376            rows,
1377            cursor_pos,
1378            cursor_opacity,
1379            skip_background_image,
1380            separator_marks,
1381        )?;
1382
1383        // Pre-create per-pane background bind group if needed (must happen before render pass).
1384        // Per-pane backgrounds are explicit user overrides and always created,
1385        // even when a custom shader or global background would normally be skipped.
1386        let pane_bg_resources = if let Some(pane_bg) = pane_background
1387            && let Some(ref path) = pane_bg.image_path
1388        {
1389            self.pane_bg_cache.get(path.as_str()).map(|entry| {
1390                self.create_pane_bg_bind_group(
1391                    entry,
1392                    viewport.x,
1393                    viewport.y,
1394                    viewport.width,
1395                    viewport.height,
1396                    pane_bg.mode,
1397                    pane_bg.opacity,
1398                    pane_bg.darken,
1399                )
1400            })
1401        } else {
1402            None
1403        };
1404
1405        let mut encoder = self
1406            .device
1407            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1408                label: Some("pane render encoder"),
1409            });
1410
1411        // Determine load operation and clear color
1412        let load_op = if clear_first {
1413            let clear_color = if self.bg_is_solid_color {
1414                wgpu::Color {
1415                    r: self.solid_bg_color[0] as f64
1416                        * self.window_opacity as f64
1417                        * viewport.opacity as f64,
1418                    g: self.solid_bg_color[1] as f64
1419                        * self.window_opacity as f64
1420                        * viewport.opacity as f64,
1421                    b: self.solid_bg_color[2] as f64
1422                        * self.window_opacity as f64
1423                        * viewport.opacity as f64,
1424                    a: self.window_opacity as f64 * viewport.opacity as f64,
1425                }
1426            } else {
1427                wgpu::Color {
1428                    r: self.background_color[0] as f64
1429                        * self.window_opacity as f64
1430                        * viewport.opacity as f64,
1431                    g: self.background_color[1] as f64
1432                        * self.window_opacity as f64
1433                        * viewport.opacity as f64,
1434                    b: self.background_color[2] as f64
1435                        * self.window_opacity as f64
1436                        * viewport.opacity as f64,
1437                    a: self.window_opacity as f64 * viewport.opacity as f64,
1438                }
1439            };
1440            wgpu::LoadOp::Clear(clear_color)
1441        } else {
1442            wgpu::LoadOp::Load
1443        };
1444
1445        {
1446            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1447                label: Some("pane render pass"),
1448                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1449                    view: surface_view,
1450                    resolve_target: None,
1451                    ops: wgpu::Operations {
1452                        load: load_op,
1453                        store: wgpu::StoreOp::Store,
1454                    },
1455                    depth_slice: None,
1456                })],
1457                depth_stencil_attachment: None,
1458                timestamp_writes: None,
1459                occlusion_query_set: None,
1460            });
1461
1462            // Set scissor rect to clip rendering to pane bounds
1463            let (sx, sy, sw, sh) = viewport.to_scissor_rect();
1464            render_pass.set_scissor_rect(sx, sy, sw, sh);
1465
1466            // Render per-pane background image within scissor rect.
1467            // Per-pane backgrounds are explicit user overrides and always render,
1468            // even when a custom shader or global background is active.
1469            if let Some((ref bind_group, ref _buf)) = pane_bg_resources {
1470                render_pass.set_pipeline(&self.bg_image_pipeline);
1471                render_pass.set_bind_group(0, bind_group, &[]);
1472                render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
1473                render_pass.draw(0..4, 0..1);
1474            }
1475
1476            // Render cell backgrounds
1477            render_pass.set_pipeline(&self.bg_pipeline);
1478            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
1479            render_pass.set_vertex_buffer(1, self.bg_instance_buffer.slice(..));
1480            render_pass.draw(0..4, 0..self.max_bg_instances as u32);
1481
1482            // Render text
1483            render_pass.set_pipeline(&self.text_pipeline);
1484            render_pass.set_bind_group(0, &self.text_bind_group, &[]);
1485            render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
1486            render_pass.set_vertex_buffer(1, self.text_instance_buffer.slice(..));
1487            render_pass.draw(0..4, 0..self.max_text_instances as u32);
1488
1489            // Render scrollbar if requested (uses its own scissor rect internally)
1490            if show_scrollbar {
1491                // Reset scissor to full surface for scrollbar
1492                render_pass.set_scissor_rect(0, 0, self.config.width, self.config.height);
1493                self.scrollbar.render(&mut render_pass);
1494            }
1495        }
1496
1497        self.queue.submit(std::iter::once(encoder.finish()));
1498        Ok(())
1499    }
1500
1501    /// Build instance buffers for a pane's cells with viewport offset
1502    ///
1503    /// This is similar to `build_instance_buffers` but adjusts all positions
1504    /// to be relative to the viewport origin.
1505    ///
1506    /// # Arguments
1507    /// * `skip_solid_background` - If true, skip adding a solid background fill for the viewport.
1508    ///   Use when a custom shader or background image was already rendered full-screen.
1509    #[allow(clippy::too_many_arguments)]
1510    fn build_pane_instance_buffers(
1511        &mut self,
1512        viewport: &PaneViewport,
1513        cells: &[Cell],
1514        cols: usize,
1515        rows: usize,
1516        cursor_pos: Option<(usize, usize)>,
1517        cursor_opacity: f32,
1518        skip_solid_background: bool,
1519        separator_marks: &[SeparatorMark],
1520    ) -> Result<()> {
1521        let _shaping_options = ShapingOptions {
1522            enable_ligatures: self.enable_ligatures,
1523            enable_kerning: self.enable_kerning,
1524            ..Default::default()
1525        };
1526
1527        // Clear previous instance buffers
1528        for instance in &mut self.bg_instances {
1529            instance.size = [0.0, 0.0];
1530            instance.color = [0.0, 0.0, 0.0, 0.0];
1531        }
1532
1533        // Add a background rectangle covering the entire pane viewport (unless skipped)
1534        // This ensures the pane has a proper background even when cells are skipped.
1535        // Skip when a custom shader or background image was already rendered full-screen.
1536        let bg_start_index = if !skip_solid_background && !self.bg_instances.is_empty() {
1537            let bg_color = self.background_color;
1538            let opacity = self.window_opacity * viewport.opacity;
1539            self.bg_instances[0] = super::types::BackgroundInstance {
1540                position: [viewport.x, viewport.y],
1541                size: [viewport.width, viewport.height],
1542                color: [
1543                    bg_color[0] * opacity,
1544                    bg_color[1] * opacity,
1545                    bg_color[2] * opacity,
1546                    opacity,
1547                ],
1548            };
1549            1 // Start cell backgrounds at index 1
1550        } else {
1551            0 // Start cell backgrounds at index 0 (no viewport fill)
1552        };
1553
1554        for instance in &mut self.text_instances {
1555            instance.size = [0.0, 0.0];
1556        }
1557
1558        // Start at bg_start_index (1 if viewport fill was added, 0 otherwise)
1559        let mut bg_index = bg_start_index;
1560        let mut text_index = 0;
1561
1562        // Content offset - positions are relative to content area (with padding applied)
1563        let (content_x, content_y) = viewport.content_origin();
1564        let opacity_multiplier = viewport.opacity;
1565
1566        for row in 0..rows {
1567            let row_start = row * cols;
1568            let row_end = (row + 1) * cols;
1569            if row_start >= cells.len() {
1570                break;
1571            }
1572            let row_cells = &cells[row_start..row_end.min(cells.len())];
1573
1574            // Background - use RLE to merge consecutive cells with same color
1575            let mut col = 0;
1576            while col < row_cells.len() {
1577                let cell = &row_cells[col];
1578                let is_default_bg = (cell.bg_color[0] as f32 / 255.0 - self.background_color[0])
1579                    .abs()
1580                    < 0.001
1581                    && (cell.bg_color[1] as f32 / 255.0 - self.background_color[1]).abs() < 0.001
1582                    && (cell.bg_color[2] as f32 / 255.0 - self.background_color[2]).abs() < 0.001;
1583
1584                // Check for cursor at this position
1585                let has_cursor = cursor_pos.is_some_and(|(cx, cy)| cx == col && cy == row)
1586                    && cursor_opacity > 0.0
1587                    && !self.cursor_hidden_for_shader;
1588
1589                if is_default_bg && !has_cursor {
1590                    col += 1;
1591                    continue;
1592                }
1593
1594                // Calculate background color with alpha and pane opacity
1595                let bg_alpha =
1596                    if self.transparency_affects_only_default_background && !is_default_bg {
1597                        1.0
1598                    } else {
1599                        self.window_opacity
1600                    };
1601                let pane_alpha = bg_alpha * opacity_multiplier;
1602                let mut bg_color = [
1603                    cell.bg_color[0] as f32 / 255.0,
1604                    cell.bg_color[1] as f32 / 255.0,
1605                    cell.bg_color[2] as f32 / 255.0,
1606                    pane_alpha,
1607                ];
1608
1609                // Handle cursor at this position
1610                if has_cursor {
1611                    use par_term_emu_core_rust::cursor::CursorStyle;
1612                    match self.cursor_style {
1613                        CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock => {
1614                            for (bg, &cursor) in bg_color.iter_mut().take(3).zip(&self.cursor_color)
1615                            {
1616                                *bg = *bg * (1.0 - cursor_opacity) + cursor * cursor_opacity;
1617                            }
1618                            bg_color[3] = bg_color[3].max(cursor_opacity * opacity_multiplier);
1619                        }
1620                        _ => {}
1621                    }
1622
1623                    // Cursor cell can't be merged
1624                    let x0 = content_x + col as f32 * self.cell_width;
1625                    let y0 = content_y + row as f32 * self.cell_height;
1626                    let x1 = x0 + self.cell_width;
1627                    let y1 = y0 + self.cell_height;
1628
1629                    if bg_index < self.max_bg_instances {
1630                        self.bg_instances[bg_index] = BackgroundInstance {
1631                            position: [
1632                                x0 / self.config.width as f32 * 2.0 - 1.0,
1633                                1.0 - (y0 / self.config.height as f32 * 2.0),
1634                            ],
1635                            size: [
1636                                (x1 - x0) / self.config.width as f32 * 2.0,
1637                                (y1 - y0) / self.config.height as f32 * 2.0,
1638                            ],
1639                            color: bg_color,
1640                        };
1641                        bg_index += 1;
1642                    }
1643                    col += 1;
1644                    continue;
1645                }
1646
1647                // RLE: Find run of consecutive cells with same background color
1648                let start_col = col;
1649                let run_color = cell.bg_color;
1650                col += 1;
1651                while col < row_cells.len() {
1652                    let next_cell = &row_cells[col];
1653                    let next_has_cursor = cursor_pos.is_some_and(|(cx, cy)| cx == col && cy == row)
1654                        && cursor_opacity > 0.0;
1655                    if next_cell.bg_color != run_color || next_has_cursor {
1656                        break;
1657                    }
1658                    col += 1;
1659                }
1660                let run_length = col - start_col;
1661
1662                // Create single quad spanning entire run
1663                let x0 = content_x + start_col as f32 * self.cell_width;
1664                let x1 = content_x + (start_col + run_length) as f32 * self.cell_width;
1665                let y0 = content_y + row as f32 * self.cell_height;
1666                let y1 = y0 + self.cell_height;
1667
1668                if bg_index < self.max_bg_instances {
1669                    self.bg_instances[bg_index] = BackgroundInstance {
1670                        position: [
1671                            x0 / self.config.width as f32 * 2.0 - 1.0,
1672                            1.0 - (y0 / self.config.height as f32 * 2.0),
1673                        ],
1674                        size: [
1675                            (x1 - x0) / self.config.width as f32 * 2.0,
1676                            (y1 - y0) / self.config.height as f32 * 2.0,
1677                        ],
1678                        color: bg_color,
1679                    };
1680                    bg_index += 1;
1681                }
1682            }
1683
1684            // Text rendering
1685            let natural_line_height = self.font_ascent + self.font_descent + self.font_leading;
1686            let vertical_padding = (self.cell_height - natural_line_height).max(0.0) / 2.0;
1687            let baseline_y =
1688                content_y + (row as f32 * self.cell_height) + vertical_padding + self.font_ascent;
1689
1690            // Compute text alpha - force opaque if keep_text_opaque is enabled
1691            let text_alpha = if self.keep_text_opaque {
1692                opacity_multiplier // Only apply pane dimming, not window transparency
1693            } else {
1694                self.window_opacity * opacity_multiplier
1695            };
1696
1697            for (col_idx, cell) in row_cells.iter().enumerate() {
1698                if cell.wide_char_spacer || cell.grapheme == " " {
1699                    continue;
1700                }
1701
1702                let chars: Vec<char> = cell.grapheme.chars().collect();
1703                if chars.is_empty() {
1704                    continue;
1705                }
1706
1707                let ch = chars[0];
1708
1709                // Check for block characters that should be rendered geometrically
1710                let char_type = block_chars::classify_char(ch);
1711                if chars.len() == 1 && block_chars::should_render_geometrically(char_type) {
1712                    let char_w = if cell.wide_char {
1713                        self.cell_width * 2.0
1714                    } else {
1715                        self.cell_width
1716                    };
1717                    let x0 = content_x + col_idx as f32 * self.cell_width;
1718                    let y0 = content_y + row as f32 * self.cell_height;
1719
1720                    let fg_color = [
1721                        cell.fg_color[0] as f32 / 255.0,
1722                        cell.fg_color[1] as f32 / 255.0,
1723                        cell.fg_color[2] as f32 / 255.0,
1724                        text_alpha,
1725                    ];
1726
1727                    // Try box drawing geometry first
1728                    let aspect_ratio = self.cell_height / char_w;
1729                    if let Some(box_geo) = block_chars::get_box_drawing_geometry(ch, aspect_ratio) {
1730                        for segment in &box_geo.segments {
1731                            let rect = segment.to_pixel_rect(x0, y0, char_w, self.cell_height);
1732
1733                            // Extension for seamless lines
1734                            let extension = 1.0;
1735                            let ext_x = if segment.x <= 0.01 { extension } else { 0.0 };
1736                            let ext_y = if segment.y <= 0.01 { extension } else { 0.0 };
1737                            let ext_w = if segment.x + segment.width >= 0.99 {
1738                                extension
1739                            } else {
1740                                0.0
1741                            };
1742                            let ext_h = if segment.y + segment.height >= 0.99 {
1743                                extension
1744                            } else {
1745                                0.0
1746                            };
1747
1748                            let final_x = rect.x - ext_x;
1749                            let final_y = rect.y - ext_y;
1750                            let final_w = rect.width + ext_x + ext_w;
1751                            let final_h = rect.height + ext_y + ext_h;
1752
1753                            if text_index < self.max_text_instances {
1754                                self.text_instances[text_index] = TextInstance {
1755                                    position: [
1756                                        final_x / self.config.width as f32 * 2.0 - 1.0,
1757                                        1.0 - (final_y / self.config.height as f32 * 2.0),
1758                                    ],
1759                                    size: [
1760                                        final_w / self.config.width as f32 * 2.0,
1761                                        final_h / self.config.height as f32 * 2.0,
1762                                    ],
1763                                    tex_offset: [
1764                                        self.solid_pixel_offset.0 as f32 / 2048.0,
1765                                        self.solid_pixel_offset.1 as f32 / 2048.0,
1766                                    ],
1767                                    tex_size: [1.0 / 2048.0, 1.0 / 2048.0],
1768                                    color: fg_color,
1769                                    is_colored: 0,
1770                                };
1771                                text_index += 1;
1772                            }
1773                        }
1774                        continue;
1775                    }
1776
1777                    // Try block element geometry
1778                    if let Some(geo_block) = block_chars::get_geometric_block(ch) {
1779                        let rect = geo_block.to_pixel_rect(x0, y0, char_w, self.cell_height);
1780
1781                        // Extension for seamless blocks
1782                        let extension = 1.0;
1783                        let ext_x = if geo_block.x == 0.0 { extension } else { 0.0 };
1784                        let ext_y = if geo_block.y == 0.0 { extension } else { 0.0 };
1785                        let ext_w = if geo_block.x + geo_block.width >= 1.0 {
1786                            extension
1787                        } else {
1788                            0.0
1789                        };
1790                        let ext_h = if geo_block.y + geo_block.height >= 1.0 {
1791                            extension
1792                        } else {
1793                            0.0
1794                        };
1795
1796                        let final_x = rect.x - ext_x;
1797                        let final_y = rect.y - ext_y;
1798                        let final_w = rect.width + ext_x + ext_w;
1799                        let final_h = rect.height + ext_y + ext_h;
1800
1801                        if text_index < self.max_text_instances {
1802                            self.text_instances[text_index] = TextInstance {
1803                                position: [
1804                                    final_x / self.config.width as f32 * 2.0 - 1.0,
1805                                    1.0 - (final_y / self.config.height as f32 * 2.0),
1806                                ],
1807                                size: [
1808                                    final_w / self.config.width as f32 * 2.0,
1809                                    final_h / self.config.height as f32 * 2.0,
1810                                ],
1811                                tex_offset: [
1812                                    self.solid_pixel_offset.0 as f32 / 2048.0,
1813                                    self.solid_pixel_offset.1 as f32 / 2048.0,
1814                                ],
1815                                tex_size: [1.0 / 2048.0, 1.0 / 2048.0],
1816                                color: fg_color,
1817                                is_colored: 0,
1818                            };
1819                            text_index += 1;
1820                        }
1821                        continue;
1822                    }
1823                }
1824
1825                // Regular glyph rendering
1826                let glyph_result = if chars.len() > 1 {
1827                    self.font_manager
1828                        .find_grapheme_glyph(&cell.grapheme, cell.bold, cell.italic)
1829                } else {
1830                    self.font_manager.find_glyph(ch, cell.bold, cell.italic)
1831                };
1832
1833                if let Some((font_idx, glyph_id)) = glyph_result {
1834                    let cache_key = ((font_idx as u64) << 32) | (glyph_id as u64);
1835                    // Check if this character should be rendered as a monochrome symbol
1836                    // (dingbats, etc.) rather than colorful emoji
1837                    let force_monochrome =
1838                        chars.len() == 1 && super::atlas::should_render_as_symbol(ch);
1839                    let info = if self.glyph_cache.contains_key(&cache_key) {
1840                        self.lru_remove(cache_key);
1841                        self.lru_push_front(cache_key);
1842                        self.glyph_cache.get(&cache_key).unwrap().clone()
1843                    } else if let Some(raster) =
1844                        self.rasterize_glyph(font_idx, glyph_id, force_monochrome)
1845                    {
1846                        let info = self.upload_glyph(cache_key, &raster);
1847                        self.glyph_cache.insert(cache_key, info.clone());
1848                        self.lru_push_front(cache_key);
1849                        info
1850                    } else {
1851                        continue;
1852                    };
1853
1854                    let char_w = if cell.wide_char {
1855                        self.cell_width * 2.0
1856                    } else {
1857                        self.cell_width
1858                    };
1859                    let x0 = content_x + col_idx as f32 * self.cell_width;
1860                    let y0 = content_y + row as f32 * self.cell_height;
1861                    let x1 = x0 + char_w;
1862                    let y1 = y0 + self.cell_height;
1863
1864                    let cell_w = x1 - x0;
1865                    let cell_h = y1 - y0;
1866                    let scale_x = cell_w / char_w;
1867                    let scale_y = cell_h / self.cell_height;
1868
1869                    let baseline_offset = baseline_y - (content_y + row as f32 * self.cell_height);
1870                    let glyph_left = x0 + (info.bearing_x * scale_x).round();
1871                    let baseline_in_cell = (baseline_offset * scale_y).round();
1872                    let glyph_top = y0 + baseline_in_cell - info.bearing_y;
1873
1874                    let render_w = info.width as f32 * scale_x;
1875                    let render_h = info.height as f32 * scale_y;
1876
1877                    let (final_left, final_top, final_w, final_h) =
1878                        if chars.len() == 1 && block_chars::should_snap_to_boundaries(char_type) {
1879                            block_chars::snap_glyph_to_cell(
1880                                glyph_left, glyph_top, render_w, render_h, x0, y0, x1, y1, 3.0, 0.5,
1881                            )
1882                        } else {
1883                            (glyph_left, glyph_top, render_w, render_h)
1884                        };
1885
1886                    let fg_color = [
1887                        cell.fg_color[0] as f32 / 255.0,
1888                        cell.fg_color[1] as f32 / 255.0,
1889                        cell.fg_color[2] as f32 / 255.0,
1890                        text_alpha,
1891                    ];
1892
1893                    if text_index < self.max_text_instances {
1894                        self.text_instances[text_index] = TextInstance {
1895                            position: [
1896                                final_left / self.config.width as f32 * 2.0 - 1.0,
1897                                1.0 - (final_top / self.config.height as f32 * 2.0),
1898                            ],
1899                            size: [
1900                                final_w / self.config.width as f32 * 2.0,
1901                                final_h / self.config.height as f32 * 2.0,
1902                            ],
1903                            tex_offset: [info.x as f32 / 2048.0, info.y as f32 / 2048.0],
1904                            tex_size: [info.width as f32 / 2048.0, info.height as f32 / 2048.0],
1905                            color: fg_color,
1906                            is_colored: if info.is_colored { 1 } else { 0 },
1907                        };
1908                        text_index += 1;
1909                    }
1910                }
1911            }
1912        }
1913
1914        // Inject command separator line instances for split panes
1915        if self.command_separator_enabled && !separator_marks.is_empty() {
1916            let width_f = self.config.width as f32;
1917            let height_f = self.config.height as f32;
1918            let opacity_multiplier = viewport.opacity;
1919            for &(screen_row, exit_code, custom_color) in separator_marks {
1920                if screen_row < rows && bg_index < self.max_bg_instances {
1921                    let x0 = content_x;
1922                    let x1 = content_x + cols as f32 * self.cell_width;
1923                    let y0 = content_y + screen_row as f32 * self.cell_height;
1924                    let color = self.separator_color(exit_code, custom_color, opacity_multiplier);
1925                    self.bg_instances[bg_index] = BackgroundInstance {
1926                        position: [x0 / width_f * 2.0 - 1.0, 1.0 - (y0 / height_f * 2.0)],
1927                        size: [
1928                            (x1 - x0) / width_f * 2.0,
1929                            self.command_separator_thickness / height_f * 2.0,
1930                        ],
1931                        color,
1932                    };
1933                    bg_index += 1;
1934                }
1935            }
1936        }
1937        let _ = bg_index; // suppress unused warning
1938
1939        // Upload instance buffers to GPU
1940        self.queue.write_buffer(
1941            &self.bg_instance_buffer,
1942            0,
1943            bytemuck::cast_slice(&self.bg_instances),
1944        );
1945        self.queue.write_buffer(
1946            &self.text_instance_buffer,
1947            0,
1948            bytemuck::cast_slice(&self.text_instances),
1949        );
1950
1951        Ok(())
1952    }
1953}