use std::cell::{Cell as StdCell, RefCell};
use std::rc::Rc;
use std::time::{Duration, Instant};
use abstracttui::app::drawer::{Drawer, DrawerEdge, DrawerFocus, DrawerSize};
use abstracttui::app::{App, Driver, Modal, RunConfig, Toast};
use abstracttui::base::Size;
use abstracttui::prelude::*;
use abstracttui::term::Capabilities;
use abstracttui::testing::CaptureTerm;
use abstracttui::ui::text;
use abstracttui::widgets::{Button, ColWidth, Column, Feed, FeedItem, FeedState, PageHost, Table};
fn config() -> RunConfig {
RunConfig {
caps: Some(Capabilities::with(|c| {
c.truecolor = true;
c.colors_256 = true;
c.unicode_ok = true;
})),
enter: None,
probe: false,
}
}
fn settle_on(
driver: &mut Driver,
app: &mut App,
term: &mut CaptureTerm,
clock: &std::cell::Cell<Instant>,
) {
for _ in 0..64 {
if driver.turn(app, term).expect("turn").idle {
return;
}
clock.set(clock.get() + Duration::from_millis(20));
}
panic!("loop failed to settle within 64 turns");
}
fn sgr_click(col: i32, row: i32) -> Vec<u8> {
format!("\x1b[<0;{col};{row}M\x1b[<0;{col};{row}m").into_bytes()
}
fn screen(term: &CaptureTerm) -> String {
term.screen().to_text()
}
#[derive(Clone)]
struct Shell {
inspector: abstracttui::app::drawer::DrawerHandle,
nav: abstracttui::app::drawer::DrawerHandle,
draft: Signal<String>,
saves: Signal<u32>,
page: Signal<String>,
detail_modal: Rc<RefCell<Option<Modal>>>,
}
#[test]
fn a_stranger_composes_the_whole_shell_from_the_docs() {
let size = Size::new(100, 30);
let mut term = CaptureTerm::new(size);
let mut app = App::new(size);
let shell_slot: Rc<RefCell<Option<Shell>>> = Rc::new(RefCell::new(None));
let slot = shell_slot.clone();
app.mount(move |cx| {
let t = abstracttui::theme::default_theme().tokens;
let overlays = cx
.use_context::<abstracttui::app::Overlays>()
.expect("App provides the overlay store as context");
let draft = cx.signal(String::new());
let saves = cx.signal(0u32);
let page = cx.signal("feed".to_string());
let feed = FeedState::new(cx);
for i in 0..12 {
feed.push(
format!("m{i}"),
FeedItem::markdown(format!("**msg {i}** hello")),
);
}
let detail_modal: Rc<RefCell<Option<Modal>>> = Rc::new(RefCell::new(None));
let dm = detail_modal.clone();
let ov = overlays.clone();
let inspector = Drawer::new(DrawerEdge::Right)
.size(DrawerSize::Percent(0.45))
.title("Inspector")
.motion(Duration::ZERO)
.install(cx, move |mount| {
let dm = dm.clone();
let ov = ov.clone();
let vp = use_viewport(mount);
let mut lines = Element::new().style(LayoutStyle::column());
for i in 0..40 {
lines = lines.child(text(format!("inspect-{i:02}")));
}
Element::new()
.style(LayoutStyle::column().gap(1).grow(1.0))
.child(
Button::new("Details")
.on_click(move || {
let dm_esc = dm.clone();
let modal = Modal::open(
&ov,
mount,
vp.get_untracked(),
Size::new(44, 8),
move |_mcx| {
Element::new()
.style(LayoutStyle::fill())
.shortcut(KeyChord::plain(Key::Escape), move |_| {
if let Some(m) = dm_esc.borrow_mut().take() {
m.close();
}
})
.child(text("MODAL DETAIL — esc closes"))
.build()
},
);
*dm.borrow_mut() = Some(modal);
})
.view(mount),
)
.child(Scroll::new(lines.build()).view(mount))
.build()
});
let nav = Drawer::new(DrawerEdge::Left)
.size(DrawerSize::Cells(22))
.focus(DrawerFocus::Passive)
.title("Navigate")
.motion(Duration::ZERO)
.install(cx, |_| {
Element::new()
.style(LayoutStyle::column().gap(1).grow(1.0))
.child(text("· feed\n· form\n· table"))
.build()
});
*slot.borrow_mut() = Some(Shell {
inspector: inspector.clone(),
nav: nav.clone(),
draft,
saves,
page,
detail_modal: detail_modal.clone(),
});
let feed_for_page = feed.clone();
let ov_toast = overlays.clone();
let host = PageHost::new()
.page("feed", "Feed", move |gcx| {
let follow = gcx.signal(false);
Scroll::new(Feed::new(&feed_for_page).gap(0).view(gcx))
.follow_tail(follow)
.view(gcx)
})
.page("form", "Form", move |gcx| {
let vp = use_viewport(gcx);
let ov = ov_toast.clone();
Element::new()
.style(LayoutStyle::column().gap(0).grow(1.0))
.child(text("display name:"))
.child(TextInput::new().value(draft).element(gcx, &t).build())
.child(
Button::new("Save")
.on_click(move || {
saves.update(|n| *n += 1);
Toast::show(
&ov,
gcx,
vp.get_untracked(),
"saved!",
Duration::from_millis(500),
);
})
.view(gcx),
)
.build()
})
.page("table", "Table", move |gcx| {
Table::new(vec![
Column::new("name", ColWidth::Cells(12)),
Column::new("state", ColWidth::Flex(1.0)),
])
.rows(vec![
vec!["alpha".into(), "ready".into()],
vec!["beta".into(), "busy".into()],
vec!["gamma".into(), "ready".into()],
])
.view(gcx)
})
.active(page)
.view(cx);
let ins = inspector.clone();
let nv = nav.clone();
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.shortcut(KeyChord::plain(Key::Char('i')), move |_| ins.toggle())
.shortcut(KeyChord::plain(Key::Char('g')), move |_| nv.toggle())
.child(host)
.build()
})
.expect("mount");
let shell = shell_slot.borrow().clone().expect("shell built");
app.tree().focus_first();
let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
let clock: Rc<StdCell<Instant>> = Rc::new(StdCell::new(Instant::now()));
driver.set_clock({
let clock = clock.clone();
move || clock.get()
});
let advance = |ms: u64| clock.set(clock.get() + Duration::from_millis(ms));
settle_on(&mut driver, &mut app, &mut term, &clock);
let s = screen(&term);
assert!(s.contains("Feed") && s.contains("msg 0"), "boot:\n{s}");
let _ = term.take_bytes();
for _ in 0..4 {
let turn = driver.turn(&mut app, &mut term).expect("idle");
assert!(turn.idle && !turn.rendered, "{turn:?}");
}
assert!(term.take_bytes().is_empty(), "parked shell wrote bytes");
term.push_input(b"\x1b[6;5~"); settle_on(&mut driver, &mut app, &mut term, &clock);
assert!(screen(&term).contains("display name:"));
term.push_input(&sgr_click(4, 4));
settle_on(&mut driver, &mut app, &mut term, &clock);
term.push_input(b"hello");
settle_on(&mut driver, &mut app, &mut term, &clock);
assert!(screen(&term).contains("hello"), "{}", screen(&term));
assert_eq!(shell.draft.get_untracked(), "hello");
term.push_input(&sgr_click(3, 5));
settle_on(&mut driver, &mut app, &mut term, &clock);
assert_eq!(shell.saves.get_untracked(), 1, "the save click landed");
assert!(
screen(&term).contains("saved!"),
"toast visible:\n{}",
screen(&term)
);
advance(60_000);
settle_on(&mut driver, &mut app, &mut term, &clock);
assert!(!screen(&term).contains("saved!"), "toast gone");
let _ = term.take_bytes();
let turn = driver.turn(&mut app, &mut term).expect("idle");
assert!(turn.idle && term.take_bytes().is_empty(), "{turn:?}");
term.push_input(&sgr_click(16, 1));
settle_on(&mut driver, &mut app, &mut term, &clock);
let s = screen(&term);
assert!(
s.contains("alpha") && s.contains("beta"),
"table page:\n{s}"
);
term.push_input(b"g");
settle_on(&mut driver, &mut app, &mut term, &clock);
assert!(shell.nav.is_open());
assert!(screen(&term).contains("Navigate"));
term.push_input(b"\x1b[5;5~"); settle_on(&mut driver, &mut app, &mut term, &clock);
assert_eq!(
shell.page.get_untracked(),
"form",
"passive drawer left the keyboard with the app"
);
term.push_input(b"i");
settle_on(&mut driver, &mut app, &mut term, &clock);
assert!(shell.inspector.is_open());
let s = screen(&term);
assert!(s.contains("Inspector") && s.contains("inspect-00"), "{s}");
for _ in 0..6 {
term.push_input(b"\x1b[<65;75;12M");
settle_on(&mut driver, &mut app, &mut term, &clock);
}
let s = screen(&term);
assert!(
!s.contains("inspect-00") && s.contains("inspect-"),
"the inspector page scrolled:\n{s}"
);
term.push_input(b"\r"); settle_on(&mut driver, &mut app, &mut term, &clock);
assert!(
screen(&term).contains("MODAL DETAIL"),
"modal above the drawer:\n{}",
screen(&term)
);
abstracttui::app::set_theme_by_id("nord");
settle_on(&mut driver, &mut app, &mut term, &clock);
let s = screen(&term);
assert!(
s.contains("MODAL DETAIL") && s.contains("Inspector") && s.contains("Navigate"),
"everything survived the theme switch:\n{s}"
);
term.push_resize(Size::new(90, 28));
settle_on(&mut driver, &mut app, &mut term, &clock);
let s = screen(&term);
assert!(
s.contains("MODAL DETAIL") && s.contains("Inspector") && s.contains("Navigate"),
"everything survived the resize (drawers re-clamp):\n{s}"
);
term.push_input(b"\x1b[27u");
settle_on(&mut driver, &mut app, &mut term, &clock);
let s = screen(&term);
assert!(!s.contains("MODAL DETAIL"), "modal closed first:\n{s}");
assert!(
shell.inspector.is_open() && s.contains("Inspector"),
"the drawer is still up under the closed modal:\n{s}"
);
term.push_input(b"\x1b[27u");
settle_on(&mut driver, &mut app, &mut term, &clock);
assert!(!shell.inspector.is_open(), "inspector closed second");
assert!(shell.nav.is_open(), "the passive nav is untouched by Esc");
term.push_input(b"g");
settle_on(&mut driver, &mut app, &mut term, &clock);
assert!(!shell.nav.is_open());
assert!(
!screen(&term).contains("Navigate"),
"closed nav left stale pixels:\n{}",
screen(&term)
);
let before = screen(&term);
term.push_input(b"\x1b[27u");
settle_on(&mut driver, &mut app, &mut term, &clock);
assert_eq!(screen(&term), before, "Esc on nothing changes nothing");
assert!(
screen(&term).contains("hello"),
"the form draft survived every overlay/theme/resize:\n{}",
screen(&term)
);
assert_eq!(shell.draft.get_untracked(), "hello");
let _ = shell.detail_modal;
abstracttui::app::set_theme_by_id("abstract-dark");
settle_on(&mut driver, &mut app, &mut term, &clock);
let _ = term.take_bytes();
assert_eq!(
abstracttui::reactive::frame_tasks_pending(),
0,
"no animation survives the journey"
);
for _ in 0..4 {
let turn = driver.turn(&mut app, &mut term).expect("final idle");
assert!(turn.idle && !turn.rendered, "{turn:?}");
}
assert!(
term.take_bytes().is_empty(),
"the whole composed shell parks at zero idle bytes"
);
}