Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,35 @@
[package]
name = "xai-ratatui-inline"
version = "0.1.0"
edition.workspace = true
license = "Apache-2.0"
authors = ["xAI"]
[dependencies]
# ANSI escape parsing for the line splitter (production). Same VTE-style
# state machine family as the prior termwiz-based parser; already present in
# the shipped dependency tree via anstream/clap.
anstyle-parse = { workspace = true }
crossterm = { workspace = true }
ratatui = { workspace = true }
unicode-width = { workspace = true }
[dev-dependencies]
ansi-width = { workspace = true }
anyhow = { workspace = true }
colored_json = { workspace = true }
criterion = { workspace = true }
lipsum = { workspace = true }
serde_json = { workspace = true }
# Dev-only reference implementation for tests/segment_differential.rs.
# Not linked into shipped binaries.
termwiz = { workspace = true }
[[bench]]
name = "bench"
harness = false
[features]
scrolling-regions = [
"ratatui/scrolling-regions",
] # required for terminal fork only

View file

@ -0,0 +1,15 @@
xai-ratatui-inline includes code derived from the Ratatui terminal UI library.
Ratatui is dual-licensed under the MIT License and the Apache License, Version 2.0.
This crates forked Terminal / viewport implementation is based on Ratatui source
(see README “Why Fork ratatui's Terminal?” and comments in src/terminal.rs).
Upstream: https://github.com/ratatui/ratatui
Copyright (c) 2023-2024 The Ratatui Developers
Copyright (c) Florian Dehau (original tui-rs lineage, where applicable)
The remainder of this crate is Copyright 2023-2026 xAI and licensed under the
Apache License, Version 2.0 (see the repository root LICENSE).
Full license texts for third-party dependencies also appear in the repository
root THIRD-PARTY-NOTICES file.

View file

@ -0,0 +1,140 @@
# ratatui-inline
A Rust library for building terminal applications with inline viewports - dynamic UI elements that stay at the bottom of the terminal while preserving scrollback history above them. Perfect for building chat-like interfaces, command prompts, and interactive terminal tools.
## What is this?
This crate provides tools for creating terminal applications where:
- A viewport (UI element) is pinned to the bottom of the terminal
- Content above the viewport becomes part of the terminal's native scrollback
- Users can scroll through history using their terminal's built-in scroll functionality
- Long lines wrap naturally without truncation
- The viewport remains visible and interactive while history accumulates above
Think of applications like:
- Chat interfaces with an input box at the bottom
- Interactive REPLs with command history
- Log viewers with controls at the bottom
- Any TUI that needs to preserve output history
## Key Features
- **Inline viewport** - UI stays at bottom while content flows above into scrollback
- **Natural text flow** - Content is printed normally, leveraging terminal's native behavior
- **Zero-copy text processing** - Efficient ANSI-aware text segmentation without allocations
- **Proper line wrapping** - Handles terminal width boundaries correctly with ANSI sequences
- **Unicode support** - Correct handling of emoji, CJK characters, combining characters
- **Terminal resize handling** - Robust resize support using RIS (Reset to Initial State)
- **Synchronized output** - Flicker-free rendering using DCS protocol
- **Cross-platform** - Works in all terminals and multiplexers (tmux, screen, etc.)
## Usage
See `examples/inline.rs` for a complete working example.
## Architecture
### Text Processing
The library uses a zero-copy approach for ANSI-aware text segmentation:
- **anstyle-parse** - ANSI/SGR-aware segmentation for zero-copy line splitting
- **Zero allocations** - Returns string slices without copying or allocating
- **Single-pass parsing** - Processes input once with proper escape sequence tracking
- **Unicode support** - Correct width calculation for emoji, CJK, combining characters
### Scrollback Implementation
The library uses a "natural flow" approach for scrollback:
1. Position cursor at viewport top
2. Print content, letting terminal handle wrapping naturally
3. Add viewport-height newlines to reserve space
4. Clear and render the viewport
This single implementation works universally across all terminals and multiplexers without special modes or workarounds.
### Line Ending Handling
- **LF (`\n`)** - Standard line ending, moves to next line
- **CRLF (`\r\n`)** - Windows-style line ending, treated as single line break
- **CR (`\r`)** - Carriage return only, resets cursor to line start (overwrites)
## Design Decisions
### Why Fork ratatui's Terminal?
The standard ratatui Terminal API doesn't expose internals needed for inline viewport manipulation:
- **Viewport area access** - Need to know current position and dimensions
- **Direct viewport positioning** - Must be able to set viewport location
- **Buffer management** - Need back buffer reset and previous buffer access
- **Resize calculations** - Require access to buffer state during resize
Our forked Terminal provides these capabilities while maintaining compatibility with ratatui's API.
### Synchronized Output
Flicker-free rendering using the DCS synchronized output protocol:
- All operations between begin/end markers are atomic
- Terminal only updates display once per batch
- Eliminates partial render states
## Performance
- **Colored JSON**: ~186μs per operation
- **Plain text**: ~75μs per operation
- **Zero allocations** in hot path
- **Single-pass parsing** for all text processing
## Testing
Comprehensive test coverage including:
- Text segmentation with ANSI sequences
- Line wrapping and Unicode handling
- All line ending types (LF, CRLF, CR)
- Viewport positioning and resizing
- Terminal resize with history re-rendering
- Mock terminal infrastructure for unit testing
### Terminal Resize Strategy
#### The Problem
When using inline viewports on the main screen (not alternate screen), terminal resize causes issues:
- Terminal reflows content automatically BEFORE the app receives SIGWINCH
- Old viewport borders get reflowed as garbage text
- Built-in `autoresize()` corrupts scrollback history
- Cursor position queries (DSR) have race conditions during rapid resize
- Different terminals handle reflow unpredictably
#### The Solution: RIS (Reset to Initial State)
We use the "nuclear option" - completely reset and re-render:
1. Send RIS (`ESC c`) to clear everything
2. Re-output entire scrollback history
3. Position viewport based on content amount
This approach:
- **Works consistently** across all terminals
- **Preserves scrollback** by re-outputting history
- **Avoids artifacts** from unpredictable reflow
- **No race conditions** from cursor queries
- **Handles all resize types** (horizontal and vertical)
## Dependencies
- `ratatui` - Terminal UI framework (forked Terminal class)
- `crossterm` - Cross-platform terminal manipulation
- `anstyle-parse` - ANSI/SGR-aware line segmentation (production)
- `unicode-width` - Unicode character width calculation
- `termwiz` - **dev-dependency only**; reference splitter for
`tests/segment_differential.rs` (not linked into shipped binaries)
## References
- [anstyle-parse](https://crates.io/crates/anstyle-parse)
- [Ratatui wrapping discussion](https://github.com/ratatui/ratatui/issues/1426)
## License / attribution
This crate includes a forked `Terminal` implementation derived from [ratatui](https://github.com/ratatui/ratatui)
(MIT / Apache-2.0). See `NOTICE` in this directory and the repository root `THIRD-PARTY-NOTICES`.

View file

@ -0,0 +1,206 @@
use std::hint::black_box;
use colored_json::ToColoredJson;
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::{TerminalOptions, Viewport};
use serde_json::json;
use xai_ratatui_inline::{LinkSpan, Terminal, split_into_line_segments};
fn generate_colored_json_content() -> String {
// Create a complex JSON structure with 50+ lines when pretty printed
let data = json!({
"users": (0..10).map(|i| json!({
"id": i,
"name": format!("User {}", i),
"email": format!("user{}@example.com", i),
"active": i % 2 == 0,
"roles": ["admin", "user", "moderator"],
"metadata": {
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-12-01T00:00:00Z",
"last_login": "2024-12-15T12:00:00Z",
"preferences": {
"theme": if i % 2 == 0 { "dark" } else { "light" },
"language": "en",
"notifications": true
}
}
})).collect::<Vec<_>>(),
"settings": {
"app_name": "Test Application",
"version": "1.2.3",
"features": {
"feature_a": true,
"feature_b": false,
"feature_c": true,
"feature_d": {
"enabled": true,
"config": {
"param1": 100,
"param2": "value",
"param3": [1, 2, 3, 4, 5]
}
}
}
},
"logs": (0..5).map(|i| json!({
"timestamp": format!("2024-12-01T{:02}:00:00Z", i),
"level": if i % 3 == 0 { "ERROR" } else if i % 2 == 0 { "WARN" } else { "INFO" },
"message": format!("Log entry number {}", i),
"context": {
"request_id": format!("req-{:04}", i * 100),
"user_id": i % 10,
"action": "process_request"
}
})).collect::<Vec<_>>()
});
// Convert to colored JSON string - this will have LOTS of ANSI color codes
serde_json::to_string_pretty(&data)
.unwrap()
.to_colored_json_auto()
.unwrap()
}
fn bench_split_into_line_segments(c: &mut Criterion) {
let colored_json = generate_colored_json_content();
// Also create a plain text version for comparison
let plain_text = colored_json
.chars()
.filter(|c| *c != '\x1b')
.collect::<String>()
.replace("[0m", "")
.replace("[31m", "")
.replace("[32m", "")
.replace("[33m", "")
.replace("[34m", "")
.replace("[35m", "")
.replace("[36m", "")
.replace("[37m", "")
.replace("[90m", "")
.replace("[1m", "")
.replace("[22m", "");
let mut group = c.benchmark_group("text_splitting");
// Benchmark colored JSON
group.bench_function("colored_json_80_cols", |b| {
b.iter(|| split_into_line_segments(black_box(&colored_json), black_box(80)));
});
// Benchmark plain text
group.bench_function("plain_text_80_cols", |b| {
b.iter(|| split_into_line_segments(black_box(&plain_text), black_box(80)));
});
group.finish();
}
/// Fill rows `[rows]` of the current frame with `ch`.
fn fill_rows(
t: &mut Terminal<CrosstermBackend<Vec<u8>>>,
ch: char,
rows: std::ops::Range<u16>,
width: u16,
) {
let line: String = ch.to_string().repeat(width as usize);
let mut frame = t.get_frame();
let buf = frame.buffer_mut();
for y in rows {
buf.set_string(0, y, &line, Style::default());
}
}
/// Build a terminal in a "ready to flush" state: a previous full-screen frame,
/// then a partial-redraw current frame (a few changed rows, like streaming
/// output) with `num_links` hyperlinks set. The returned terminal is cloned per
/// benchmark iteration so the measured call is just the flush.
fn dirty_terminal(
width: u16,
height: u16,
num_links: usize,
) -> Terminal<CrosstermBackend<Vec<u8>>> {
let area = Rect::new(0, 0, width, height);
let mut t = Terminal::with_options(
CrosstermBackend::new(Vec::<u8>::new()),
TerminalOptions {
viewport: Viewport::Fixed(area),
},
)
.unwrap();
// Previous frame: full screen of 'a'.
fill_rows(&mut t, 'a', 0..height, width);
t.set_frame_links(&[]);
let _ = t.flush_with_links();
t.swap_buffers();
// Current frame: mostly unchanged ('a'), a few changed rows ('b') — a
// realistic partial redraw. The diff still visits every cell, which is where
// the per-cell link resolution cost lives.
fill_rows(&mut t, 'a', 0..height, width);
fill_rows(&mut t, 'b', 0..height.min(3), width);
let spans: Vec<LinkSpan> = (0..num_links)
.map(|i| {
let row = (i as u16) % height;
LinkSpan {
row,
col_start: 0,
col_end: width.min(24),
url: "https://example.com/some/path".into(),
id: Some(i as u32),
}
})
.collect();
t.set_frame_links(&spans);
t
}
/// Benchmarks the OSC 8 hyperlink render path on a 256x100 viewport: the plain
/// `flush` baseline, `flush_with_links` with no links (early-exit fast path),
/// and `flush_with_links` with 50 links (the link-aware diff + emit).
fn bench_flush_with_links(c: &mut Criterion) {
const W: u16 = 256;
const H: u16 = 100;
let mut group = c.benchmark_group("hyperlink_flush");
group.bench_function("flush_baseline_no_links", |b| {
b.iter_batched(
|| dirty_terminal(W, H, 0),
|mut t| black_box(t.flush()),
BatchSize::SmallInput,
);
});
group.bench_function("flush_with_links_no_links", |b| {
b.iter_batched(
|| dirty_terminal(W, H, 0),
|mut t| black_box(t.flush_with_links()),
BatchSize::SmallInput,
);
});
group.bench_function("flush_with_links_50_links", |b| {
b.iter_batched(
|| dirty_terminal(W, H, 50),
|mut t| black_box(t.flush_with_links()),
BatchSize::SmallInput,
);
});
group.finish();
}
criterion_group!(
benches,
bench_split_into_line_segments,
bench_flush_with_links
);
criterion_main!(benches);

View file

@ -0,0 +1,310 @@
use std::{
io,
time::{Duration, Instant},
};
use ansi_width::ansi_width;
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
style::Color as CColor,
};
use lipsum::lipsum;
use ratatui::{
TerminalOptions, Viewport,
prelude::CrosstermBackend,
style::{Color, Style},
widgets::Block,
};
use xai_ratatui_inline::{
Terminal, emit_to_scrollback, resize_purge_rerender, resize_viewport_height,
with_synchronized_output,
};
/// Build a line with markers for visualization
fn build_marked_line(line_num: usize, content: &str) -> String {
format!("[[{:02}]] {} [[{:02}]]", line_num, content, line_num)
}
/// Generate test content based on pattern
fn generate_test_line(line_num: usize, terminal_width: usize) -> String {
let i = line_num - 1;
let mut line = match i % 3 {
0 => lipsum(4), // Short line
1 => lipsum(40), // Long line
2 => {
// Unicode string with ANSI colors and hyperlink for testing
let unicode_with_ansi = "\x1b[31m😀\u{200D}\x1b[0m\x1b[32mé\x1b[0m中\u{0300}\x1b[34mX\x1b[0m\x1b]8;;https://example.com\x1b\\H\x1b]8;;\x1b\\";
let visual_width = ansi_width(unicode_with_ansi);
let marker_adjustment = if i % 6 >= 3 { 7 } else { 14 };
let remaining_width = terminal_width * 2 - marker_adjustment - visual_width;
format!("{unicode_with_ansi}{}", "B".repeat(remaining_width))
}
_ => unreachable!(),
};
// Add newline prefix for cases 3-5, alternating between \n and \r\n
if i % 6 >= 3 {
line = format!("{}\n{}\r\n{line}", lipsum(2), lipsum(8));
}
line
}
/// Generate tall content that's 1.5x terminal height
fn generate_tall_content(terminal_width: usize, terminal_height: usize) -> String {
let num_lines = terminal_height;
let mut lines = Vec::new();
for i in 1..=num_lines {
lines.push(format!(
"Line {i:03}/{num_lines:03}: {}",
if i % 2 == 1 {
lipsum((terminal_width / 25).max(1))
} else {
lipsum((terminal_width / 5).max(1))
}
));
}
lines.join("\n")
}
/// Build content with ANSI color codes
fn colorize_content(content: &str, color: CColor) -> String {
let color_code = match color {
CColor::Black => 30,
CColor::Red | CColor::DarkRed => 31,
CColor::Green | CColor::DarkGreen => 32,
CColor::Yellow | CColor::DarkYellow => 33,
CColor::Blue | CColor::DarkBlue => 34,
CColor::Magenta | CColor::DarkMagenta => 35,
CColor::Cyan | CColor::DarkCyan => 36,
CColor::White | CColor::Grey | CColor::DarkGrey => 37,
_ => 37, // Default to white for RGB or other colors
};
format!("\x1b[{}m{}\x1b[0m", color_code, content)
}
fn init_terminal(inline_height: u16) -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
crossterm::terminal::enable_raw_mode()?;
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
fn try_restore() -> io::Result<()> {
crossterm::terminal::disable_raw_mode()?;
Ok(())
}
if let Err(err) = try_restore() {
eprintln!("Failed to restore terminal: {err}");
}
hook(info);
}));
let backend = CrosstermBackend::new(io::stdout());
let options = TerminalOptions {
viewport: Viewport::Inline(inline_height),
};
let terminal = Terminal::with_options(backend, options)?;
Ok(terminal)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
// Clear only the viewport, preserving log content above it
terminal.clear()?;
crossterm::terminal::disable_raw_mode()?;
Ok(())
}
enum KeyAction {
Quit,
Normal,
Tall,
IncreaseViewport,
DecreaseViewport,
}
/// Spinner animation state
struct Spinner {
frames: Vec<&'static str>,
current: usize,
interval: Duration,
next_frame_time: Instant,
}
impl Spinner {
fn new() -> Self {
let frames = vec!["", "", "", "", "", "", "", "", "", ""];
Self {
frames,
current: 0,
interval: Duration::from_millis(100),
next_frame_time: Instant::now() + Duration::from_millis(100),
}
}
fn tick(&mut self) {
self.current = (self.current + 1) % self.frames.len();
self.next_frame_time = Instant::now() + self.interval;
}
fn current_frame(&self) -> &str {
self.frames[self.current]
}
fn time_until_next_frame(&self) -> Duration {
self.next_frame_time
.saturating_duration_since(Instant::now())
}
}
enum PollResult {
KeyPressed(KeyAction),
AnimationTick,
Resize,
}
fn poll_for_key_or_animation(spinner: &Spinner) -> io::Result<PollResult> {
let timeout = spinner.time_until_next_frame();
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) => {
if key.code == KeyCode::Esc
|| (key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL))
|| key.code == KeyCode::Char('q')
|| key.code == KeyCode::Char('Q')
{
return Ok(PollResult::KeyPressed(KeyAction::Quit));
} else if key.code == KeyCode::Char('t') || key.code == KeyCode::Char('T') {
return Ok(PollResult::KeyPressed(KeyAction::Tall));
} else if key.code == KeyCode::Char('+') || key.code == KeyCode::Char('=') {
return Ok(PollResult::KeyPressed(KeyAction::IncreaseViewport));
} else if key.code == KeyCode::Char('-') || key.code == KeyCode::Char('_') {
return Ok(PollResult::KeyPressed(KeyAction::DecreaseViewport));
} else if matches!(key.code, KeyCode::Char(_) | KeyCode::Enter) {
return Ok(PollResult::KeyPressed(KeyAction::Normal));
}
// Key we don't care about, continue polling
poll_for_key_or_animation(spinner)
}
Event::Resize(_, _) => Ok(PollResult::Resize),
_ => {
// Other event we don't care about, continue polling
poll_for_key_or_animation(spinner)
}
}
} else {
// Timeout reached, time to animate
Ok(PollResult::AnimationTick)
}
}
fn render_viewport(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
spinner: &Spinner,
viewport_height: u16,
) -> io::Result<()> {
terminal.draw(|frame| {
let area = frame.area();
let spinner_text = format!(
" {} Inline Viewport ({} lines) {} ",
spinner.current_frame(),
viewport_height,
spinner.current_frame()
);
let block = Block::bordered()
.title(spinner_text)
.border_style(Style::default().fg(Color::Cyan));
frame.render_widget(block, area);
})?;
Ok(())
}
fn main() -> Result<()> {
const INITIAL_HEIGHT: u16 = 3;
const MIN_HEIGHT: u16 = 2;
let mut terminal = init_terminal(INITIAL_HEIGHT)?;
let mut spinner = Spinner::new();
let mut viewport_height = INITIAL_HEIGHT;
render_viewport(&mut terminal, &spinner, viewport_height)?;
let colors = [
CColor::Green,
CColor::Yellow,
CColor::Magenta,
CColor::Blue,
CColor::Red,
CColor::Cyan,
];
let mut line_num = 1;
let mut scrollback_history = String::new(); // has crlf-s instead of lf-s
loop {
match poll_for_key_or_animation(&spinner)? {
PollResult::AnimationTick => {
spinner.tick();
render_viewport(&mut terminal, &spinner, viewport_height)?;
}
PollResult::Resize => {
with_synchronized_output(&mut terminal, |terminal| {
resize_purge_rerender(terminal, &scrollback_history)?;
render_viewport(terminal, &spinner, viewport_height)?;
Ok(())
})?;
}
PollResult::KeyPressed(action) => match action {
KeyAction::Quit => break,
KeyAction::IncreaseViewport | KeyAction::DecreaseViewport => {
let new_viewport_height = if let KeyAction::IncreaseViewport = action {
viewport_height + 1
} else {
viewport_height.saturating_sub(1)
}
.clamp(MIN_HEIGHT, terminal.size()?.height.saturating_sub(1));
if new_viewport_height != viewport_height {
viewport_height = new_viewport_height;
with_synchronized_output(&mut terminal, |terminal| {
resize_viewport_height(terminal, viewport_height)?;
render_viewport(terminal, &spinner, viewport_height)?;
Ok(())
})?;
}
}
KeyAction::Tall | KeyAction::Normal => {
let area = terminal.size()?;
let (terminal_width, terminal_height) =
(area.width as usize, area.height as usize);
let content = match action {
KeyAction::Tall => generate_tall_content(terminal_width, terminal_height),
KeyAction::Normal => generate_test_line(line_num, terminal_width),
_ => unreachable!(),
};
let marked_line = build_marked_line(line_num, &content);
let colored_content =
colorize_content(&marked_line, colors[(line_num - 1) % colors.len()]);
// Save to scrollback history, replace LF with CRLF
if !scrollback_history.is_empty() {
scrollback_history.push_str("\r\n");
}
scrollback_history.push_str(&colored_content.replace('\n', "\r\n"));
// Emit to scrollback and render viewport
with_synchronized_output(&mut terminal, |terminal| {
emit_to_scrollback(terminal, &colored_content)?;
render_viewport(terminal, &spinner, viewport_height)?;
Ok(())
})?;
line_num += 1;
}
},
}
}
restore_terminal(&mut terminal)?;
Ok(())
}

