use std::cell::RefCell;
use std::rc::Rc;
use abstracttui::app::{App, Driver, RunConfig};
use abstracttui::base::Size;
use abstracttui::layout::Style as LayoutStyle;
use abstracttui::reactive::{Scope, Signal};
use abstracttui::term::Capabilities;
use abstracttui::testing::CaptureTerm;
use abstracttui::ui::{dyn_view_scoped, text, Element, View};
use abstracttui::widgets::{Feed, FeedItem, FeedState, Scroll};
const ROWS: i32 = 30;
const PARKED_AT: i32 = 9;
fn config() -> RunConfig {
RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.unicode_ok = true;
})),
enter: None,
probe: false,
..RunConfig::default()
}
}
type Wires = Rc<RefCell<Option<(Signal<i32>, Signal<bool>, Signal<(i32, i32)>)>>>;
type PageFn = dyn Fn(Scope, Signal<i32>, Signal<(i32, i32)>) -> View;
fn remountable(size: Size, build: Box<PageFn>) -> (App, CaptureTerm, Wires) {
let mut app = App::new(size);
let wires: Wires = Rc::new(RefCell::new(None));
let w = wires.clone();
app.mount(move |cx| {
let oy = cx.signal(0i32);
let show = cx.signal(true);
let ext = cx.signal((0i32, 0i32));
*w.borrow_mut() = Some((oy, show, ext));
dyn_view_scoped(LayoutStyle::column(), move |gcx| {
if show.get() {
build(gcx, oy, ext)
} else {
text("HIDDEN")
}
})
})
.expect("mount");
(app, CaptureTerm::new(size), wires)
}
fn feed(gcx: Scope) -> View {
let feed = FeedState::new(gcx);
for i in 0..ROWS {
feed.push(format!("k{i}"), FeedItem::text(format!("item-{i:02}")));
}
Feed::new(&feed).gap(0).view(gcx)
}
fn feed_page(gcx: Scope, oy: Signal<i32>, _ext: Signal<(i32, i32)>) -> View {
Scroll::new(feed(gcx)).offset_y(oy).view(gcx)
}
fn warm_feed_page(gcx: Scope, oy: Signal<i32>, ext: Signal<(i32, i32)>) -> View {
Scroll::new(feed(gcx))
.offset_y(oy)
.extent_signal(ext)
.view(gcx)
}
fn column_page(gcx: Scope, oy: Signal<i32>, _ext: Signal<(i32, i32)>) -> View {
let mut col = Element::new().style(LayoutStyle::column());
for i in 0..ROWS {
col = col.child(text(format!("item-{i:02}")));
}
Scroll::new(col.build()).offset_y(oy).view(gcx)
}
fn park_and_remount(
build: impl Fn(Scope, Signal<i32>, Signal<(i32, i32)>) -> View + 'static,
) -> (i32, String) {
let size = Size::new(44, 14);
let (mut app, mut term, wires) = remountable(size, Box::new(build));
let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
let settle = |driver: &mut Driver, app: &mut App, term: &mut CaptureTerm| {
for _ in 0..16 {
if driver.turn(app, term).expect("turn").idle {
return;
}
}
panic!("loop failed to settle within 16 turns");
};
settle(&mut driver, &mut app, &mut term);
let (oy, show, _ext) = wires.borrow().expect("wires");
oy.set(PARKED_AT);
settle(&mut driver, &mut app, &mut term);
assert_eq!(
oy.get_untracked(),
PARKED_AT,
"precondition: a mounted Scroll honours a bound offset write"
);
show.set(false);
settle(&mut driver, &mut app, &mut term);
assert_eq!(
oy.get_untracked(),
PARKED_AT,
"disposal is clean — the unmount itself never writes the offset"
);
show.set(true);
settle(&mut driver, &mut app, &mut term);
(oy.get_untracked(), term.screen().to_text())
}
#[test]
fn plain_column_offset_survives_a_remount() {
let (offset, screen) = park_and_remount(column_page);
assert_eq!(
offset, PARKED_AT,
"a one-frame extent leaves the bound offset alone"
);
assert!(
screen.contains("item-09") && !screen.contains("item-00"),
"the remounted column comes back where it was parked:\n{screen}"
);
}
#[test]
fn feed_offset_survives_a_remount() {
let (offset, screen) = park_and_remount(feed_page);
assert_eq!(
offset, PARKED_AT,
"the remount must not rewrite the app's own offset signal"
);
assert!(
screen.contains("item-09") && !screen.contains("item-00"),
"the remounted feed comes back where it was parked:\n{screen}"
);
}
#[test]
fn extent_signal_warm_start_protects_a_bound_offset() {
let (offset, _screen) = park_and_remount(warm_feed_page);
assert_eq!(
offset, PARKED_AT,
"a warm-started extent must not clamp the offset to 0"
);
}
#[test]
fn disposal_does_not_publish_into_a_bound_layout_signal() {
let size = Size::new(44, 14);
let (mut app, mut term, wires) = remountable(size, Box::new(warm_feed_page));
let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
let settle = |driver: &mut Driver, app: &mut App, term: &mut CaptureTerm| {
for _ in 0..16 {
if driver.turn(app, term).expect("turn").idle {
return;
}
}
panic!("loop failed to settle within 16 turns");
};
settle(&mut driver, &mut app, &mut term);
let (_oy, show, ext) = wires.borrow().expect("wires");
let published = ext.get_untracked();
assert_eq!(
published.1, ROWS,
"precondition: a mounted Scroll publishes its real extent, so this \
test is watching a signal that is actually live"
);
show.set(false);
settle(&mut driver, &mut app, &mut term);
assert_eq!(
ext.get_untracked(),
published,
"an unmounting element published into a bound layout signal — \
the value a survivor reads is now the corpse's"
);
}
#[test]
fn a_publish_scheduled_before_disposal_does_not_land_after_it() {
let size = Size::new(44, 14);
let (mut app, mut term, wires) = remountable(size, Box::new(warm_feed_page));
let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
driver.turn(&mut app, &mut term).expect("turn");
let (_oy, show, ext) = wires.borrow().expect("wires");
let before_disposal = ext.get_untracked();
show.set(false);
for _ in 0..16 {
if driver.turn(&mut app, &mut term).expect("turn").idle {
break;
}
}
assert_eq!(
ext.get_untracked(),
before_disposal,
"a publish scheduled by an element that is now gone landed anyway"
);
}
#[test]
fn a_following_feed_does_not_rewind_to_the_top_on_remount() {
let size = Size::new(44, 14);
let mut app = App::new(size);
type Wires = Rc<RefCell<Option<(Signal<i32>, Signal<bool>, Signal<bool>)>>>;
let wires: Wires = Rc::new(RefCell::new(None));
let w = wires.clone();
app.mount(move |cx| {
let oy = cx.signal(0i32);
let show = cx.signal(true);
let follow = cx.signal(true);
let ext = cx.signal((0i32, 0i32));
*w.borrow_mut() = Some((oy, show, follow));
dyn_view_scoped(LayoutStyle::column(), move |gcx| {
if show.get() {
Scroll::new(feed(gcx))
.offset_y(oy)
.follow_tail(follow)
.extent_signal(ext)
.view(gcx)
} else {
text("HIDDEN")
}
})
})
.expect("mount");
let mut term = CaptureTerm::new(size);
let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
let settle = |driver: &mut Driver, app: &mut App, term: &mut CaptureTerm| {
for _ in 0..16 {
if driver.turn(app, term).expect("turn").idle {
return;
}
}
panic!("loop failed to settle within 16 turns");
};
settle(&mut driver, &mut app, &mut term);
let (oy, show, _follow) = wires.borrow().expect("wires");
let tail = oy.get_untracked();
assert!(
tail > 0,
"precondition: a following feed of {ROWS} rows parks at its tail, not {tail}"
);
show.set(false);
settle(&mut driver, &mut app, &mut term);
show.set(true);
let mut seen = Vec::new();
for _ in 0..16 {
let idle = driver.turn(&mut app, &mut term).expect("turn").idle;
seen.push(oy.get_untracked());
if idle {
break;
}
}
assert!(
!seen.contains(&0),
"the follow pin rewound a following reader to the top mid-remount \
(offsets per turn: {seen:?}, tail was {tail})"
);
assert_eq!(
oy.get_untracked(),
tail,
"and it settles back on the tail: {seen:?}"
);
}