Skip to main content

par_term_render/cell_renderer/
render.rs

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