use std::time::Instant;
use abstracttui::app::{App, Driver, RunConfig};
use abstracttui::base::{Point, Size};
use abstracttui::layout::Style as LayoutStyle;
use abstracttui::reactive::{batch, create_root, flush_effects, run_due_timers, Signal};
use abstracttui::term::Capabilities;
use abstracttui::testing::{CaptureTerm, VtScreen};
use abstracttui::ui::{text, BufferCanvas, Canvas, Element, Key, KeyEvent, UiEvent, UiTree};
use abstracttui::widgets::{Feed, FeedItem, FeedState, Scroll};
fn config() -> RunConfig {
RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.colors_256 = true;
})),
enter: None,
probe: false,
}
}
fn settle(driver: &mut Driver, app: &mut App, term: &mut CaptureTerm) -> usize {
let mut renders = 0;
for _ in 0..64 {
let turn = driver.turn(app, term).expect("turn");
if turn.rendered {
renders += 1;
}
if turn.idle {
return renders;
}
}
panic!("loop failed to settle within 64 turns");
}
fn row_dump(screen: &VtScreen, y: i32) -> String {
let mut out = String::new();
for x in 0..screen.size().w {
let c = screen.cell(x, y).unwrap();
out.push_str(&format!("{}:{:?}:{:?};", c.ch(), c.paint.fg, c.paint.bg));
}
out
}
fn wheel(term: &mut CaptureTerm, up: bool, col: i32, row: i32) {
let code = if up { 64 } else { 65 };
term.push_input(format!("\x1b[<{code};{col};{row}M").as_bytes());
}
struct Wiring {
feed: FeedState,
follow: Signal<bool>,
}
fn transcript_app(size: Size) -> (App, Wiring) {
let mut app = App::new(size);
let mut wiring = None;
app.mount(|cx| {
let feed = FeedState::new(cx);
let follow = cx.signal(true);
wiring = Some(Wiring {
feed: feed.clone(),
follow,
});
Element::new()
.style(LayoutStyle::column())
.child(text("HEADER chrome row that must never repaint"))
.child(
Scroll::new(Feed::new(&feed).gap(0).view(cx))
.follow_tail(follow)
.view(cx),
)
.build()
})
.expect("mount");
(app, wiring.expect("wiring"))
}
#[test]
fn follow_tail_acceptance_appends_wheel_and_resize() {
let size = Size::new(40, 10);
let mut term = CaptureTerm::new(size);
let (mut app, w) = transcript_app(size);
let mut driver = Driver::new(&mut app, &mut term, config()).expect("enter");
settle(&mut driver, &mut app, &mut term);
for i in 0..20 {
w.feed
.push(format!("m{i}"), FeedItem::text(format!("line {i}")));
}
settle(&mut driver, &mut app, &mut term);
assert!(
term.screen()
.to_text()
.lines()
.nth(9)
.unwrap()
.contains("line 19"),
"growth must keep the tail visible:\n{}",
term.screen().to_text()
);
assert!(w.follow.get_untracked(), "still following at the bottom");
wheel(&mut term, true, 3, 5);
settle(&mut driver, &mut app, &mut term);
assert!(!w.follow.get_untracked(), "wheel-up must disengage");
let held: Vec<String> = (0..10).map(|y| row_dump(term.screen(), y)).collect();
w.feed.push("m20", FeedItem::text("line 20"));
settle(&mut driver, &mut app, &mut term);
for (y, before) in held.iter().enumerate() {
assert_eq!(
&row_dump(term.screen(), y as i32),
before,
"row {y} moved while disengaged"
);
}
wheel(&mut term, false, 3, 5);
settle(&mut driver, &mut app, &mut term);
assert!(
!w.follow.get_untracked(),
"one row above the bottom is not the bottom"
);
wheel(&mut term, false, 3, 5);
settle(&mut driver, &mut app, &mut term);
assert!(w.follow.get_untracked(), "bottom edge must re-arm");
w.feed.push("m21", FeedItem::text("line 21"));
settle(&mut driver, &mut app, &mut term);
assert!(
term.screen()
.to_text()
.lines()
.nth(9)
.unwrap()
.contains("line 21"),
"re-armed follow pins the new tail"
);
term.push_resize(Size::new(40, 6));
settle(&mut driver, &mut app, &mut term);
assert!(w.follow.get_untracked(), "resize must not disengage");
assert!(
term.screen()
.to_text()
.lines()
.nth(5)
.unwrap()
.contains("line 21"),
"tail pinned after shrinking to 6 rows:\n{}",
term.screen().to_text()
);
assert_eq!(term.screen().unknown_seq_count(), 0, "modeled traffic only");
}
#[test]
fn streaming_append_damage_stays_inside_the_pane_and_bytes_stay_bounded() {
let size = Size::new(40, 12);
let mut term = CaptureTerm::new(size);
let (mut app, w) = transcript_app(size);
let mut driver = Driver::new(&mut app, &mut term, config()).expect("enter");
settle(&mut driver, &mut app, &mut term);
w.feed.push_stream("ans");
settle(&mut driver, &mut app, &mut term);
let header_before = row_dump(term.screen(), 0);
let _ = term.take_bytes();
let answer = "# Answer\n\nStreaming prose that wraps and grows the pane \
row by row as tokens arrive.\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\n\
- first point\n- second point\n\nclosing paragraph with a tail long \
enough to keep wrapping while pinned to the bottom edge.";
let tokens: Vec<String> = answer
.chars()
.collect::<Vec<_>>()
.chunks(4)
.map(|c| c.iter().collect())
.collect();
let (mut total_bytes, mut max_bytes, mut frames) = (0usize, 0usize, 0usize);
for tok in &tokens {
w.feed.stream_append("ans", tok);
frames += settle(&mut driver, &mut app, &mut term);
let bytes = term.take_bytes().len();
total_bytes += bytes;
max_bytes = max_bytes.max(bytes);
}
w.feed.stream_finish("ans");
settle(&mut driver, &mut app, &mut term);
println!(
"steady streaming: {} tokens, {frames} frames, {total_bytes} bytes total, \
{:.0} bytes/token avg, {max_bytes} max/token",
tokens.len(),
total_bytes as f64 / tokens.len() as f64,
);
assert_eq!(
row_dump(term.screen(), 0),
header_before,
"header row repainted during streaming"
);
assert_eq!(term.screen().unknown_seq_count(), 0, "modeled traffic only");
assert!(
max_bytes < 32 * 1024,
"one token cost {max_bytes} bytes — streaming is repainting the world"
);
assert!(
term.screen().to_text().contains("let x = 1;"),
"streamed fence must render as code"
);
}
struct CountingCanvas {
inner: BufferCanvas,
puts: std::cell::Cell<usize>,
}
impl CountingCanvas {
fn new(size: Size) -> CountingCanvas {
CountingCanvas {
inner: BufferCanvas::new(size),
puts: std::cell::Cell::new(0),
}
}
}
impl Canvas for CountingCanvas {
fn size(&self) -> Size {
self.inner.size()
}
fn put(
&mut self,
p: Point,
ch: char,
fg: abstracttui::base::Rgba,
bg: abstracttui::base::Rgba,
) {
self.puts.set(self.puts.get() + 1);
self.inner.put(p, ch, fg, bg);
}
}
impl abstracttui::ui::StyledCanvas for CountingCanvas {}
fn settle_tree(tree: &mut UiTree, size: Size) -> BufferCanvas {
flush_effects();
tree.layout();
let mut canvas = BufferCanvas::new(size);
tree.draw(&mut canvas);
for _ in 0..4 {
let fired = run_due_timers(Instant::now());
flush_effects();
tree.layout();
canvas = BufferCanvas::new(size);
tree.draw(&mut canvas);
if fired == 0 && !tree.has_pending_work() {
break;
}
}
canvas
}
#[test]
fn feed_10k_inside_measured_scroll_draws_only_a_screenful() {
let size = Size::new(30, 10);
let mut tree = UiTree::new(size);
let mut wiring = None;
let (root, ()) = create_root(|cx| {
let feed = FeedState::new(cx);
let follow = cx.signal(true);
wiring = Some((feed.clone(), follow));
let view = Scroll::new(Feed::new(&feed).gap(0).view(cx))
.follow_tail(follow)
.view(cx);
tree.mount(cx, view);
});
let (feed, follow) = wiring.expect("wiring");
let push_started = Instant::now();
batch(|| {
for i in 0..10_000 {
feed.push(format!("m{i}"), FeedItem::text(format!("item number {i}")));
}
});
let push_elapsed = push_started.elapsed();
let _ = settle_tree(&mut tree, size);
assert!(follow.get_untracked());
let mut canvas = CountingCanvas::new(size);
let draw_started = Instant::now();
tree.draw(&mut canvas);
let draw_elapsed = draw_started.elapsed();
let cost = canvas.puts.get();
let budget = (size.w * size.h) as usize * 3;
println!(
"10k in scroll: batched appends {push_elapsed:?}, pinned draw {draw_elapsed:?}, \
{cost} puts (budget {budget})"
);
assert!(
cost <= budget,
"drawing 10k items in a Scroll cost {cost} puts (budget {budget}) — windowing broke"
);
assert!(
canvas.inner.row_text(9).contains("item number 9999"),
"pinned to the true tail: {:?}",
canvas.inner.row_text(9)
);
tree.dispatch(&UiEvent::Key(KeyEvent::plain(Key::Tab)));
tree.dispatch(&UiEvent::Key(KeyEvent::plain(Key::Home)));
let canvas = settle_tree(&mut tree, size);
assert!(canvas.row_text(0).contains("item number 0"));
assert!(!follow.get_untracked(), "Home is a user scroll: disengaged");
root.dispose();
}
#[test]
fn tail_tokens_behind_closed_blocks_typeset_only_the_open_block() {
let size = Size::new(40, 8);
let mut term = CaptureTerm::new(size);
let (mut app, w) = transcript_app(size);
let mut driver = Driver::new(&mut app, &mut term, config()).expect("enter");
settle(&mut driver, &mut app, &mut term);
w.feed.push_stream("s");
for i in 0..40 {
w.feed
.stream_append("s", &format!("closed paragraph {i}\n\n"));
}
settle(&mut driver, &mut app, &mut term);
let baseline = w.feed.blocks_typeset_total();
for _ in 0..60 {
w.feed.stream_append("s", "token ");
driver.turn(&mut app, &mut term).expect("turn");
}
settle(&mut driver, &mut app, &mut term);
let cost = w.feed.blocks_typeset_total() - baseline;
println!("60 tail tokens behind 40 closed blocks re-typeset {cost} blocks");
assert!(
cost <= 60,
"tail tokens re-typeset {cost} blocks — the closed prefix thawed"
);
assert!(w.follow.get_untracked(), "follow held through the burst");
assert!(
term.screen().to_text().contains("token"),
"tail visible while pinned"
);
}
#[test]
fn measure_100k_appends_and_full_feed_repaint() {
let size = Size::new(60, 20);
let mut tree = UiTree::new(size);
let mut wiring = None;
let (root, ()) = create_root(|cx| {
let feed = FeedState::new(cx);
wiring = Some(feed.clone());
let view = Scroll::new(Feed::new(&feed).gap(0).view(cx)).view(cx);
tree.mount(cx, view);
});
let feed = wiring.expect("wiring");
let _ = settle_tree(&mut tree, size);
let started = Instant::now();
batch(|| {
for i in 0..100_000 {
feed.push(
format!("m{i}"),
FeedItem::text(format!("log line {i} with payload")),
);
}
});
flush_effects();
let append_elapsed = started.elapsed();
let started = Instant::now();
for i in 0..1_000 {
feed.push(format!("u{i}"), FeedItem::text(format!("unbatched {i}")));
}
let unbatched_elapsed = started.elapsed();
let _ = settle_tree(&mut tree, size);
let rows = feed.total_rows().get_untracked();
let started = Instant::now();
let mut canvas = BufferCanvas::new(size);
tree.draw(&mut canvas);
let repaint_elapsed = started.elapsed();
println!(
"100k batched appends: {append_elapsed:?} ({:.1} µs/item); \
1k unbatched: {unbatched_elapsed:?} ({:.1} µs/item); \
extent {rows} rows; full repaint {repaint_elapsed:?}",
append_elapsed.as_micros() as f64 / 100_000.0,
unbatched_elapsed.as_micros() as f64 / 1_000.0,
);
assert_eq!(feed.len(), 101_000);
assert!(rows >= 101_000, "every item contributes its row: {rows}");
assert!(
append_elapsed.as_secs() < 30,
"100k appends took {append_elapsed:?} — append stopped being O(1)"
);
assert!(
repaint_elapsed.as_millis() < 2_000,
"windowed repaint took {repaint_elapsed:?} — drawing stopped being windowed"
);
root.dispose();
}
#[test]
fn one_row_controls_survive_overflow_pressure() {
use abstracttui::theme::default_theme;
use abstracttui::widgets::{Button, Separator};
let t = default_theme().tokens;
let size = Size::new(16, 6);
let mut tree = UiTree::new(size);
let (root, ()) = create_root(|cx| {
let tall = Element::new()
.style(LayoutStyle::default().h(600))
.child(text("filler"))
.build();
let view = Element::new()
.style(LayoutStyle::column().h(6))
.child(tall)
.child(Button::new("Save").element(cx, &t).build())
.child(Separator::horizontal().element(&t).build())
.build();
tree.mount(cx, view);
});
let canvas = settle_tree(&mut tree, size);
let dump: Vec<String> = (0..6).map(|y| canvas.row_text(y)).collect();
assert!(
dump.iter().any(|r| r.contains("Save")),
"default-styled Button must keep its row under overflow:\n{dump:#?}"
);
assert!(
dump.iter().any(|r| r.starts_with('─')),
"default-styled Separator must keep its row under overflow:\n{dump:#?}"
);
root.dispose();
}
#[test]
fn clear_rebuilds_a_bounded_window_and_follow_repins() {
let size = Size::new(30, 6);
let mut term = CaptureTerm::new(size);
let (mut app, w) = transcript_app(size);
let mut driver = Driver::new(&mut app, &mut term, config()).expect("enter");
settle(&mut driver, &mut app, &mut term);
for i in 0..30 {
w.feed
.push(format!("m{i}"), FeedItem::text(format!("old {i}")));
}
settle(&mut driver, &mut app, &mut term);
batch(|| {
w.feed.clear();
for i in 20..30 {
w.feed
.push(format!("m{i}"), FeedItem::text(format!("kept {i}")));
}
});
settle(&mut driver, &mut app, &mut term);
assert_eq!(w.feed.len(), 10);
let screen = term.screen().to_text();
assert!(
screen.contains("kept 29") && !screen.contains("old 19"),
"window rebuilt in place:\n{screen}"
);
assert!(w.follow.get_untracked(), "follow survives the rebuild");
assert_eq!(term.screen().unknown_seq_count(), 0);
}