View file

@ -0,0 +1,135 @@
use std::io::{self, Write};
use crossterm::{
QueueableCommand as _,
terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate},
};
use ratatui::{
layout::{Rect, Size},
prelude::Backend,
};
use crate::Terminal;
/// Trait for terminal operations needed by emit_to_scrollback and other functions.
pub trait TerminalLike {
/// The writer type that will be used for output
type Writer: Write;
/// Get the terminal size
fn size(&self) -> io::Result<Size>;
/// Get the current viewport area
fn viewport_area(&self) -> Rect;
/// Clear the terminal
fn clear(&mut self) -> io::Result<()>;
/// Reset the back buffer without clearing the screen
fn reset_back_buffer(&mut self);
/// Set the viewport area
fn set_viewport_area(&mut self, area: Rect);
/// Get a mutable reference to the writer
fn writer_mut(&mut self) -> &mut Self::Writer;
}
// Implementation for our Terminal with any Backend that implements Write
impl<B: Backend + Write> TerminalLike for Terminal<B> {
type Writer = B;
fn size(&self) -> io::Result<Size> {
self.backend().size()
}
fn viewport_area(&self) -> Rect {
self.viewport_area()
}
fn clear(&mut self) -> io::Result<()> {
self.clear()
}
fn reset_back_buffer(&mut self) {
self.reset_back_buffer()
}
fn set_viewport_area(&mut self, area: Rect) {
self.set_viewport_area(area)
}
fn writer_mut(&mut self) -> &mut Self::Writer {
self.backend_mut()
}
}
/// Execute a function with synchronized terminal output to prevent flicker
///
/// This wraps the provided function with terminal synchronized output mode,
/// making all terminal operations within the function atomic.
/// Supported by most modern terminals (iTerm2, kitty, WezTerm, Windows Terminal, etc.)
/// Gracefully ignored by terminals that don't support it.
///
/// IMPORTANT: if the closure panics, it is responsibility of the caller to clean
/// this up, otherwise the terminal may hang forever (depends on the terminal / mux).
pub fn with_synchronized_output<T, F, R>(terminal: &mut T, f: F) -> io::Result<R>
where
T: TerminalLike,
F: FnOnce(&mut T) -> io::Result<R>,
{
// Begin synchronized output
terminal.writer_mut().queue(BeginSynchronizedUpdate)?;
// Execute the provided function
let result = f(terminal);
// End synchronized output and flush
terminal.writer_mut().queue(EndSynchronizedUpdate)?;
terminal.writer_mut().flush()?;
result
}
#[cfg(test)]
mod tests {
use std::io::Write;
use crate::tests::MockTerminal;
use super::*;
#[test]
fn test_synchronized_output() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Use synchronized output wrapper
let result = with_synchronized_output(&mut terminal, |terminal| {
_ = terminal.writer_mut().write(b"Test content")?;
terminal.writer_mut().flush()?;
Ok(())
});
assert!(result.is_ok());
// Check that synchronized output markers were written
let buffer = &terminal.writer.buffer;
let text = String::from_utf8_lossy(buffer);
// Should contain begin and end synchronized update sequences
assert!(
text.contains("\x1b[?2026h"),
"Should have begin synchronized update"
);
assert!(
text.contains("\x1b[?2026l"),
"Should have end synchronized update"
);
// Content should be between the markers
assert!(text.contains("Test content"));
// Should have flushed (once in emit_to_scrollback, once in with_synchronized_output)
assert_eq!(terminal.writer.flush_count, 2);
}
}

