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}