use std::cell::RefCell;
use std::rc::Rc;
use super::*;
use crate::base::{Point, Size};
use crate::reactive::{flush_effects, run_due_timers};
use crate::ui::{BufferCanvas, Canvas, UiTree};
use crate::widgets::itest_util::{mount_widget, render};
pub(super) fn settle(tree: &mut UiTree, size: Size) -> BufferCanvas {
flush_effects();
tree.layout();
let _ = render(tree, size);
run_due_timers(std::time::Instant::now());
flush_effects();
tree.layout();
render(tree, size)
}
pub(super) fn mount_feed(size: Size) -> (crate::reactive::RootScope, UiTree, FeedState) {
let holder: Rc<RefCell<Option<FeedState>>> = Rc::new(RefCell::new(None));
let h = holder.clone();
let (root, tree) = mount_widget(size, move |cx| {
let feed = FeedState::new(cx);
*h.borrow_mut() = Some(feed.clone());
crate::ui::Element::new()
.style(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.child(Feed::new(&feed).view(cx))
.build()
});
let feed = holder.borrow().clone().expect("state captured");
(root, tree, feed)
}
#[test]
fn markdown_text_and_code_items_render_with_gap_rows() {
let size = Size::new(30, 12);
let (root, mut tree, feed) = mount_feed(size);
feed.push("m1", FeedItem::markdown("# Hello\n\nfirst message"));
feed.push("m2", FeedItem::text("plain line"));
feed.push("m3", FeedItem::code("rust", "fn main() {}"));
let canvas = settle(&mut tree, size);
let dump: Vec<String> = (0..size.h).map(|y| canvas.row_text(y)).collect();
let row_of = |needle: &str| {
dump.iter()
.position(|r| r.contains(needle))
.unwrap_or_else(|| panic!("{needle:?} not rendered:\n{dump:#?}"))
};
let hello = row_of("Hello");
let first = row_of("first message");
let plain = row_of("plain line");
let code = row_of("fn main");
assert!(hello < first && first < plain && plain < code);
assert_eq!(dump[plain - 1].trim(), "", "gap row before item 2");
assert!(
feed.total_rows().get_untracked() > 0,
"extent synced after settle"
);
root.dispose();
}
#[test]
fn duplicate_key_replaces_and_update_reflows_later_items() {
let size = Size::new(24, 10);
let (root, mut tree, feed) = mount_feed(size);
feed.push("a", FeedItem::text("alpha"));
feed.push("b", FeedItem::text("beta"));
let _ = settle(&mut tree, size);
let before = feed.total_rows().get_untracked();
feed.push("a", FeedItem::text("alpha two\nlines now"));
assert_eq!(feed.len(), 2, "duplicate key must replace");
let canvas = settle(&mut tree, size);
let dump: Vec<String> = (0..size.h).map(|y| canvas.row_text(y)).collect();
assert!(dump.iter().any(|r| r.contains("alpha two")));
assert!(
dump.iter().any(|r| r.contains("beta")),
"later item still renders after the earlier one grew:\n{dump:#?}"
);
let after = feed.total_rows().get_untracked();
assert_eq!(after, before + 1, "one extra wrapped row shifts the total");
assert!(feed.update("b", FeedItem::text("BETA")));
assert!(!feed.update("zz", FeedItem::text("nope")));
let canvas = settle(&mut tree, size);
assert!((0..size.h).any(|y| canvas.row_text(y).contains("BETA")));
root.dispose();
}
#[test]
fn streamed_item_matches_static_item_pixels() {
let source = "# Answer\n\nSome **bold** prose that wraps around the pane.\n\n```rust\nlet x = 1;\n```\n\n| tool | calls |\n|:-----|------:|\n| grep | 12 |\n\n- point one\n- [x] shipped ~~draft~~\n\ntail paragraph";
let size = Size::new(28, 22);
let (root_a, mut tree_a, feed_a) = mount_feed(size);
feed_a.push_stream("s");
let chars: Vec<char> = source.chars().collect();
for chunk in chars.chunks(3) {
let s: String = chunk.iter().collect();
feed_a.stream_append("s", &s);
}
feed_a.stream_finish("s");
let canvas_a = settle(&mut tree_a, size);
let (root_b, mut tree_b, feed_b) = mount_feed(size);
feed_b.push("s", FeedItem::markdown(source));
let canvas_b = settle(&mut tree_b, size);
for y in 0..size.h {
for x in 0..size.w {
assert_eq!(
canvas_a.cell(Point::new(x, y)),
canvas_b.cell(Point::new(x, y)),
"pixel divergence at ({x},{y}):\nstreamed: {:?}\nstatic: {:?}",
canvas_a.row_text(y),
canvas_b.row_text(y)
);
assert_eq!(
canvas_a.attrs_at(Point::new(x, y)),
canvas_b.attrs_at(Point::new(x, y)),
"attr divergence at ({x},{y})"
);
}
}
let dump: Vec<String> = (0..size.h).map(|y| canvas_b.row_text(y)).collect();
assert!(
dump.iter().any(|r| r.trim_start().starts_with('─')),
"table separator rule rendered:\n{dump:#?}"
);
assert!(
dump.iter().any(|r| r.contains("[x] shipped")),
"task item rendered:\n{dump:#?}"
);
root_a.dispose();
root_b.dispose();
}
#[test]
fn markdown_items_render_tables_tasks_and_strikethrough() {
let t = crate::theme::default_theme().tokens;
let size = Size::new(30, 12);
let (root, mut tree, feed) = mount_feed(size);
feed.push(
"m",
FeedItem::markdown(
"| tool | calls |\n|:-----|------:|\n| grep | 12 |\n\n- [x] done\n\n~~old~~ new",
),
);
let canvas = settle(&mut tree, size);
let dump: Vec<String> = (0..size.h).map(|y| canvas.row_text(y)).collect();
let row_of = |needle: &str| {
dump.iter()
.position(|r| r.contains(needle))
.unwrap_or_else(|| panic!("{needle:?} not rendered:\n{dump:#?}"))
};
let hdr = row_of("tool");
let hx = dump[hdr].find("tool").unwrap() as i32;
assert!(canvas
.attrs_at(Point::new(hx, hdr as i32))
.contains(crate::render::Attrs::BOLD));
let sep = hdr + 1;
assert!(dump[sep].trim_start().starts_with('─'), "{:?}", dump[sep]);
let sx = dump[sep].find('─').unwrap() as i32;
assert_eq!(canvas.cell(Point::new(sx, sep as i32)).unwrap().1, t.border);
let body = row_of("grep");
let calls_end = dump[hdr].find("calls").unwrap() + "calls".len();
let num_end = dump[body].find("12").unwrap() + "12".len();
assert_eq!(num_end, calls_end, "right-aligned numeric cell");
let task = row_of("[x] done");
let tx = dump[task].find("[x]").unwrap() as i32;
assert_eq!(
canvas.cell(Point::new(tx, task as i32)).unwrap().1,
t.accent_alt
);
let strike = row_of("old");
let ox = dump[strike].find("old").unwrap() as i32;
let nx = dump[strike].find("new").unwrap() as i32;
assert!(canvas
.attrs_at(Point::new(ox, strike as i32))
.contains(crate::render::Attrs::STRIKE));
assert!(!canvas
.attrs_at(Point::new(nx, strike as i32))
.contains(crate::render::Attrs::STRIKE));
root.dispose();
}
#[test]
fn feed_item_with_image_measures_without_decoding() {
use crate::widgets::markdown::imageflow::decode_count;
let bmp = crate::gfx::Bitmap::from_fn(8, 8, |x, _| {
if x < 4 {
crate::base::Rgba::WHITE
} else {
crate::base::Rgba::BLACK
}
});
let path =
std::env::temp_dir().join(format!("abstracttui_feed_img_{}.png", std::process::id()));
std::fs::write(&path, crate::gfx::png_encode::encode(&bmp)).unwrap();
let size = Size::new(20, 2);
let holder: Rc<RefCell<Option<FeedState>>> = Rc::new(RefCell::new(None));
let h = holder.clone();
let (root, mut tree) = mount_widget(size, move |cx| {
let feed = FeedState::new(cx);
*h.borrow_mut() = Some(feed.clone());
Feed::new(&feed)
.layout(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.view(cx)
});
let feed = holder.borrow().clone().expect("state");
let before = decode_count();
feed.push("head", FeedItem::text("above the fold"));
feed.push(
"img",
FeedItem::markdown(format!("", path.display())),
);
let canvas = settle(&mut tree, size);
assert!(canvas.row_text(0).contains("above the fold"));
assert_eq!(feed.total_rows().get_untracked(), 7, "probe-sized extent");
assert_eq!(decode_count(), before, "measure + clipped draw: no decode");
tree.set_viewport(Size::new(20, 8));
let canvas = settle(&mut tree, Size::new(20, 8));
assert_eq!(
decode_count(),
before + 1,
"first visible draw decodes once"
);
let (_, _, lbg) = canvas.cell(Point::new(1, 3)).unwrap();
let (_, _, rbg) = canvas.cell(Point::new(6, 3)).unwrap();
assert!(lbg.r > 200, "left half bright: {lbg:?}");
assert!(rbg.r < 60, "right half dark: {rbg:?}");
let _ = std::fs::remove_file(&path);
root.dispose();
}
#[test]
fn stream_appends_typeset_only_the_open_block() {
let size = Size::new(40, 8);
let (root, mut tree, feed) = mount_feed(size);
feed.push_stream("s");
let _ = settle(&mut tree, size); for i in 0..40 {
feed.stream_append("s", &format!("closed paragraph {i}\n\n"));
}
let baseline = feed.blocks_typeset_total();
for _ in 0..60 {
feed.stream_append("s", "token ");
}
let cost = feed.blocks_typeset_total() - baseline;
assert!(
cost <= 60,
"60 tail tokens re-typeset {cost} blocks — closed blocks are being revisited"
);
root.dispose();
}
#[test]
fn streamed_table_retypesets_only_the_open_region() {
let size = Size::new(40, 8);
let (root, mut tree, feed) = mount_feed(size);
feed.push_stream("s");
let _ = settle(&mut tree, size); for i in 0..20 {
feed.stream_append("s", &format!("closed paragraph {i}\n\n"));
}
feed.stream_append("s", "| tool | calls |\n|---|---:|\n");
let baseline = feed.blocks_typeset_total();
for i in 0..30 {
feed.stream_append("s", &format!("| step {i} | {i} |\n"));
}
let grow_cost = feed.blocks_typeset_total() - baseline;
assert!(
grow_cost <= 30,
"30 table rows re-typeset {grow_cost} blocks — the closed prefix thawed"
);
feed.stream_append("s", "\n");
let sealed = feed.blocks_typeset_total();
for _ in 0..20 {
feed.stream_append("s", "tail token ");
}
let tail_cost = feed.blocks_typeset_total() - sealed;
assert!(
tail_cost <= 20,
"20 tail tokens re-typeset {tail_cost} blocks — the sealed table is being revisited"
);
feed.stream_finish("s");
let tall = Size::new(40, 60);
tree.set_viewport(tall);
let canvas = settle(&mut tree, tall);
let dump: Vec<String> = (0..tall.h).map(|y| canvas.row_text(y)).collect();
assert!(
dump.iter()
.any(|r| r.contains("tool") && r.contains("calls")),
"table header rendered:\n{dump:#?}"
);
assert!(
dump.iter().any(|r| r.trim_start().starts_with('─')),
"table separator rule rendered"
);
root.dispose();
}
struct CountingCanvas {
inner: BufferCanvas,
puts: Rc<RefCell<usize>>,
}
impl Canvas for CountingCanvas {
fn size(&self) -> Size {
self.inner.size()
}
fn put(&mut self, p: Point, ch: char, fg: crate::base::Rgba, bg: crate::base::Rgba) {
*self.puts.borrow_mut() += 1;
self.inner.put(p, ch, fg, bg);
}
}
impl crate::ui::StyledCanvas for CountingCanvas {}
#[test]
fn feed_10k_items_draws_only_the_window() {
let size = Size::new(30, 10);
let holder: Rc<RefCell<Option<FeedState>>> = Rc::new(RefCell::new(None));
let h = holder.clone();
let (root, mut tree) = mount_widget(size, move |cx| {
let feed = FeedState::new(cx);
*h.borrow_mut() = Some(feed.clone());
Feed::new(&feed)
.layout(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.view(cx)
});
let feed = holder.borrow().clone().expect("state");
for i in 0..10_000 {
feed.push(
format!("m{i}"),
FeedItem::text(format!("message number {i}")),
);
}
let _ = settle(&mut tree, size);
let puts = Rc::new(RefCell::new(0usize));
let mut canvas = CountingCanvas {
inner: BufferCanvas::new(size),
puts: puts.clone(),
};
tree.draw(&mut canvas);
let cost = *puts.borrow();
let budget = (size.w * size.h) as usize * 3;
assert!(
cost <= budget,
"drawing 10k items cost {cost} puts (budget {budget}) — the feed is not windowing"
);
assert!(
canvas.inner.row_text(0).contains("message number 0"),
"head visible in fixed-box mode: {:?}",
canvas.inner.row_text(0)
);
root.dispose();
}
#[test]
fn width_change_retypesets_and_resyncs_the_extent() {
let size = Size::new(40, 8);
let (root, mut tree, feed) = mount_feed(size);
feed.push(
"a",
FeedItem::markdown("a paragraph long enough to wrap differently at different widths"),
);
let _ = settle(&mut tree, size);
let wide_rows = feed.total_rows().get_untracked();
assert!(wide_rows >= 2, "wrapped at 40 cols: {wide_rows}");
tree.set_viewport(Size::new(20, 8));
let narrow = Size::new(20, 8);
let canvas = settle(&mut tree, narrow);
let narrow_rows = feed.total_rows().get_untracked();
assert!(
narrow_rows > wide_rows,
"narrower pane must wrap into more rows ({wide_rows} -> {narrow_rows})"
);
assert!(canvas.row_text(0).contains("a paragraph"));
root.dispose();
}
#[test]
fn custom_blocks_occupy_their_height_and_draw() {
let size = Size::new(24, 8);
let (root, mut tree, feed) = mount_feed(size);
let drawn: Rc<RefCell<Vec<crate::base::Rect>>> = Rc::new(RefCell::new(Vec::new()));
let d = drawn.clone();
feed.push(
"c",
FeedItem::new()
.block(FeedBlock::Text("above".into()))
.block(FeedBlock::Custom(CustomBlock::new(
|_w| 2,
move |canvas, rect| {
d.borrow_mut().push(rect);
canvas.fill(
rect,
'#',
crate::base::Rgba::WHITE,
crate::base::Rgba::BLACK,
);
},
)))
.block(FeedBlock::Text("below".into())),
);
let canvas = settle(&mut tree, size);
let dump: Vec<String> = (0..size.h).map(|y| canvas.row_text(y)).collect();
let above = dump.iter().position(|r| r.contains("above")).unwrap();
let below = dump.iter().position(|r| r.contains("below")).unwrap();
let hashes = dump.iter().position(|r| r.starts_with("##")).unwrap();
assert!(above < hashes && hashes < below);
let rect = drawn.borrow().last().copied().expect("custom drew");
assert_eq!(rect.h, 2, "honest height honored");
assert_eq!(rect.w, size.w);
root.dispose();
}
#[test]
fn appends_at_known_width_sync_the_extent_immediately() {
let size = Size::new(24, 6);
let (root, mut tree, feed) = mount_feed(size);
feed.push("a", FeedItem::text("one"));
let _ = settle(&mut tree, size);
let before = feed.total_rows().get_untracked();
feed.push("b", FeedItem::text("two"));
let after = feed.total_rows().get_untracked();
assert_eq!(after, before + 2, "gap + one row, synchronously");
root.dispose();
}
fn mount_feed_with_marker(size: Size) -> (crate::reactive::RootScope, UiTree, FeedState) {
let holder: Rc<RefCell<Option<FeedState>>> = Rc::new(RefCell::new(None));
let h = holder.clone();
let (root, tree) = mount_widget(size, move |cx| {
let feed = FeedState::new(cx);
*h.borrow_mut() = Some(feed.clone());
crate::ui::Element::new()
.style(LayoutStyle::column().width(Dimension::Percent(1.0)))
.child(Feed::new(&feed).gap(0).view(cx))
.child(crate::ui::text("MARKER"))
.build()
});
let feed = holder.borrow().clone().expect("state captured");
(root, tree, feed)
}
fn marker_row(canvas: &BufferCanvas, h: i32) -> Option<i32> {
(0..h).find(|&y| canvas.row_text(y).contains("MARKER"))
}
#[test]
fn a_content_sized_feed_answers_its_height_on_the_first_frame() {
let size = Size::new(30, 12);
let (root, mut tree, feed) = mount_feed_with_marker(size);
for i in 0..5 {
feed.push(format!("k{i}"), FeedItem::text(format!("line {i}")));
}
flush_effects();
tree.layout();
let canvas = render(&mut tree, size);
assert_eq!(
marker_row(&canvas, size.h),
Some(5),
"five rows of feed must push the marker to row 5 on the first \
frame, not one placeholder row:\n{}",
(0..6)
.map(|y| canvas.row_text(y))
.collect::<Vec<_>>()
.join("\n")
);
let canvas = settle(&mut tree, size);
assert_eq!(marker_row(&canvas, size.h), Some(5), "no correction pass");
assert_eq!(
feed.total_rows().get_untracked(),
5,
"the public extent agrees with the layout"
);
root.dispose();
}
#[test]
fn the_public_extent_is_published_without_a_draw() {
let size = Size::new(30, 12);
let (root, mut tree, feed) = mount_feed_with_marker(size);
for i in 0..4 {
feed.push(format!("k{i}"), FeedItem::text(format!("row {i}")));
}
flush_effects();
tree.layout(); run_due_timers(std::time::Instant::now());
flush_effects();
assert_eq!(
feed.total_rows().get_untracked(),
4,
"the deferred publish must fire from the SOLVE, not only from paint"
);
root.dispose();
}
#[test]
fn an_empty_content_sized_feed_reserves_no_row() {
let size = Size::new(30, 12);
let (root, mut tree, feed) = mount_feed_with_marker(size);
let canvas = settle(&mut tree, size);
assert_eq!(marker_row(&canvas, size.h), Some(0), "no items, no rows");
assert_eq!(feed.total_rows().get_untracked(), 0);
feed.push("k", FeedItem::text("first"));
let canvas = settle(&mut tree, size);
assert_eq!(marker_row(&canvas, size.h), Some(1));
root.dispose();
}
#[test]
fn measure_and_draw_refuse_the_same_narrow_widths() {
let size = Size::new(1, 6);
let (root, mut tree, feed) = mount_feed_with_marker(size);
feed.push("k", FeedItem::text("wrapped content here"));
let _ = settle(&mut tree, size);
assert_eq!(
feed.total_rows().get_untracked(),
0,
"a width the draw refuses must measure as nothing, not as content"
);
root.dispose();
}