Skip to main content

thndrs_lib/cli/renderer/
mod.rs

1//! Terminal rendering for `thndrs`.
2//!
3//! The row model
4//! ([`style`], [`layout`], [`row`], [`cursor`]) is independent of crossterm I/O
5//! so wrapping, padding, truncation, cursor coordinates, and snapshots remain
6//! unit-testable. [`alternate`] owns the production Ratatui surface and terminal
7//! lifecycle.
8
9pub mod adapter;
10pub mod alternate;
11pub mod cursor;
12pub mod git;
13pub mod highlight;
14pub mod layout;
15pub mod live;
16pub mod path_display;
17pub mod row;
18pub mod style;
19pub mod transcript;
20pub mod view;
21
22#[cfg(test)]
23mod tests {
24    use super::cursor;
25    use super::cursor::prompt_cursor;
26    use super::layout::{content_width, wrap_text};
27    use super::row::{CursorCoord, Frame, Row};
28    use super::style::{CellStyle, Color, Span};
29
30    /// Build a helper that wraps styled text into padded rows.
31    fn padded_rows(text: &str, style: CellStyle, width: usize, pad_style: CellStyle) -> Vec<Row> {
32        let content_w = content_width(width);
33        let wrapped = wrap_text(text, content_w);
34        wrapped
35            .into_iter()
36            .map(|line| {
37                let spans = if line.is_empty() { Vec::new() } else { vec![Span::styled(line, style)] };
38                Row::padded(spans, width, pad_style)
39            })
40            .collect()
41    }
42
43    #[test]
44    fn row_model_narrow_width() {
45        let rows = padded_rows(
46            "hello world",
47            CellStyle::new().fg(Color::Green),
48            10,
49            CellStyle::default(),
50        );
51
52        assert_eq!(rows.len(), 2);
53        assert_eq!(rows[0].text_width(), 10);
54        assert!(rows[0].text().contains("hello"));
55        assert!(rows[1].text().contains("world"));
56    }
57
58    #[test]
59    fn row_model_normal_width() {
60        let rows = padded_rows(
61            "hello world this is a test",
62            CellStyle::default(),
63            80,
64            CellStyle::default(),
65        );
66
67        assert_eq!(rows.len(), 1);
68        assert_eq!(rows[0].text_width(), 80);
69        assert!(rows[0].text().contains("hello world this is a test"));
70    }
71
72    #[test]
73    fn row_model_wide_width() {
74        let rows = padded_rows("hello world", CellStyle::default(), 200, CellStyle::default());
75        assert_eq!(rows.len(), 1);
76        assert_eq!(rows[0].text_width(), 200);
77    }
78
79    #[test]
80    fn row_model_tiny_width_no_panic() {
81        let rows = padded_rows("hello", CellStyle::default(), 1, CellStyle::default());
82        assert!(!rows.is_empty());
83    }
84
85    #[test]
86    fn row_model_zero_width_does_not_panic() {
87        let rows = padded_rows("hello", CellStyle::default(), 0, CellStyle::default());
88        assert!(!rows.is_empty());
89        assert!(rows.iter().all(|r| r.width == 0));
90    }
91
92    #[test]
93    fn prompt_cursor_single_line() {
94        let coord = prompt_cursor("hello world", 5, 76, 3);
95        assert_eq!(coord, CursorCoord { row: 0, col: 8 });
96    }
97
98    #[test]
99    fn prompt_cursor_wrapped_line() {
100        let coord = prompt_cursor("abcdefghij", 7, 3, 0);
101        assert_eq!(coord, CursorCoord { row: 2, col: 1 });
102    }
103
104    #[test]
105    fn prompt_cursor_multiline_explicit_newline() {
106        let coord = prompt_cursor("line1\nline2\nline3", 8, 76, 0);
107        assert_eq!(coord, CursorCoord { row: 1, col: 2 });
108    }
109
110    #[test]
111    fn prompt_cursor_indented_multiline() {
112        let coord = prompt_cursor("ab\ncd", 4, 76, 3);
113        assert_eq!(coord, CursorCoord { row: 1, col: 4 });
114    }
115
116    #[test]
117    fn prompt_cursor_multibyte() {
118        let coord = prompt_cursor("héllo wörld", 6, 76, 0);
119        assert_eq!(coord, CursorCoord { row: 0, col: 6 });
120    }
121
122    #[test]
123    fn prompt_cursor_multibyte_wrapped() {
124        let coord = prompt_cursor("ééééé", 4, 2, 0);
125        assert_eq!(coord, CursorCoord { row: 1, col: 2 });
126    }
127
128    #[test]
129    fn snapshot_simple_styled_frame() {
130        let mut frame = Frame::new(20);
131        let blue = CellStyle::new().fg(Color::Blue).bg(Color::Rgb { r: 30, g: 33, b: 50 });
132        frame.push(Row::padded(vec![Span::styled("Assistant", blue.bold())], 20, blue));
133        frame.push(Row::padded(
134            vec![Span::styled("hello world", CellStyle::default().bg(blue.bg))],
135            20,
136            blue,
137        ));
138        frame.set_cursor(CursorCoord::new(2, 3));
139        insta::assert_snapshot!(frame.render_styled());
140    }
141
142    #[test]
143    fn snapshot_transcript_block() {
144        let mut frame = Frame::new(40);
145        let bg = Color::Rgb { r: 30, g: 33, b: 50 };
146        let surface = CellStyle::default().bg(bg);
147
148        let rows = vec![
149            Row::padded(
150                vec![Span::styled("User", CellStyle::new().fg(Color::Blue).bg(bg).bold())],
151                40,
152                surface,
153            ),
154            Row::padded(
155                vec![Span::styled("hello there", CellStyle::default().bg(bg))],
156                40,
157                surface,
158            ),
159        ];
160        frame.rows.extend(rows);
161        insta::assert_snapshot!(frame.render_styled());
162    }
163
164    #[test]
165    fn snapshot_prompt_with_cursor() {
166        let mut frame = Frame::new(30);
167        let prompt_style = CellStyle::new().fg(Color::Yellow);
168        frame.push(Row::padded(
169            vec![Span::styled("❯  hello", prompt_style)],
170            30,
171            CellStyle::default(),
172        ));
173        frame.set_cursor(prompt_cursor("hello", 5, 27, 3));
174        insta::assert_snapshot!(frame.render_styled());
175    }
176
177    #[test]
178    fn snapshot_multiline_prompt() {
179        let text = "line one\nline two";
180        let mut frame = Frame::new(30);
181        let style = CellStyle::new().fg(Color::Yellow);
182        let indent = 3;
183        let body_width = 27;
184        for line in cursor::prompt_rows(text, body_width) {
185            let spans = vec![Span::styled("❯  ", style), Span::styled(line, CellStyle::default())];
186            frame.push(Row::padded(spans, 30, CellStyle::default()));
187        }
188        frame.set_cursor(prompt_cursor(text, 9, body_width, indent));
189        insta::assert_snapshot!(frame.render_styled());
190    }
191
192    #[test]
193    fn snapshot_narrow_width_block() {
194        let mut frame = Frame::new(10);
195        let bg = Color::Rgb { r: 30, g: 33, b: 50 };
196        let surface = CellStyle::default().bg(bg);
197        frame.push(Row::padded(
198            vec![Span::styled("Err", CellStyle::new().fg(Color::Red).bg(bg).bold())],
199            10,
200            surface,
201        ));
202        frame.push(Row::padded(
203            vec![Span::styled("bad input value here", CellStyle::default().bg(bg))],
204            10,
205            surface,
206        ));
207        insta::assert_snapshot!(frame.render_styled());
208    }
209}