Skip to main content

freya_terminal/
rendering.rs

1use std::hash::{
2    Hash,
3    Hasher,
4};
5
6use alacritty_terminal::{
7    selection::SelectionRange,
8    term::cell::{
9        Cell,
10        Flags,
11    },
12};
13use freya_core::{
14    fifo_cache::FifoCache,
15    prelude::Color,
16};
17use freya_engine::prelude::{
18    Canvas,
19    Font,
20    FontCollection,
21    Paint,
22    Paragraph,
23    ParagraphBuilder,
24    ParagraphStyle,
25    SkRect,
26    TextBlob,
27    TextStyle,
28};
29use rustc_hash::FxHasher;
30use torin::prelude::Area;
31
32use crate::{
33    colors::map_ansi_color,
34    url::link_ranges,
35};
36
37/// Cached per-row drawing primitives keyed by a hash of the row contents.
38pub(crate) enum CachedRow {
39    /// Same-color glyph runs ready to be redrawn directly.
40    TextBlobs(Vec<(TextBlob, Color)>),
41    /// Skia paragraph used when font fallback was required (emoji, wide chars).
42    Paragraph(Paragraph),
43}
44
45/// Single renderer that draws the terminal background, a row's cell
46/// backgrounds, glyph runs, selection overlay, cursor, and scrollbar.
47pub(crate) struct Renderer<'a> {
48    pub canvas: &'a Canvas,
49    pub paint: &'a mut Paint,
50    pub font: &'a Font,
51    pub font_collection: &'a mut FontCollection,
52    pub row_cache: &'a mut FifoCache<u64, CachedRow>,
53    pub area: Area,
54    pub char_width: f32,
55    pub line_height: f32,
56    pub baseline_offset: f32,
57    pub foreground: Color,
58    pub background: Color,
59    pub selection_color: Color,
60    pub font_family: &'a str,
61    pub font_size: f32,
62    pub selection: Option<SelectionRange>,
63    pub display_offset: usize,
64}
65
66impl Renderer<'_> {
67    pub fn render_background(&mut self) {
68        self.paint.set_color(self.background);
69        self.canvas.draw_rect(
70            SkRect::new(
71                self.area.min_x(),
72                self.area.min_y(),
73                self.area.max_x(),
74                self.area.max_y(),
75            ),
76            self.paint,
77        );
78    }
79
80    /// Render one row: cell backgrounds, glyphs, hyperlink underlines, then any selection overlay.
81    pub fn render_row(&mut self, row_idx: usize, row: &[Cell], y: f32) {
82        self.render_cell_backgrounds(row, y);
83        self.render_text_row(row, y);
84        self.render_hyperlink_underlines(row, y);
85        self.render_selection(row_idx, row.len(), y);
86    }
87
88    pub fn render_cursor(&mut self, cell: &Cell, y: f32, cursor_col: usize) {
89        let left = self.area.min_x() + (cursor_col as f32) * self.char_width;
90        let right = left + self.char_width.max(1.0);
91        let bottom = y + self.line_height.max(1.0);
92
93        self.paint.set_color(self.foreground);
94        self.canvas.draw_rect(
95            SkRect::new(left, y.round(), right, bottom.round()),
96            self.paint,
97        );
98
99        let glyph = match cell.c {
100            '\0' | '\t' => ' ',
101            c => c,
102        };
103        let mut buf = [0u8; 4];
104        let content: &str = glyph.encode_utf8(&mut buf);
105
106        self.paint.set_color(self.background);
107        if let Some(blob) = TextBlob::from_pos_text_h(content, &[0.0], 0.0, self.font) {
108            self.canvas
109                .draw_text_blob(&blob, (left, y + self.baseline_offset), self.paint);
110        }
111    }
112
113    pub fn render_scrollbar(
114        &mut self,
115        scroll_offset: usize,
116        total_scrollback: usize,
117        rows_count: usize,
118    ) {
119        let viewport_height = self.area.height();
120        let total_rows = rows_count + total_scrollback;
121        let total_content_height = total_rows as f32 * self.line_height;
122
123        let scrollbar_height = (viewport_height * viewport_height / total_content_height).max(20.0);
124        let track_height = viewport_height - scrollbar_height;
125        let scroll_ratio = scroll_offset as f32 / total_scrollback as f32;
126        let thumb_y = self.area.min_y() + track_height * (1.0 - scroll_ratio);
127
128        let scrollbar_x = self.area.max_x() - 4.0;
129        let corner_radius = 2.0;
130
131        self.paint.set_anti_alias(true);
132        self.paint.set_color(Color::from_argb(50, 0, 0, 0));
133        self.canvas.draw_round_rect(
134            SkRect::new(
135                scrollbar_x,
136                self.area.min_y(),
137                self.area.max_x(),
138                self.area.max_y(),
139            ),
140            corner_radius,
141            corner_radius,
142            self.paint,
143        );
144
145        self.paint.set_color(Color::from_argb(60, 255, 255, 255));
146        self.canvas.draw_round_rect(
147            SkRect::new(
148                scrollbar_x,
149                thumb_y,
150                self.area.max_x(),
151                thumb_y + scrollbar_height,
152            ),
153            corner_radius,
154            corner_radius,
155            self.paint,
156        );
157    }
158
159    fn render_cell_backgrounds(&mut self, row: &[Cell], y: f32) {
160        let mut run_start: Option<(usize, Color)> = None;
161        let mut col = 0;
162        while col < row.len() {
163            let cell = &row[col];
164            if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
165                col += 1;
166                continue;
167            }
168            let cell_bg = if cell.flags.contains(Flags::INVERSE) {
169                map_ansi_color(cell.fg, self.foreground, self.background)
170            } else {
171                map_ansi_color(cell.bg, self.foreground, self.background)
172            };
173            let end_col = if cell.flags.contains(Flags::WIDE_CHAR) {
174                col + 2
175            } else {
176                col + 1
177            };
178
179            if cell_bg != self.background {
180                match &run_start {
181                    Some((_, color)) if *color == cell_bg => {}
182                    Some((start, color)) => {
183                        self.fill_cells(*start, col, *color, y);
184                        run_start = Some((col, cell_bg));
185                    }
186                    None => {
187                        run_start = Some((col, cell_bg));
188                    }
189                }
190            } else if let Some((start, color)) = run_start.take() {
191                self.fill_cells(start, col, color, y);
192            }
193            col = end_col;
194        }
195        if let Some((start, color)) = run_start {
196            self.fill_cells(start, col, color, y);
197        }
198    }
199
200    fn fill_cells(&mut self, start: usize, end: usize, color: Color, y: f32) {
201        let left = self.area.min_x() + (start as f32) * self.char_width;
202        let right = self.area.min_x() + (end as f32) * self.char_width;
203        self.paint.set_color(color);
204        self.canvas.draw_rect(
205            SkRect::new(left, y.round(), right, (y + self.line_height).round()),
206            self.paint,
207        );
208    }
209
210    /// Draw a thin underline beneath OSC 8 hyperlink runs and detected plain-text URLs.
211    fn render_hyperlink_underlines(&mut self, row: &[Cell], y: f32) {
212        let underline_y = (y + self.line_height - 2.0).round();
213        self.paint.set_color(self.foreground);
214        for (start, end) in link_ranges(row) {
215            let left = self.area.min_x() + (start as f32) * self.char_width;
216            let right = self.area.min_x() + (end as f32) * self.char_width;
217            self.canvas.draw_rect(
218                SkRect::new(left, underline_y, right, underline_y + 1.0),
219                self.paint,
220            );
221        }
222    }
223
224    fn render_selection(&mut self, row_idx: usize, row_len: usize, y: f32) {
225        let Some(range) = self.selection else {
226            return;
227        };
228        let offset = self.display_offset as i64;
229        let start_row = range.start.line.0 as i64 + offset;
230        let end_row = range.end.line.0 as i64 + offset;
231        let row_i = row_idx as i64;
232        if row_i < start_row || row_i > end_row {
233            return;
234        }
235        let sel_start = if row_i == start_row {
236            range.start.column.0
237        } else {
238            0
239        };
240        let sel_end = if row_i == end_row {
241            (range.end.column.0 + 1).min(row_len)
242        } else {
243            row_len
244        };
245        if sel_start >= sel_end {
246            return;
247        }
248        let left = self.area.min_x() + (sel_start as f32) * self.char_width;
249        let right = self.area.min_x() + (sel_end as f32) * self.char_width;
250        self.paint.set_color(self.selection_color);
251        self.canvas.draw_rect(
252            SkRect::new(left, y.round(), right, (y + self.line_height).round()),
253            self.paint,
254        );
255    }
256
257    /// Draw a row's glyphs, hashing the row contents to hit `row_cache` on
258    /// repeat frames. Picks the fast `TextBlob` path or the `Paragraph` path
259    /// when font fallback (emoji, wide chars) is needed.
260    fn render_text_row(&mut self, row: &[Cell], y: f32) {
261        let mut hasher = FxHasher::default();
262        let mut needs_fallback = false;
263        for cell in row.iter() {
264            if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
265                continue;
266            }
267            let text = cell_text(cell);
268            let cell_fg = self.cell_foreground(cell);
269            text.hash(&mut hasher);
270            cell_fg.hash(&mut hasher);
271            if !needs_fallback {
272                needs_fallback = cell.flags.contains(Flags::WIDE_CHAR)
273                    || (!text.is_ascii() && self.font.text_to_glyphs_vec(&text).contains(&0));
274            }
275        }
276        let cache_key = hasher.finish();
277        let text_y = y + self.baseline_offset;
278        let area_min_x = self.area.min_x();
279
280        if let Some(cached) = self.row_cache.get(&cache_key) {
281            match cached {
282                CachedRow::TextBlobs(blobs) => {
283                    for (blob, color) in blobs {
284                        self.paint.set_color(*color);
285                        self.canvas
286                            .draw_text_blob(blob, (area_min_x, text_y), self.paint);
287                    }
288                }
289                CachedRow::Paragraph(paragraph) => {
290                    paragraph.paint(self.canvas, (area_min_x, y));
291                }
292            }
293        } else if needs_fallback {
294            self.render_paragraph(row, y, cache_key);
295        } else {
296            self.render_textblob(row, text_y, cache_key);
297        }
298    }
299
300    fn cell_foreground(&self, cell: &Cell) -> Color {
301        let raw = if cell.flags.contains(Flags::INVERSE) {
302            cell.bg
303        } else {
304            cell.fg
305        };
306        map_ansi_color(raw, self.foreground, self.background)
307    }
308
309    /// Fast path: same-color glyphs batched into one `TextBlob`, each glyph
310    /// pinned to its grid x-offset to preserve monospace alignment.
311    fn render_textblob(&mut self, row: &[Cell], text_y: f32, cache_key: u64) {
312        let mut current_color: Option<Color> = None;
313        let mut glyphs = String::new();
314        let mut glyph_positions: Vec<f32> = Vec::new();
315        let mut blobs: Vec<(TextBlob, Color)> = Vec::new();
316
317        for (col_idx, cell) in row.iter().enumerate() {
318            if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
319                continue;
320            }
321            let cell_fg = self.cell_foreground(cell);
322            let text = cell_text(cell);
323            let x = (col_idx as f32) * self.char_width;
324
325            if current_color != Some(cell_fg) {
326                if let Some(prev_color) = current_color {
327                    self.flush_blob(&glyphs, &glyph_positions, text_y, &mut blobs, prev_color);
328                    glyphs.clear();
329                    glyph_positions.clear();
330                }
331                current_color = Some(cell_fg);
332            }
333            for _ in text.chars() {
334                glyph_positions.push(x);
335            }
336            glyphs.push_str(&text);
337        }
338
339        if let Some(color) = current_color
340            && !glyphs.is_empty()
341        {
342            self.flush_blob(&glyphs, &glyph_positions, text_y, &mut blobs, color);
343        }
344
345        self.row_cache
346            .insert(cache_key, CachedRow::TextBlobs(blobs));
347    }
348
349    fn flush_blob(
350        &mut self,
351        glyphs: &str,
352        glyph_positions: &[f32],
353        text_y: f32,
354        blobs: &mut Vec<(TextBlob, Color)>,
355        color: Color,
356    ) {
357        if let Some(blob) = TextBlob::from_pos_text_h(glyphs, glyph_positions, 0.0, self.font) {
358            self.paint.set_color(color);
359            self.canvas
360                .draw_text_blob(&blob, (self.area.min_x(), text_y), self.paint);
361            blobs.push((blob, color));
362        }
363    }
364
365    /// Slow path: Paragraph with font fallback for emoji/wide chars.
366    fn render_paragraph(&mut self, row: &[Cell], row_y: f32, cache_key: u64) {
367        let mut text_style = TextStyle::new();
368        text_style.set_font_size(self.font_size);
369        text_style.set_font_families(&[self.font_family]);
370        text_style.set_color(self.foreground);
371
372        let mut builder =
373            ParagraphBuilder::new(&ParagraphStyle::default(), self.font_collection.clone());
374
375        for cell in row.iter() {
376            if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
377                continue;
378            }
379            let mut cell_style = text_style.clone();
380            cell_style.set_color(self.cell_foreground(cell));
381            builder.push_style(&cell_style);
382            builder.add_text(cell_text(cell).as_str());
383        }
384
385        let mut paragraph = builder.build();
386        paragraph.layout(f32::MAX);
387        paragraph.paint(self.canvas, (self.area.min_x(), row_y));
388
389        self.row_cache
390            .insert(cache_key, CachedRow::Paragraph(paragraph));
391    }
392}
393
394/// Visible text for a cell, mapping empty (`\0`) and tab (`\t`) cells to a space.
395fn cell_text(cell: &Cell) -> String {
396    let mut s = String::new();
397    s.push(match cell.c {
398        '\0' | '\t' => ' ',
399        c => c,
400    });
401    if let Some(extra) = cell.zerowidth() {
402        for c in extra {
403            s.push(*c);
404        }
405    }
406    s
407}