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