use std::fmt; use std::io; use std::io::Write; use crate::wrapping::word_wrap_lines_borrowed; use crossterm::Command; use crossterm::cursor::MoveTo; use crossterm::queue; use crossterm::style::Color as CColor; use crossterm::style::Colors; use crossterm::style::Print; use crossterm::style::SetAttribute; use crossterm::style::SetBackgroundColor; use crossterm::style::SetColors; use crossterm::style::SetForegroundColor; use crossterm::terminal::Clear; use crossterm::terminal::ClearType; use ratatui::layout::Size; use ratatui::prelude::Backend; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::text::Line; use ratatui::text::Span; /// Insert `lines` above the viewport using the terminal's backend writer /// (avoids direct stdout references). pub fn insert_history_lines(terminal: &mut crate::custom_terminal::Terminal, lines: Vec) where B: Backend + Write, { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); let mut area = terminal.viewport_area; let mut should_update_area = false; let last_cursor_pos = terminal.last_known_cursor_pos; let writer = terminal.backend_mut(); // Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same // formatting as the TUI. This avoids character-level hard wrapping by the terminal. let wrapped = word_wrap_lines_borrowed(&lines, area.width.max(1) as usize); let wrapped_lines = wrapped.len() as u16; let cursor_top = if area.bottom() < screen_size.height { // If the viewport is not at the bottom of the screen, scroll it down to make room. // Don't scroll it past the bottom of the screen. let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); // Emit ANSI to scroll the lower region (from the top of the viewport to the bottom // of the screen) downward by `scroll_amount` lines. We do this by: // 1) Limiting the scroll region to [area.top()+1 .. screen_height] (1-based bounds) // 2) Placing the cursor at the top margin of that region // 3) Emitting Reverse Index (RI, ESC M) `scroll_amount` times // 4) Resetting the scroll region back to full screen let top_1based = area.top() + 1; // Convert 0-based row to 1-based for DECSTBM queue!(writer, SetScrollRegion(top_1based..screen_size.height)).ok(); queue!(writer, MoveTo(0, area.top())).ok(); for _ in 0..scroll_amount { // Reverse Index (RI): ESC M queue!(writer, Print("\x1bM")).ok(); } queue!(writer, ResetScrollRegion).ok(); let cursor_top = area.top().saturating_sub(1); area.y += scroll_amount; should_update_area = true; cursor_top } else { area.top().saturating_sub(1) }; // Limit the scroll region to the lines from the top of the screen to the // top of the viewport. With this in place, when we add lines inside this // area, only the lines in this area will be scrolled. We place the cursor // at the end of the scroll region, and add lines starting there. // // ┌─Screen───────────────────────┐ // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│ // │┆ ┆│ // │┆ ┆│ // │┆ ┆│ // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│ // │╭─Viewport───────────────────╮│ // ││ ││ // │╰────────────────────────────╯│ // └──────────────────────────────┘ queue!(writer, SetScrollRegion(1..area.top())).ok(); // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the // terminal's last_known_cursor_position, which hopefully will still be accurate after we // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) queue!(writer, MoveTo(0, cursor_top)).ok(); for line in wrapped { queue!(writer, Print("\r\n")).ok(); queue!( writer, SetColors(Colors::new( line.style .fg .map(std::convert::Into::into) .unwrap_or(CColor::Reset), line.style .bg .map(std::convert::Into::into) .unwrap_or(CColor::Reset) )) ) .ok(); queue!(writer, Clear(ClearType::UntilNewLine)).ok(); // Merge line-level style into each span so that ANSI colors reflect // line styles (e.g., blockquotes with green fg). let merged_spans: Vec = line .spans .iter() .map(|s| Span { style: s.style.patch(line.style), content: s.content.clone(), }) .collect(); write_spans(writer, merged_spans.iter()).ok(); } queue!(writer, ResetScrollRegion).ok(); // Restore the cursor position to where it was before we started. queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y)).ok(); let _ = writer; if should_update_area { terminal.set_viewport_area(area); } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SetScrollRegion(pub std::ops::Range); impl Command for SetScrollRegion { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { write!(f, "\x1b[{};{}r", self.0.start, self.0.end) } #[cfg(windows)] fn execute_winapi(&self) -> std::io::Result<()> { panic!("tried to execute SetScrollRegion command using WinAPI, use ANSI instead"); } #[cfg(windows)] fn is_ansi_code_supported(&self) -> bool { // TODO(nornagon): is this supported on Windows? true } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResetScrollRegion; impl Command for ResetScrollRegion { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { write!(f, "\x1b[r") } #[cfg(windows)] fn execute_winapi(&self) -> std::io::Result<()> { panic!("tried to execute ResetScrollRegion command using WinAPI, use ANSI instead"); } #[cfg(windows)] fn is_ansi_code_supported(&self) -> bool { // TODO(nornagon): is this supported on Windows? true } } struct ModifierDiff { pub from: Modifier, pub to: Modifier, } impl ModifierDiff { fn queue(self, mut w: W) -> io::Result<()> where W: io::Write, { use crossterm::style::Attribute as CAttribute; let removed = self.from - self.to; if removed.contains(Modifier::REVERSED) { queue!(w, SetAttribute(CAttribute::NoReverse))?; } if removed.contains(Modifier::BOLD) { queue!(w, SetAttribute(CAttribute::NormalIntensity))?; if self.to.contains(Modifier::DIM) { queue!(w, SetAttribute(CAttribute::Dim))?; } } if removed.contains(Modifier::ITALIC) { queue!(w, SetAttribute(CAttribute::NoItalic))?; } if removed.contains(Modifier::UNDERLINED) { queue!(w, SetAttribute(CAttribute::NoUnderline))?; } if removed.contains(Modifier::DIM) { queue!(w, SetAttribute(CAttribute::NormalIntensity))?; } if removed.contains(Modifier::CROSSED_OUT) { queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; } if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { queue!(w, SetAttribute(CAttribute::NoBlink))?; } let added = self.to - self.from; if added.contains(Modifier::REVERSED) { queue!(w, SetAttribute(CAttribute::Reverse))?; } if added.contains(Modifier::BOLD) { queue!(w, SetAttribute(CAttribute::Bold))?; } if added.contains(Modifier::ITALIC) { queue!(w, SetAttribute(CAttribute::Italic))?; } if added.contains(Modifier::UNDERLINED) { queue!(w, SetAttribute(CAttribute::Underlined))?; } if added.contains(Modifier::DIM) { queue!(w, SetAttribute(CAttribute::Dim))?; } if added.contains(Modifier::CROSSED_OUT) { queue!(w, SetAttribute(CAttribute::CrossedOut))?; } if added.contains(Modifier::SLOW_BLINK) { queue!(w, SetAttribute(CAttribute::SlowBlink))?; } if added.contains(Modifier::RAPID_BLINK) { queue!(w, SetAttribute(CAttribute::RapidBlink))?; } Ok(()) } } fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> where I: IntoIterator>, { let mut fg = Color::Reset; let mut bg = Color::Reset; let mut last_modifier = Modifier::empty(); for span in content { let mut modifier = Modifier::empty(); modifier.insert(span.style.add_modifier); modifier.remove(span.style.sub_modifier); if modifier != last_modifier { let diff = ModifierDiff { from: last_modifier, to: modifier, }; diff.queue(&mut writer)?; last_modifier = modifier; } let next_fg = span.style.fg.unwrap_or(Color::Reset); let next_bg = span.style.bg.unwrap_or(Color::Reset); if next_fg != fg || next_bg != bg { queue!( writer, SetColors(Colors::new(next_fg.into(), next_bg.into())) )?; fg = next_fg; bg = next_bg; } queue!(writer, Print(span.content.clone()))?; } queue!( writer, SetForegroundColor(CColor::Reset), SetBackgroundColor(CColor::Reset), SetAttribute(crossterm::style::Attribute::Reset), ) } #[cfg(test)] mod tests { use super::*; use crate::markdown_render::render_markdown_text; use crate::test_backend::VT100Backend; use ratatui::layout::Rect; use ratatui::style::Color; #[test] fn writes_bold_then_regular_spans() { use ratatui::style::Stylize; let spans = ["A".bold(), "B".into()]; let mut actual: Vec = Vec::new(); write_spans(&mut actual, spans.iter()).unwrap(); let mut expected: Vec = Vec::new(); queue!( expected, SetAttribute(crossterm::style::Attribute::Bold), Print("A"), SetAttribute(crossterm::style::Attribute::NormalIntensity), Print("B"), SetForegroundColor(CColor::Reset), SetBackgroundColor(CColor::Reset), SetAttribute(crossterm::style::Attribute::Reset), ) .unwrap(); assert_eq!( String::from_utf8(actual).unwrap(), String::from_utf8(expected).unwrap() ); } #[test] fn vt100_blockquote_line_emits_green_fg() { // Set up a small off-screen terminal let width: u16 = 40; let height: u16 = 10; let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); // Place viewport on the last line so history inserts scroll upward let viewport = Rect::new(0, height - 1, width, 1); term.set_viewport_area(viewport); // Build a blockquote-like line: apply line-level green style and prefix "> " let mut line: Line<'static> = Line::from(vec!["> ".into(), "Hello world".into()]); line = line.style(Color::Green); insert_history_lines(&mut term, vec![line]); let mut saw_colored = false; 'outer: for row in 0..height { for col in 0..width { if let Some(cell) = term.backend().vt100().screen().cell(row, col) && cell.has_contents() && cell.fgcolor() != vt100::Color::Default { saw_colored = true; break 'outer; } } } assert!( saw_colored, "expected at least one colored cell in vt100 output" ); } #[test] fn vt100_blockquote_wrap_preserves_color_on_all_wrapped_lines() { // Force wrapping by using a narrow viewport width and a long blockquote line. let width: u16 = 20; let height: u16 = 8; let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); // Viewport is the last line so history goes directly above it. let viewport = Rect::new(0, height - 1, width, 1); term.set_viewport_area(viewport); // Create a long blockquote with a distinct prefix and enough text to wrap. let mut line: Line<'static> = Line::from(vec![ "> ".into(), "This is a long quoted line that should wrap".into(), ]); line = line.style(Color::Green); insert_history_lines(&mut term, vec![line]); // Parse and inspect the final screen buffer. let screen = term.backend().vt100().screen(); // Collect rows that are non-empty; these should correspond to our wrapped lines. let mut non_empty_rows: Vec = Vec::new(); for row in 0..height { let mut any = false; for col in 0..width { if let Some(cell) = screen.cell(row, col) && cell.has_contents() && cell.contents() != "\0" && cell.contents() != " " { any = true; break; } } if any { non_empty_rows.push(row); } } // Expect at least two rows due to wrapping. assert!( non_empty_rows.len() >= 2, "expected wrapped output to span >=2 rows, got {non_empty_rows:?}", ); // For each non-empty row, ensure all non-space cells are using a non-default fg color. for row in non_empty_rows { for col in 0..width { if let Some(cell) = screen.cell(row, col) { let contents = cell.contents(); if !contents.is_empty() && contents != " " { assert!( cell.fgcolor() != vt100::Color::Default, "expected non-default fg on row {row} col {col}, got {:?}", cell.fgcolor() ); } } } } } #[test] fn vt100_colored_prefix_then_plain_text_resets_color() { let width: u16 = 40; let height: u16 = 6; let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); let viewport = Rect::new(0, height - 1, width, 1); term.set_viewport_area(viewport); // First span colored, rest plain. let line: Line<'static> = Line::from(vec![ Span::styled("1. ", ratatui::style::Style::default().fg(Color::LightBlue)), Span::raw("Hello world"), ]); insert_history_lines(&mut term, vec![line]); let screen = term.backend().vt100().screen(); // Find the first non-empty row; verify first three cells are colored, following cells default. 'rows: for row in 0..height { let mut has_text = false; for col in 0..width { if let Some(cell) = screen.cell(row, col) && cell.has_contents() && cell.contents() != " " { has_text = true; break; } } if !has_text { continue; } // Expect "1. Hello world" starting at col 0. for col in 0..3 { let cell = screen.cell(row, col).unwrap(); assert!( cell.fgcolor() != vt100::Color::Default, "expected colored prefix at col {col}, got {:?}", cell.fgcolor() ); } for col in 3..(3 + "Hello world".len() as u16) { let cell = screen.cell(row, col).unwrap(); assert_eq!( cell.fgcolor(), vt100::Color::Default, "expected default color for plain text at col {col}, got {:?}", cell.fgcolor() ); } break 'rows; } } #[test] fn vt100_deep_nested_mixed_list_third_level_marker_is_colored() { // Markdown with five levels (ordered → unordered → ordered → unordered → unordered). let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n"; let text = render_markdown_text(md); let lines: Vec> = text.lines.clone(); let width: u16 = 60; let height: u16 = 12; let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1); term.set_viewport_area(viewport); insert_history_lines(&mut term, lines); let screen = term.backend().vt100().screen(); // Reconstruct screen rows as strings to locate the 3rd level line. let rows: Vec = screen.rows(0, width).collect(); let needle = "1. Third level (ordered)"; let row_idx = rows .iter() .position(|r| r.contains(needle)) .unwrap_or_else(|| { panic!("expected to find row containing {needle:?}, have rows: {rows:?}") }); let col_start = rows[row_idx].find(needle).unwrap() as u16; // column where '1' starts // Verify that the numeric marker ("1.") at the third level is colored // (non-default fg) and the content after the following space resets to default. for c in [col_start, col_start + 1] { let cell = screen.cell(row_idx as u16, c).unwrap(); assert!( cell.fgcolor() != vt100::Color::Default, "expected colored 3rd-level marker at row {row_idx} col {c}, got {:?}", cell.fgcolor() ); } let content_col = col_start + 3; // skip '1', '.', and the space if let Some(cell) = screen.cell(row_idx as u16, content_col) { assert_eq!( cell.fgcolor(), vt100::Color::Default, "expected default color for 3rd-level content at row {row_idx} col {content_col}, got {:?}", cell.fgcolor() ); } } }