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