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