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