par_term_render/cell_renderer/pane_render/mod.rs
1// ARC-009 TODO: This file is 1035 lines (limit: 800). Extract the following into
2// sibling modules within this pane_render/ directory:
3//
4// rle_merge.rs — RLE background-color merge inner loop (currently inlined in
5// build_pane_instance_buffers). Extract helper:
6// `fn merge_rle_bg_spans(cells, ...) -> Vec<BackgroundInstance>`
7//
8// powerline.rs — Powerline fringe-extension logic (~80 lines). Extract helper:
9// `fn extend_powerline_fringes(spans, cell_w, cell_h) -> Vec<BackgroundInstance>`
10//
11// IMPORTANT invariants to preserve (see MEMORY.md and CLAUDE.md):
12// • 3-phase draw ordering: bg instances → text instances → cursor overlays
13// • `fill_default_bg_cells` controls default-bg skip in bg-image mode
14// • `skip_solid_background` must NOT be used to gate default-bg rendering
15//
16// Tracking: Issue ARC-009 in AUDIT.md.
17
18use super::block_chars;
19use super::instance_buffers::{
20 CURSOR_BRIGHTNESS_THRESHOLD, STIPPLE_OFF_PX, STIPPLE_ON_PX, UNDERLINE_HEIGHT_RATIO,
21};
22use super::{BackgroundInstance, Cell, CellRenderer, PaneViewport, TextInstance};
23use anyhow::Result;
24use par_term_config::{SeparatorMark, color_u8x4_rgb_to_f32, color_u8x4_rgb_to_f32_a};
25mod cursor_overlays;
26mod separators;
27
28use cursor_overlays::CursorOverlayParams;
29
30/// Atlas texture size in pixels. Must match the value used at atlas creation time.
31/// See `PREFERRED_ATLAS_SIZE` in `pipeline.rs` and `atlas_size` on `CellRendererAtlas`.
32pub(crate) const ATLAS_SIZE: f32 = 2048.0;
33
34/// Parameters for rendering a single pane to a surface texture view.
35pub struct PaneRenderViewParams<'a> {
36 pub viewport: &'a PaneViewport,
37 pub cells: &'a [Cell],
38 pub cols: usize,
39 pub rows: usize,
40 pub cursor_pos: Option<(usize, usize)>,
41 pub cursor_opacity: f32,
42 pub show_scrollbar: bool,
43 pub clear_first: bool,
44 pub skip_background_image: bool,
45 /// When true, emit background quads for default-bg cells (fills gaps in background-image mode).
46 /// Set to false in custom shader mode so the shader output shows through.
47 pub fill_default_bg_cells: bool,
48 pub separator_marks: &'a [SeparatorMark],
49 pub pane_background: Option<&'a par_term_config::PaneBackground>,
50}
51
52/// Parameters for building GPU instance buffers for a pane.
53pub(super) struct PaneInstanceBuildParams<'a> {
54 pub viewport: &'a PaneViewport,
55 pub cells: &'a [Cell],
56 pub cols: usize,
57 pub rows: usize,
58 pub cursor_pos: Option<(usize, usize)>,
59 pub cursor_opacity: f32,
60 pub skip_solid_background: bool,
61 pub fill_default_bg_cells: bool,
62 pub separator_marks: &'a [SeparatorMark],
63}
64
65impl CellRenderer {
66 /// Render a single pane's content within a viewport to an existing surface texture
67 ///
68 /// This method renders cells to a specific region of the render target,
69 /// using a GPU scissor rect to clip to the pane bounds.
70 ///
71 /// # Arguments
72 /// * `surface_view` - The texture view to render to
73 /// * `viewport` - The pane's viewport (position, size, focus state, opacity)
74 /// * `cells` - The cells to render (should match viewport grid size)
75 /// * `cols` - Number of columns in the cell grid
76 /// * `rows` - Number of rows in the cell grid
77 /// * `cursor_pos` - Cursor position (col, row) within this pane, or None if no cursor
78 /// * `cursor_opacity` - Cursor opacity (0.0 = hidden, 1.0 = fully visible)
79 /// * `show_scrollbar` - Whether to render the scrollbar for this pane
80 /// * `clear_first` - If true, clears the viewport region before rendering
81 /// * `skip_background_image` - If true, skip rendering the background image. Use this
82 /// when the background image has already been rendered full-screen (for split panes).
83 pub fn render_pane_to_view(
84 &mut self,
85 surface_view: &wgpu::TextureView,
86 p: PaneRenderViewParams<'_>,
87 ) -> Result<()> {
88 let PaneRenderViewParams {
89 viewport,
90 cells,
91 cols,
92 rows,
93 cursor_pos,
94 cursor_opacity,
95 show_scrollbar,
96 clear_first,
97 skip_background_image,
98 fill_default_bg_cells,
99 separator_marks,
100 pane_background,
101 } = p;
102 // Build instance buffers for this pane's cells.
103 // Returns cursor_overlay_start: the bg_instance index where cursor overlays begin.
104 // Used for 3-phase rendering (bgs → text → cursor overlays).
105 let cursor_overlay_start = self.build_pane_instance_buffers(PaneInstanceBuildParams {
106 viewport,
107 cells,
108 cols,
109 rows,
110 cursor_pos,
111 cursor_opacity,
112 skip_solid_background: skip_background_image,
113 fill_default_bg_cells,
114 separator_marks,
115 })?;
116
117 // Pre-update per-pane background uniform buffer and bind group if needed (must happen
118 // before the render pass). Buffers are allocated once and reused across frames.
119 // Per-pane backgrounds are explicit user overrides and always prepared, even when a
120 // custom shader or global background would normally be skipped.
121 let has_pane_bg = if let Some(pane_bg) = pane_background
122 && let Some(ref path) = pane_bg.image_path
123 && self.bg_state.pane_bg_cache.contains_key(path.as_str())
124 {
125 self.prepare_pane_bg_bind_group(
126 path.as_str(),
127 super::background::PaneBgBindGroupParams {
128 pane_x: viewport.x,
129 pane_y: viewport.y,
130 pane_width: viewport.width,
131 pane_height: viewport.height,
132 mode: pane_bg.mode,
133 opacity: pane_bg.opacity,
134 darken: pane_bg.darken,
135 },
136 );
137 true
138 } else {
139 false
140 };
141
142 // Retrieve cached path for use in the render pass (must be done before borrow in pass).
143 let pane_bg_path: Option<String> = if has_pane_bg {
144 pane_background
145 .and_then(|pb| pb.image_path.as_ref())
146 .map(|p| p.to_string())
147 } else {
148 None
149 };
150
151 let mut encoder = self
152 .device
153 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
154 label: Some("pane render encoder"),
155 });
156
157 // Determine load operation and clear color
158 let load_op = if clear_first {
159 let clear_color = if self.bg_state.bg_is_solid_color {
160 wgpu::Color {
161 r: self.bg_state.solid_bg_color[0] as f64
162 * self.window_opacity as f64
163 * viewport.opacity as f64,
164 g: self.bg_state.solid_bg_color[1] as f64
165 * self.window_opacity as f64
166 * viewport.opacity as f64,
167 b: self.bg_state.solid_bg_color[2] as f64
168 * self.window_opacity as f64
169 * viewport.opacity as f64,
170 a: self.window_opacity as f64 * viewport.opacity as f64,
171 }
172 } else {
173 wgpu::Color {
174 r: self.background_color[0] as f64
175 * self.window_opacity as f64
176 * viewport.opacity as f64,
177 g: self.background_color[1] as f64
178 * self.window_opacity as f64
179 * viewport.opacity as f64,
180 b: self.background_color[2] as f64
181 * self.window_opacity as f64
182 * viewport.opacity as f64,
183 a: self.window_opacity as f64 * viewport.opacity as f64,
184 }
185 };
186 wgpu::LoadOp::Clear(clear_color)
187 } else {
188 wgpu::LoadOp::Load
189 };
190
191 {
192 let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
193 label: Some("pane render pass"),
194 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
195 view: surface_view,
196 resolve_target: None,
197 ops: wgpu::Operations {
198 load: load_op,
199 store: wgpu::StoreOp::Store,
200 },
201 depth_slice: None,
202 })],
203 depth_stencil_attachment: None,
204 timestamp_writes: None,
205 occlusion_query_set: None,
206 });
207
208 // Set scissor rect to clip rendering to pane bounds
209 let (sx, sy, sw, sh) = viewport.to_scissor_rect();
210 render_pass.set_scissor_rect(sx, sy, sw, sh);
211
212 // Render per-pane background image within scissor rect.
213 // Per-pane backgrounds are explicit user overrides and always render,
214 // even when a custom shader or global background is active.
215 if let Some(ref path) = pane_bg_path
216 && let Some(cached) = self.bg_state.pane_bg_uniform_cache.get(path.as_str())
217 {
218 render_pass.set_pipeline(&self.pipelines.bg_image_pipeline);
219 render_pass.set_bind_group(0, &cached.bind_group, &[]);
220 render_pass.set_vertex_buffer(0, self.buffers.vertex_buffer.slice(..));
221 render_pass.draw(0..4, 0..1);
222 }
223
224 self.emit_three_phase_draw_calls(
225 &mut render_pass,
226 cursor_overlay_start as u32,
227 self.buffers.actual_bg_instances as u32,
228 );
229
230 // Render scrollbar if requested (uses its own scissor rect internally)
231 if show_scrollbar {
232 // Reset scissor to full surface for scrollbar
233 render_pass.set_scissor_rect(0, 0, self.config.width, self.config.height);
234 self.scrollbar.render(&mut render_pass);
235 }
236 }
237
238 self.queue.submit(std::iter::once(encoder.finish()));
239 Ok(())
240 }
241
242 /// Build instance buffers for a pane's cells with viewport offset.
243 ///
244 /// Similar to `build_instance_buffers` but adjusts all positions to be relative to the
245 /// viewport origin. Also appends cursor overlay instances (beam bar and hollow borders)
246 /// after the cell background instances.
247 ///
248 /// Returns the index in `bg_instances` where cursor overlays begin (`cursor_overlay_start`).
249 /// The caller uses this for 3-phase rendering: cell bgs, text, then cursor overlays on top.
250 ///
251 /// `skip_solid_background`: if true, skip the solid background fill for the viewport
252 /// (use when a custom shader or background image was already rendered full-screen).
253 fn build_pane_instance_buffers(&mut self, p: PaneInstanceBuildParams<'_>) -> Result<usize> {
254 let PaneInstanceBuildParams {
255 viewport,
256 cells,
257 cols,
258 rows,
259 cursor_pos,
260 cursor_opacity,
261 skip_solid_background,
262 fill_default_bg_cells,
263 separator_marks,
264 } = p;
265 // Clear previous instance buffers
266 for instance in &mut self.bg_instances {
267 instance.size = [0.0, 0.0];
268 instance.color = [0.0, 0.0, 0.0, 0.0];
269 }
270
271 // Add a background rectangle covering the entire pane viewport (unless skipped)
272 // This ensures the pane has a proper background even when cells are skipped.
273 // Skip when a custom shader or background image was already rendered full-screen.
274 let bg_start_index = if !skip_solid_background && !self.bg_instances.is_empty() {
275 let bg_color = self.background_color;
276 let opacity = self.window_opacity * viewport.opacity;
277 let width_f = self.config.width as f32;
278 let height_f = self.config.height as f32;
279 self.bg_instances[0] = super::types::BackgroundInstance {
280 position: [
281 viewport.x / width_f * 2.0 - 1.0,
282 1.0 - (viewport.y / height_f * 2.0),
283 ],
284 size: [
285 viewport.width / width_f * 2.0,
286 viewport.height / height_f * 2.0,
287 ],
288 color: [
289 bg_color[0] * opacity,
290 bg_color[1] * opacity,
291 bg_color[2] * opacity,
292 opacity,
293 ],
294 };
295 1 // Start cell backgrounds at index 1
296 } else {
297 0 // Start cell backgrounds at index 0 (no viewport fill)
298 };
299
300 for instance in &mut self.text_instances {
301 instance.size = [0.0, 0.0];
302 }
303
304 // Start at bg_start_index (1 if viewport fill was added, 0 otherwise)
305 let mut bg_index = bg_start_index;
306 let mut text_index = 0;
307
308 // Content offset - positions are relative to content area (with padding applied)
309 let (content_x, content_y) = viewport.content_origin();
310 let opacity_multiplier = viewport.opacity;
311
312 for row in 0..rows {
313 let row_start = row * cols;
314 let row_end = (row + 1) * cols;
315 if row_start >= cells.len() {
316 break;
317 }
318 let row_cells = &cells[row_start..row_end.min(cells.len())];
319
320 // Background - use RLE to merge consecutive cells with same color
321 let mut col = 0;
322 while col < row_cells.len() {
323 let cell = &row_cells[col];
324 let bg_f = color_u8x4_rgb_to_f32(cell.bg_color);
325 let is_default_bg = (bg_f[0] - self.background_color[0]).abs() < 0.001
326 && (bg_f[1] - self.background_color[1]).abs() < 0.001
327 && (bg_f[2] - self.background_color[2]).abs() < 0.001;
328
329 // Check for cursor at this position (position check only, no opacity gate)
330 let cursor_at_cell = cursor_pos.is_some_and(|(cx, cy)| cx == col && cy == row)
331 && !self.cursor.hidden_for_shader;
332 // Hollow cursor (unfocused + Hollow style) must show regardless of blink opacity
333 let render_hollow_here = cursor_at_cell
334 && !self.is_focused
335 && self.cursor.unfocused_style == par_term_config::UnfocusedCursorStyle::Hollow;
336 let has_cursor = (cursor_at_cell && cursor_opacity > 0.0) || render_hollow_here;
337
338 // Skip cells with half-block characters (▄/▀).
339 // These are rendered entirely through the text pipeline to avoid
340 // cross-pipeline coordinate seams that cause visible banding.
341 let is_half_block = {
342 let mut chars = cell.grapheme.chars();
343 matches!(chars.next(), Some('\u{2580}' | '\u{2584}')) && chars.next().is_none()
344 };
345
346 // Skip default-bg cells only when NOT in background-image/shader mode.
347 // When skip_solid_background is true (background image or custom shader active),
348 // no viewport fill is drawn, so default-bg cells between colored segments would
349 // show the background image through — causing visible gaps/lines in the tmux
350 // status bar. In that mode we render them with the theme background color instead.
351 // Skip default-bg cells unless fill_default_bg_cells is set (background-image mode).
352 // In normal mode: viewport fill quad covers them — no individual quad needed.
353 // In shader mode: shader output must show through — do not paint over it.
354 // In bg-image mode: fill_default_bg_cells=true — render with theme bg color to
355 // close gaps that would otherwise show the background image unexpectedly.
356 if is_half_block || (is_default_bg && !has_cursor && !fill_default_bg_cells) {
357 col += 1;
358 continue;
359 }
360
361 // Calculate background color with alpha and pane opacity
362 let bg_alpha =
363 if self.transparency_affects_only_default_background && !is_default_bg {
364 1.0
365 } else {
366 self.window_opacity
367 };
368 let pane_alpha = bg_alpha * opacity_multiplier;
369 let mut bg_color = color_u8x4_rgb_to_f32_a(cell.bg_color, pane_alpha);
370
371 // TODO(QA-002/QA-008): extract into `render_cursor_cell()` helper.
372 // Signature would be:
373 // fn render_cursor_cell(&mut self, col: usize, row: usize,
374 // content_x: f32, content_y: f32, bg_color: [f32; 4],
375 // cursor_opacity: f32, render_hollow_here: bool,
376 // bg_index: &mut usize)
377 // Handle cursor at this position
378 if has_cursor {
379 use par_term_emu_core_rust::cursor::CursorStyle;
380 match self.cursor.style {
381 CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock => {
382 if !render_hollow_here {
383 // Solid block cursor: blend cursor color into background
384 for (bg, &cursor) in
385 bg_color.iter_mut().take(3).zip(&self.cursor.color)
386 {
387 *bg = *bg * (1.0 - cursor_opacity) + cursor * cursor_opacity;
388 }
389 bg_color[3] = bg_color[3].max(cursor_opacity * opacity_multiplier);
390 }
391 // If hollow: keep original background color (outline added as overlay)
392 }
393 _ => {}
394 }
395
396 // Cursor cell can't be merged
397 // Snap to pixel boundaries to match text pipeline alignment
398 let x0 = (content_x + col as f32 * self.grid.cell_width).round();
399 let x1 = (content_x + (col + 1) as f32 * self.grid.cell_width).round();
400 let y0 = (content_y + row as f32 * self.grid.cell_height).round();
401 let y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
402
403 if bg_index < self.buffers.max_bg_instances {
404 self.bg_instances[bg_index] = BackgroundInstance {
405 position: [
406 x0 / self.config.width as f32 * 2.0 - 1.0,
407 1.0 - (y0 / self.config.height as f32 * 2.0),
408 ],
409 size: [
410 (x1 - x0) / self.config.width as f32 * 2.0,
411 (y1 - y0) / self.config.height as f32 * 2.0,
412 ],
413 color: bg_color,
414 };
415 bg_index += 1;
416 }
417 col += 1;
418 continue;
419 }
420
421 // RLE: Find run of consecutive cells with same background color
422 let start_col = col;
423 let run_color = cell.bg_color;
424 col += 1;
425 while col < row_cells.len() {
426 let next_cell = &row_cells[col];
427 let next_cursor_at_cell = cursor_pos
428 .is_some_and(|(cx, cy)| cx == col && cy == row)
429 && !self.cursor.hidden_for_shader;
430 let next_hollow = next_cursor_at_cell
431 && !self.is_focused
432 && self.cursor.unfocused_style
433 == par_term_config::UnfocusedCursorStyle::Hollow;
434 let next_has_cursor =
435 (next_cursor_at_cell && cursor_opacity > 0.0) || next_hollow;
436 let next_is_half_block = {
437 let mut chars = next_cell.grapheme.chars();
438 matches!(chars.next(), Some('\u{2580}' | '\u{2584}'))
439 && chars.next().is_none()
440 };
441 if next_cell.bg_color != run_color || next_has_cursor || next_is_half_block {
442 break;
443 }
444 col += 1;
445 }
446 let run_length = col - start_col;
447
448 // Create single quad spanning entire run.
449 // Snap all edges to pixel boundaries to match the text pipeline and
450 // eliminate sub-pixel gaps between adjacent differently-colored cell runs.
451 let x0 = (content_x + start_col as f32 * self.grid.cell_width).round();
452 let x1 =
453 (content_x + (start_col + run_length) as f32 * self.grid.cell_width).round();
454 let y0 = (content_y + row as f32 * self.grid.cell_height).round();
455 let y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
456
457 // Extend the colored bg quad 1 px under adjacent powerline separator glyphs
458 // to eliminate the dark fringe at their anti-aliased edges.
459 //
460 // Powerline separators with default bg rely on the viewport fill (no BG quad
461 // in normal mode). Their anti-aliased corner/edge pixels blend:
462 // fg * alpha + dark_fill * (1 - alpha) → visible dark fringe
463 // Extending the adjacent colored quad by 1 px underneath changes the blend to:
464 // fg * alpha + colored * (1 - alpha) → seamless transition
465 // The 1 px is small enough to be hidden under the glyph itself.
466 let is_default_bg_cell = |bg: [u8; 4]| -> bool {
467 let f = color_u8x4_rgb_to_f32(bg);
468 (f[0] - self.background_color[0]).abs() < 0.001
469 && (f[1] - self.background_color[1]).abs() < 0.001
470 && (f[2] - self.background_color[2]).abs() < 0.001
471 };
472 // Extend right if the next cell is any powerline separator with default bg.
473 // Covers anti-aliased left edges and transparent left corners of left-pointing seps.
474 let x1 = if col < row_cells.len()
475 && matches!(
476 row_cells[col].grapheme.as_str(),
477 "\u{E0B0}"
478 | "\u{E0B1}"
479 | "\u{E0B2}"
480 | "\u{E0B3}"
481 | "\u{E0B4}"
482 | "\u{E0B5}"
483 | "\u{E0B6}"
484 | "\u{E0B7}"
485 )
486 && is_default_bg_cell(row_cells[col].bg_color)
487 {
488 x1 + 1.0
489 } else {
490 x1
491 };
492 // Extend left if the previous cell is any powerline separator with default bg.
493 // Covers anti-aliased right edges and transparent right corners of right-pointing seps.
494 let x0 = if start_col > 0
495 && matches!(
496 row_cells[start_col - 1].grapheme.as_str(),
497 "\u{E0B0}"
498 | "\u{E0B1}"
499 | "\u{E0B2}"
500 | "\u{E0B3}"
501 | "\u{E0B4}"
502 | "\u{E0B5}"
503 | "\u{E0B6}"
504 | "\u{E0B7}"
505 )
506 && is_default_bg_cell(row_cells[start_col - 1].bg_color)
507 {
508 x0 - 1.0
509 } else {
510 x0
511 };
512
513 // In background-image mode (skip_solid_background=true), right-pointing
514 // separator cells (E0B0/E0B1/E0B4/E0B5) are rendered in the RLE path and
515 // their BG quad is drawn AFTER the adjacent colored run's quad. This causes
516 // them to overwrite the 1px EXT-RIGHT extension from the colored run.
517 //
518 // Fix: when this cell IS a right-pointing separator with a colored left
519 // neighbor, trim our own BG quad x0 by 1px so the colored extension stays
520 // visible under the separator's left edge.
521 let x0 = if skip_solid_background
522 && is_default_bg
523 && matches!(
524 row_cells[start_col].grapheme.as_str(),
525 "\u{E0B0}" | "\u{E0B1}" | "\u{E0B4}" | "\u{E0B5}"
526 )
527 && start_col > 0
528 && !is_default_bg_cell(row_cells[start_col - 1].bg_color)
529 {
530 x0 + 1.0
531 } else {
532 x0
533 };
534
535 if bg_index < self.buffers.max_bg_instances {
536 self.bg_instances[bg_index] = BackgroundInstance {
537 position: [
538 x0 / self.config.width as f32 * 2.0 - 1.0,
539 1.0 - (y0 / self.config.height as f32 * 2.0),
540 ],
541 size: [
542 (x1 - x0) / self.config.width as f32 * 2.0,
543 (y1 - y0) / self.config.height as f32 * 2.0,
544 ],
545 color: bg_color,
546 };
547 bg_index += 1;
548 }
549 }
550
551 // Text rendering
552 let natural_line_height =
553 self.font.font_ascent + self.font.font_descent + self.font.font_leading;
554 let vertical_padding = (self.grid.cell_height - natural_line_height).max(0.0) / 2.0;
555 let baseline_y = content_y
556 + (row as f32 * self.grid.cell_height)
557 + vertical_padding
558 + self.font.font_ascent;
559
560 // Compute text alpha - force opaque if keep_text_opaque is enabled
561 let text_alpha = if self.keep_text_opaque {
562 opacity_multiplier // Only apply pane dimming, not window transparency
563 } else {
564 self.window_opacity * opacity_multiplier
565 };
566
567 // Check if this row has the cursor and it's a visible block cursor
568 // (for cursor text color override in split-pane rendering)
569 let cursor_is_block_on_this_row = {
570 use par_term_emu_core_rust::cursor::CursorStyle;
571 cursor_pos.is_some_and(|(_, cy)| cy == row)
572 && cursor_opacity > 0.0
573 && !self.cursor.hidden_for_shader
574 && matches!(
575 self.cursor.style,
576 CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock
577 )
578 && (self.is_focused
579 || self.cursor.unfocused_style
580 == par_term_config::UnfocusedCursorStyle::Same)
581 };
582
583 for (col_idx, cell) in row_cells.iter().enumerate() {
584 if cell.wide_char_spacer || cell.grapheme == " " {
585 continue;
586 }
587
588 let chars: Vec<char> = cell.grapheme.chars().collect();
589 if chars.is_empty() {
590 continue;
591 }
592
593 let ch = chars[0];
594
595 // Determine text color - apply cursor_text_color (or auto-contrast) when the
596 // block cursor is on this cell, otherwise use the cell's foreground color.
597 let render_fg_color: [f32; 4] = if cursor_is_block_on_this_row
598 && cursor_pos.is_some_and(|(cx, _)| cx == col_idx)
599 {
600 if let Some(cursor_text) = self.cursor.text_color {
601 [cursor_text[0], cursor_text[1], cursor_text[2], text_alpha]
602 } else {
603 let cursor_brightness =
604 (self.cursor.color[0] + self.cursor.color[1] + self.cursor.color[2])
605 / 3.0;
606 if cursor_brightness > CURSOR_BRIGHTNESS_THRESHOLD {
607 [0.0, 0.0, 0.0, text_alpha]
608 } else {
609 [1.0, 1.0, 1.0, text_alpha]
610 }
611 }
612 } else {
613 color_u8x4_rgb_to_f32_a(cell.fg_color, text_alpha)
614 };
615
616 // TODO(QA-002/QA-008): extract into `render_block_char()` helper.
617 // The block char path returns early via `continue` — the helper would
618 // return `bool` (true = rendered, caller should continue) and write
619 // directly into `self.text_instances[text_index]`.
620 // Check for block characters that should be rendered geometrically
621 let char_type = block_chars::classify_char(ch);
622 if chars.len() == 1 && block_chars::should_render_geometrically(char_type) {
623 let char_w = if cell.wide_char {
624 self.grid.cell_width * 2.0
625 } else {
626 self.grid.cell_width
627 };
628 let x0 = (content_x + col_idx as f32 * self.grid.cell_width).round();
629 let y0 = (content_y + row as f32 * self.grid.cell_height).round();
630 let y1 = (content_y + (row + 1) as f32 * self.grid.cell_height).round();
631 let snapped_cell_height = y1 - y0;
632
633 // Try box drawing geometry first
634 let aspect_ratio = snapped_cell_height / char_w;
635 if let Some(box_geo) = block_chars::get_box_drawing_geometry(ch, aspect_ratio) {
636 for segment in &box_geo.segments {
637 let rect = segment
638 .to_pixel_rect(x0, y0, char_w, snapped_cell_height)
639 .snap_to_pixels();
640
641 // Extension for seamless lines
642 let extension = 1.0;
643 let ext_x = if segment.x <= 0.01 { extension } else { 0.0 };
644 let ext_y = if segment.y <= 0.01 { extension } else { 0.0 };
645 let ext_w = if segment.x + segment.width >= 0.99 {
646 extension
647 } else {
648 0.0
649 };
650 let ext_h = if segment.y + segment.height >= 0.99 {
651 extension
652 } else {
653 0.0
654 };
655
656 let final_x = rect.x - ext_x;
657 let final_y = rect.y - ext_y;
658 let final_w = rect.width + ext_x + ext_w;
659 let final_h = rect.height + ext_y + ext_h;
660
661 if text_index < self.buffers.max_text_instances {
662 self.text_instances[text_index] = TextInstance {
663 position: [
664 final_x / self.config.width as f32 * 2.0 - 1.0,
665 1.0 - (final_y / self.config.height as f32 * 2.0),
666 ],
667 size: [
668 final_w / self.config.width as f32 * 2.0,
669 final_h / self.config.height as f32 * 2.0,
670 ],
671 tex_offset: [
672 self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
673 self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
674 ],
675 tex_size: [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE],
676 color: render_fg_color,
677 is_colored: 0,
678 };
679 text_index += 1;
680 }
681 }
682 continue;
683 }
684
685 // Half-block characters (▄/▀): render BOTH halves through the
686 // text pipeline to eliminate cross-pipeline coordinate seams.
687 // Use snapped cell edges (no extensions) for seamless tiling.
688 if ch == '\u{2584}' || ch == '\u{2580}' {
689 let x1 = (content_x + (col_idx + 1) as f32 * self.grid.cell_width).round();
690 let cell_w = x1 - x0;
691 let y_mid = y0 + self.grid.cell_height / 2.0;
692
693 let bg_half_color = color_u8x4_rgb_to_f32_a(cell.bg_color, text_alpha);
694 let (top_color, bottom_color) = if ch == '\u{2584}' {
695 (bg_half_color, render_fg_color) // ▄: top=bg, bottom=fg
696 } else {
697 (render_fg_color, bg_half_color) // ▀: top=fg, bottom=bg
698 };
699
700 let tex_offset = [
701 self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
702 self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
703 ];
704 let tex_size = [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE];
705
706 // Top half: [y0, y_mid)
707 if text_index < self.buffers.max_text_instances {
708 self.text_instances[text_index] = TextInstance {
709 position: [
710 x0 / self.config.width as f32 * 2.0 - 1.0,
711 1.0 - (y0 / self.config.height as f32 * 2.0),
712 ],
713 size: [
714 cell_w / self.config.width as f32 * 2.0,
715 (y_mid - y0) / self.config.height as f32 * 2.0,
716 ],
717 tex_offset,
718 tex_size,
719 color: top_color,
720 is_colored: 0,
721 };
722 text_index += 1;
723 }
724
725 // Bottom half: [y_mid, y1)
726 if text_index < self.buffers.max_text_instances {
727 self.text_instances[text_index] = TextInstance {
728 position: [
729 x0 / self.config.width as f32 * 2.0 - 1.0,
730 1.0 - (y_mid / self.config.height as f32 * 2.0),
731 ],
732 size: [
733 cell_w / self.config.width as f32 * 2.0,
734 (y1 - y_mid) / self.config.height as f32 * 2.0,
735 ],
736 tex_offset,
737 tex_size,
738 color: bottom_color,
739 is_colored: 0,
740 };
741 text_index += 1;
742 }
743 continue;
744 }
745
746 // Try block element geometry
747 if let Some(geo_block) = block_chars::get_geometric_block(ch) {
748 let rect = geo_block.to_pixel_rect(x0, y0, char_w, self.grid.cell_height);
749
750 // Add small extension to prevent gaps (1 pixel overlap).
751 let extension = 1.0;
752 let ext_x = if geo_block.x == 0.0 { extension } else { 0.0 };
753 let ext_y = if geo_block.y == 0.0 { extension } else { 0.0 };
754 let ext_w = if geo_block.x + geo_block.width >= 1.0 {
755 extension
756 } else {
757 0.0
758 };
759 let ext_h = if geo_block.y + geo_block.height >= 1.0 {
760 extension
761 } else {
762 0.0
763 };
764
765 let final_x = rect.x - ext_x;
766 let final_y = rect.y - ext_y;
767 let final_w = rect.width + ext_x + ext_w;
768 let final_h = rect.height + ext_y + ext_h;
769
770 if text_index < self.buffers.max_text_instances {
771 self.text_instances[text_index] = TextInstance {
772 position: [
773 final_x / self.config.width as f32 * 2.0 - 1.0,
774 1.0 - (final_y / self.config.height as f32 * 2.0),
775 ],
776 size: [
777 final_w / self.config.width as f32 * 2.0,
778 final_h / self.config.height as f32 * 2.0,
779 ],
780 tex_offset: [
781 self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
782 self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
783 ],
784 tex_size: [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE],
785 color: render_fg_color,
786 is_colored: 0,
787 };
788 text_index += 1;
789 }
790 continue;
791 }
792 }
793
794 // Check if this character should be rendered as a monochrome symbol.
795 // Also handle symbol + VS16 (U+FE0F): strip VS16, render monochrome.
796 let (force_monochrome, base_char) = if chars.len() == 1 {
797 (super::atlas::should_render_as_symbol(ch), ch)
798 } else if chars.len() == 2
799 && chars[1] == '\u{FE0F}'
800 && super::atlas::should_render_as_symbol(chars[0])
801 {
802 (true, chars[0])
803 } else {
804 (false, ch)
805 };
806
807 // TODO(QA-002/QA-008): extract into `resolve_glyph_with_fallback()` helper.
808 // The loop iterates over font fallbacks until a rasterizable glyph is found.
809 // Signature would be:
810 // fn resolve_glyph_with_fallback(&mut self, cell: &Cell, base_char: char,
811 // force_monochrome: bool) -> Option<GlyphInfo>
812 // Regular glyph rendering — use single-char lookup when force_monochrome
813 // strips VS16, otherwise grapheme-aware lookup for multi-char sequences.
814 let mut glyph_result = if force_monochrome || chars.len() == 1 {
815 self.font_manager
816 .find_glyph(base_char, cell.bold, cell.italic)
817 } else {
818 self.font_manager
819 .find_grapheme_glyph(&cell.grapheme, cell.bold, cell.italic)
820 };
821
822 // Try to find a renderable glyph with font fallback for failures.
823 let mut excluded_fonts: Vec<usize> = Vec::new();
824 let resolved_info = loop {
825 match glyph_result {
826 Some((font_idx, glyph_id)) => {
827 let cache_key = ((font_idx as u64) << 32) | (glyph_id as u64);
828 if let Some(info) = self.get_or_rasterize_glyph(
829 font_idx,
830 glyph_id,
831 force_monochrome,
832 cache_key,
833 ) {
834 break Some(info);
835 }
836 // Rasterization failed — try next font
837 excluded_fonts.push(font_idx);
838 glyph_result = self.font_manager.find_glyph_excluding(
839 base_char,
840 cell.bold,
841 cell.italic,
842 &excluded_fonts,
843 );
844 }
845 None => break None,
846 }
847 };
848
849 // Last resort: colored emoji when no font has vector outlines
850 let resolved_info = if resolved_info.is_none() && force_monochrome {
851 let mut glyph_result2 =
852 self.font_manager
853 .find_glyph(base_char, cell.bold, cell.italic);
854 loop {
855 match glyph_result2 {
856 Some((font_idx, glyph_id)) => {
857 // Bit 63 distinguishes the colored-fallback cache entry.
858 let cache_key =
859 ((font_idx as u64) << 32) | (glyph_id as u64) | (1u64 << 63);
860 if let Some(info) = self
861 .get_or_rasterize_glyph(font_idx, glyph_id, false, cache_key)
862 {
863 break Some(info);
864 }
865 glyph_result2 = self.font_manager.find_glyph_excluding(
866 base_char,
867 cell.bold,
868 cell.italic,
869 &[font_idx],
870 );
871 }
872 None => break None,
873 }
874 }
875 } else {
876 resolved_info
877 };
878
879 if let Some(info) = resolved_info {
880 let char_w = if cell.wide_char {
881 self.grid.cell_width * 2.0
882 } else {
883 self.grid.cell_width
884 };
885 let x0 = content_x + col_idx as f32 * self.grid.cell_width;
886 let y0 = content_y + row as f32 * self.grid.cell_height;
887 let x1 = x0 + char_w;
888 let y1 = y0 + self.grid.cell_height;
889
890 let cell_w = x1 - x0;
891 let cell_h = y1 - y0;
892 let scale_x = cell_w / char_w;
893 let scale_y = cell_h / self.grid.cell_height;
894
895 let baseline_offset =
896 baseline_y - (content_y + row as f32 * self.grid.cell_height);
897 let glyph_left = x0 + (info.bearing_x * scale_x).round();
898 let baseline_in_cell = (baseline_offset * scale_y).round();
899 let glyph_top = y0 + baseline_in_cell - info.bearing_y;
900
901 let render_w = info.width as f32 * scale_x;
902 let render_h = info.height as f32 * scale_y;
903
904 let (final_left, final_top, final_w, final_h) =
905 if chars.len() == 1 && block_chars::should_snap_to_boundaries(char_type) {
906 block_chars::snap_glyph_to_cell(block_chars::SnapGlyphParams {
907 glyph_left,
908 glyph_top,
909 render_w,
910 render_h,
911 cell_x0: x0,
912 cell_y0: y0,
913 cell_x1: x1,
914 cell_y1: y1,
915 snap_threshold: 3.0,
916 extension: 0.5,
917 })
918 } else {
919 (glyph_left, glyph_top, render_w, render_h)
920 };
921
922 if text_index < self.buffers.max_text_instances {
923 self.text_instances[text_index] = TextInstance {
924 position: [
925 final_left / self.config.width as f32 * 2.0 - 1.0,
926 1.0 - (final_top / self.config.height as f32 * 2.0),
927 ],
928 size: [
929 final_w / self.config.width as f32 * 2.0,
930 final_h / self.config.height as f32 * 2.0,
931 ],
932 tex_offset: [info.x as f32 / ATLAS_SIZE, info.y as f32 / ATLAS_SIZE],
933 tex_size: [
934 info.width as f32 / ATLAS_SIZE,
935 info.height as f32 / ATLAS_SIZE,
936 ],
937 color: render_fg_color,
938 is_colored: if info.is_colored { 1 } else { 0 },
939 };
940 text_index += 1;
941 }
942 }
943 }
944
945 // Underlines: emit a thin rectangle at the bottom of each underlined cell.
946 // Mirrors the logic in text_instance_builder.rs but uses pane-local coordinates.
947 {
948 let underline_thickness = (self.grid.cell_height * UNDERLINE_HEIGHT_RATIO)
949 .max(1.0)
950 .round();
951 let tex_offset = [
952 self.atlas.solid_pixel_offset.0 as f32 / ATLAS_SIZE,
953 self.atlas.solid_pixel_offset.1 as f32 / ATLAS_SIZE,
954 ];
955 let tex_size = [1.0 / ATLAS_SIZE, 1.0 / ATLAS_SIZE];
956 let y0 = content_y + (row + 1) as f32 * self.grid.cell_height - underline_thickness;
957 let ndc_y = 1.0 - (y0 / self.config.height as f32 * 2.0);
958 let ndc_h = underline_thickness / self.config.height as f32 * 2.0;
959 let is_stipple =
960 self.link_underline_style == par_term_config::LinkUnderlineStyle::Stipple;
961 let stipple_period = STIPPLE_ON_PX + STIPPLE_OFF_PX;
962
963 for col_idx in 0..cols {
964 if row_start + col_idx >= cells.len() {
965 break;
966 }
967 let cell = &cells[row_start + col_idx];
968 if !cell.underline {
969 continue;
970 }
971 let fg = color_u8x4_rgb_to_f32_a(cell.fg_color, text_alpha);
972 let cell_x0 = content_x + col_idx as f32 * self.grid.cell_width;
973
974 if is_stipple {
975 let mut px = 0.0;
976 while px < self.grid.cell_width
977 && text_index < self.buffers.max_text_instances
978 {
979 let seg_w = STIPPLE_ON_PX.min(self.grid.cell_width - px);
980 let x = cell_x0 + px;
981 self.text_instances[text_index] = TextInstance {
982 position: [x / self.config.width as f32 * 2.0 - 1.0, ndc_y],
983 size: [seg_w / self.config.width as f32 * 2.0, ndc_h],
984 tex_offset,
985 tex_size,
986 color: fg,
987 is_colored: 0,
988 };
989 text_index += 1;
990 px += stipple_period;
991 }
992 } else if text_index < self.buffers.max_text_instances {
993 self.text_instances[text_index] = TextInstance {
994 position: [cell_x0 / self.config.width as f32 * 2.0 - 1.0, ndc_y],
995 size: [self.grid.cell_width / self.config.width as f32 * 2.0, ndc_h],
996 tex_offset,
997 tex_size,
998 color: fg,
999 is_colored: 0,
1000 };
1001 text_index += 1;
1002 }
1003 }
1004 }
1005 }
1006
1007 // Inject command separator line instances — see separators.rs
1008 bg_index = self.emit_separator_instances(
1009 separator_marks,
1010 cols,
1011 rows,
1012 content_x,
1013 content_y,
1014 opacity_multiplier,
1015 bg_index,
1016 );
1017
1018 // --- Cursor overlays (beam/underline bar + hollow borders) ---
1019 // These are rendered in Phase 3 (on top of text) via the 3-phase draw in render_pane_to_view.
1020 // Record where cursor overlays start — everything after this index is an overlay.
1021 let cursor_overlay_start = bg_index;
1022
1023 if let Some((cursor_col, cursor_row)) = cursor_pos {
1024 let cursor_x0 = content_x + cursor_col as f32 * self.grid.cell_width;
1025 let cursor_x1 = cursor_x0 + self.grid.cell_width;
1026 let cursor_y0 = (content_y + cursor_row as f32 * self.grid.cell_height).round();
1027 let cursor_y1 = (content_y + (cursor_row + 1) as f32 * self.grid.cell_height).round();
1028
1029 // Emit guide, shadow, beam/underline bar, hollow outline — see cursor_overlays.rs
1030 bg_index = self.emit_cursor_overlays(
1031 CursorOverlayParams {
1032 cursor_x0,
1033 cursor_x1,
1034 cursor_y0,
1035 cursor_y1,
1036 cols,
1037 content_x,
1038 cursor_opacity,
1039 },
1040 bg_index,
1041 );
1042 }
1043
1044 // Update actual instance counts for draw calls
1045 self.buffers.actual_bg_instances = bg_index;
1046 self.buffers.actual_text_instances = text_index;
1047
1048 // Upload instance buffers to GPU
1049 self.queue.write_buffer(
1050 &self.buffers.bg_instance_buffer,
1051 0,
1052 bytemuck::cast_slice(&self.bg_instances),
1053 );
1054 self.queue.write_buffer(
1055 &self.buffers.text_instance_buffer,
1056 0,
1057 bytemuck::cast_slice(&self.text_instances),
1058 );
1059
1060 Ok(cursor_overlay_start)
1061 }
1062}