gpui_terminal/render.rs
1//! Terminal rendering module.
2//!
3//! This module provides [`TerminalRenderer`], which handles efficient rendering of
4//! terminal content using GPUI's text and drawing systems.
5//!
6//! # Rendering Pipeline
7//!
8//! The renderer processes the terminal grid in several stages:
9//!
10//! ```text
11//! Terminal Grid → Layout Phase → Paint Phase
12//! │ │
13//! ├─ Collect backgrounds
14//! ├─ Batch text runs
15//! │ │
16//! │ ├─ Paint default background
17//! │ ├─ Paint non-default backgrounds
18//! │ ├─ Paint text characters
19//! │ └─ Paint cursor
20//! ```
21//!
22//! # Optimizations
23//!
24//! The renderer includes several optimizations to minimize draw calls:
25//!
26//! 1. **Background Merging**: Adjacent cells with the same background color are
27//! merged into single rectangles, reducing the number of quads to paint.
28//!
29//! 2. **Text Batching**: Adjacent cells with identical styling (color, bold, italic)
30//! are grouped into [`BatchedTextRun`]s for efficient text shaping.
31//!
32//! 3. **Default Background Skip**: Cells with the default background color don't
33//! generate separate background rectangles.
34//!
35//! 4. **Cell Measurement**: Font metrics are measured once using the 'M' character
36//! and cached for consistent cell dimensions.
37//!
38//! # Cell Dimensions
39//!
40//! Cell size is calculated from actual font metrics:
41//!
42//! - **Width**: Measured from shaped 'M' character (typically widest in monospace)
43//! - **Height**: `(ascent + descent) × line_height_multiplier`
44//!
45//! The `line_height_multiplier` (default 1.2) adds extra vertical space to
46//! accommodate tall glyphs from nerd fonts and other icon fonts.
47//!
48//! # Example
49//!
50//! ```ignore
51//! use gpui::px;
52//! use gpui_terminal::{ColorPalette, TerminalRenderer};
53//!
54//! let renderer = TerminalRenderer::new(
55//! "JetBrains Mono".to_string(),
56//! px(14.0),
57//! 1.2, // line height multiplier
58//! ColorPalette::default(),
59//! );
60//! ```
61
62use crate::colors::ColorPalette;
63use crate::event::GpuiEventProxy;
64use alacritty_terminal::grid::Dimensions;
65use alacritty_terminal::index::{Column, Line, Point as AlacPoint};
66use alacritty_terminal::term::Term;
67use alacritty_terminal::term::cell::{Cell, Flags};
68use alacritty_terminal::term::color::Colors;
69use alacritty_terminal::vte::ansi::Color;
70use gpui::{
71 App, Bounds, Edges, Font, FontFeatures, FontStyle, FontWeight, Hsla, Pixels, Point,
72 SharedString, Size, TextRun, UnderlineStyle, Window, px, quad, transparent_black,
73};
74
75/// A batched run of text with consistent styling.
76///
77/// This struct groups adjacent terminal cells with identical visual attributes
78/// to reduce the number of text rendering calls.
79#[derive(Debug, Clone)]
80pub struct BatchedTextRun {
81 /// The text content to render
82 pub text: String,
83
84 /// Starting column position
85 pub start_col: usize,
86
87 /// Row position
88 pub row: usize,
89
90 /// Foreground color
91 pub fg_color: Hsla,
92
93 /// Background color
94 pub bg_color: Hsla,
95
96 /// Bold flag
97 pub bold: bool,
98
99 /// Italic flag
100 pub italic: bool,
101
102 /// Underline flag
103 pub underline: bool,
104}
105
106/// Background rectangle to paint.
107///
108/// Represents a rectangular region with a solid color background.
109#[derive(Debug, Clone)]
110pub struct BackgroundRect {
111 /// Starting column position
112 pub start_col: usize,
113
114 /// Ending column position (exclusive)
115 pub end_col: usize,
116
117 /// Row position
118 pub row: usize,
119
120 /// Background color
121 pub color: Hsla,
122}
123
124impl BackgroundRect {
125 /// Check if this rectangle can be merged with another.
126 ///
127 /// Two rectangles can be merged if they:
128 /// - Are on the same row
129 /// - Have the same color
130 /// - Are horizontally adjacent
131 fn can_merge_with(&self, other: &Self) -> bool {
132 self.row == other.row && self.color == other.color && self.end_col == other.start_col
133 }
134}
135
136/// Terminal renderer with font settings and cell dimensions.
137///
138/// This struct manages the rendering of terminal content, including text,
139/// backgrounds, and cursor. It maintains font metrics and provides the
140/// [`paint`](Self::paint) method for drawing the terminal grid.
141///
142/// # Font Metrics
143///
144/// Cell dimensions are calculated from actual font measurements via
145/// [`measure_cell`](Self::measure_cell). This ensures accurate character
146/// positioning regardless of the font used.
147///
148/// # Usage
149///
150/// The renderer is typically used internally by [`TerminalView`](crate::TerminalView),
151/// but can also be used directly for custom rendering:
152///
153/// ```ignore
154/// // Measure cell dimensions (call once per font change)
155/// renderer.measure_cell(window);
156///
157/// // Paint the terminal grid
158/// renderer.paint(bounds, padding, &term, window, cx);
159/// ```
160///
161/// # Performance
162///
163/// For optimal performance:
164/// - Call `measure_cell` only when font settings change
165/// - The `paint` method is designed to be called every frame
166/// - Background and text batching minimize GPU draw calls
167#[derive(Clone)]
168pub struct TerminalRenderer {
169 /// Font family name (e.g., "Fira Code", "Menlo")
170 pub font_family: String,
171
172 /// Font size in pixels
173 pub font_size: Pixels,
174
175 /// Width of a single character cell
176 pub cell_width: Pixels,
177
178 /// Height of a single character cell (line height)
179 pub cell_height: Pixels,
180
181 /// Multiplier for line height to accommodate tall glyphs
182 pub line_height_multiplier: f32,
183
184 /// Color palette for resolving terminal colors
185 pub palette: ColorPalette,
186}
187
188impl TerminalRenderer {
189 /// Creates a new terminal renderer with the given font settings and color palette.
190 ///
191 /// # Arguments
192 ///
193 /// * `font_family` - The name of the font family to use
194 /// * `font_size` - The font size in pixels
195 /// * `line_height_multiplier` - Multiplier for line height (e.g., 1.2 for 20% extra)
196 /// * `palette` - The color palette to use for terminal colors
197 ///
198 /// # Returns
199 ///
200 /// A new `TerminalRenderer` instance with default cell dimensions.
201 ///
202 /// # Examples
203 ///
204 /// ```
205 /// use gpui::px;
206 /// use gpui_terminal::render::TerminalRenderer;
207 /// use gpui_terminal::ColorPalette;
208 ///
209 /// let renderer = TerminalRenderer::new("Fira Code".to_string(), px(14.0), 1.2, ColorPalette::default());
210 /// ```
211 pub fn new(
212 font_family: String,
213 font_size: Pixels,
214 line_height_multiplier: f32,
215 palette: ColorPalette,
216 ) -> Self {
217 // Default cell dimensions - will be measured on first paint
218 // Using 0.6 as approximate em-width ratio for monospace fonts
219 let cell_width = font_size * 0.6;
220 let cell_height = font_size * 1.4; // Line height with some spacing
221
222 Self {
223 font_family,
224 font_size,
225 cell_width,
226 cell_height,
227 line_height_multiplier,
228 palette,
229 }
230 }
231
232 /// Measure cell dimensions based on actual font metrics.
233 ///
234 /// This method measures the actual width and height of characters
235 /// using the GPUI text system.
236 ///
237 /// # Arguments
238 ///
239 /// * `window` - The GPUI window for text system access
240 pub fn measure_cell(&mut self, window: &mut Window) {
241 // Measure using a reference character (M is typically the widest)
242 let font = Font {
243 family: self.font_family.clone().into(),
244 features: FontFeatures::default(),
245 fallbacks: None,
246 weight: FontWeight::NORMAL,
247 style: FontStyle::Normal,
248 };
249
250 let text_run = TextRun {
251 len: 1,
252 font,
253 color: gpui::black(),
254 background_color: None,
255 underline: None,
256 strikethrough: None,
257 };
258
259 // Shape a single 'M' character to get its metrics
260 let shaped = window
261 .text_system()
262 .shape_line("M".into(), self.font_size, &[text_run], None);
263
264 // Get the width from the shaped line (accessed via Deref to LineLayout)
265 if shaped.width > px(0.0) {
266 self.cell_width = shaped.width;
267 }
268
269 // Calculate height from ascent + descent with multiplier for tall glyphs (nerd fonts, etc.)
270 let line_height = shaped.ascent + shaped.descent;
271 if line_height > px(0.0) {
272 self.cell_height = line_height * self.line_height_multiplier;
273 }
274 }
275
276 /// Layout cells into batched text runs and background rects for a single row.
277 ///
278 /// This method processes a row of terminal cells and groups adjacent cells
279 /// with identical styling into batched runs. It also collects background
280 /// rectangles that need to be painted.
281 ///
282 /// # Arguments
283 ///
284 /// * `row` - The row number
285 /// * `cells` - Iterator over (column, Cell) pairs
286 /// * `colors` - Terminal color configuration
287 ///
288 /// # Returns
289 ///
290 /// A tuple of `(backgrounds, text_runs)` where:
291 /// - `backgrounds` is a vector of merged background rectangles
292 /// - `text_runs` is a vector of batched text runs
293 pub fn layout_row(
294 &self,
295 row: usize,
296 cells: impl Iterator<Item = (usize, Cell)>,
297 colors: &Colors,
298 ) -> (Vec<BackgroundRect>, Vec<BatchedTextRun>) {
299 let mut backgrounds = Vec::new();
300 let mut text_runs = Vec::new();
301
302 let mut current_run: Option<BatchedTextRun> = None;
303 let mut current_bg: Option<BackgroundRect> = None;
304
305 for (col, cell) in cells {
306 // Skip wide character spacers
307 if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
308 continue;
309 }
310
311 // Extract cell styling
312 let fg_color = self.palette.resolve(cell.fg, colors);
313 let bg_color = self.palette.resolve(cell.bg, colors);
314 let bold = cell.flags.contains(Flags::BOLD);
315 let italic = cell.flags.contains(Flags::ITALIC);
316 let underline = cell.flags.contains(Flags::UNDERLINE);
317
318 // Get the character (or space if empty)
319 let ch = if cell.c == ' ' || cell.c == '\0' {
320 ' '
321 } else {
322 cell.c
323 };
324
325 // Handle background rectangles
326 if let Some(ref mut bg_rect) = current_bg {
327 if bg_rect.color == bg_color && bg_rect.end_col == col {
328 // Extend current background
329 bg_rect.end_col = col + 1;
330 } else {
331 // Save current background and start new one
332 backgrounds.push(bg_rect.clone());
333 current_bg = Some(BackgroundRect {
334 start_col: col,
335 end_col: col + 1,
336 row,
337 color: bg_color,
338 });
339 }
340 } else {
341 // Start new background
342 current_bg = Some(BackgroundRect {
343 start_col: col,
344 end_col: col + 1,
345 row,
346 color: bg_color,
347 });
348 }
349
350 // Handle text runs
351 if let Some(ref mut run) = current_run {
352 if run.fg_color == fg_color
353 && run.bg_color == bg_color
354 && run.bold == bold
355 && run.italic == italic
356 && run.underline == underline
357 {
358 // Extend current run
359 run.text.push(ch);
360 } else {
361 // Save current run and start new one
362 text_runs.push(run.clone());
363 current_run = Some(BatchedTextRun {
364 text: ch.to_string(),
365 start_col: col,
366 row,
367 fg_color,
368 bg_color,
369 bold,
370 italic,
371 underline,
372 });
373 }
374 } else {
375 // Start new run
376 current_run = Some(BatchedTextRun {
377 text: ch.to_string(),
378 start_col: col,
379 row,
380 fg_color,
381 bg_color,
382 bold,
383 italic,
384 underline,
385 });
386 }
387 }
388
389 // Push final run and background
390 if let Some(run) = current_run {
391 text_runs.push(run);
392 }
393 if let Some(bg) = current_bg {
394 backgrounds.push(bg);
395 }
396
397 // Merge adjacent backgrounds with same color
398 let merged_backgrounds = self.merge_backgrounds(backgrounds);
399
400 (merged_backgrounds, text_runs)
401 }
402
403 /// Merge adjacent background rects with same color.
404 ///
405 /// This optimization reduces the number of rectangles to paint by
406 /// combining horizontally adjacent rectangles that share the same color.
407 ///
408 /// # Arguments
409 ///
410 /// * `rects` - Vector of background rectangles to merge
411 ///
412 /// # Returns
413 ///
414 /// A new vector with merged rectangles
415 fn merge_backgrounds(&self, mut rects: Vec<BackgroundRect>) -> Vec<BackgroundRect> {
416 if rects.is_empty() {
417 return rects;
418 }
419
420 let mut merged = Vec::new();
421 let mut current = rects.remove(0);
422
423 for rect in rects {
424 if current.can_merge_with(&rect) {
425 current.end_col = rect.end_col;
426 } else {
427 merged.push(current);
428 current = rect;
429 }
430 }
431
432 merged.push(current);
433 merged
434 }
435
436 /// Paint terminal content to the window.
437 ///
438 /// This is the main rendering method that draws the terminal grid,
439 /// including backgrounds, text, and cursor.
440 ///
441 /// # Arguments
442 ///
443 /// * `bounds` - The bounding box to render within
444 /// * `padding` - Padding around the terminal content
445 /// * `term` - The terminal state
446 /// * `window` - The GPUI window
447 /// * `cx` - The application context
448 pub fn paint(
449 &self,
450 bounds: Bounds<Pixels>,
451 padding: Edges<Pixels>,
452 term: &Term<GpuiEventProxy>,
453 window: &mut Window,
454 _cx: &mut App,
455 ) {
456 // Get terminal dimensions
457 let grid = term.grid();
458 let num_lines = grid.screen_lines();
459 let num_cols = grid.columns();
460 let colors = term.colors();
461
462 // Calculate default background color
463 let default_bg = self.palette.resolve(
464 Color::Named(alacritty_terminal::vte::ansi::NamedColor::Background),
465 colors,
466 );
467
468 // Paint default background (covers full bounds including padding)
469 window.paint_quad(quad(
470 bounds,
471 px(0.0),
472 default_bg,
473 Edges::<Pixels>::default(),
474 transparent_black(),
475 Default::default(),
476 ));
477
478 // Calculate origin offset (content starts after padding)
479 let origin = Point {
480 x: bounds.origin.x + padding.left,
481 y: bounds.origin.y + padding.top,
482 };
483
484 // Iterate over visible lines
485 for line_idx in 0..num_lines {
486 let line = Line(line_idx as i32);
487
488 // Collect cells for this line
489 let cells: Vec<(usize, Cell)> = (0..num_cols)
490 .map(|col_idx| {
491 let col = Column(col_idx);
492 let point = AlacPoint::new(line, col);
493 let cell = grid[point].clone();
494 (col_idx, cell)
495 })
496 .collect();
497
498 // Layout the row for backgrounds
499 let (backgrounds, _) = self.layout_row(line_idx, cells.iter().cloned(), colors);
500
501 // Paint backgrounds
502 for bg_rect in backgrounds {
503 // Skip if it's the default background color
504 if bg_rect.color == default_bg {
505 continue;
506 }
507
508 let x = origin.x + self.cell_width * (bg_rect.start_col as f32);
509 let y = origin.y + self.cell_height * (bg_rect.row as f32);
510 let width = self.cell_width * ((bg_rect.end_col - bg_rect.start_col) as f32);
511 let height = self.cell_height;
512
513 let rect_bounds = Bounds {
514 origin: Point { x, y },
515 size: Size { width, height },
516 };
517
518 window.paint_quad(quad(
519 rect_bounds,
520 px(0.0),
521 bg_rect.color,
522 Edges::<Pixels>::default(),
523 transparent_black(),
524 Default::default(),
525 ));
526 }
527
528 // Calculate vertical offset to center text in cell
529 // The multiplier adds extra height; we want to distribute it evenly top/bottom
530 let base_height = self.cell_height / self.line_height_multiplier;
531 let vertical_offset = (self.cell_height - base_height) / 2.0;
532
533 // Paint each character individually at exact cell positions
534 // This ensures perfect alignment for terminal emulation
535 for (col_idx, cell) in cells {
536 let ch = cell.c;
537
538 // Skip empty cells (space or null)
539 if ch == ' ' || ch == '\0' {
540 continue;
541 }
542
543 let x = origin.x + self.cell_width * (col_idx as f32);
544 let y = origin.y + self.cell_height * (line_idx as f32) + vertical_offset;
545
546 // Get cell colors
547 let fg_color = self.palette.resolve(cell.fg, colors);
548
549 // Get cell flags for styling
550 let flags = cell.flags;
551 let bold = flags.contains(alacritty_terminal::term::cell::Flags::BOLD);
552 let italic = flags.contains(alacritty_terminal::term::cell::Flags::ITALIC);
553 let underline = flags.contains(alacritty_terminal::term::cell::Flags::UNDERLINE);
554
555 // Create font with styling
556 let font = Font {
557 family: self.font_family.clone().into(),
558 features: FontFeatures::default(),
559 fallbacks: None,
560 weight: if bold {
561 FontWeight::BOLD
562 } else {
563 FontWeight::NORMAL
564 },
565 style: if italic {
566 FontStyle::Italic
567 } else {
568 FontStyle::Normal
569 },
570 };
571
572 // Create text run for this single character
573 let char_str = ch.to_string();
574 let text_run = TextRun {
575 len: char_str.len(),
576 font,
577 color: fg_color,
578 background_color: None,
579 underline: if underline {
580 Some(UnderlineStyle {
581 thickness: px(1.0),
582 color: Some(fg_color),
583 wavy: false,
584 })
585 } else {
586 None
587 },
588 strikethrough: None,
589 };
590
591 // Shape and paint the character
592 let text: SharedString = char_str.into();
593 let shaped_line =
594 window
595 .text_system()
596 .shape_line(text, self.font_size, &[text_run], None);
597
598 // Paint at exact cell position (ignore errors)
599 let _ = shaped_line.paint(Point { x, y }, self.cell_height, window, _cx);
600 }
601 }
602
603 // Paint cursor
604 let cursor_point = grid.cursor.point;
605 let cursor_x = origin.x + self.cell_width * (cursor_point.column.0 as f32);
606 let cursor_y = origin.y + self.cell_height * (cursor_point.line.0 as f32);
607
608 let cursor_color = self.palette.resolve(
609 Color::Named(alacritty_terminal::vte::ansi::NamedColor::Cursor),
610 colors,
611 );
612
613 let cursor_bounds = Bounds {
614 origin: Point {
615 x: cursor_x,
616 y: cursor_y,
617 },
618 size: Size {
619 width: self.cell_width,
620 height: self.cell_height,
621 },
622 };
623
624 window.paint_quad(quad(
625 cursor_bounds,
626 px(0.0),
627 cursor_color,
628 Edges::<Pixels>::default(),
629 transparent_black(),
630 Default::default(),
631 ));
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 #[test]
640 fn test_renderer_creation() {
641 let renderer = TerminalRenderer::new(
642 "Fira Code".to_string(),
643 px(14.0),
644 1.2,
645 ColorPalette::default(),
646 );
647 assert_eq!(renderer.font_family, "Fira Code");
648 assert_eq!(renderer.font_size, px(14.0));
649 assert_eq!(renderer.line_height_multiplier, 1.2);
650 }
651
652 #[test]
653 fn test_background_rect_merge() {
654 let black = Hsla::black();
655
656 let rect1 = BackgroundRect {
657 start_col: 0,
658 end_col: 5,
659 row: 0,
660 color: black,
661 };
662
663 let rect2 = BackgroundRect {
664 start_col: 5,
665 end_col: 10,
666 row: 0,
667 color: black,
668 };
669
670 assert!(rect1.can_merge_with(&rect2));
671
672 let rect3 = BackgroundRect {
673 start_col: 5,
674 end_col: 10,
675 row: 1,
676 color: black,
677 };
678
679 assert!(!rect1.can_merge_with(&rect3));
680 }
681
682 #[test]
683 fn test_merge_backgrounds() {
684 let renderer = TerminalRenderer::new(
685 "monospace".to_string(),
686 px(14.0),
687 1.2,
688 ColorPalette::default(),
689 );
690 let black = Hsla::black();
691
692 let rects = vec![
693 BackgroundRect {
694 start_col: 0,
695 end_col: 5,
696 row: 0,
697 color: black,
698 },
699 BackgroundRect {
700 start_col: 5,
701 end_col: 10,
702 row: 0,
703 color: black,
704 },
705 ];
706
707 let merged = renderer.merge_backgrounds(rects);
708 assert_eq!(merged.len(), 1);
709 assert_eq!(merged[0].start_col, 0);
710 assert_eq!(merged[0].end_col, 10);
711 }
712}