Skip to main content

tui/rendering/
renderer.rs

1use super::frame::Frame;
2use super::line::Line;
3use super::render_context::ViewContext;
4use super::terminal_screen::{TerminalCommand, TerminalScreen};
5use super::visual_frame::VisualFrame;
6use crate::rendering::render_context::Size;
7use crate::theme::Theme;
8use std::io::{self, Write};
9use std::sync::Arc;
10
11#[cfg(feature = "syntax")]
12use crate::syntax_highlighting::SyntaxHighlighter;
13
14pub enum RendererCommand {
15    Bell,
16    ClearScreen,
17    SetTheme(Theme),
18    SetMouseCapture(bool),
19}
20
21#[doc = include_str!("../docs/renderer.md")]
22pub struct Renderer<W: Write> {
23    terminal: TerminalScreen<W>,
24    size: Size,
25    theme: Arc<Theme>,
26    #[cfg(feature = "syntax")]
27    highlighter: Arc<SyntaxHighlighter>,
28    prev_frame: Option<VisualFrame>,
29    resized: bool,
30}
31
32impl<W: Write> Renderer<W> {
33    pub fn new(writer: W, theme: Theme, size: impl Into<Size>) -> Self {
34        Self {
35            terminal: TerminalScreen::new(writer),
36            size: size.into(),
37            theme: Arc::new(theme),
38            #[cfg(feature = "syntax")]
39            highlighter: Arc::new(SyntaxHighlighter::new()),
40            prev_frame: None,
41            resized: false,
42        }
43    }
44
45    /// Render a frame using a closure.
46    ///
47    /// The closure receives a `ViewContext` and returns a Frame.
48    pub fn render_frame(&mut self, f: impl FnOnce(&ViewContext) -> Frame) -> io::Result<()> {
49        let context = self.context();
50        let frame = f(&context).clamp_cursor();
51        self.render_frame_internal(frame)
52    }
53
54    pub fn cleanup(&mut self) -> io::Result<()> {
55        let visible_rows = self.prev_frame.as_ref().map_or(0, |f| f.visible_lines().len());
56        self.terminal.cleanup(visible_rows)?;
57        self.prev_frame = None;
58        Ok(())
59    }
60
61    pub fn clear_screen(&mut self) -> io::Result<()> {
62        let commands = vec![TerminalCommand::ClearAll];
63        self.terminal.execute_batch(&commands)?;
64        self.prev_frame = None;
65        self.resized = false;
66        Ok(())
67    }
68
69    pub fn push_to_scrollback(&mut self, lines: &[Line]) -> io::Result<()> {
70        self.push_lines_to_scrollback(lines, self.size.width)
71    }
72
73    pub fn on_resize(&mut self, size: impl Into<Size>) {
74        self.size = size.into();
75        self.terminal.reset_cursor_offset();
76        self.prev_frame = None;
77        self.resized = true;
78    }
79
80    pub fn context(&self) -> ViewContext {
81        ViewContext {
82            size: self.size,
83            theme: self.theme.clone(),
84            #[cfg(feature = "syntax")]
85            highlighter: self.highlighter.clone(),
86        }
87    }
88
89    pub fn set_theme(&mut self, theme: Theme) {
90        self.theme = Arc::new(theme);
91    }
92
93    pub fn apply_commands(&mut self, commands: Vec<RendererCommand>) -> io::Result<()> {
94        for cmd in commands {
95            match cmd {
96                RendererCommand::Bell => self.terminal.execute(&TerminalCommand::Bell)?,
97                RendererCommand::ClearScreen => self.clear_screen()?,
98                RendererCommand::SetTheme(theme) => self.set_theme(theme),
99                RendererCommand::SetMouseCapture(enable) => {
100                    self.terminal.execute(&TerminalCommand::SetMouseCapture(enable))?;
101                    self.terminal.writer.flush()?;
102                }
103            }
104        }
105        Ok(())
106    }
107
108    pub fn writer(&self) -> &W {
109        &self.terminal.writer
110    }
111
112    #[cfg(any(test, feature = "testing"))]
113    pub fn test_writer_mut(&mut self) -> &mut W {
114        &mut self.terminal.writer
115    }
116
117    #[cfg(test)]
118    fn committed_scrollback_count(&self) -> usize {
119        self.prev_frame.as_ref().map_or(0, |f| f.scrollback_lines().len())
120    }
121
122    fn render_frame_internal(&mut self, frame: Frame) -> io::Result<()> {
123        let next_frame = VisualFrame::from_frame(frame, self.size);
124        let next_scrollback = next_frame.scrollback_lines();
125        let prev = self.prev_frame.as_ref();
126        let prev_scrollback = prev.map_or(&[][..], VisualFrame::scrollback_lines);
127        let can_incremental = !self.resized && next_scrollback.starts_with(prev_scrollback);
128        let (mut commands, mut prev_for_diff, new_scrollback) = if can_incremental {
129            (vec![TerminalCommand::RestoreCursorPosition], prev, &next_scrollback[prev_scrollback.len()..])
130        } else {
131            (vec![TerminalCommand::ClearAll], None, next_scrollback)
132        };
133
134        if !new_scrollback.is_empty() {
135            commands.push(TerminalCommand::PushScrollbackLines {
136                previous_visible_rows: prev_for_diff.map_or(0, |f| f.visible_lines().len()),
137                lines: new_scrollback,
138            });
139            prev_for_diff = None;
140        }
141
142        if let Some(diff) = VisualFrame::diff(prev_for_diff, &next_frame) {
143            commands.push(Self::rewrite_command(&diff));
144        }
145
146        commands.extend(Self::cursor_commands(&next_frame));
147        self.terminal.execute_batch(&commands)?;
148        self.prev_frame = Some(next_frame);
149        self.resized = false;
150        Ok(())
151    }
152
153    fn rewrite_command<'a>(diff: &super::visual_frame::LineDiff<'a>) -> TerminalCommand<'a> {
154        TerminalCommand::RewriteVisibleLines {
155            rows_up: diff_rows_up(diff),
156            append_after_existing: diff_should_append(diff),
157            lines: diff.lines,
158        }
159    }
160
161    fn cursor_commands(frame: &VisualFrame) -> [TerminalCommand<'_>; 2] {
162        let cursor = frame.cursor();
163        let rows_up = to_u16(frame.visible_lines().len().saturating_sub(1).saturating_sub(cursor.row));
164
165        [
166            TerminalCommand::SetCursorVisible(cursor.is_visible),
167            TerminalCommand::PlaceCursor { rows_up, col: to_u16(cursor.col) },
168        ]
169    }
170
171    fn push_lines_to_scrollback(&mut self, lines: &[Line], width: u16) -> io::Result<()> {
172        use super::visual_frame::prepare_lines_for_scrollback;
173
174        let visual = prepare_lines_for_scrollback(lines, width);
175        let previous_visible_rows = self.prev_frame.as_ref().map_or(0, |f| f.visible_lines().len());
176        self.prev_frame = None;
177
178        if visual.is_empty() {
179            return Ok(());
180        }
181
182        self.terminal.execute_batch(&[
183            TerminalCommand::RestoreCursorPosition,
184            TerminalCommand::PushScrollbackLines { previous_visible_rows, lines: &visual },
185        ])
186    }
187}
188
189fn diff_rows_up(diff: &super::visual_frame::LineDiff<'_>) -> u16 {
190    if diff.rewrite_from < diff.previous_row_count {
191        to_u16(diff.previous_row_count - 1 - diff.rewrite_from)
192    } else {
193        0
194    }
195}
196
197fn diff_should_append(diff: &super::visual_frame::LineDiff<'_>) -> bool {
198    diff.rewrite_from >= diff.previous_row_count && diff.previous_row_count > 0
199}
200
201fn to_u16(n: usize) -> u16 {
202    u16::try_from(n).unwrap_or(u16::MAX)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::rendering::frame::Cursor;
209
210    struct FakeWriter {
211        bytes: Vec<u8>,
212    }
213
214    impl FakeWriter {
215        fn new() -> Self {
216            Self { bytes: Vec::new() }
217        }
218    }
219
220    impl Write for FakeWriter {
221        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
222            self.bytes.extend_from_slice(buf);
223            Ok(buf.len())
224        }
225        fn flush(&mut self) -> io::Result<()> {
226            Ok(())
227        }
228    }
229
230    fn renderer(size: (u16, u16)) -> Renderer<FakeWriter> {
231        Renderer::new(FakeWriter::new(), Theme::default(), size)
232    }
233
234    fn output(r: &Renderer<FakeWriter>) -> String {
235        String::from_utf8_lossy(&r.terminal.writer.bytes).into_owned()
236    }
237
238    fn frame(lines: &[&str]) -> Frame {
239        Frame::new(lines.iter().map(|line| Line::new(*line)).collect()).with_cursor(Cursor {
240            row: lines.len().saturating_sub(1),
241            col: 0,
242            is_visible: true,
243        })
244    }
245
246    fn frame_with_cursor(lines: &[&str], row: usize, col: usize) -> Frame {
247        Frame::new(lines.iter().map(|line| Line::new(*line)).collect()).with_cursor(Cursor {
248            row,
249            col,
250            is_visible: true,
251        })
252    }
253
254    /// Render `first`, clear output buffer, render `second`, return the output.
255    fn diff_output(r: &mut Renderer<FakeWriter>, first: Frame, second: Frame) -> String {
256        r.render_frame_internal(first).unwrap();
257        r.terminal.writer.bytes.clear();
258        r.render_frame_internal(second).unwrap();
259        output(r)
260    }
261
262    fn assert_has(output: &str, needle: &str, msg: &str) {
263        assert!(output.contains(needle), "{msg}: {output:?}");
264    }
265
266    fn assert_missing(output: &str, needle: &str, msg: &str) {
267        assert!(!output.contains(needle), "{msg}: {output:?}");
268    }
269
270    #[test]
271    fn set_theme_replaces_render_context_theme() {
272        let mut r = Renderer::new(Vec::new(), Theme::default(), (80, 24));
273        let new_theme = Theme::default();
274        let expected = new_theme.text_primary();
275        r.set_theme(new_theme);
276        assert_eq!(r.context().theme.text_primary(), expected);
277    }
278
279    #[cfg(feature = "syntax")]
280    #[test]
281    fn set_theme_replaces_render_context_theme_from_file() {
282        let mut r = Renderer::new(Vec::new(), Theme::default(), (80, 24));
283        let custom_tmtheme = r#"<?xml version="1.0" encoding="UTF-8"?>
284<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
285<plist version="1.0">
286<dict>
287    <key>name</key>
288    <string>Custom</string>
289    <key>settings</key>
290    <array>
291        <dict>
292            <key>settings</key>
293            <dict>
294                <key>foreground</key>
295                <string>#112233</string>
296                <key>background</key>
297                <string>#000000</string>
298            </dict>
299        </dict>
300    </array>
301</dict>
302</plist>"#;
303        let temp_dir = tempfile::TempDir::new().unwrap();
304        let theme_path = temp_dir.path().join("custom.tmTheme");
305        std::fs::write(&theme_path, custom_tmtheme).unwrap();
306        r.set_theme(Theme::load_from_path(&theme_path));
307        assert_eq!(r.context().theme.text_primary(), crossterm::style::Color::Rgb { r: 0x11, g: 0x22, b: 0x33 });
308    }
309
310    #[test]
311    fn identical_rerender_produces_no_content_output() {
312        for lines in [vec![], vec!["hello", "world"]] {
313            let mut r = renderer((80, 24));
314            let f = frame(&lines);
315            let out = diff_output(&mut r, f.clone(), f);
316            for word in &lines {
317                assert_missing(&out, word, "identical re-render should not rewrite content");
318            }
319            assert_missing(&out, "\x1b[J", "should not clear from cursor down");
320        }
321    }
322
323    #[test]
324    fn first_render_writes_all_lines() {
325        let mut r = renderer((80, 24));
326        let f = frame(&["hello", "world"]);
327        r.render_frame_internal(f).unwrap();
328        let out = output(&r);
329        for word in ["hello", "world"] {
330            assert_has(&out, word, "first render should contain line");
331        }
332    }
333
334    #[test]
335    fn changing_middle_line_rewrites_from_diff() {
336        let mut r = renderer((80, 24));
337        let out = diff_output(&mut r, frame(&["aaa", "bbb", "ccc"]), frame(&["aaa", "BBB", "ccc"]));
338        for word in ["BBB", "ccc"] {
339            assert_has(&out, word, "changed/subsequent lines should be rewritten");
340        }
341    }
342
343    #[test]
344    fn appending_line_moves_to_next_row_before_writing() {
345        let mut r = renderer((80, 24));
346        let out = diff_output(&mut r, frame(&["aaa", "bbb"]), frame(&["aaa", "bbb", "ccc"]));
347        let ccc_pos = out.find("ccc").expect("missing appended line");
348        assert!(out[..ccc_pos].contains("\r\n"), "should move to next row before appending: {out:?}");
349    }
350
351    #[test]
352    fn push_to_scrollback_restores_cursor_even_when_nothing_new_is_flushed() {
353        let mut r = renderer((80, 2));
354        let f = frame_with_cursor(&["L1", "L2", "L3", "L4"], 2, 0);
355        r.render_frame_internal(f).unwrap();
356        r.terminal.writer.bytes.clear();
357        r.push_to_scrollback(&[Line::new("already flushed 1"), Line::new("already flushed 2")]).unwrap();
358        assert_has(&output(&r), "\x1b[1B", "should restore cursor before early return");
359    }
360
361    #[test]
362    fn push_to_scrollback_clears_prev_visible_lines() {
363        let mut r = renderer((80, 24));
364        let f = frame(&["managed line"]);
365        r.render_frame_internal(f.clone()).unwrap();
366        r.push_to_scrollback(&[Line::new("scrolled")]).unwrap();
367        r.terminal.writer.bytes.clear();
368        r.render_frame_internal(f).unwrap();
369        assert_has(&output(&r), "managed line", "should re-render managed content after scrollback");
370    }
371
372    #[test]
373    fn push_to_scrollback_empty_is_noop() {
374        let mut r = renderer((80, 24));
375        r.push_to_scrollback(&[]).unwrap();
376        assert!(r.terminal.writer.bytes.is_empty());
377    }
378
379    #[test]
380    fn clear_screen_emits_clear_all_and_purge() {
381        let mut r = renderer((80, 24));
382        r.clear_screen().unwrap();
383        let out = output(&r);
384        for (seq, label) in [("\x1b[2J", "ClearAll"), ("\x1b[3J", "Purge"), ("\x1b[1;1H", "cursor home")] {
385            assert_has(&out, seq, &format!("missing {label}"));
386        }
387    }
388
389    #[test]
390    fn clear_screen_resets_resize_state() {
391        let mut r = renderer((80, 24));
392        r.clear_screen().unwrap();
393        r.terminal.writer.bytes.clear();
394        r.render_frame_internal(frame(&["hello"])).unwrap();
395        let out = output(&r);
396        assert_has(&out, "hello", "should render content");
397        assert_missing(&out, "\x1b[2J", "render after clear_screen should not re-clear viewport");
398    }
399
400    #[test]
401    fn resize_marks_terminal_for_full_clear_and_redraw() {
402        let mut r = renderer((10, 4));
403        r.render_frame_internal(frame(&["abcdefghij"])).unwrap();
404        r.terminal.writer.bytes.clear();
405        r.on_resize((5, 4));
406        r.render_frame_internal(frame(&["abcdefghij"])).unwrap();
407        let out = output(&r);
408        for (seq, label) in
409            [("\x1b[2J", "ClearAll"), ("\x1b[3J", "Purge"), ("abcde", "wrapped-1"), ("fghij", "wrapped-2")]
410        {
411            assert_has(&out, seq, &format!("resize should emit {label}"));
412        }
413    }
414
415    #[test]
416    fn on_resize_resets_prev_frame() {
417        let mut r = renderer((80, 24));
418        r.render_frame_internal(frame(&["hello"])).unwrap();
419        assert!(r.prev_frame.is_some());
420        r.on_resize((40, 12));
421        assert!(r.prev_frame.is_none(), "on_resize should reset prev_frame");
422    }
423
424    #[test]
425    fn visual_frame_splits_overflow_from_visible_lines() {
426        let mut r = renderer((80, 2));
427        let f = frame_with_cursor(&["L1", "L2", "L3", "L4"], 3, 0);
428        r.render_frame_internal(f).unwrap();
429        assert_eq!(r.committed_scrollback_count(), 2);
430    }
431
432    #[test]
433    fn cursor_remapped_after_wrap() {
434        let mut r = renderer((3, 24));
435        let f = frame_with_cursor(&["abcdef"], 0, 5);
436        r.render_frame_internal(f).unwrap();
437        assert_has(&output(&r), "\x1b[2C", "cursor should be at col 2 (MoveRight(2))");
438    }
439
440    fn cleanup_output(r: &mut Renderer<FakeWriter>) -> String {
441        r.terminal.writer.bytes.clear();
442        r.cleanup().unwrap();
443        output(r)
444    }
445
446    #[test]
447    fn cleanup_clears_rendered_region() {
448        let mut r = renderer((80, 24));
449        r.render_frame_internal(frame(&["aaa", "bbb", "ccc"])).unwrap();
450        let out = cleanup_output(&mut r);
451        assert_has(&out, "\x1b[2A", "should move up to top of rendered region");
452        assert_has(&out, "\x1b[J", "should clear from cursor down");
453    }
454
455    #[test]
456    fn cleanup_restores_cursor_offset() {
457        let mut r = renderer((80, 24));
458        let f = frame_with_cursor(&["aaa", "bbb", "ccc"], 0, 0);
459        r.render_frame_internal(f).unwrap();
460        let out = cleanup_output(&mut r);
461        assert_has(&out, "\x1b[2B", "should move down past cursor offset");
462        assert_has(&out, "\x1b[2A", "should then move up to top");
463        assert_has(&out, "\x1b[J", "should clear from cursor down");
464    }
465
466    #[test]
467    fn cleanup_shows_hidden_cursor() {
468        let mut r = renderer((80, 24));
469        let f = Frame::new(vec![Line::new("hello")]).with_cursor(Cursor::hidden());
470        r.render_frame_internal(f).unwrap();
471        let out = cleanup_output(&mut r);
472        assert_has(&out, "\x1b[?25h", "should show cursor");
473    }
474
475    #[test]
476    fn cleanup_without_render_does_not_move_cursor() {
477        let mut r = renderer((80, 24));
478        let out = cleanup_output(&mut r);
479        for n in 1..=10 {
480            assert_missing(&out, &format!("\x1b[{n}A"), "should not move up");
481            assert_missing(&out, &format!("\x1b[{n}B"), "should not move down");
482        }
483        assert_missing(&out, "\x1b[?25h", "should not show cursor (already visible)");
484    }
485
486    #[test]
487    fn cleanup_after_clear_screen_is_noop() {
488        let mut r = renderer((80, 24));
489        r.render_frame_internal(frame(&["aaa", "bbb"])).unwrap();
490        r.clear_screen().unwrap();
491        let out = cleanup_output(&mut r);
492        assert_missing(&out, "\x1b[2A", "should not move up");
493        assert_missing(&out, "\x1b[2B", "should not move down");
494    }
495
496    #[test]
497    fn cleanup_resets_prev_frame() {
498        let mut r = renderer((80, 24));
499        r.render_frame_internal(frame(&["hello"])).unwrap();
500        r.cleanup().unwrap();
501        assert!(r.prev_frame.is_none(), "cleanup should reset prev_frame");
502    }
503}