mirror of
https://github.com/openai/codex.git
synced 2026-11-15 21:50:46 +00:00
> large behavior change to how the TUI owns its viewport, history, and suspend behavior. > Core model is in place; a few items are still being polished before this is ready to merge. We've moved this over to a new tui2 crate from being directly on the tui crate. To enable use --enable tui2 (or the equivalent in your config.toml). See https://developers.openai.com/codex/local-config#feature-flags Note that this serves as a baseline for the changes that we're making to be applied rapidly. Tui2 may not track later changes in the main tui. It's experimental and may not be where we land on things. --- ## Summary This PR moves the Codex TUI off of “cooperating” with the terminal’s scrollback and onto a model where the in‑memory transcript is the single source of truth. The TUI now owns scrolling, selection, copy, and suspend/exit printing based on that transcript, and only writes to terminal scrollback in append‑only fashion on suspend/exit. It also fixes streaming wrapping so streamed responses reflow with the viewport, and introduces configuration to control whether we print history on suspend or only on exit. High‑level goals: - Ensure history is complete, ordered, and never silently dropped. - Print each logical history cell at most once into scrollback, even with resizes and suspends. - Make scrolling, selection, and copy match the visible transcript, not the terminal’s notion of scrollback. - Keep suspend/alt‑screen behavior predictable across terminals. --- ## Core Design Changes ### Transcript & viewport ownership - Treat the transcript as a list of **cells** (user prompts, agent messages, system/info rows, streaming segments). - On each frame: - Compute a **transcript region** as “full terminal frame minus the bottom input area”. - Flatten all cells into visual lines plus metadata (which cell + which line within that cell). - Use scroll state to choose which visual line is at the top of the region. - Clear that region and draw just the visible slice of lines. - The terminal’s scrollback is no longer part of the live layout algorithm; it is only ever written to when we decide to print history. ### User message styling - User prompts now render as clear blocks with: - A blank padding line above and below. - A full‑width background for every line in the block (including the prompt line itself). - The same block styling is used when we print history into scrollback, so the transcript looks consistent whether you are in the TUI or scrolling back after exit/suspend. --- ## Scrolling, Mouse, Selection, and Copy ### Scrolling - Scrolling is defined in terms of the flattened transcript lines: - Mouse wheel scrolls up/down by fixed line increments. - PgUp/PgDn/Home/End operate on the same scroll model. - The footer shows: - Whether you are “following live output” vs “scrolled up”. - Current scroll position (line / total). - When there is no history yet, the bottom pane is **pegged high** and gradually moves down as the transcript fills, matching the existing UX. ### Selection - Click‑and‑drag defines a **linear selection** over transcript line/column coordinates, not raw screen rows. - Selection is **content‑anchored**: - When you scroll, the selection moves with the underlying lines instead of sticking to a fixed Y position. - This holds both when scrolling manually and when new content streams in, as long as you are in “follow” mode. - The selection only covers the “transcript text” area: - Left gutter/prefix (bullets, markers) is intentionally excluded. - This keeps copy/paste cleaner and avoids including structural margin characters. ### Copy (`Ctrl+Y`) - Introduce a small clipboard abstraction (`ClipboardManager`‑style) and use a cross‑platform clipboard crate under the hood. - When `Ctrl+Y` is pressed and a non‑empty selection exists: - Re‑render the transcript region off‑screen using the same wrapping as the visible viewport. - Walk the selected line/column range over that buffer to reconstruct the exact text: - Includes spaces between words. - Preserves empty lines within the selection. - Send the resulting text to the system clipboard. - Show a short status message in the footer indicating success/failure. - Copy is **best‑effort**: - Clipboard failures (headless environment, sandbox, remote sessions) are handled gracefully via status messages; they do not crash the TUI. - Copy does *not* insert a new history entry; it only affects the status bar. --- ## Streaming and Wrapping ### Previous behavior Previously, streamed markdown: - Was wrapped at a fixed width **at commit time** inside the streaming collector. - Those wrapped `Line<'static>` values were then wrapped again at display time. - As a result, streamed paragraphs could not “un‑wrap” when the terminal width increased; they were permanently split according to the width at the start of the stream. ### New behavior This PR implements the first step from `codex-rs/tui/streaming_wrapping_design.md`: - Streaming collector is constructed **without** a fixed width for wrapping. - It still: - Buffers the full markdown source for the current stream. - Commits only at newline boundaries. - Emits logical lines as new content becomes available. - Agent message cells now wrap streamed content only at **display time**, based on the current viewport width, just like non‑streaming messages. - Consequences: - Streamed responses reflow correctly when the terminal is resized. - Animation steps are per logical line instead of per “pre‑wrapped” visual line; this makes some commits slightly larger but keeps the behavior simple and predictable. Streaming responses are still represented as a sequence of logical history entries (first line + continuations) and integrate with the same scrolling, selection, and printing model. --- ## Printing History on Suspend and Exit ### High‑water mark and append‑only scrollback - Introduce a **cell‑based high‑water mark** (`printed_history_cells`) on the transcript: - Represents “how many cells at the front of the transcript have already been printed”. - Completely independent of wrapped line counts or terminal geometry. - Whenever we print history (suspend or exit): - Take the suffix of `transcript_cells` beyond `printed_history_cells`. - Render just that suffix into styled lines at the **current** width. - Write those lines to stdout. - Advance `printed_history_cells` to cover all cells we just printed. - Older cells are never re‑rendered for scrollback. They stay in whatever wrapping they had when printed, which is acceptable as long as the logical content is present once. ### Suspend (`Ctrl+Z`) - On suspend: - Leave alt screen if active and restore normal terminal modes. - Render the not‑yet‑printed suffix of the transcript and append it to normal scrollback. - Advance the high‑water mark. - Suspend the process. - On resume (`fg`): - Re‑enter the TUI mode (alt screen + input modes). - Clear the viewport region and fully redraw from in‑memory transcript and state. This gives predictable behavior across terminals without trying to maintain scrollback live. ### Exit - On exit: - Render any remaining unprinted cells once and write them to stdout. - Add an extra blank line after the final Codex history cell before printing token usage, so the transcript and usage info are visually separated. - If you never suspended, exit prints the entire transcript exactly once. - If you suspended one or more times, exit prints only the cells appended after the last suspend. --- ## Configuration: Suspend Printing This PR also adds configuration to control **when** we print history: - New TUI config option to gate printing on suspend: - At minimum: - `print_on_suspend = true` – current behavior: print new history at each suspend *and* on exit. - `print_on_suspend = false` – only print on exit. - Default is tuned to preserve current behavior, but this can be revisited based on feedback. - The config is respected in the suspend path: - If disabled, suspend only restores terminal modes and stops rendering but does not print new history. - Exit still prints the full not‑yet‑printed suffix once. This keeps the core viewport logic agnostic to preference, while letting users who care about quiet scrollback opt out of suspend printing. --- ## Tradeoffs What we gain: - A single authoritative history model (the in‑memory transcript). - Deterministic viewport rendering independent of terminal quirks. - Suspend/exit flows that: - Print each logical history cell exactly once. - Work across resizes and different terminals. - Interact cleanly with alt screen and raw‑mode toggling. - Consistent, content‑anchored scrolling, selection, and copy. - Streaming messages that reflow correctly with the viewport width. What we accept: - Scrollback may contain older cells wrapped differently than newer ones. - Streaming responses appear in scrollback as a sequence of blocks corresponding to their streaming structure, not as a single retroactively reflowed paragraph. - We do not attempt to rewrite or reflow already‑printed scrollback. For deeper rationale and diagrams, see `docs/tui_viewport_and_history.md` and `codex-rs/tui/streaming_wrapping_design.md`. --- ## Still to Do Before This PR Is Ready These are scoped to this PR (not long‑term future work): - [ ] **Streaming wrapping polish** - Double‑check all streaming paths use display‑time wrapping only. - Ensure tests cover resizing after streaming has started. - [ ] **Suspend printing config** - Finalize config shape and default (keep existing behavior vs opt‑out). - Wire config through TUI startup and document it in the appropriate config docs. - [x] **Bottom pane positioning** - Ensure the bottom pane is pegged high when there’s no history and smoothly moves down as the transcript fills, matching the current behavior across startup and resume. - [x] **Transcript mouse scrolling** - Re‑enable wheel‑based transcript scrolling on top of the new scroll model. - Make sure mouse scroll does not get confused with “alternate scroll” modes from terminals. - [x] **Mouse selection vs streaming** - When selection is active, stop auto‑scrolling on streaming so the selection remains stable on the selected content. - Ensure that when streaming continues after selection is cleared, “follow latest output” mode resumes correctly. - [ ] **Auto‑scroll during drag** - While the user is dragging a selection, auto‑scroll when the cursor is at/near the top or bottom of the transcript viewport to allow selecting beyond the current visible window. - [ ] **Feature flag / rollout** - Investigate gating the new viewport/history behavior behind a feature flag for initial rollout, so we can fall back to the old behavior if needed during early testing. - [ ] **Before/after videos** - Capture short clips showing: - Scrolling (mouse + keys). - Selection and copy. - Streaming behavior under resize. - Suspend/resume and exit printing. - Use these to validate UX and share context in the PR discussion.
531 lines
19 KiB
Rust
531 lines
19 KiB
Rust
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<B>(
|
|
terminal: &mut crate::custom_terminal::Terminal<B>,
|
|
lines: Vec<Line>,
|
|
) -> io::Result<()>
|
|
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))?;
|
|
queue!(writer, MoveTo(0, area.top()))?;
|
|
for _ in 0..scroll_amount {
|
|
// Reverse Index (RI): ESC M
|
|
queue!(writer, Print("\x1bM"))?;
|
|
}
|
|
queue!(writer, ResetScrollRegion)?;
|
|
|
|
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()))?;
|
|
|
|
// 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))?;
|
|
|
|
for line in wrapped {
|
|
queue!(writer, Print("\r\n"))?;
|
|
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)
|
|
))
|
|
)?;
|
|
queue!(writer, Clear(ClearType::UntilNewLine))?;
|
|
// Merge line-level style into each span so that ANSI colors reflect
|
|
// line styles (e.g., blockquotes with green fg).
|
|
let merged_spans: Vec<Span> = line
|
|
.spans
|
|
.iter()
|
|
.map(|s| Span {
|
|
style: s.style.patch(line.style),
|
|
content: s.content.clone(),
|
|
})
|
|
.collect();
|
|
write_spans(writer, merged_spans.iter())?;
|
|
}
|
|
|
|
queue!(writer, ResetScrollRegion)?;
|
|
|
|
// Restore the cursor position to where it was before we started.
|
|
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
|
|
|
|
let _ = writer;
|
|
if should_update_area {
|
|
terminal.set_viewport_area(area);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SetScrollRegion(pub std::ops::Range<u16>);
|
|
|
|
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<W>(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(())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
|
|
where
|
|
I: IntoIterator<Item = &'a Span<'a>>,
|
|
{
|
|
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<u8> = Vec::new();
|
|
write_spans(&mut actual, spans.iter()).unwrap();
|
|
|
|
let mut expected: Vec<u8> = 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])
|
|
.expect("Failed to insert history lines in test");
|
|
|
|
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])
|
|
.expect("Failed to insert history lines in test");
|
|
|
|
// 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<u16> = 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])
|
|
.expect("Failed to insert history lines in test");
|
|
|
|
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<Line<'static>> = 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).expect("Failed to insert history lines in test");
|
|
|
|
let screen = term.backend().vt100().screen();
|
|
|
|
// Reconstruct screen rows as strings to locate the 3rd level line.
|
|
let rows: Vec<String> = 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()
|
|
);
|
|
}
|
|
}
|
|
}
|