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