Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
35
crates/codegen/xai-ratatui-inline/Cargo.toml
Normal file
35
crates/codegen/xai-ratatui-inline/Cargo.toml
Normal 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
|
||||
15
crates/codegen/xai-ratatui-inline/NOTICE
Normal file
15
crates/codegen/xai-ratatui-inline/NOTICE
Normal 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 crate’s 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.
|
||||
140
crates/codegen/xai-ratatui-inline/README.md
Normal file
140
crates/codegen/xai-ratatui-inline/README.md
Normal 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`.
|
||||
206
crates/codegen/xai-ratatui-inline/benches/bench.rs
Normal file
206
crates/codegen/xai-ratatui-inline/benches/bench.rs
Normal 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);
|
||||
310
crates/codegen/xai-ratatui-inline/examples/inline.rs
Normal file
310
crates/codegen/xai-ratatui-inline/examples/inline.rs
Normal 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(())
|
||||
}
|
||||
135
crates/codegen/xai-ratatui-inline/src/common.rs
Normal file
135
crates/codegen/xai-ratatui-inline/src/common.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
crates/codegen/xai-ratatui-inline/src/lib.rs
Normal file
16
crates/codegen/xai-ratatui-inline/src/lib.rs
Normal 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},
|
||||
};
|
||||
357
crates/codegen/xai-ratatui-inline/src/resize.rs
Normal file
357
crates/codegen/xai-ratatui-inline/src/resize.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
234
crates/codegen/xai-ratatui-inline/src/scrollback.rs
Normal file
234
crates/codegen/xai-ratatui-inline/src/scrollback.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
372
crates/codegen/xai-ratatui-inline/src/segment.rs
Normal file
372
crates/codegen/xai-ratatui-inline/src/segment.rs
Normal 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
|
||||
}
|
||||
}
|
||||
1448
crates/codegen/xai-ratatui-inline/src/terminal.rs
Normal file
1448
crates/codegen/xai-ratatui-inline/src/terminal.rs
Normal file
File diff suppressed because it is too large
Load diff
417
crates/codegen/xai-ratatui-inline/src/tests.rs
Normal file
417
crates/codegen/xai-ratatui-inline/src/tests.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
218
crates/codegen/xai-ratatui-inline/tests/segment_differential.rs
Normal file
218
crates/codegen/xai-ratatui-inline/tests/segment_differential.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue