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