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
20pub 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 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 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 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}