View file

@ -0,0 +1,16 @@
mod common;
mod resize;
mod scrollback;
mod segment;
mod terminal;
#[cfg(test)]
mod tests;
pub use self::{
common::{TerminalLike, with_synchronized_output},
resize::{resize_purge_rerender, resize_viewport_height},
scrollback::emit_to_scrollback,
segment::split_into_line_segments,
terminal::{LinkSpan, Terminal},
};

View file

@ -0,0 +1,357 @@
use std::io::{self, Write as _};
use crossterm::{cursor::MoveTo, queue, style::Print};
use ratatui::layout::Rect;
use crate::{common::TerminalLike, segment::split_into_line_segments};
/// Handles terminal resize by completely re-rendering the scrollback history.
///
/// This function uses a "nuclear option" approach: it sends RIS (Reset to Initial State)
/// to clear the entire terminal, then re-outputs all scrollback history and positions
/// the viewport appropriately.
///
/// # Why this approach?
///
/// When the terminal is resized, text reflow happens automatically *before* our application
/// receives the resize signal (SIGWINCH). This creates several problems:
///
/// 1. **Scrollback corruption**: The built-in `terminal.autoresize()` doesn't handle reflowed
/// content properly, often damaging scrollback history or leaving visual artifacts.
///
/// 2. **Viewport artifacts**: The old viewport borders get reflowed along with regular text,
/// appearing as garbage above the new viewport position. While we could try to move the
/// viewport up to avoid this, it becomes impossible when the viewport is already near the top.
///
/// 3. **Unpredictable reflow**: Different terminals handle text reflow differently, making it
/// nearly impossible to predict exactly where content will end up after resize. We tried
/// calculating reflow based on character counts, but edge cases and terminal-specific
/// behaviors made this unreliable.
///
/// The RIS + re-render approach is more drastic but provides consistency across all terminals
/// and resize scenarios. It's especially important for horizontal resizing where text reflow
/// is most problematic.
///
/// # Arguments
///
/// * `terminal` - The terminal instance to resize
/// * `history` - The complete scrollback history (with CRLF line endings)
///
/// # Returns
///
/// Returns `Ok(())` on success, or an I/O error if terminal operations fail.
pub fn resize_purge_rerender<T: TerminalLike>(terminal: &mut T, history: &str) -> io::Result<()> {
let viewport = terminal.viewport_area();
let size = terminal.size()?;
// Clear current screen, clear scrollbackhistory and move the cursor to the top left corner
// note: we could've also used RIS (\x1bc) hard reset, but it doesn't clear scrollback in iterm/terminal.app
terminal.writer_mut().write_all(b"\x1b[2J\x1b[3J\x1b[H")?;
terminal.writer_mut().flush()?;
// Count newlines in history as a quick check for whether we have enough content
// The +1 accounts for content on the first line (before any newlines)
let num_newlines = 1 + history
.as_bytes()
.iter()
.filter(|&&c| c == b'\n')
.take(size.height.into()) // Only count up to screen height for efficiency
.count() as u16;
// Re-output the entire scrollback history
queue!(terminal.writer_mut(), Print(history))?;
// Add blank lines to reserve space for the viewport
for _ in 0..viewport.height {
queue!(terminal.writer_mut(), Print("\r\n"))?;
}
// Calculate where to position the viewport
let viewport_y = if num_newlines + viewport.height >= size.height {
// We have enough content to fill the screen, viewport goes at the bottom
size.height.saturating_sub(viewport.height)
} else {
// Not enough content to fill the screen, need to calculate exact position
// Use split_into_line_segments to account for line wrapping
let segments = split_into_line_segments(history, size.width.into());
let num_visible_lines = segments.len().min(u16::MAX as _) as u16;
// Position viewport right after the content, but not beyond screen bottom
num_visible_lines.min(size.height.saturating_sub(viewport.height))
};
// Flush all queued commands
terminal.writer_mut().flush()?;
// Resize and clear the viewport
terminal.set_viewport_area(ratatui::layout::Rect {
x: 0,
y: viewport_y,
width: size.width,
height: viewport.height,
});
terminal.clear()?;
Ok(())
}
/// Resize the viewport to a new height with terminal dimensions being the same.
///
/// When shrinking: Always anchors to top (gap appears at bottom)
/// When growing: Tries to expand down first, then pushes content up if needed
pub fn resize_viewport_height<T: TerminalLike>(
terminal: &mut T,
new_height: u16,
) -> io::Result<()> {
macro_rules! queue {
($($command:expr),* $(,)?) => {{
$(crossterm::queue!(terminal.writer_mut(), $command)?;)*
Ok::<(), io::Error>(())
}};
}
let size = terminal.size()?;
let current_viewport = terminal.viewport_area();
let old_height = current_viewport.height;
if new_height == old_height {
return Ok(());
}
// Ensure new height is valid
if new_height == 0 || new_height >= size.height {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"Invalid viewport height: {} (terminal height: {})",
new_height, size.height
),
));
}
if new_height > old_height {
// Growing: Smart expansion - try to expand down first, then push content up if needed
let growth = new_height - old_height;
let bottom_edge = current_viewport.y + current_viewport.height;
let space_below = size.height.saturating_sub(bottom_edge);
// Calculate the new y position
let new_y = if space_below >= growth {
// We have enough space below - expand down, keep same y
current_viewport.y
} else {
// Need to push content up
// Either use all space below and push up the rest, or anchor to bottom
if space_below > 0 {
// Use available space below and push up for the remainder
current_viewport.y.saturating_sub(growth - space_below)
} else {
// Already at bottom, push everything up
size.height.saturating_sub(new_height)
}
};
// If we need to scroll content up
if new_y < current_viewport.y {
let scroll_amount = current_viewport.y - new_y;
// Move to bottom and emit newlines to push content into scrollback
queue!(MoveTo(0, size.height - 1))?;
for _ in 0..scroll_amount {
queue!(Print("\r\n"))?;
}
terminal.writer_mut().flush()?;
}
// Clear the old viewport
terminal.clear()?;
// Set the new viewport area
terminal.set_viewport_area(Rect::new(0, new_y, current_viewport.width, new_height));
} else {
// Shrinking: Always anchor to top (gap appears at bottom)
terminal.clear()?;
terminal.set_viewport_area(Rect::new(
0,
current_viewport.y,
current_viewport.width,
new_height,
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::tests::MockTerminal;
use super::*;
#[test]
fn test_viewport_resize_shrink() {
let mut terminal = MockTerminal::new(80, 25, 5);
let original_y = terminal.viewport_area.y; // Should be 20 (25-5)
// Shrink viewport from 5 to 3 (always anchors at top)
resize_viewport_height(&mut terminal, 3).unwrap();
// Check viewport was updated - y should stay the same
assert_eq!(terminal.viewport_area.height, 3);
assert_eq!(terminal.viewport_area.y, original_y); // Should still be 20
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_viewport_resize_smart_expand() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Start at position 20 (not at bottom)
terminal.viewport_area.y = 20;
// Expand viewport from 3 to 5 - should expand downward first
resize_viewport_height(&mut terminal, 5).unwrap();
// Check that it expanded down (kept same y)
assert_eq!(terminal.viewport_area.height, 5);
assert_eq!(terminal.viewport_area.y, 20); // Should stay at 20
assert_eq!(terminal.clear_count, 1);
// Now expand more - should hit bottom and push content up
resize_viewport_height(&mut terminal, 6).unwrap();
assert_eq!(terminal.viewport_area.height, 6);
assert_eq!(terminal.viewport_area.y, 19); // Should move up to 19
assert_eq!(terminal.clear_count, 2);
}
#[test]
fn test_viewport_resize_invalid() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Try invalid heights
assert!(resize_viewport_height(&mut terminal, 0).is_err());
assert!(resize_viewport_height(&mut terminal, 25).is_err());
assert!(resize_viewport_height(&mut terminal, 26).is_err());
// Valid edge cases
assert!(resize_viewport_height(&mut terminal, 1).is_ok());
assert!(resize_viewport_height(&mut terminal, 24).is_ok());
}
#[test]
fn test_viewport_resize_no_op() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Resize to same height
resize_viewport_height(&mut terminal, 3).unwrap();
// Should not have cleared
assert_eq!(terminal.clear_count, 0);
assert_eq!(terminal.viewport_area.height, 3);
}
#[test]
fn test_resize_purge_rerender_empty_history() {
let mut terminal = MockTerminal::new(80, 25, 3);
terminal.viewport_area.y = 22; // Bottom position
// Test with empty history
resize_purge_rerender(&mut terminal, "").unwrap();
// Viewport should be at top since there's no content
assert_eq!(terminal.viewport_area.y, 0);
assert_eq!(terminal.viewport_area.height, 3);
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_resize_purge_rerender_small_history() {
let mut terminal = MockTerminal::new(80, 25, 3);
terminal.viewport_area.y = 22; // Bottom position
// Test with small history (just a few lines)
let history = "Line 1\r\nLine 2\r\nLine 3\r\n";
resize_purge_rerender(&mut terminal, history).unwrap();
// split_into_line_segments will count this as 3 segments (one per line)
// So viewport should be positioned at y=3
assert_eq!(terminal.viewport_area.y, 3);
assert_eq!(terminal.viewport_area.height, 3);
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_resize_purge_rerender_full_screen_history() {
let mut terminal = MockTerminal::new(80, 25, 3);
terminal.viewport_area.y = 22; // Bottom position
// Create history with more lines than screen height
let mut history = String::new();
for i in 1..=30 {
history.push_str(&format!("Line {}\r\n", i));
}
resize_purge_rerender(&mut terminal, &history).unwrap();
// With full screen of content, viewport should be at bottom
assert_eq!(terminal.viewport_area.y, 25 - 3); // screen_height - viewport_height
assert_eq!(terminal.viewport_area.height, 3);
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_resize_purge_rerender_with_wrapped_lines() {
let mut terminal = MockTerminal::new(40, 10, 2); // Narrow terminal
terminal.viewport_area.y = 8;
// Create a line that will wrap
let long_line = "A".repeat(100); // Will wrap to ~3 lines on 40-column terminal
let history = format!("{}\r\nShort line\r\n", long_line);
resize_purge_rerender(&mut terminal, &history).unwrap();
// The actual position depends on split_into_line_segments calculation
// But it should position the viewport appropriately
assert!(terminal.viewport_area.y <= 10 - 2);
assert_eq!(terminal.viewport_area.height, 2);
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_resize_purge_rerender_preserves_viewport_dimensions() {
let mut terminal = MockTerminal::new(100, 30, 5);
let original_width = terminal.viewport_area.width;
let original_height = terminal.viewport_area.height;
let history = "Some content\r\n";
resize_purge_rerender(&mut terminal, history).unwrap();
// Width and height should be preserved, only y position changes
assert_eq!(terminal.viewport_area.width, original_width);
assert_eq!(terminal.viewport_area.height, original_height);
}
#[test]
fn test_resize_purge_rerender_captures_output() {
let mut terminal = MockTerminal::new(80, 25, 3);
let history = "Test line\r\n";
resize_purge_rerender(&mut terminal, history).unwrap();
// Verify RIS command was sent to writer (not real stdout)
let output = String::from_utf8_lossy(&terminal.writer.buffer);
assert!(
output.contains("\x1b[2J\x1b[3J\x1b[H"),
"Should contain reset commands"
);
assert!(output.contains("Test line"), "Should contain history");
// Ensure we flushed the writer
assert!(
terminal.writer.flush_count > 0,
"Should have flushed writer"
);
}
}

View file

@ -0,0 +1,234 @@
use std::io::{self, Write};
use crossterm::{cursor::MoveTo, style::Print};
use ratatui::layout::Rect;
use crate::{common::TerminalLike, segment::split_into_line_segments};
// ANSI escape sequence constants.
// CSI J with the default parameter (0): erase from cursor to end of display.
// Byte-identical to what the previous termwiz constant
// (`CSI::Edit(Edit::EraseInDisplay(EraseInDisplay::EraseToEndOfDisplay))`)
// rendered, and to crossterm's `Clear(ClearType::FromCursorDown)`.
const ANSI_CLEAR_FROM_CURSOR_DOWN: &str = "\x1b[J";
pub fn emit_to_scrollback<T: TerminalLike>(terminal: &mut T, content: &str) -> io::Result<()> {
macro_rules! queue {
($($command:expr),* $(,)?) => {{
$(crossterm::queue!(terminal.writer_mut(), $command)?;)*
Ok::<(), io::Error>(())
}};
}
let size = terminal.size()?;
let viewport_area = terminal.viewport_area();
let terminal_width = size.width as usize;
debug_assert!(viewport_area.bottom() <= size.height);
// Use zero-copy line segmentation
let segments = split_into_line_segments(content, terminal_width);
// Calculate where viewport will end up after content
let new_viewport_y =
(viewport_area.y + segments.len() as u16).min(size.height - viewport_area.height);
// Position from viewport top and clear from this position down
queue!(
MoveTo(0, viewport_area.y),
Print(ANSI_CLEAR_FROM_CURSOR_DOWN),
)?;
// Now print the content
queue!(MoveTo(0, viewport_area.y))?;
for segment in &segments {
queue!(Print(segment))?; // this already includes crlfs if there's any
}
// Create exact viewport space
for _ in 0..viewport_area.height {
queue!(Print("\r\n"))?;
}
// Clear the new viewport area for rendering
queue!(
MoveTo(0, new_viewport_y),
Print(ANSI_CLEAR_FROM_CURSOR_DOWN),
)?;
// We'll flush by default; the caller is expected to have this in sync block anyway
terminal.writer_mut().flush()?;
// Reset the back buffer so next render knows viewport is empty
terminal.reset_back_buffer();
// Reposition viewport if needed
if new_viewport_y != viewport_area.y {
terminal.set_viewport_area(Rect {
y: new_viewport_y,
..viewport_area
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::tests::MockTerminal;
use super::*;
// Helper to parse ANSI sequences from the captured buffer
fn parse_ansi_sequences(buffer: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(buffer);
let mut sequences = Vec::new();
let mut current = String::new();
let mut in_escape = false;
for ch in text.chars() {
if ch == '\x1b' {
if !current.is_empty() {
sequences.push(current.clone());
current.clear();
}
in_escape = true;
current.push(ch);
} else if in_escape {
current.push(ch);
// Simple heuristic: most ANSI sequences end with a letter
if ch.is_alphabetic() {
sequences.push(current.clone());
current.clear();
in_escape = false;
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
sequences.push(current);
}
sequences
}
#[test]
fn test_simple_content() {
let mut terminal = MockTerminal::new(80, 25, 3);
let content = "Hello, World!";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
// Check that content was written
let buffer = &terminal.writer.buffer;
assert!(!buffer.is_empty());
// Should have flushed
assert_eq!(terminal.writer.flush_count, 1);
}
#[test]
fn test_tall_content() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Create content that will span more lines than viewport height
let content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
// Check that content was written
let buffer = &terminal.writer.buffer;
assert!(!buffer.is_empty());
// Should contain the content
let text = String::from_utf8_lossy(buffer);
assert!(text.contains("Line 1"));
assert!(text.contains("Line 5"));
// Should have flushed
assert_eq!(terminal.writer.flush_count, 1);
}
#[test]
fn test_content_with_viewport_at_bottom() {
let mut terminal = MockTerminal::new(80, 25, 3);
let content = "Hello, Multiplexer!";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
// Check that content was written
let buffer = &terminal.writer.buffer;
assert!(!buffer.is_empty());
// Should have flushed
assert_eq!(terminal.writer.flush_count, 1);
// Viewport should remain at bottom
assert_eq!(terminal.viewport_area.y, 22); // 25 - 3
}
#[test]
fn test_viewport_not_at_bottom() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Move viewport away from bottom
terminal.viewport_area.y = 10;
let content = "Test content";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared
assert_eq!(terminal.clear_count, 1);
// Viewport should have moved down
assert_eq!(terminal.viewport_updates.len(), 1);
assert!(terminal.viewport_updates[0].y > 10);
}
#[test]
fn test_long_lines_wrapping() {
let mut terminal = MockTerminal::new(20, 10, 2);
// Content longer than terminal width
let content = "This is a very long line that should wrap at terminal boundaries";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
// Should have written content
let buffer = &terminal.writer.buffer;
assert!(!buffer.is_empty());
// Should have flushed
assert_eq!(terminal.writer.flush_count, 1);
}
#[test]
fn test_ansi_color_preservation() {
let mut terminal = MockTerminal::new(80, 25, 3);
let content = "\x1b[31mRed Text\x1b[0m";
emit_to_scrollback(&mut terminal, content).unwrap();
// Check that ANSI codes are preserved in output
let buffer = &terminal.writer.buffer;
let text = String::from_utf8_lossy(buffer);
assert!(text.contains("Red Text"), "Text should be in output");
// The ANSI codes might be in the segment's content
let sequences = parse_ansi_sequences(buffer);
let has_color = sequences.iter().any(|s| s.contains("Red Text"));
assert!(has_color, "Colored text should be present");
}
}

View file

@ -0,0 +1,372 @@
use std::fmt;
use anstyle_parse::{DefaultCharAccumulator, Params, Parser, Perform};
use unicode_width::UnicodeWidthChar as _;
/// Represents a line segment (physical row) with its content and ANSI state
#[derive(Debug, Clone)]
pub struct LineSegment<'a> {
/// Contiguous string content
pub content: &'a str,
/// Has a trailing crlf at the end of it
pub ends_with_crlf: bool,
}
impl fmt::Display for LineSegment<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// To write without crlf, can simply write segment.content
write!(f, "{}", self.content)?;
if self.ends_with_crlf {
write!(f, "\r\n")?;
}
Ok(())
}
}
/// The parse events `split_into_line_segments` distinguishes. Everything the
/// splitter cares about: printable characters (visual width), CR, LF; every
/// other action (SGR colors, cursor moves, OSC, …) merely extends the current
/// segment byte range.
enum SegmentEvent {
Print(char),
CarriageReturn,
LineFeed,
/// Any other complete escape/control action.
Other,
}
/// `anstyle_parse::Perform` implementor that records the single event (if
/// any) produced by the byte just fed to the parser.
///
/// The VTE state machine dispatches at most one action per input byte, so a
/// one-slot buffer is sufficient. Print events are dispatched on the *final*
/// byte of a UTF-8 sequence; the char itself tells us how many bytes it spans.
#[derive(Default)]
struct EventCollector {
event: Option<SegmentEvent>,
}
impl Perform for EventCollector {
fn print(&mut self, c: char) {
self.event = Some(SegmentEvent::Print(c));
}
fn execute(&mut self, byte: u8) {
self.event = Some(match byte {
b'\r' => SegmentEvent::CarriageReturn,
b'\n' => SegmentEvent::LineFeed,
_ => SegmentEvent::Other,
});
}
fn csi_dispatch(&mut self, _: &Params, _: &[u8], _: bool, _: u8) {
self.event = Some(SegmentEvent::Other);
}
fn esc_dispatch(&mut self, _: &[u8], _: bool, _: u8) {
self.event = Some(SegmentEvent::Other);
}
fn osc_dispatch(&mut self, _: &[&[u8]], _: bool) {
self.event = Some(SegmentEvent::Other);
}
fn hook(&mut self, _: &Params, _: &[u8], _: bool, _: u8) {
self.event = Some(SegmentEvent::Other);
}
fn put(&mut self, _: u8) {
self.event = Some(SegmentEvent::Other);
}
fn unhook(&mut self) {
self.event = Some(SegmentEvent::Other);
}
}
/// Main function for splitting text into line segments with zero-copy slices
pub fn split_into_line_segments<'a>(input: &'a str, term_width: usize) -> Vec<LineSegment<'a>> {
let mut parser = Parser::<DefaultCharAccumulator>::new();
let mut performer = EventCollector::default();
let mut segments = Vec::<LineSegment>::new();
let mut segment_start = 0_usize;
let mut segment_end = 0_usize;
let mut visual_width = 0_usize;
let mut has_visual = false;
let mut prev_is_cr = false;
macro_rules! push_segment {
($end:expr, $crlf:expr) => {
#[allow(unused_assignments)]
{
segments.push(LineSegment {
content: &input[segment_start..$end],
ends_with_crlf: $crlf,
});
visual_width = 0;
has_visual = false;
}
};
}
for (index, byte) in input.bytes().enumerate() {
parser.advance(&mut performer, byte);
let Some(event) = performer.event.take() else {
// Mid-sequence byte (escape params, UTF-8 continuation, …): the
// action it belongs to is dispatched on the sequence's final byte
// and its bytes are claimed then.
continue;
};
let mut is_cr = false;
match event {
SegmentEvent::LineFeed => {
// Emit current segment but strip \r if the segment ended with it.
// Note: `segment_end` (not `index`) is deliberate — a LF can
// fire mid-escape-sequence ("\x1b[3\n1m"), and the pending
// escape bytes must not leak into the emitted segment.
push_segment!(segment_end - usize::from(prev_is_cr), true);
// We skip \n itself (and possibly the preceding \r, and any
// pending escape bytes) so they don't end up in segments
segment_end = index + 1;
segment_start = segment_end;
}
SegmentEvent::CarriageReturn => {
// Reset visual width and continue with the current segment
segment_end = index + 1;
visual_width = 0;
is_cr = true;
}
SegmentEvent::Print(ch) => {
// Input is a valid &str, so print fires on the last byte of
// the char's UTF-8 encoding; anything unclaimed before the
// char (e.g. an aborted escape) folds into the current
// segment so the wrap point lands on the char boundary.
let char_bytes = ch.len_utf8();
segment_end = index + 1 - char_bytes;
// The only case where visual width actually grows
// (assuming we don't have cursor move etc, only CSI::Sgr/Control/Print)
let char_width = ch.width().unwrap_or(0);
let new_width = visual_width + char_width;
if new_width > term_width && has_visual {
// We're beyond term width, emit current segment and start next one from this char
push_segment!(segment_end, false);
segment_start = segment_end;
segment_end += char_bytes;
visual_width = char_width; // Reset to just this character's width
has_visual = true;
// Very unlikely edge case: char_width > term size and we have to flush it again
if char_width > term_width {
push_segment!(segment_end, false);
segment_start = segment_end;
}
} else {
// We can safely extend our current pending segment
segment_end += char_bytes;
visual_width = new_width;
has_visual = true;
}
}
SegmentEvent::Other => {
// Extend current segment with other ansi markers
segment_end = index + 1;
}
}
prev_is_cr = is_cr;
}
// Trailing bytes that never completed an action (e.g. a dangling "\x1b[")
// are left out of `segment_end`, matching the previous termwiz-based
// implementation which never consumed incomplete sequences.
// We have pending segment that hasn't been pushed, without crlf
if segment_end > segment_start {
let input_start = input.as_ptr();
if let Some(last) = segments.last_mut() {
// There's at least one segment
let last_start = last.content.as_ptr();
let last_end = unsafe { last_start.add(last.content.len()) };
if !last.ends_with_crlf && !has_visual {
// Last segment doesn't end with crlf and the current one has no visual actions, concatenate
debug_assert_eq!(segment_start, (last_end as usize - input_start as usize));
let last_offset = last_start as usize - input_start as usize;
last.content = &input[last_offset..segment_end];
} else {
// There's last segment but either it ends with lf or pending segment has visual width
// note: pending segment can't have lf because otherwise we would have matched on it
push_segment!(segment_end, false);
}
} else {
// There's no segments, this is the only one (and with no lf)
push_segment!(segment_end, false);
}
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_string() {
let segments = split_into_line_segments("", 10);
assert_eq!(segments.len(), 0);
}
#[test]
fn test_simple_text() {
let input = "hello";
let segments = split_into_line_segments(input, 10);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "hello");
assert!(!segments[0].ends_with_crlf);
}
#[test]
fn test_text_wrapping() {
let input = "hello world";
let segments = split_into_line_segments(input, 8);
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].content, "hello wo");
assert!(!segments[0].ends_with_crlf);
assert_eq!(segments[1].content, "rld");
assert!(!segments[1].ends_with_crlf);
}
#[test]
fn test_newline_handling() {
let input = "line1\nline2";
let segments = split_into_line_segments(input, 20);
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].content, "line1");
assert!(segments[0].ends_with_crlf);
assert_eq!(segments[1].content, "line2");
assert!(!segments[1].ends_with_crlf);
}
#[test]
fn test_crlf_handling() {
let input = "line1\r\nline2\nline3";
let segments = split_into_line_segments(input, 20);
assert_eq!(segments.len(), 3);
// First segment: "line1" (the \r\n is stripped)
assert_eq!(segments[0].content, "line1");
assert!(segments[0].ends_with_crlf);
// Second segment: "line2"
assert_eq!(segments[1].content, "line2");
assert!(segments[1].ends_with_crlf);
// Third segment: "line3"
assert_eq!(segments[2].content, "line3");
assert!(!segments[2].ends_with_crlf);
}
#[test]
fn test_bare_cr_resets_width() {
// CR resets visual position, so "12345\r67" fits in width 10
let input = "12345\r67";
let segments = split_into_line_segments(input, 10);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "12345\r67");
assert!(!segments[0].ends_with_crlf);
}
#[test]
fn test_edge_case_char_wider_than_terminal() {
// Emoji is 2 wide, terminal is 1 wide
let input = "😊";
let segments = split_into_line_segments(input, 1);
// Should still create one segment even though it exceeds width
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "😊");
}
#[test]
fn test_zero_width_segment_merging() {
// Test merging of trailing zero-width content (no newline at end)
let input = "line1\x1b[31m";
let segments = split_into_line_segments(input, 20);
// The color code should be in the same segment
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "line1\x1b[31m");
assert!(!segments[0].ends_with_crlf);
// Test that ANSI after newline creates a separate segment
let input2 = "line1\n\x1b[31m";
let segments2 = split_into_line_segments(input2, 20);
assert_eq!(segments2.len(), 2);
assert_eq!(segments2[0].content, "line1");
assert!(segments2[0].ends_with_crlf);
assert_eq!(segments2[1].content, "\x1b[31m");
assert!(!segments2[1].ends_with_crlf);
}
#[test]
fn test_multiple_ansi_codes() {
let input = "\x1b[1m\x1b[31mBold Red\x1b[0m";
let segments = split_into_line_segments(input, 20);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, input);
}
#[test]
fn test_wrap_at_exact_width() {
let input = "12345678"; // exactly 8 chars
let segments = split_into_line_segments(input, 8);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "12345678");
}
#[test]
fn test_wrap_with_trailing_ansi() {
// Text fills line, then ANSI codes
let input = "12345678\x1b[0m90";
let segments = split_into_line_segments(input, 8);
assert_eq!(segments.len(), 2);
// First segment gets the reset code since no visual content follows it on same line
assert_eq!(segments[0].content, "12345678\x1b[0m");
assert_eq!(segments[1].content, "90");
}
#[test]
fn test_cr_before_lf() {
// Make sure \r right before \n is stripped
let input = "test\r\n";
let segments = split_into_line_segments(input, 10);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "test");
assert!(segments[0].ends_with_crlf);
}
#[test]
fn test_multiple_segments_with_ansi() {
let input = "\x1b[32mline1\nline2\nline3\x1b[0m";
let segments = split_into_line_segments(input, 20);
assert_eq!(segments.len(), 3);
assert!(segments[0].content.starts_with("\x1b[32m"));
assert!(segments[0].ends_with_crlf);
assert_eq!(segments[1].content, "line2");
assert!(segments[1].ends_with_crlf);
assert!(segments[2].content.ends_with("\x1b[0m"));
assert!(!segments[2].ends_with_crlf);
}
#[test]
fn test_visual_width_calculation_with_unicode() {
// "你好" is 4 visual width (2 per character)
let input = "hello 你好";
let segments = split_into_line_segments(input, 10);
assert_eq!(segments.len(), 1); // "hello 你好" = 6 + 4 = 10, exactly fits
let segments2 = split_into_line_segments(input, 9);
assert_eq!(segments2.len(), 2); // Doesn't fit, must wrap
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,417 @@
use std::{
collections::VecDeque,
io::{self, Write},
};
use ratatui::layout::{Rect, Size};
use crate::common::TerminalLike;
/// Mock terminal for testing
#[derive(Debug, Clone)]
pub struct MockTerminal {
pub size: Size,
pub viewport_area: Rect,
pub clear_count: usize,
pub viewport_updates: Vec<Rect>,
pub writer: MockWriter,
}
/// Mock writer that captures all output
#[derive(Debug, Clone)]
pub struct MockWriter {
pub buffer: Vec<u8>,
pub flush_count: usize,
pub commands: VecDeque<String>,
}
impl Write for MockWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buf);
// Parse and store readable command representation
if let Ok(s) = std::str::from_utf8(buf) {
self.commands.push_back(s.to_string());
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.flush_count += 1;
Ok(())
}
}
impl MockTerminal {
pub fn new(width: u16, height: u16, viewport_height: u16) -> Self {
let viewport_y = height - viewport_height;
Self {
size: Size { width, height },
viewport_area: Rect::new(0, viewport_y, width, viewport_height),
clear_count: 0,
viewport_updates: Vec::new(),
writer: MockWriter {
buffer: Vec::new(),
flush_count: 0,
commands: VecDeque::new(),
},
}
}
}
impl TerminalLike for MockTerminal {
type Writer = MockWriter;
fn size(&self) -> io::Result<Size> {
Ok(self.size)
}
fn viewport_area(&self) -> Rect {
self.viewport_area
}
fn clear(&mut self) -> io::Result<()> {
self.clear_count += 1;
Ok(())
}
fn set_viewport_area(&mut self, area: Rect) {
self.viewport_updates.push(area);
self.viewport_area = area;
}
fn writer_mut(&mut self) -> &mut Self::Writer {
&mut self.writer
}
fn reset_back_buffer(&mut self) {
// Mock implementation - just track that it was called
self.clear_count += 1;
}
}
/// Tests for the diffed OSC 8 hyperlink layer (`set_frame_links` /
/// `flush_with_links`).
mod links {
use std::io::{self, Write};
use ratatui::backend::{Backend, WindowSize};
use ratatui::buffer::Cell;
use ratatui::layout::{Position, Rect, Size};
use ratatui::style::Style;
use ratatui::{TerminalOptions, Viewport};
use crate::{LinkSpan, Terminal};
/// Backend that records the raw byte stream and renders each drawn cell as
/// its bare symbol, so tests can assert on OSC 8 sequences interleaved with
/// cell content without depending on crossterm's exact SGR output.
#[derive(Default)]
struct RecordingBackend {
buf: Vec<u8>,
/// Total lines passed to `append_lines` (used by the
/// `set_viewport_height` grow-path test).
appended_lines: u16,
}
impl Write for RecordingBackend {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.buf.extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Backend for RecordingBackend {
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
for (_x, _y, cell) in content {
self.buf.extend_from_slice(cell.symbol().as_bytes());
}
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
Ok(Position::ORIGIN)
}
fn set_cursor_position<P: Into<Position>>(&mut self, _position: P) -> io::Result<()> {
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
Ok(())
}
fn clear_region(&mut self, _clear_type: ratatui::backend::ClearType) -> io::Result<()> {
Ok(())
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
self.appended_lines += n;
Ok(())
}
fn size(&self) -> io::Result<Size> {
Ok(Size::new(80, 24))
}
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: Size::new(80, 24),
pixels: Size::new(0, 0),
})
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn term(w: u16, h: u16) -> Terminal<RecordingBackend> {
Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(0, 0, w, h)),
},
)
.unwrap()
}
fn span(col_start: u16, col_end: u16, url: &str, id: Option<u32>) -> LinkSpan {
LinkSpan {
row: 0,
col_start,
col_end,
url: url.into(),
id,
}
}
/// Render `text` at (0,0), set `spans`, flush, and return the bytes emitted
/// during this single frame.
fn frame(t: &mut Terminal<RecordingBackend>, text: &str, spans: &[LinkSpan]) -> String {
t.backend_mut().buf.clear();
{
let mut f = t.get_frame();
f.buffer_mut().set_string(0, 0, text, Style::default());
}
t.set_frame_links(spans);
t.flush_with_links().unwrap();
t.swap_buffers();
String::from_utf8(t.backend().buf.clone()).unwrap()
}
#[test]
fn emits_osc8_around_linked_cells() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
assert!(
out.contains("\x1b]8;;https://x.ai\x07"),
"missing open: {out:?}"
);
assert!(out.contains("AB"));
assert!(out.contains("\x1b]8;;\x07"), "missing close: {out:?}");
}
#[test]
fn no_link_emits_no_osc8() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[]);
assert!(!out.contains("\x1b]8;"), "unexpected OSC8: {out:?}");
}
#[test]
fn grow_viewport_scrolls_committed_lines_into_history() {
// A small inline viewport near the bottom of the screen, grown to full
// height, must scroll the rows it will cover up into native scrollback
// (append_lines) instead of overwriting them. Regression guard for the
// previously-commented-out scroll_up in set_viewport_height's grow path
// (the overlay host depends on this in minimal mode).
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Inline(3),
},
)
.unwrap();
// Pin the 3-row viewport near the bottom of the 24-row screen.
t.set_viewport_area(Rect::new(0, 21, 80, 3));
let before = t.backend().appended_lines;
// Grow to full height: overflow = (21 + 24) - 24 = 21 rows must scroll up.
t.set_viewport_height(24).unwrap();
let scrolled = t.backend().appended_lines - before;
assert!(
scrolled >= 21,
"expected >= 21 lines scrolled into history, got {scrolled}"
);
}
/// Regression: `set_viewport_height` must judge grow-vs-shrink against the
/// live `viewport_area.height`, not the stored `Viewport::Inline(height)`.
///
/// Minimal mode resizes the viewport out-of-band via `set_viewport_area`
/// (its content-anchored commit path shrinks the region before
/// `insert_before`), which leaves the stored `Inline` height STALE. If the
/// next `set_viewport_height` compared against that stale (larger) height, a
/// genuine grow would be misread as a shrink: the grow-time `scroll_up`
/// would be skipped and the viewport's top would not move up, so the taller
/// viewport would run off the bottom of the screen (dropdown items rendered
/// off-screen — the "empty dropdown over a full screen" bug).
#[test]
fn grow_after_out_of_band_area_shrink_still_scrolls() {
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
// Stored Inline height starts tall (mimics a streaming turn that
// grew the viewport to near full screen).
viewport: Viewport::Inline(21),
},
)
.unwrap();
// Out-of-band shrink to a 3-row viewport pinned at the bottom of the
// 24-row screen — as the commit path does. This does NOT update the
// stored Inline height (still 21), creating the drift.
t.set_viewport_area(Rect::new(0, 21, 80, 3));
let before = t.backend().appended_lines;
// Grow to 10 rows. Against the real height (3) this is a GROW that
// overflows the bottom by (21 + 10) - 24 = 7 rows, which must scroll up.
// Against the stale stored height (21) it would look like a shrink and
// scroll nothing.
t.set_viewport_height(10).unwrap();
let scrolled = t.backend().appended_lines - before;
assert!(
scrolled >= 7,
"grow after an out-of-band area shrink must scroll the covered rows \
into history (expected >= 7, got {scrolled})"
);
// The viewport top moved up so the whole 10-row region fits on screen.
let area = t.viewport_area();
assert_eq!(area.height, 10, "height should be the requested 10");
assert!(
area.y + area.height <= 24,
"viewport must fit on screen, got y={} h={}",
area.y,
area.height
);
}
#[test]
fn link_removed_next_frame_rewrites_cells_without_osc8() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
// Same glyphs, but the link is gone: the cells must be rewritten (so the
// terminal's hyperlink clears) and carry no OSC 8. This is the `/new`
// regression — clearing is driven purely by the diff.
let out = frame(&mut t, "AB", &[]);
assert!(out.contains("AB"), "cells should be redrawn: {out:?}");
assert!(!out.contains("\x1b]8;"), "stale OSC8 leaked: {out:?}");
}
#[test]
fn unchanged_link_and_content_emits_nothing() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
// Identical glyphs AND identical link → empty diff → no output at all.
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
assert!(out.is_empty(), "expected empty diff, got: {out:?}");
}
#[test]
fn retargeted_link_rewrites_cells() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://a", None)]);
let out = frame(&mut t, "AB", &[span(0, 2, "https://b", None)]);
assert!(
out.contains("\x1b]8;;https://b\x07"),
"new url not emitted: {out:?}"
);
}
#[test]
fn emit_id_param_included() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", Some(7))]);
assert!(
out.contains("\x1b]8;id=7;https://x.ai\x07"),
"id param missing: {out:?}"
);
}
#[test]
fn url_control_chars_sanitized() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x\x07\x1b/y", None)]);
assert!(
out.contains("\x1b]8;;https://x/y\x07"),
"url not sanitized: {out:?}"
);
}
#[test]
fn distinct_links_split_into_separate_runs() {
let mut t = term(20, 3);
// "AxB": A→a, gap x (no link), B→b.
let out = frame(
&mut t,
"AxB",
&[span(0, 1, "https://a", None), span(2, 3, "https://b", None)],
);
// Each link wraps exactly its own cell; the gap is not wrapped.
assert!(
out.contains("\x1b]8;;https://a\x07A\x1b]8;;\x07"),
"a-run: {out:?}"
);
assert!(
out.contains("\x1b]8;;https://b\x07B\x1b]8;;\x07"),
"b-run: {out:?}"
);
}
#[test]
fn wide_char_under_link_wraps_lead_cell_only() {
let mut t = term(20, 3);
// A width-2 char occupies two cells; only the lead cell is drawn, and
// the OSC 8 wraps it.
let out = frame(&mut t, "", &[span(0, 2, "https://x.ai", None)]);
assert!(
out.contains("\x1b]8;;https://x.ai\x07\x1b]8;;\x07"),
"wide-char run: {out:?}"
);
}
#[test]
fn nonzero_origin_viewport_maps_links() {
// The screen→cell mapping subtracts the viewport offset; verify a link
// at an absolute (row, col) inside a non-origin viewport wraps the right
// cells (regression guard for `(y - area.y)` / `(x - area.x)`).
let area = Rect::new(2, 5, 20, 4);
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Fixed(area),
},
)
.unwrap();
{
let mut f = t.get_frame();
f.buffer_mut().set_string(2, 5, "AB", Style::default());
}
t.set_frame_links(&[LinkSpan {
row: 5,
col_start: 2,
col_end: 4,
url: "https://x.ai".into(),
id: None,
}]);
t.flush_with_links().unwrap();
let out = String::from_utf8(t.backend().buf.clone()).unwrap();
assert!(
out.contains("\x1b]8;;https://x.ai\x07AB\x1b]8;;\x07"),
"non-origin mapping: {out:?}"
);
}
}

