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