View file

@ -0,0 +1,218 @@
//! Differential test: the anstyle-parse-based `split_into_line_segments`
//! against a reference copy of the previous termwiz-based implementation.
//!
//! Production code uses `anstyle-parse` for ANSI line splitting. This test
//! embeds the prior termwiz-based splitter as a reference and asserts
//! identical observable output so the rewrite cannot drift. `termwiz` is a
//! dev-dependency only (powers this test; not linked into shipped binaries).
//!
//! Inputs are restricted to what the production splitter actually sees:
//! complete escape sequences (the old implementation `debug_assert`ed on
//! trailing incomplete ones) and no raw C1 controls encoded as UTF-8 (termwiz
//! maps e.g. U+0085 to a control action while VTE prints it; that corner was
//! unspecified before and is not exercised by terminal output we render).
use xai_ratatui_inline::split_into_line_segments;
// ─── Reference: the previous termwiz-based implementation, verbatim ────────
struct RefSegment<'a> {
content: &'a str,
ends_with_crlf: bool,
}
fn reference_split<'a>(input: &'a str, term_width: usize) -> Vec<RefSegment<'a>> {
use termwiz::escape::{Action, ControlCode, parser::Parser};
use unicode_width::UnicodeWidthChar as _;
let mut parser = Parser::new();
let mut remaining_bytes = input.as_bytes();
let mut segments = Vec::<RefSegment>::new();
let mut segment_start = 0_usize;
let mut segment_end = 0_usize;
let mut visual_width = 0_usize;
let mut has_visual = false;
let mut prev_is_cr = false;
macro_rules! push_segment {
($end:expr, $crlf:expr) => {
#[allow(unused_assignments)]
{
segments.push(RefSegment {
content: &input[segment_start..$end],
ends_with_crlf: $crlf,
});
visual_width = 0;
has_visual = false;
}
};
}
while let Some((ansi_action, consumed)) = parser.parse_first(remaining_bytes) {
remaining_bytes = &remaining_bytes[consumed..];
let mut is_cr = false;
match ansi_action {
Action::Control(ControlCode::LineFeed) => {
push_segment!(segment_end - usize::from(prev_is_cr), true);
segment_end += consumed;
segment_start = segment_end;
}
Action::Control(ControlCode::CarriageReturn) => {
segment_end += consumed;
visual_width = 0;
is_cr = true;
}
Action::Print(ch) => {
let char_width = ch.width().unwrap_or(0);
let new_width = visual_width + char_width;
if new_width > term_width && has_visual {
push_segment!(segment_end, false);
segment_start = segment_end;
segment_end += consumed;
visual_width = char_width;
has_visual = true;
if char_width > term_width {
push_segment!(segment_end, false);
segment_start = segment_end;
}
} else {
segment_end += consumed;
visual_width = new_width;
has_visual = true;
}
}
Action::PrintString(_) => unreachable!(),
_ => {
segment_end += consumed;
}
}
prev_is_cr = is_cr;
}
assert!(remaining_bytes.is_empty(), "{remaining_bytes:?}");
if segment_end > segment_start {
let input_start = input.as_ptr();
if let Some(last) = segments.last_mut() {
let last_start = last.content.as_ptr();
let last_end = unsafe { last_start.add(last.content.len()) };
if !last.ends_with_crlf && !has_visual {
assert_eq!(segment_start, (last_end as usize - input_start as usize));
let last_offset = last_start as usize - input_start as usize;
last.content = &input[last_offset..];
} else {
push_segment!(segment_end, false);
}
} else {
push_segment!(segment_end, false);
}
}
segments
}
// ─── Comparison harness ─────────────────────────────────────────────────────
#[track_caller]
fn assert_same(input: &str, widths: &[usize]) {
for &width in widths {
let actual = split_into_line_segments(input, width);
let expected = reference_split(input, width);
let actual_view: Vec<(&str, bool)> = actual
.iter()
.map(|s| (s.content, s.ends_with_crlf))
.collect();
let expected_view: Vec<(&str, bool)> = expected
.iter()
.map(|s| (s.content, s.ends_with_crlf))
.collect();
assert_eq!(
actual_view, expected_view,
"divergence for width {width}, input: {input:?}"
);
}
}
const WIDTHS: &[usize] = &[1, 2, 3, 5, 8, 10, 20, 80, 200];
#[test]
fn corpus_matches_reference() {
let corpus: &[&str] = &[
"",
"hello",
"hello world, this is a longer line that will wrap several times",
"line1\nline2\nline3",
"line1\r\nline2\r\n",
"12345\r67",
"\r\r\n\n\r",
"😊😊😊 emoji wall 😊😊😊",
"hello 你好 混合 width",
"\x1b[31mred\x1b[0m plain \x1b[1;32;44mstyled\x1b[m",
"\x1b[31m\x1b[1m\x1b[4mnested styles no text\x1b[0m",
"12345678\x1b[0m90",
"text\x1b]8;;https://example.com\x07link\x1b]8;;\x07 after",
"osc title\x1b]0;window title\x07body",
"cursor \x1b[2Amoves \x1b[10;20H everywhere",
"tab\tand\x08backspace and \x07bell",
"\x1b[38;5;196mext colors\x1b[38;2;10;20;30m truecolor\x1b[0m",
"interrupted \x1b[3\nmid-sequence",
"\x1b[31m\nstyle then newline",
"trailing style 12345678\x1b[0m",
"\x1b(Bcharset\x1b)0 escapes",
"zero\u{200b}width\u{fe0f}chars",
"combining a\u{0301}e\u{0301} accents",
];
for input in corpus {
assert_same(input, WIDTHS);
}
}
/// Deterministic pseudo-random ANSI soup (xorshift, no extra deps).
#[test]
fn randomized_ansi_soup_matches_reference() {
let mut state = 0x243F_6A88_85A3_08D3_u64; // seed: pi digits
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
const PIECES: &[&str] = &[
"word",
"a",
"longer-token",
" ",
" ",
"\n",
"\r",
"\r\n",
"😊",
"你好",
"é",
"\u{200b}",
"\x1b[31m",
"\x1b[0m",
"\x1b[1;44;38;5;10m",
"\x1b[2K",
"\x1b[10D",
"\x1b]0;title\x07",
"\x1b]8;;http://x\x07",
"\t",
"\x07",
];
for _ in 0..2000 {
let mut input = String::new();
let len = (next() % 30) as usize;
for _ in 0..len {
input.push_str(PIECES[(next() % PIECES.len() as u64) as usize]);
}
let width = 1 + (next() % 40) as usize;
assert_same(&input, &[width]);
}
}