use crossterm::event::{
Event as CtEvent, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton,
MouseEvent as CtMouseEvent, MouseEventKind,
};
use rdom_core::{ListenerOptions, NodeId};
use std::cell::Cell;
use std::rc::Rc;
use crate::TuiDom;
use crate::layout::Size;
use crate::render::{Rect, Terminal, TestBackend};
use crate::runtime::app::{App, AppContext, ControlFlow};
use crate::style::{Color, Stylesheet, TuiStyle};
fn test_app(dom: TuiDom, sheet: Stylesheet, viewport: Rect) -> App<TestBackend> {
let backend = TestBackend::new(viewport.width, viewport.height);
let terminal = Terminal::new(backend).unwrap();
App::with_backend(dom, sheet, terminal).unwrap()
}
fn ctrl_c() -> CtEvent {
CtEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
}
fn key(code: KeyCode) -> CtEvent {
CtEvent::Key(KeyEvent::new(code, KeyModifiers::empty()))
}
fn key_release(code: KeyCode) -> CtEvent {
CtEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Release,
state: KeyEventState::empty(),
})
}
fn click_at(x: u16, y: u16) -> Vec<CtEvent> {
vec![
CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::empty(),
}),
CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::empty(),
}),
]
}
#[test]
fn inserting_a_list_item_renumbers_the_ordered_list() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let ol = dom.create_element("ol");
dom.append_child(root, ol).unwrap();
let mut items = Vec::new();
for text in ["a", "b", "c"] {
let li = dom.create_element("li");
let t = dom.create_text_node(text);
dom.append_child(li, t).unwrap();
dom.append_child(ol, li).unwrap();
items.push(li);
}
let mut app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 20, 6));
app.draw_if_dirty().unwrap();
let marker = |app: &App<TestBackend>, id: NodeId| {
app.dom()
.node(id)
.ext()
.and_then(|e| e.computed_before.as_ref())
.and_then(|p| p.content.clone())
};
assert_eq!(marker(&app, items[2]).as_deref(), Some("3. "));
let inserted = app.dom_mut().create_element("li");
let t = app.dom_mut().create_text_node("new");
app.dom_mut().append_child(inserted, t).unwrap();
app.dom_mut()
.insert_before(ol, inserted, Some(items[1]))
.unwrap();
app.draw_if_dirty().unwrap();
assert_eq!(marker(&app, items[0]).as_deref(), Some("1. "));
assert_eq!(marker(&app, inserted).as_deref(), Some("2. "));
assert_eq!(marker(&app, items[1]).as_deref(), Some("3. "));
assert_eq!(marker(&app, items[2]).as_deref(), Some("4. "));
}
#[test]
fn fresh_app_starts_with_redraw_requested() {
let dom: TuiDom = TuiDom::new();
let app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
assert!(app.needs_redraw(), "initial paint must fire");
assert!(!app.should_quit());
}
#[test]
fn draw_if_dirty_clears_the_flag() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.draw_if_dirty().unwrap();
assert!(!app.needs_redraw());
}
#[test]
fn draw_if_dirty_no_op_when_not_dirty() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.draw_if_dirty().unwrap(); let before_bytes = app.terminal().backend().bytes().to_vec();
app.draw_if_dirty().unwrap();
let after_bytes = app.terminal().backend().bytes().to_vec();
assert_eq!(before_bytes, after_bytes, "second draw emitted nothing new");
}
#[test]
fn ctrl_c_sets_should_quit() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(ctrl_c());
assert!(app.should_quit());
}
#[test]
fn key_dispatches_to_focused_element() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("button");
dom.append_child(root, btn).unwrap();
dom.set_focused(Some(btn));
let fired = Rc::new(Cell::new(false));
let f = fired.clone();
dom.add_event_listener(btn, "keydown", ListenerOptions::default(), move |_| {
f.set(true);
})
.unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(key(KeyCode::Enter));
assert!(fired.get(), "focused element received keydown");
}
#[test]
fn resize_dispatches_resize_event_on_root() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let fired = Rc::new(Cell::new(0u32));
let f = fired.clone();
dom.add_event_listener(root, "resize", ListenerOptions::default(), move |_| {
f.set(f.get() + 1);
})
.unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(CtEvent::Resize(40, 10));
app.handle_event(CtEvent::Resize(60, 15));
assert_eq!(
fired.get(),
2,
"resize fires once per Resize event (no coalescing across events)"
);
}
#[test]
fn key_release_dispatches_keyup_to_focused_element() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("button");
dom.append_child(root, btn).unwrap();
dom.set_focused(Some(btn));
let keydown_fired = Rc::new(Cell::new(false));
let keyup_fired = Rc::new(Cell::new(false));
let kd = keydown_fired.clone();
let ku = keyup_fired.clone();
dom.add_event_listener(btn, "keydown", ListenerOptions::default(), move |_| {
kd.set(true);
})
.unwrap();
dom.add_event_listener(btn, "keyup", ListenerOptions::default(), move |_| {
ku.set(true);
})
.unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(key_release(KeyCode::Enter));
assert!(!keydown_fired.get(), "Release event must NOT fire keydown");
assert!(
keyup_fired.get(),
"Release event fires keyup on the focused element"
);
}
#[test]
fn key_release_does_not_run_default_actions() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("button");
let b = dom.create_element("button");
dom.append_child(root, a).unwrap();
dom.append_child(root, b).unwrap();
dom.set_attribute(a, "tabindex", "0").unwrap();
dom.set_attribute(b, "tabindex", "0").unwrap();
dom.set_focused(Some(a));
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(key_release(KeyCode::Tab));
assert_eq!(
app.dom().focused(),
Some(a),
"Tab Release must not advance focus (only Press does)"
);
}
#[test]
fn shift_f10_dispatches_contextmenu_on_focused_element() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("button");
dom.append_child(root, btn).unwrap();
dom.set_focused(Some(btn));
let fired = Rc::new(Cell::new(false));
let f = fired.clone();
dom.add_event_listener(btn, "contextmenu", ListenerOptions::default(), move |_| {
f.set(true);
})
.unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(CtEvent::Key(KeyEvent::new(
KeyCode::F(10),
KeyModifiers::SHIFT,
)));
assert!(
fired.get(),
"Shift+F10 fires contextmenu on focused element"
);
}
#[test]
fn key_with_no_focus_dispatches_to_root() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let fired = Rc::new(Cell::new(false));
let f = fired.clone();
dom.add_event_listener(root, "keydown", ListenerOptions::default(), move |_| {
f.set(true);
})
.unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(key(KeyCode::Char('x')));
assert!(fired.get());
}
#[test]
fn resize_event_marks_redraw() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.draw_if_dirty().unwrap(); assert!(!app.needs_redraw());
app.handle_event(CtEvent::Resize(40, 10));
assert!(app.needs_redraw(), "resize must request redraw");
}
#[test]
fn click_through_app_reaches_handler() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("btn");
dom.append_child(root, btn).unwrap();
let clicks = Rc::new(Cell::new(0));
let c = clicks.clone();
dom.add_event_listener(btn, "click", ListenerOptions::default(), move |_| {
c.set(c.get() + 1);
})
.unwrap();
let sheet = Stylesheet::bare().rule_unchecked(
"btn",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(3)),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 20, 10));
app.draw_if_dirty().unwrap();
for ev in click_at(3, 1) {
app.handle_event(ev);
}
assert_eq!(clicks.get(), 1);
}
#[test]
fn mouse_hover_transition_sets_needs_redraw() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let sheet = Stylesheet::bare().rule_unchecked(
"div",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(3)),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 20, 10));
app.draw_if_dirty().unwrap();
assert!(!app.needs_redraw());
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::Moved,
column: 3,
row: 1,
modifiers: KeyModifiers::empty(),
}));
assert!(app.needs_redraw(), "hover transition requests redraw");
}
#[test]
fn on_tick_fires_and_can_request_quit() {
let dom: TuiDom = TuiDom::new();
let fired = Rc::new(Cell::new(0));
let f = fired.clone();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(
move |_ctx: &mut AppContext<'_>| {
f.set(f.get() + 1);
if f.get() >= 2 {
ControlFlow::Quit
} else {
ControlFlow::Continue
}
},
);
app.tick();
assert_eq!(fired.get(), 1);
assert!(!app.should_quit());
app.tick();
assert_eq!(fired.get(), 2);
assert!(app.should_quit(), "ControlFlow::Quit propagates");
}
#[test]
fn on_tick_can_mutate_dom_through_context() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(
move |ctx: &mut AppContext<'_>| {
let _ = ctx.dom.set_attribute(div, "data-touched", "1");
ControlFlow::Continue
},
);
app.tick();
assert!(!app.dirty_roots_snapshot().is_empty());
}
#[test]
fn context_request_redraw_forces_paint_on_next_cycle() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(
|ctx: &mut AppContext<'_>| {
ctx.request_redraw();
ControlFlow::Continue
},
);
app.draw_if_dirty().unwrap();
assert!(!app.needs_redraw());
app.tick();
assert!(app.needs_redraw());
}
#[test]
fn context_quit_sets_should_quit() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(
|ctx: &mut AppContext<'_>| {
ctx.quit();
ControlFlow::Continue },
);
app.tick();
assert!(app.should_quit());
}
#[test]
fn multiple_mutations_coalesce_into_one_paint() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let mut children = Vec::new();
for _ in 0..5 {
let id = dom.create_element("span");
dom.append_child(root, id).unwrap();
children.push(id);
}
let child_refs: Vec<NodeId> = children.clone();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(
move |ctx: &mut AppContext<'_>| {
for &id in &child_refs {
let _ = ctx.dom.set_attribute(id, "data-touched", "1");
}
ControlFlow::Continue
},
);
app.draw_if_dirty().unwrap();
app.tick();
let roots_after_tick = app.dirty_roots_snapshot();
assert!(
!roots_after_tick.is_empty(),
"tick should leave dirty roots from the 5 attribute mutations"
);
app.draw_if_dirty().unwrap();
assert!(
app.dirty_roots_snapshot().is_empty(),
"draw_if_dirty must drain the dirty roots in one pass — coalescing 5 \
mutations into one paint cycle"
);
assert!(!app.needs_redraw());
}
#[test]
fn style_sheets_returns_single_construction_sheet() {
let dom: TuiDom = TuiDom::new();
let sheet = Stylesheet::bare().rule_unchecked("div", TuiStyle::new().fg(Color::Rgb(255, 0, 0)));
let initial_rule_count = sheet.rules().len();
let app = test_app(dom, sheet, Rect::new(0, 0, 20, 5));
let sheets = app.style_sheets();
assert_eq!(sheets.len(), 1);
assert_eq!(sheets[0].rules().len(), initial_rule_count);
assert_eq!(initial_rule_count, 1, "bare + one rule = 1 rule total");
}
#[test]
fn set_stylesheet_replaces_primary_in_place() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
assert_eq!(app.style_sheets().len(), 1);
assert_eq!(app.style_sheets()[0].rules().len(), 0, "bare = 0 rules");
app.set_stylesheet(
Stylesheet::bare().rule_unchecked("span", TuiStyle::new().fg(Color::Rgb(0, 0, 255))),
);
assert_eq!(app.style_sheets().len(), 1, "still single-element");
assert_eq!(
app.style_sheets()[0].rules().len(),
1,
"replacement sheet (1 rule) is now in slot 0"
);
}
#[test]
fn push_stylesheet_appends_and_returns_distinct_ids() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
assert_eq!(app.style_sheets().len(), 1, "construction sheet present");
let id1 = app.push_stylesheet(Stylesheet::bare());
let id2 = app.push_stylesheet(Stylesheet::bare());
assert_eq!(app.style_sheets().len(), 3, "two pushes appended");
assert_ne!(id1, id2, "each push gets a distinct id");
}
#[test]
fn remove_stylesheet_removes_only_that_slot() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
let id_a = app.push_stylesheet(
Stylesheet::bare().rule_unchecked("a", TuiStyle::new().fg(Color::Rgb(1, 0, 0))),
);
let id_b = app.push_stylesheet(
Stylesheet::bare().rule_unchecked("b", TuiStyle::new().fg(Color::Rgb(2, 0, 0))),
);
let _id_c = app.push_stylesheet(
Stylesheet::bare().rule_unchecked("c", TuiStyle::new().fg(Color::Rgb(3, 0, 0))),
);
assert_eq!(app.style_sheets().len(), 4);
app.remove_stylesheet(id_b);
let sheets = app.style_sheets();
assert_eq!(sheets.len(), 3, "one removed");
assert_eq!(sheets[1].rules().len(), 1, "A still present at index 1");
assert_eq!(sheets[2].rules().len(), 1, "C shifted to index 2");
app.remove_stylesheet(id_a);
assert_eq!(app.style_sheets().len(), 2);
}
#[test]
fn remove_stylesheet_unknown_id_is_noop() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
let id = app.push_stylesheet(Stylesheet::bare());
app.remove_stylesheet(id);
let len_before = app.style_sheets().len();
app.remove_stylesheet(id);
assert_eq!(app.style_sheets().len(), len_before, "stale id no-op");
}
#[test]
fn later_pushed_sheet_wins_same_specificity() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let base = Stylesheet::bare().rule_unchecked("div", TuiStyle::new().fg(Color::Rgb(255, 0, 0)));
let mut app = test_app(dom, base, Rect::new(0, 0, 20, 5));
let _blue = app.push_stylesheet(
Stylesheet::bare().rule_unchecked("div", TuiStyle::new().fg(Color::Rgb(0, 0, 255))),
);
app.draw_if_dirty().unwrap();
let fg = crate::style::cascade::computed_of(app.dom(), div).fg;
assert_eq!(
fg,
Color::Rgb(0, 0, 255),
"later-pushed sheet wins same-specificity contest"
);
}
#[test]
fn remove_stylesheet_triggers_recascade() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let base = Stylesheet::bare().rule_unchecked("div", TuiStyle::new().fg(Color::Rgb(255, 0, 0)));
let mut app = test_app(dom, base, Rect::new(0, 0, 20, 5));
let blue = app.push_stylesheet(
Stylesheet::bare().rule_unchecked("div", TuiStyle::new().fg(Color::Rgb(0, 0, 255))),
);
app.draw_if_dirty().unwrap();
assert_eq!(
crate::style::cascade::computed_of(app.dom(), div).fg,
Color::Rgb(0, 0, 255),
"blue while pushed"
);
app.remove_stylesheet(blue);
app.draw_if_dirty().unwrap();
assert_eq!(
crate::style::cascade::computed_of(app.dom(), div).fg,
Color::Rgb(255, 0, 0),
"back to base red after removal"
);
}
#[test]
fn stylesheet_mutation_forces_full_cascade_even_when_dom_is_already_dirty() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("a");
let b = dom.create_element("b");
dom.append_child(root, a).unwrap();
dom.append_child(root, b).unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
let colors = app.push_stylesheet(
Stylesheet::bare()
.rule_unchecked("a", TuiStyle::new().fg(Color::Rgb(255, 0, 0)))
.rule_unchecked("b", TuiStyle::new().fg(Color::Rgb(0, 255, 0))),
);
app.draw_if_dirty().unwrap();
assert_eq!(
crate::style::cascade::computed_of(app.dom(), a).fg,
Color::Rgb(255, 0, 0),
"<a> picks up red from the pushed sheet"
);
app.dom_mut().set_attribute(b, "class", "x").unwrap();
app.remove_stylesheet(colors);
app.draw_if_dirty().unwrap();
assert_eq!(
crate::style::cascade::computed_of(app.dom(), a).fg,
rdom_tui_initial_fg(),
"<a> must re-cascade to initial — the sheet that produced red is gone, \
even though <a> wasn't in the dirty tracker when the sheet was removed",
);
}
fn rdom_tui_initial_fg() -> Color {
crate::style::ComputedStyle::initial().fg
}
#[test]
fn cascade_with_zero_stylesheets_renders_initial_styles() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let mut app = test_app(
dom,
Stylesheet::bare().rule_unchecked("div", TuiStyle::new().fg(Color::Rgb(255, 0, 0))),
Rect::new(0, 0, 20, 5),
);
let id = app.set_stylesheet(
Stylesheet::bare().rule_unchecked("div", TuiStyle::new().fg(Color::Rgb(0, 255, 0))),
);
app.draw_if_dirty().unwrap();
assert_eq!(
crate::style::cascade::computed_of(app.dom(), div).fg,
Color::Rgb(0, 255, 0),
"green from the post-set sheet"
);
app.remove_stylesheet(id);
assert_eq!(app.style_sheets().len(), 0, "no sheets registered");
app.draw_if_dirty().unwrap();
assert_eq!(
crate::style::cascade::computed_of(app.dom(), div).fg,
rdom_tui_initial_fg(),
"div re-cascaded to initial — nothing left to apply"
);
}
#[test]
fn dom_mut_and_stylesheet_swap_work() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
let div = app.dom_mut().create_element("div");
let root = app.dom_mut().root();
app.dom_mut().append_child(root, div).unwrap();
app.set_stylesheet(
Stylesheet::bare().rule_unchecked("div", TuiStyle::new().fg(Color::Rgb(255, 0, 0))),
);
assert!(app.needs_redraw(), "stylesheet swap requests redraw");
}
#[test]
fn context_dispatch_fires_synchronously() {
use rdom_core::Event;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("btn");
dom.append_child(root, btn).unwrap();
let fired = Rc::new(Cell::new(false));
let f = fired.clone();
dom.add_event_listener(btn, "custom", ListenerOptions::default(), move |_| {
f.set(true);
})
.unwrap();
let btn_id = btn;
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(move |ctx| {
let mut e = Event::new("custom");
ctx.dispatch(btn_id, &mut e).expect("live target");
ControlFlow::Continue
});
assert!(!fired.get());
app.tick();
assert!(fired.get());
}
#[test]
fn context_dispatch_reports_a_dropped_target() {
use rdom_core::{DomError, Event};
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let gone = dom.create_element("div");
dom.append_child(root, gone).unwrap();
dom.drop_subtree(gone).unwrap();
let result = Rc::new(Cell::new(None));
let r = result.clone();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(move |ctx| {
r.set(Some(ctx.dispatch(gone, &mut Event::new("custom"))));
ControlFlow::Continue
});
app.tick();
assert_eq!(result.take(), Some(Err(DomError::InvalidNode(gone))));
}
#[test]
fn context_queue_dispatch_runs_after_tick_returns() {
use rdom_core::Event;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("btn");
dom.append_child(root, btn).unwrap();
let order = Rc::new(std::cell::RefCell::new(Vec::<&'static str>::new()));
let o1 = order.clone();
dom.add_event_listener(btn, "queued", ListenerOptions::default(), move |_| {
o1.borrow_mut().push("queued-fired");
})
.unwrap();
let btn_id = btn;
let o2 = order.clone();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(move |ctx| {
o2.borrow_mut().push("tick-start");
ctx.queue_dispatch(btn_id, Event::new("queued"));
o2.borrow_mut().push("tick-end");
ControlFlow::Continue
});
app.tick();
assert_eq!(
*order.borrow(),
vec!["tick-start", "tick-end", "queued-fired"]
);
}
#[test]
fn context_queue_dispatch_of_a_clone_taken_in_a_listener_dispatches() {
use rdom_core::Event;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("btn");
dom.append_child(root, btn).unwrap();
let fired = Rc::new(Cell::new(0));
let stash = Rc::new(std::cell::RefCell::new(None::<Event>));
{
let (fired, stash) = (fired.clone(), stash.clone());
dom.add_event_listener(btn, "ping", ListenerOptions::default(), move |ctx| {
fired.set(fired.get() + 1);
ctx.event.prevent_default();
stash.borrow_mut().get_or_insert_with(|| ctx.event.clone());
})
.unwrap();
}
let (btn_id, s) = (btn, stash.clone());
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(move |ctx| {
if let Some(copy) = s.borrow_mut().take() {
ctx.queue_dispatch(btn_id, copy);
} else {
ctx.dispatch(btn_id, &mut Event::new("ping"))
.expect("live target");
}
ControlFlow::Continue
});
app.tick();
assert_eq!(fired.get(), 1);
app.tick();
assert_eq!(fired.get(), 2, "the queued clone dispatched");
}
#[test]
fn context_queue_dispatch_of_an_event_moved_out_of_a_dispatch_dispatches() {
use rdom_core::Event;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("btn");
dom.append_child(root, btn).unwrap();
let fired = Rc::new(Cell::new(0));
let stash = Rc::new(std::cell::RefCell::new(None::<Event>));
{
let (fired, stash) = (fired.clone(), stash.clone());
dom.add_event_listener(btn, "ping", ListenerOptions::default(), move |ctx| {
fired.set(fired.get() + 1);
if stash.borrow().is_none() && fired.get() == 1 {
let moved = std::mem::replace(ctx.event, Event::new("ping"));
*stash.borrow_mut() = Some(moved);
}
})
.unwrap();
}
let (btn_id, s) = (btn, stash.clone());
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5)).on_tick(move |ctx| {
if let Some(moved) = s.borrow_mut().take() {
ctx.queue_dispatch(btn_id, moved);
} else {
ctx.dispatch(btn_id, &mut Event::new("ping"))
.expect("live target");
}
ControlFlow::Continue
});
app.tick();
assert_eq!(fired.get(), 1);
app.tick();
assert_eq!(fired.get(), 2, "the queued event dispatched");
}
#[test]
fn handle_request_redraw_sets_needs_redraw_on_next_iteration() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.draw_if_dirty().unwrap();
assert!(!app.needs_redraw());
let handle = app.handle();
std::thread::spawn(move || {
handle.request_redraw();
})
.join()
.unwrap();
app.drain_handle_signals();
assert!(app.needs_redraw());
}
#[test]
fn handle_quit_sets_should_quit() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
let handle = app.handle();
std::thread::spawn(move || {
handle.quit();
})
.join()
.unwrap();
app.drain_handle_signals();
assert!(app.should_quit());
}
#[test]
fn handle_inject_runs_closure_on_loop_thread() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
let handle = app.handle();
std::thread::spawn(move || {
handle.inject(|ctx: &mut AppContext<'_>| {
let d = ctx.dom.create_element("div");
let r = ctx.dom.root();
let _ = ctx.dom.append_child(r, d);
ctx.request_redraw();
});
})
.join()
.unwrap();
app.drain_handle_injections();
assert!(!app.dirty_roots_snapshot().is_empty());
assert!(app.needs_redraw());
}
#[test]
fn handle_is_send_and_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<crate::AppHandle>();
assert_sync::<crate::AppHandle>();
}
#[test]
fn tab_key_moves_focus_through_tabindex_chain() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("a");
let b = dom.create_element("b");
dom.set_attribute(a, "tabindex", "0").unwrap();
dom.set_attribute(b, "tabindex", "0").unwrap();
dom.append_child(root, a).unwrap();
dom.append_child(root, b).unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(key(KeyCode::Tab));
assert_eq!(app.dom().focused(), Some(a));
app.handle_event(key(KeyCode::Tab));
assert_eq!(app.dom().focused(), Some(b));
app.handle_event(key(KeyCode::Tab));
assert_eq!(app.dom().focused(), Some(a), "wraps");
}
#[test]
fn tab_skips_elements_in_a_display_none_subtree() {
use crate::layout::Display;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("button");
let hidden_box = dom.create_element("div");
let hidden_btn = dom.create_element("button");
let dialog = dom.create_element("dialog");
let dialog_btn = dom.create_element("button");
let c = dom.create_element("button");
dom.append_child(hidden_box, hidden_btn).unwrap();
dom.append_child(dialog, dialog_btn).unwrap();
for n in [a, hidden_box, dialog, c] {
dom.append_child(root, n).unwrap();
}
dom.set_attribute(hidden_box, "class", "gone").unwrap();
let sheet = Stylesheet::new().rule_unchecked(".gone", TuiStyle::new().display(Display::None));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 40, 10));
app.draw_if_dirty().unwrap(); app.handle_event(key(KeyCode::Tab));
assert_eq!(app.dom().focused(), Some(a));
app.handle_event(key(KeyCode::Tab));
assert_eq!(
app.dom().focused(),
Some(c),
"hidden and closed-dialog buttons skipped"
);
app.handle_event(key(KeyCode::Tab));
assert_eq!(app.dom().focused(), Some(a), "wraps past them too");
}
#[test]
fn shift_tab_moves_focus_backward() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("a");
let b = dom.create_element("b");
dom.set_attribute(a, "tabindex", "0").unwrap();
dom.set_attribute(b, "tabindex", "0").unwrap();
dom.append_child(root, a).unwrap();
dom.append_child(root, b).unwrap();
dom.set_focused(Some(b));
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(CtEvent::Key(KeyEvent::new(
KeyCode::Tab,
KeyModifiers::SHIFT,
)));
assert_eq!(app.dom().focused(), Some(a));
}
#[test]
fn tab_key_preventdefault_blocks_focus_move() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let a = dom.create_element("a");
let b = dom.create_element("b");
dom.set_attribute(a, "tabindex", "0").unwrap();
dom.set_attribute(b, "tabindex", "0").unwrap();
dom.append_child(root, a).unwrap();
dom.append_child(root, b).unwrap();
dom.set_focused(Some(a));
dom.add_event_listener(a, "keydown", ListenerOptions::default(), |ctx| {
ctx.event.prevent_default()
})
.unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
app.handle_event(key(KeyCode::Tab));
assert_eq!(
app.dom().focused(),
Some(a),
"prevent_default blocks Tab navigation"
);
}
#[test]
fn mousedown_alone_leaves_collapsed_caret_not_whole_paragraph() {
use crate::layout::Display;
use crossterm::event::{MouseButton, MouseEvent as CtMouseEvent, MouseEventKind};
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let prose = dom.create_element("p");
dom.set_attribute(prose, "tabindex", "0").unwrap();
let t = dom.create_text_node("Terminal UIs should let you select text easily.");
dom.append_child(prose, t).unwrap();
let tail = dom.create_element("span");
dom.append_child(prose, tail).unwrap();
dom.append_child(root, prose).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked("p", TuiStyle::new().width(Size::Flex(1)))
.rule_unchecked("span", TuiStyle::new().display(Display::Inline));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 80, 5));
app.draw_if_dirty().unwrap();
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 0,
modifiers: KeyModifiers::empty(),
}));
let sel = app
.dom()
.selection()
.expect("mousedown on text should produce a selection");
assert!(
sel.is_collapsed(),
"single mousedown must leave a collapsed caret, got anchor={:?} focus={:?}",
sel.anchor,
sel.focus,
);
assert!(
app.router.selection_drag.is_some(),
"text-selection drag is in progress"
);
assert_eq!(app.dom().pointer_capture(), None, "and takes no capture");
}
#[test]
fn mousedown_then_small_drag_stays_within_dragged_range() {
use crate::layout::Display;
use crossterm::event::{MouseButton, MouseEvent as CtMouseEvent, MouseEventKind};
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let prose = dom.create_element("p");
dom.set_attribute(prose, "tabindex", "0").unwrap();
let t = dom.create_text_node("Terminal UIs should let you select text easily.");
dom.append_child(prose, t).unwrap();
let tail = dom.create_element("span");
dom.append_child(prose, tail).unwrap();
dom.append_child(root, prose).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked("p", TuiStyle::new().width(Size::Flex(1)))
.rule_unchecked("span", TuiStyle::new().display(Display::Inline));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 80, 5));
app.draw_if_dirty().unwrap();
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 0,
modifiers: KeyModifiers::empty(),
}));
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 12,
row: 0,
modifiers: KeyModifiers::empty(),
}));
let sel = app.dom().selection().expect("drag produces a selection");
let (lo, hi) = if sel.anchor.offset <= sel.focus.offset {
(sel.anchor.offset, sel.focus.offset)
} else {
(sel.focus.offset, sel.anchor.offset)
};
assert!(
hi - lo <= 4,
"small drag should span ~2 graphemes, got {} bytes from {} to {}",
hi - lo,
lo,
hi,
);
}
#[test]
fn drag_up_over_user_select_none_extends_past_it_not_collapse() {
use crate::layout::{Display, UserSelect};
use crossterm::event::{MouseButton, MouseEvent as CtMouseEvent, MouseEventKind};
use rdom_core::Position;
let mev = |kind, x: u16, y: u16| {
CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
};
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let mk = |dom: &mut TuiDom, txt: &str| -> (NodeId, NodeId) {
let p = dom.create_element("p");
let t = dom.create_text_node(txt);
dom.append_child(p, t).unwrap();
let tail = dom.create_element("span"); dom.append_child(p, tail).unwrap();
(p, t)
};
let (a, t_a) = mk(&mut dom, "AAAA"); let (chrome, _t_chrome) = mk(&mut dom, "NN"); dom.set_attribute(chrome, "class", "chrome").unwrap();
let (p_mid, _t_mid) = mk(&mut dom, "PPPP"); let (b, t_b) = mk(&mut dom, "BBBB"); for n in [a, chrome, p_mid, b] {
dom.append_child(root, n).unwrap();
}
let sheet = Stylesheet::bare()
.rule_unchecked(
"p",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(20)),
)
.rule_unchecked("span", TuiStyle::new().display(Display::Inline))
.rule_unchecked(".chrome", TuiStyle::new().user_select(UserSelect::None));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 20, 6));
app.draw_if_dirty().unwrap();
app.handle_event(mev(MouseEventKind::Down(MouseButton::Left), 3, 3));
app.handle_event(mev(MouseEventKind::Drag(MouseButton::Left), 1, 1));
let sel = app.dom().selection().expect("drag keeps a selection");
assert_ne!(
sel.focus.node, t_b,
"drag up over user-select:none must not collapse back into the anchor block"
);
assert_eq!(sel.focus, Position::new(t_a, 4));
}
#[test]
fn mousedown_focuses_focusable_ancestor() {
use crossterm::event::{MouseButton, MouseEvent as CtMouseEvent, MouseEventKind};
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("btn");
dom.set_attribute(btn, "tabindex", "0").unwrap();
dom.append_child(root, btn).unwrap();
let sheet = Stylesheet::bare().rule_unchecked(
"btn",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(3)),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 20, 10));
app.draw_if_dirty().unwrap();
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 3,
row: 1,
modifiers: KeyModifiers::empty(),
}));
assert_eq!(
app.dom().focused(),
Some(btn),
"mousedown focuses focusable ancestor"
);
}
#[test]
fn handle_injection_can_quit() {
let dom: TuiDom = TuiDom::new();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 20, 5));
let handle = app.handle();
handle.inject(|ctx: &mut AppContext<'_>| ctx.quit());
app.drain_handle_injections();
assert!(app.should_quit());
}
use crate::runtime::selection::clipboard::MemoryClipboard;
fn ctrl(ch: char) -> CtEvent {
CtEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL))
}
fn clipboard_app(
seeded: Option<&str>,
selection_range: Option<(usize, usize)>,
) -> (App<TestBackend>, rdom_core::NodeId) {
let mut dom = TuiDom::new();
let root = dom.root();
let p = dom.create_element("p");
let t = dom.create_text_node("hello world");
dom.append_child(p, t).unwrap();
let span = dom.create_element("span");
dom.append_child(p, span).unwrap();
dom.append_child(root, p).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"p",
TuiStyle::new()
.display(crate::layout::Display::Block)
.width(Size::Fixed(20)),
)
.rule_unchecked(
"span",
TuiStyle::new().display(crate::layout::Display::Inline),
);
if let Some((start, end)) = selection_range {
use rdom_core::{Position, Selection};
dom.set_selection(Some(Selection::new(
Position::new(t, start),
Position::new(t, end),
)));
}
let app = test_app(dom, sheet, Rect::new(0, 0, 40, 10));
let app = match seeded {
Some(s) => app.with_clipboard(Box::new(MemoryClipboard::with_text(s))),
None => app.with_clipboard(Box::new(MemoryClipboard::new())),
};
(app, t)
}
#[test]
fn ctrl_c_with_selection_copies_instead_of_quitting() {
let (mut app, _t) = clipboard_app(None, Some((6, 11)));
app.handle_event(ctrl('c'));
assert!(!app.should_quit());
}
#[test]
fn ctrl_c_without_selection_still_quits() {
let (mut app, _t) = clipboard_app(None, None);
app.handle_event(ctrl('c'));
assert!(app.should_quit());
}
#[test]
fn ctrl_c_with_collapsed_caret_still_quits() {
use rdom_core::{Position, Selection};
let (mut app, t) = clipboard_app(None, None);
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 3))));
app.handle_event(ctrl('c'));
assert!(app.should_quit());
}
#[test]
fn copy_fires_copy_event_with_selection_text_in_detail() {
let (mut app, _t) = clipboard_app(None, Some((6, 11)));
let root = app.dom().root();
let detail = Rc::new(std::cell::RefCell::new(None));
{
let detail = detail.clone();
app.dom_mut()
.add_event_listener(root, "copy", ListenerOptions::default(), move |ctx| {
*detail.borrow_mut() = ctx.event.detail.as_string().map(String::from);
})
.unwrap();
}
app.handle_event(ctrl('c'));
assert_eq!(detail.borrow().as_deref(), Some("world"));
}
#[test]
fn copy_event_prevent_default_suppresses_clipboard_write() {
let (mut app, _t) = clipboard_app(Some("old"), Some((6, 11)));
let root = app.dom().root();
app.dom_mut()
.add_event_listener(root, "copy", ListenerOptions::default(), |ctx| {
ctx.event.prevent_default();
})
.unwrap();
app.handle_event(ctrl('c'));
let pasted = Rc::new(std::cell::RefCell::new(None));
{
let pasted = pasted.clone();
let root = app.dom().root();
app.dom_mut()
.add_event_listener(root, "paste", ListenerOptions::default(), move |ctx| {
*pasted.borrow_mut() = ctx.event.detail.as_string().map(String::from);
})
.unwrap();
}
app.handle_event(ctrl('v'));
assert_eq!(pasted.borrow().as_deref(), Some("old"));
}
#[test]
fn cut_fires_cut_event_with_selection_text() {
let (mut app, _t) = clipboard_app(None, Some((0, 5)));
let root = app.dom().root();
let detail = Rc::new(std::cell::RefCell::new(None));
{
let detail = detail.clone();
app.dom_mut()
.add_event_listener(root, "cut", ListenerOptions::default(), move |ctx| {
*detail.borrow_mut() = ctx.event.detail.as_string().map(String::from);
})
.unwrap();
}
app.handle_event(ctrl('x'));
assert_eq!(detail.borrow().as_deref(), Some("hello"));
}
#[test]
fn paste_fires_on_focused_element_with_clipboard_text() {
let (mut app, _t) = clipboard_app(Some("pasted text"), None);
let root = app.dom().root();
let detail = Rc::new(std::cell::RefCell::new(None));
{
let detail = detail.clone();
app.dom_mut()
.add_event_listener(root, "paste", ListenerOptions::default(), move |ctx| {
*detail.borrow_mut() = ctx.event.detail.as_string().map(String::from);
})
.unwrap();
}
app.handle_event(ctrl('v'));
assert_eq!(detail.borrow().as_deref(), Some("pasted text"));
assert!(!app.should_quit());
}
#[test]
fn paste_with_empty_clipboard_fires_empty_detail() {
let (mut app, _t) = clipboard_app(None, None);
let root = app.dom().root();
let detail = Rc::new(std::cell::RefCell::new(None));
{
let detail = detail.clone();
app.dom_mut()
.add_event_listener(root, "paste", ListenerOptions::default(), move |ctx| {
*detail.borrow_mut() = ctx.event.detail.as_string().map(String::from);
})
.unwrap();
}
app.handle_event(ctrl('v'));
assert_eq!(detail.borrow().as_deref(), Some(""));
}
#[test]
fn cmd_c_also_copies_via_super_modifier() {
let (mut app, _t) = clipboard_app(None, Some((6, 11)));
let root = app.dom().root();
let detail = Rc::new(std::cell::RefCell::new(None));
{
let detail = detail.clone();
app.dom_mut()
.add_event_listener(root, "copy", ListenerOptions::default(), move |ctx| {
*detail.borrow_mut() = ctx.event.detail.as_string().map(String::from);
})
.unwrap();
}
app.handle_event(CtEvent::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::SUPER,
)));
assert_eq!(detail.borrow().as_deref(), Some("world"));
assert!(!app.should_quit());
}
use std::panic::{self, AssertUnwindSafe};
#[test]
fn handler_panic_propagates_from_handle_event() {
let mut dom = TuiDom::new();
let root = dom.root();
dom.add_event_listener(root, "keydown", ListenerOptions::default(), |_| {
panic!("handler boom");
})
.unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 10, 5));
let outcome = panic::catch_unwind(AssertUnwindSafe(|| {
app.handle_event(key(KeyCode::Char('x')))
}));
assert!(outcome.is_err(), "handler panic should propagate");
let payload = outcome.err().unwrap();
let msg = payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("");
assert!(
msg.contains("handler boom"),
"expected panic message to surface, got {msg:?}"
);
}
#[test]
fn dispatch_after_handler_panic_keeps_listener_and_dom_usable() {
let mut dom = TuiDom::new();
let root = dom.root();
let survivor_fired = Rc::new(Cell::new(false));
dom.add_event_listener(root, "keydown", ListenerOptions::default(), |_| {
panic!("first");
})
.unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 10, 5));
let listeners_before = app.dom().listener_count(root);
let first = panic::catch_unwind(AssertUnwindSafe(|| {
app.handle_event(key(KeyCode::Char('x')));
}));
assert!(first.is_err(), "the bomb must fire");
assert_eq!(
app.dom().listener_count(root),
listeners_before,
"a panicking listener stays registered"
);
{
let fired = survivor_fired.clone();
app.dom_mut()
.add_event_listener(
root,
"custom-probe",
ListenerOptions::default(),
move |_| {
fired.set(true);
},
)
.unwrap();
}
app.handle_event(CtEvent::Resize(20, 10));
let mut probe = rdom_core::Event::new("custom-probe");
app.dom_mut().dispatch_event(root, &mut probe).unwrap();
assert!(
survivor_fired.get(),
"the Dom dispatches normally after the panic"
);
let second = panic::catch_unwind(AssertUnwindSafe(|| {
app.handle_event(key(KeyCode::Char('x')))
}));
assert!(
second.is_err(),
"the listener is still there and still fires"
);
}
#[test]
fn terminal_guard_drop_runs_leave_tui_mode() {
use crate::render::TerminalGuard;
{
let _g = TerminalGuard::new();
}
let mut g = TerminalGuard::new();
g.disarm();
drop(g);
}
#[test]
fn panic_hook_install_is_idempotent() {
use crate::runtime::app::panic_hook;
panic_hook::install();
assert!(panic_hook::is_installed());
panic_hook::install();
panic_hook::install();
assert!(panic_hook::is_installed());
}
use rdom_core::{Position, Selection};
fn editable_clipboard_app(
text: &str,
seed: &str,
) -> (App<TestBackend>, rdom_core::NodeId, rdom_core::NodeId) {
let mut dom = TuiDom::new();
let root = dom.root();
let p = dom.create_element("p");
dom.set_attribute(p, "contenteditable", "true").unwrap();
let t = dom.create_text_node(text);
dom.append_child(p, t).unwrap();
let span = dom.create_element("span");
dom.append_child(p, span).unwrap();
dom.append_child(root, p).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"p",
TuiStyle::new()
.display(crate::layout::Display::Block)
.width(Size::Fixed(40)),
)
.rule_unchecked(
"span",
TuiStyle::new().display(crate::layout::Display::Inline),
);
let app = test_app(dom, sheet, Rect::new(0, 0, 60, 10));
let app = app.with_clipboard(Box::new(MemoryClipboard::with_text(seed)));
(app, p, t)
}
#[test]
fn ctrl_v_on_editable_inserts_clipboard_text_at_caret() {
let (mut app, p, t) = editable_clipboard_app("hello ", "world");
app.dom_mut().set_focused(Some(p));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 6))));
app.handle_event(ctrl('v'));
assert_eq!(app.dom().node(t).node_value(), Some("hello world"));
let sel = app.dom().selection().unwrap();
assert!(sel.is_collapsed());
assert_eq!(sel.focus, Position::new(t, 11));
}
#[test]
fn ctrl_v_on_editable_replaces_range_when_selection_is_non_collapsed() {
let (mut app, p, t) = editable_clipboard_app("hello WORLD", "there");
app.dom_mut().set_focused(Some(p));
app.dom_mut().set_selection(Some(Selection::new(
Position::new(t, 6),
Position::new(t, 11),
)));
app.handle_event(ctrl('v'));
assert_eq!(app.dom().node(t).node_value(), Some("hello there"));
}
#[test]
fn ctrl_v_paste_event_prevent_default_blocks_insert() {
let (mut app, p, t) = editable_clipboard_app("hello ", "world");
app.dom_mut().set_focused(Some(p));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 6))));
app.dom_mut()
.add_event_listener(p, "paste", ListenerOptions::default(), |ctx| {
ctx.event.prevent_default();
})
.unwrap();
app.handle_event(ctrl('v'));
assert_eq!(app.dom().node(t).node_value(), Some("hello "));
}
#[test]
fn ctrl_v_on_non_editable_fires_event_but_does_not_mutate_dom() {
let mut dom = TuiDom::new();
let root = dom.root();
let p = dom.create_element("p"); let t = dom.create_text_node("hello");
dom.append_child(p, t).unwrap();
dom.append_child(root, p).unwrap();
dom.set_focused(Some(p));
let detail = Rc::new(std::cell::RefCell::new(None));
{
let detail = detail.clone();
dom.add_event_listener(p, "paste", ListenerOptions::default(), move |ctx| {
*detail.borrow_mut() = ctx.event.detail.as_string().map(String::from);
})
.unwrap();
}
let sheet = Stylesheet::bare();
let mut app = test_app(dom, sheet, Rect::new(0, 0, 30, 5));
app = app.with_clipboard(Box::new(MemoryClipboard::with_text("X")));
app.handle_event(ctrl('v'));
assert_eq!(detail.borrow().as_deref(), Some("X"));
assert_eq!(app.dom().node(t).node_value(), Some("hello"));
}
#[test]
fn ctrl_x_on_editable_deletes_selection_and_copies_to_clipboard() {
let (mut app, p, t) = editable_clipboard_app("hello world", "");
app.dom_mut().set_focused(Some(p));
app.dom_mut().set_selection(Some(Selection::new(
Position::new(t, 6),
Position::new(t, 11),
)));
app.handle_event(ctrl('x'));
assert_eq!(app.dom().node(t).node_value(), Some("hello "));
let t2 = t;
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t2, 6))));
app.handle_event(ctrl('v'));
assert_eq!(app.dom().node(t2).node_value(), Some("hello world"));
}
#[test]
fn ctrl_x_on_non_editable_copies_but_does_not_delete() {
let mut dom = TuiDom::new();
let root = dom.root();
let p = dom.create_element("p");
let t = dom.create_text_node("hello world");
dom.append_child(p, t).unwrap();
dom.append_child(root, p).unwrap();
dom.set_focused(Some(p));
dom.set_selection(Some(Selection::new(
Position::new(t, 6),
Position::new(t, 11),
)));
let sheet = Stylesheet::bare();
let mut app = test_app(dom, sheet, Rect::new(0, 0, 30, 5));
app = app.with_clipboard(Box::new(MemoryClipboard::new()));
app.handle_event(ctrl('x'));
assert_eq!(app.dom().node(t).node_value(), Some("hello world"));
let sel = app.dom().selection().unwrap();
assert_eq!(sel.anchor, Position::new(t, 6));
assert_eq!(sel.focus, Position::new(t, 11));
}
#[test]
fn ctrl_x_prevent_default_blocks_both_copy_and_delete() {
let (mut app, p, t) = editable_clipboard_app("hello world", "");
app.dom_mut().set_focused(Some(p));
app.dom_mut().set_selection(Some(Selection::new(
Position::new(t, 6),
Position::new(t, 11),
)));
app.dom_mut()
.add_event_listener(p, "cut", ListenerOptions::default(), |ctx| {
ctx.event.prevent_default();
})
.unwrap();
app.handle_event(ctrl('x'));
assert_eq!(app.dom().node(t).node_value(), Some("hello world"));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 0))));
let detail = Rc::new(std::cell::RefCell::new(None));
{
let detail = detail.clone();
app.dom_mut()
.add_event_listener(p, "paste", ListenerOptions::default(), move |ctx| {
*detail.borrow_mut() = ctx.event.detail.as_string().map(String::from);
})
.unwrap();
}
app.handle_event(ctrl('v'));
assert_eq!(detail.borrow().as_deref(), Some(""));
}
#[test]
fn paste_into_editable_is_undoable() {
let (mut app, p, t) = editable_clipboard_app("hello", " world");
app.dom_mut().set_focused(Some(p));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 5))));
app.handle_event(ctrl('v'));
assert_eq!(app.dom().node(t).node_value(), Some("hello world"));
app.handle_event(ctrl('z'));
assert_eq!(app.dom().node(t).node_value(), Some("hello"));
}
#[test]
fn cut_on_editable_is_undoable() {
let (mut app, p, t) = editable_clipboard_app("hello world", "");
app.dom_mut().set_focused(Some(p));
app.dom_mut().set_selection(Some(Selection::new(
Position::new(t, 6),
Position::new(t, 11),
)));
app.handle_event(ctrl('x'));
assert_eq!(app.dom().node(t).node_value(), Some("hello "));
app.handle_event(ctrl('z'));
assert_eq!(app.dom().node(t).node_value(), Some("hello world"));
}
fn input_app(initial: &str) -> (App<TestBackend>, NodeId, NodeId) {
let mut dom = TuiDom::new();
let root = dom.root();
let input = dom.create_element("input");
dom.set_attribute(input, "value", initial).unwrap();
dom.append_child(root, input).unwrap();
let app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 40, 5));
let t = app
.dom()
.node(input)
.child_nodes()
.next()
.map(|c| c.id())
.unwrap();
(app, input, t)
}
fn textarea_app(initial: &str) -> (App<TestBackend>, NodeId, NodeId) {
let mut dom = TuiDom::new();
let root = dom.root();
let ta = dom.create_element("textarea");
let t = dom.create_text_node(initial);
dom.append_child(ta, t).unwrap();
dom.append_child(root, ta).unwrap();
let app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 40, 5));
(app, ta, t)
}
#[test]
fn enter_on_input_does_not_insert_newline() {
let (mut app, input, t) = input_app("hello");
app.dom_mut().set_focused(Some(input));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 5))));
app.handle_event(key(KeyCode::Enter));
assert_eq!(app.dom().node(t).node_value(), Some("hello"));
assert_eq!(app.dom().node(input).get_attribute("value"), Some("hello"));
}
#[test]
fn enter_on_textarea_inserts_newline_at_caret() {
let (mut app, ta, t) = textarea_app("ab");
app.dom_mut().set_focused(Some(ta));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 1))));
app.handle_event(key(KeyCode::Enter));
assert_eq!(app.dom().node(t).node_value(), Some("a\nb"));
}
#[test]
fn enter_on_contenteditable_paragraph_inserts_newline() {
let (mut app, p, t) = editable_clipboard_app("ab", "");
app.dom_mut().set_focused(Some(p));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 1))));
app.handle_event(key(KeyCode::Enter));
assert_eq!(app.dom().node(t).node_value(), Some("a\nb"));
}
#[test]
fn typing_in_input_mirrors_value_attribute_per_keystroke() {
let (mut app, input, t) = input_app("");
app.dom_mut().set_focused(Some(input));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 0))));
for ch in ['h', 'i'] {
app.handle_event(key(KeyCode::Char(ch)));
}
assert_eq!(app.dom().node(input).get_attribute("value"), Some("hi"));
}
#[test]
fn typing_in_readonly_input_does_not_change_value() {
let (mut app, input, t) = input_app("hi");
app.dom_mut().set_attribute(input, "readonly", "").unwrap();
app.dom_mut().set_focused(Some(input));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 2))));
app.handle_event(key(KeyCode::Char('!')));
assert_eq!(app.dom().node(t).node_value(), Some("hi"));
assert_eq!(app.dom().node(input).get_attribute("value"), Some("hi"));
}
#[test]
fn ctrl_enter_on_textarea_does_not_insert_newline() {
let (mut app, ta, t) = textarea_app("ab");
app.dom_mut().set_focused(Some(ta));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 2))));
app.handle_event(CtEvent::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::CONTROL,
)));
assert_eq!(app.dom().node(t).node_value(), Some("ab"));
}
#[test]
fn transitionend_event_carries_typed_transition_detail() {
use crate::runtime::animation::{AnimatedProp, PendingEvent, TransitionEventKind};
use std::cell::RefCell;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 10, 3));
let captured: Rc<RefCell<Option<(String, f64)>>> = Rc::new(RefCell::new(None));
{
let captured = captured.clone();
app.dom_mut()
.add_event_listener(
div,
"transitionend",
ListenerOptions::default(),
move |ctx| {
let t = ctx
.event
.detail
.as_transition()
.expect("transition event must carry typed detail");
*captured.borrow_mut() = Some((t.property_name.clone(), t.elapsed));
},
)
.unwrap();
}
app.animations_mut_for_test()
.queue_event_for_test(PendingEvent {
slot: crate::ext::StyleSlot::Host,
node: div,
kind: TransitionEventKind::End,
property: AnimatedProp::Fg,
elapsed_seconds: 0.1,
});
app.dispatch_animation_events_for_test();
let result = captured.borrow();
let (property_name, elapsed) = result.as_ref().expect("listener fired");
assert_eq!(property_name, "color");
assert!(
(elapsed - 0.1).abs() < 1e-6,
"elapsed should round-trip; got {elapsed}"
);
}
#[test]
fn transitionstart_and_transitioncancel_also_carry_typed_detail() {
use crate::runtime::animation::{AnimatedProp, PendingEvent, TransitionEventKind};
use std::cell::RefCell;
let kinds = [
(TransitionEventKind::Start, "transitionstart"),
(TransitionEventKind::Cancel, "transitioncancel"),
];
for (kind, event_name) in kinds {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let div = dom.create_element("div");
dom.append_child(root, div).unwrap();
let mut app = test_app(dom, Stylesheet::bare(), Rect::new(0, 0, 10, 3));
let captured: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
{
let captured = captured.clone();
app.dom_mut()
.add_event_listener(div, event_name, ListenerOptions::default(), move |ctx| {
let t = ctx
.event
.detail
.as_transition()
.unwrap_or_else(|| panic!("{event_name} must carry typed detail"));
*captured.borrow_mut() = Some(t.property_name.clone());
})
.unwrap();
}
app.animations_mut_for_test()
.queue_event_for_test(PendingEvent {
slot: crate::ext::StyleSlot::Host,
node: div,
kind,
property: AnimatedProp::Bg,
elapsed_seconds: 0.0,
});
app.dispatch_animation_events_for_test();
assert_eq!(captured.borrow().as_deref(), Some("background-color"));
}
}
mod canvas_interactive_spike {
use super::*;
use crate::accessors::TuiAccessors;
use std::cell::RefCell;
fn canvas_app() -> (App<TestBackend>, NodeId) {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let cv = dom.create_element("canvas");
dom.set_attribute(cv, "tabindex", "0").unwrap();
dom.append_child(root, cv).unwrap();
let sheet = Stylesheet::bare().rule_unchecked(
"canvas",
TuiStyle::new()
.width(Size::Fixed(20))
.height(Size::Fixed(6)),
);
let app = test_app(dom, sheet, Rect::new(0, 0, 40, 10));
(app, cv)
}
#[test]
fn canvas_with_tabindex_is_focusable_programmatically() {
let (mut app, cv) = canvas_app();
assert!(crate::runtime::focus::tabindex::is_focusable(app.dom(), cv));
crate::runtime::focus::focus_node(app.dom_mut(), Some(cv));
assert_eq!(app.dom().focused(), Some(cv));
}
#[test]
fn canvas_receives_focus_via_tab_key() {
let (mut app, cv) = canvas_app();
app.draw_if_dirty().unwrap();
assert_eq!(app.dom().focused(), None);
app.handle_event(key(KeyCode::Tab));
assert_eq!(
app.dom().focused(),
Some(cv),
"Tab should land focus on the only focusable element"
);
}
#[test]
fn canvas_receives_focus_via_click() {
let (mut app, cv) = canvas_app();
app.draw_if_dirty().unwrap();
for ev in click_at(3, 2) {
app.handle_event(ev);
}
assert_eq!(app.dom().focused(), Some(cv));
}
#[test]
fn focused_canvas_receives_keydown_with_key_and_modifiers() {
let (mut app, cv) = canvas_app();
let seen: Rc<RefCell<Vec<(String, bool)>>> = Rc::new(RefCell::new(Vec::new()));
let s = seen.clone();
app.dom_mut()
.add_event_listener(cv, "keydown", ListenerOptions::default(), move |ctx| {
let kb = ctx
.event
.detail
.as_keyboard()
.expect("keydown carries keyboard detail");
s.borrow_mut().push((kb.key.clone(), kb.modifiers.shift));
})
.unwrap();
app.draw_if_dirty().unwrap();
crate::runtime::focus::focus_node(app.dom_mut(), Some(cv));
app.handle_event(CtEvent::Key(KeyEvent::new(
KeyCode::Down,
KeyModifiers::empty(),
)));
app.handle_event(CtEvent::Key(KeyEvent::new(
KeyCode::Char('j'),
KeyModifiers::SHIFT,
)));
let got = seen.borrow().clone();
assert_eq!(
got,
vec![("ArrowDown".to_string(), false), ("j".to_string(), true)],
"listener must see translated key + shift modifier"
);
}
#[test]
fn canvas_keydown_prevent_default_intercepts_tab() {
let (mut app, cv) = canvas_app();
app.dom_mut()
.add_event_listener(cv, "keydown", ListenerOptions::default(), move |ctx| {
let kb = ctx.event.detail.as_keyboard().unwrap();
if kb.key == "Tab" {
ctx.event.prevent_default();
}
})
.unwrap();
app.draw_if_dirty().unwrap();
crate::runtime::focus::focus_node(app.dom_mut(), Some(cv));
assert_eq!(app.dom().focused(), Some(cv));
app.handle_event(key(KeyCode::Tab));
assert_eq!(
app.dom().focused(),
Some(cv),
"prevent_default on Tab must suppress focus navigation"
);
}
#[test]
fn focused_canvas_receives_arrow_keys() {
let (mut app, cv) = canvas_app();
let count = Rc::new(Cell::new(0u32));
let c = count.clone();
app.dom_mut()
.add_event_listener(cv, "keydown", ListenerOptions::default(), move |ctx| {
if ctx
.event
.detail
.as_keyboard()
.unwrap()
.key
.starts_with("Arrow")
{
c.set(c.get() + 1);
}
})
.unwrap();
app.draw_if_dirty().unwrap();
crate::runtime::focus::focus_node(app.dom_mut(), Some(cv));
for code in [KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right] {
app.handle_event(key(code));
}
assert_eq!(count.get(), 4);
}
#[test]
fn canvas_click_delivers_global_coords_convertible_to_local() {
let (mut app, cv) = canvas_app();
let pos: Rc<RefCell<Option<(i32, i32)>>> = Rc::new(RefCell::new(None));
let p = pos.clone();
app.dom_mut()
.add_event_listener(cv, "click", ListenerOptions::default(), move |ctx| {
let m = ctx.event.detail.as_mouse().expect("mouse detail");
*p.borrow_mut() = Some((m.client_x, m.client_y));
})
.unwrap();
app.draw_if_dirty().unwrap();
for ev in click_at(5, 3) {
app.handle_event(ev);
}
let (gx, gy) = pos.borrow().expect("click must reach canvas");
assert_eq!(
(gx, gy),
(5, 3),
"client_x/client_y are GLOBAL screen cells"
);
let rect = app
.dom()
.node(cv)
.bounding_rect()
.expect("canvas has a rect");
let local_x = gx - rect.x;
let local_y = gy - rect.y;
assert_eq!(
(local_x, local_y),
(5, 3),
"with canvas at origin, local == global; rect.x/y is the subtractable origin"
);
}
#[test]
fn focused_canvas_receives_wheel_with_delta() {
let (mut app, cv) = canvas_app();
let dy: Rc<RefCell<Vec<i32>>> = Rc::new(RefCell::new(Vec::new()));
let d = dy.clone();
app.dom_mut()
.add_event_listener(cv, "wheel", ListenerOptions::default(), move |ctx| {
let m = ctx.event.detail.as_mouse().unwrap();
d.borrow_mut().push(m.delta_y);
})
.unwrap();
app.draw_if_dirty().unwrap();
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::ScrollDown,
column: 5,
row: 3,
modifiers: KeyModifiers::empty(),
}));
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::ScrollUp,
column: 5,
row: 3,
modifiers: KeyModifiers::empty(),
}));
assert_eq!(*dy.borrow(), vec![1, -1], "wheel delta_y reaches canvas");
}
#[test]
fn canvas_receives_mousedown() {
let (mut app, cv) = canvas_app();
let hits = Rc::new(Cell::new(0u32));
let h = hits.clone();
app.dom_mut()
.add_event_listener(cv, "mousedown", ListenerOptions::default(), move |_| {
h.set(h.get() + 1);
})
.unwrap();
app.draw_if_dirty().unwrap();
for ev in click_at(4, 2) {
app.handle_event(ev);
}
assert_eq!(hits.get(), 1);
}
#[test]
fn nested_canvas_bounding_rect_is_absolute_screen_coords() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let outer = dom.create_element("div");
let inner = dom.create_element("div");
let cv = dom.create_element("canvas");
dom.set_attribute(cv, "tabindex", "0").unwrap();
dom.append_child(root, outer).unwrap();
dom.append_child(outer, inner).unwrap();
dom.append_child(inner, cv).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"div",
TuiStyle::new().padding(crate::layout::Padding::all(2)),
)
.rule_unchecked(
"canvas",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(4)),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 40, 20));
app.draw_if_dirty().unwrap();
let rect = app.dom().node(cv).bounding_rect().unwrap();
assert_eq!(
(rect.x, rect.y),
(4, 4),
"bounding_rect returns absolute screen coords, not parent-relative"
);
let local_x = 4 - rect.x;
let local_y = 4 - rect.y;
assert_eq!((local_x, local_y), (0, 0));
}
}
#[test]
fn dropping_focused_node_in_handler_does_not_panic() {
use rdom_core::ListenerOptions;
let mut dom = TuiDom::new();
let root = dom.root();
let btn = dom.create_element("button");
dom.append_child(root, btn).unwrap();
dom.add_event_listener(btn, "keydown", ListenerOptions::default(), move |ctx| {
if let Some(n) = ctx.dom.focused() {
let _ = ctx.dom.drop_subtree(n);
}
})
.unwrap();
let mut app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 20, 5));
app.dom_mut().set_focused(Some(btn));
app.handle_event(key(KeyCode::Char('a'))); assert!(
!app.dom().contains(btn),
"the focused node was removed in the handler"
);
}
#[test]
fn dropping_a_sibling_then_a_dirtied_sibling_does_not_panic() {
let mut dom = TuiDom::new();
let root = dom.root();
let host = dom.create_element("host");
dom.append_child(root, host).unwrap();
let a = dom.create_element("a");
dom.append_child(host, a).unwrap();
let b = dom.create_element("b");
dom.append_child(host, b).unwrap();
let mut app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 20, 5));
app.draw_if_dirty().unwrap();
app.dom_mut().drop_subtree(a).unwrap();
app.dom_mut().drop_subtree(b).unwrap();
app.draw_if_dirty().unwrap(); assert!(!app.dom().contains(b));
}
#[test]
fn advance_runs_injected_closures_before_drawing() {
let mut app = test_app(TuiDom::new(), Stylesheet::bare(), Rect::new(0, 0, 10, 3));
app.draw_if_dirty().unwrap();
app.handle().inject(|ctx| {
ctx.push_stylesheet(Stylesheet::bare());
});
app.advance(0).unwrap();
assert_eq!(app.style_sheets().len(), 2, "the injected push ran");
assert!(!app.needs_redraw(), "and the frame it dirtied was drawn");
}
#[test]
fn advance_drives_a_scheduler_interval_deterministically() {
use crate::runtime::timers::TuiTimers;
use std::cell::Cell;
use std::rc::Rc;
let mut dom = TuiDom::new();
let root = dom.root();
let el = dom.create_element("div");
dom.append_child(root, el).unwrap();
let ticks = Rc::new(Cell::new(0u32));
let t = ticks.clone();
dom.add_event_listener(el, "keydown", ListenerOptions::default(), move |ctx| {
let t = t.clone();
ctx.set_interval(
move |_| {
t.set(t.get() + 1);
true },
50,
);
})
.unwrap();
let mut app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 10, 3));
app.dom_mut().set_focused(Some(el));
app.handle_event(key(KeyCode::Char('a'))); assert_eq!(ticks.get(), 0, "interval hasn't come due yet");
app.advance(50).unwrap();
assert_eq!(ticks.get(), 1, "one period → one tick");
app.advance(50).unwrap();
assert_eq!(ticks.get(), 2);
app.advance(50).unwrap();
assert_eq!(ticks.get(), 3, "deterministic, one tick per period");
}
#[test]
fn listener_reached_from_a_timer_callback_can_schedule_timers() {
use crate::runtime::timers::TuiTimers;
use std::cell::Cell;
use std::rc::Rc;
let mut dom = TuiDom::new();
let root = dom.root();
let el = dom.create_element("div");
dom.append_child(root, el).unwrap();
let inner_fired = Rc::new(Cell::new(0u32));
{
let inner_fired = inner_fired.clone();
dom.add_event_listener(el, "probe", ListenerOptions::default(), move |ctx| {
let inner_fired = inner_fired.clone();
ctx.set_timeout(move |_| inner_fired.set(inner_fired.get() + 1), 10);
})
.unwrap();
}
dom.add_event_listener(el, "keydown", ListenerOptions::default(), move |ctx| {
ctx.set_timeout(
move |tctx| {
let mut probe = rdom_core::Event::new("probe");
tctx.dom.dispatch_event(el, &mut probe).unwrap();
},
10,
);
})
.unwrap();
let mut app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 10, 3));
app.dom_mut().set_focused(Some(el));
app.handle_event(key(KeyCode::Char('a'))); app.advance(10).unwrap(); assert_eq!(inner_fired.get(), 0);
app.advance(10).unwrap(); assert_eq!(inner_fired.get(), 1);
}
#[test]
fn listener_reached_from_an_injected_dispatch_can_schedule_timers() {
use crate::runtime::timers::TuiTimers;
use std::cell::Cell;
use std::rc::Rc;
let mut dom = TuiDom::new();
let root = dom.root();
let el = dom.create_element("div");
dom.append_child(root, el).unwrap();
let fired = Rc::new(Cell::new(0u32));
{
let fired = fired.clone();
dom.add_event_listener(el, "probe", ListenerOptions::default(), move |ctx| {
let fired = fired.clone();
ctx.set_timeout(move |_| fired.set(fired.get() + 1), 5);
})
.unwrap();
}
let mut app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 10, 3));
let handle = app.handle();
handle.inject(move |ctx: &mut AppContext<'_>| {
ctx.dispatch(el, &mut rdom_core::Event::new("probe"))
.expect("live target");
});
app.drain_handle_injections();
app.advance(5).unwrap();
assert_eq!(fired.get(), 1);
}
#[test]
fn typing_past_the_bottom_of_a_textarea_scrolls_the_caret_into_view() {
use crate::layout::{Overflow, Size, WhiteSpace};
use rdom_style::Stylesheet;
let mut dom = TuiDom::new();
let root = dom.root();
let ta = dom.create_element("textarea");
let t = dom.create_text_node("a\nb");
dom.append_child(ta, t).unwrap();
dom.append_child(root, ta).unwrap();
let sheet = Stylesheet::new().rule_unchecked(
"textarea",
TuiStyle::new()
.height(Size::Fixed(2))
.width(Size::Fixed(20))
.white_space(WhiteSpace::Pre)
.overflow_y(Overflow::Auto),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 40, 6));
app.draw_if_dirty().unwrap(); app.dom_mut().set_focused(Some(ta));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 3))));
for _ in 0..3 {
app.handle_event(key(KeyCode::Enter));
app.draw_if_dirty().unwrap();
}
assert_eq!(app.dom().node(t).node_value(), Some("a\nb\n\n\n"));
let ext = app.dom().node(ta).ext().unwrap();
assert_eq!(ext.scroll_content_height, 5, "five lines of content");
assert!(
ext.scroll_y >= 3,
"scrolled so the caret row (line 4) is inside the 2-row port, scroll_y = {}",
ext.scroll_y
);
let (_, caret_y) =
crate::runtime::editing::caret::cell_of_position(app.dom(), Position::new(t, 6)).unwrap();
let box_y = ext.layout.y;
assert!(
(caret_y as i32) >= box_y && (caret_y as i32) < box_y + ext.layout.height as i32,
"caret row {caret_y} inside box at {box_y}+{}",
ext.layout.height
);
}
#[test]
fn checkbox_flips_before_click_dispatch_and_reverts_when_canceled() {
use std::cell::Cell;
use std::rc::Rc;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let cb = dom.create_element("input");
dom.set_attribute(cb, "type", "checkbox").unwrap();
dom.append_child(root, cb).unwrap();
let seen_checked = Rc::new(Cell::new(None));
{
let seen = seen_checked.clone();
dom.add_event_listener(cb, "click", ListenerOptions::default(), move |ctx| {
seen.set(Some(ctx.dom.node(cb).has_attribute("checked")));
})
.unwrap();
}
let mut app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 20, 3));
let mut click = rdom_core::Event::new("click");
click.bubbles = true;
click.cancelable = true;
app.dom_mut().dispatch_event(cb, &mut click).unwrap();
assert_eq!(
seen_checked.get(),
Some(true),
"listener sees the flipped state"
);
assert!(app.dom().node(cb).has_attribute("checked"));
app.dom_mut()
.add_event_listener(cb, "click", ListenerOptions::default(), |ctx| {
ctx.event.prevent_default();
})
.unwrap();
let mut click = rdom_core::Event::new("click");
click.bubbles = true;
click.cancelable = true;
app.dom_mut().dispatch_event(cb, &mut click).unwrap();
assert!(
app.dom().node(cb).has_attribute("checked"),
"canceled click: still checked"
);
}
#[test]
fn double_click_detection_uses_the_scheduler_clock() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use std::cell::Cell;
use std::rc::Rc;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let el = dom.create_element("div");
dom.append_child(root, el).unwrap();
let dbl = Rc::new(Cell::new(0u32));
{
let dbl = dbl.clone();
dom.add_event_listener(el, "dblclick", ListenerOptions::default(), move |_| {
dbl.set(dbl.get() + 1);
})
.unwrap();
}
let sheet = Stylesheet::bare().rule_unchecked(
"div",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(3)),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 20, 5));
app.draw_if_dirty().unwrap();
let mouse = |kind, x: u16, y: u16| {
CtEvent::Mouse(MouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
};
let click = |app: &mut App<TestBackend>| {
app.handle_event(mouse(MouseEventKind::Down(MouseButton::Left), 3, 1));
app.handle_event(mouse(MouseEventKind::Up(MouseButton::Left), 3, 1));
};
click(&mut app);
app.advance(600).unwrap(); click(&mut app);
assert_eq!(
dbl.get(),
0,
"no dblclick: the clicks are 600 ms apart on the scheduler clock"
);
click(&mut app); assert_eq!(dbl.get(), 1);
}
#[test]
fn checkbox_activation_survives_stop_propagation() {
use std::cell::Cell;
use std::rc::Rc;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let cb = dom.create_element("input");
dom.set_attribute(cb, "type", "checkbox").unwrap();
dom.append_child(root, cb).unwrap();
let changes = Rc::new(Cell::new(0));
{
let changes = changes.clone();
dom.add_event_listener(cb, "change", ListenerOptions::default(), move |_| {
changes.set(changes.get() + 1);
})
.unwrap();
}
dom.add_event_listener(cb, "click", ListenerOptions::default(), |ctx| {
ctx.event.stop_propagation();
})
.unwrap();
let mut app = test_app(dom, Stylesheet::new(), Rect::new(0, 0, 20, 3));
let mut click = rdom_core::Event::new("click");
click.bubbles = true;
click.cancelable = true;
app.dom_mut().dispatch_event(cb, &mut click).unwrap();
assert!(
app.dom().node(cb).has_attribute("checked"),
"flipped despite stopPropagation"
);
assert_eq!(changes.get(), 1, "change fired despite stopPropagation");
}
#[test]
fn drag_autoscroll_scrolls_a_container_held_at_the_edge() {
use crate::layout::Overflow;
use crate::node::TuiNodeExt;
let mev = |kind, x: u16, y: u16| {
CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
};
let mut dom = TuiDom::new();
let root = dom.root();
let scroller = dom.create_element("scroller");
dom.append_child(root, scroller).unwrap();
for _ in 0..10 {
let r = dom.create_element("r");
dom.append_child(scroller, r).unwrap(); }
dom.add_event_listener(
scroller,
"mousedown",
ListenerOptions::default(),
move |ctx| {
ctx.dom.set_pointer_capture(scroller).unwrap();
ctx.dom.set_drag_autoscroll(true);
ctx.event.prevent_default(); },
)
.unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"scroller",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(3))
.overflow(Overflow::Auto),
)
.rule_unchecked("r", TuiStyle::new().height(Size::Fixed(1)));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 12, 6));
app.draw_if_dirty().unwrap();
let scroll_y = |app: &App<TestBackend>, id: NodeId| -> usize {
app.dom()
.node(id)
.tui_ext()
.map(|e| e.scroll_y)
.unwrap_or(0)
};
app.handle_event(mev(MouseEventKind::Down(MouseButton::Left), 2, 1));
app.handle_event(mev(MouseEventKind::Drag(MouseButton::Left), 2, 2));
let before = scroll_y(&app, scroller);
app.advance(50).unwrap();
let after1 = scroll_y(&app, scroller);
assert!(
after1 > before,
"held at the bottom edge autoscrolls down ({before} -> {after1})"
);
app.advance(50).unwrap();
assert!(
scroll_y(&app, scroller) > after1,
"keeps scrolling each period while held"
);
app.handle_event(mev(MouseEventKind::Up(MouseButton::Left), 2, 2));
let at_release = scroll_y(&app, scroller);
app.advance(500).unwrap();
assert_eq!(
scroll_y(&app, scroller),
at_release,
"no autoscroll after mouseup releases the capture"
);
}
#[test]
fn text_selection_drag_past_edge_autoscrolls_and_keeps_extending() {
use crate::layout::{Display, Overflow};
use crate::node::TuiNodeExt;
let mev = |kind, x: u16, y: u16| {
CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
};
let mut dom = TuiDom::new();
let root = dom.root();
let scroller = dom.create_element("div");
dom.append_child(root, scroller).unwrap();
let p = dom.create_element("p");
let text: String = (0..40)
.map(|i| format!("w{}", i % 10))
.collect::<Vec<_>>()
.join(" ");
let t = dom.create_text_node(&text);
dom.append_child(p, t).unwrap();
let tail = dom.create_element("span"); dom.append_child(p, tail).unwrap();
dom.append_child(scroller, p).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"div",
TuiStyle::new()
.width(Size::Fixed(12))
.height(Size::Fixed(3))
.overflow(Overflow::Auto),
)
.rule_unchecked(
"p",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(12)),
)
.rule_unchecked("span", TuiStyle::new().display(Display::Inline));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 14, 6));
app.draw_if_dirty().unwrap();
let scroll_y = |app: &App<TestBackend>| -> usize {
app.dom()
.node(scroller)
.tui_ext()
.map(|e| e.scroll_y)
.unwrap_or(0)
};
let focus_off = |app: &App<TestBackend>| -> usize {
app.dom().selection().map(|s| s.focus.offset).unwrap_or(0)
};
app.handle_event(mev(MouseEventKind::Down(MouseButton::Left), 1, 0));
assert!(
app.dom().selection().is_some(),
"mousedown on text starts a selection"
);
assert!(
app.router.selection_drag.is_some(),
"text-selection drag is in progress"
);
assert_eq!(app.dom().pointer_capture(), None, "and takes no capture");
app.handle_event(mev(MouseEventKind::Drag(MouseButton::Left), 1, 2));
let focus_before = focus_off(&app);
for _ in 0..8 {
app.advance(50).unwrap();
}
assert!(
scroll_y(&app) > 0,
"held at the bottom edge autoscrolls the container"
);
assert!(
focus_off(&app) > focus_before,
"the selection keeps extending into revealed lines \
(focus {} should advance past {focus_before})",
focus_off(&app)
);
app.handle_event(mev(MouseEventKind::Up(MouseButton::Left), 1, 2));
let focus_at_release = focus_off(&app);
app.advance(500).unwrap();
assert_eq!(
focus_off(&app),
focus_at_release,
"no further extension after release"
);
}
#[test]
fn autoscroll_continues_after_the_anchor_block_scrolls_out_of_view() {
use crate::layout::{Display, Overflow};
use crate::node::TuiNodeExt;
let mev = |kind, x: u16, y: u16| {
CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
};
let mut dom = TuiDom::new();
let root = dom.root();
let scroller = dom.create_element("div");
dom.append_child(root, scroller).unwrap();
let top = dom.create_element("p");
let t_top = dom.create_text_node("TOP");
dom.append_child(top, t_top).unwrap();
let top_tail = dom.create_element("span");
dom.append_child(top, top_tail).unwrap();
dom.append_child(scroller, top).unwrap();
let body = dom.create_element("p");
let body_text: String = (0..40)
.map(|i| format!("w{}", i % 10))
.collect::<Vec<_>>()
.join(" ");
let t_body = dom.create_text_node(&body_text);
dom.append_child(body, t_body).unwrap();
let body_tail = dom.create_element("span");
dom.append_child(body, body_tail).unwrap();
dom.append_child(scroller, body).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"div",
TuiStyle::new()
.width(Size::Fixed(12))
.height(Size::Fixed(3))
.overflow(Overflow::Auto),
)
.rule_unchecked(
"p",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(12)),
)
.rule_unchecked("span", TuiStyle::new().display(Display::Inline));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 14, 6));
app.draw_if_dirty().unwrap();
let scroll_y = |app: &App<TestBackend>| -> usize {
app.dom()
.node(scroller)
.tui_ext()
.map(|e| e.scroll_y)
.unwrap_or(0)
};
let content_h = app
.dom()
.node(scroller)
.tui_ext()
.map(|e| e.scroll_content_height)
.unwrap_or(0);
let max_scroll = content_h.saturating_sub(3);
assert!(max_scroll > 5, "need a tall scroll range, got {max_scroll}");
app.handle_event(mev(MouseEventKind::Down(MouseButton::Left), 1, 0));
app.handle_event(mev(MouseEventKind::Drag(MouseButton::Left), 1, 2));
for _ in 0..40 {
app.advance(50).unwrap();
}
assert_eq!(
scroll_y(&app),
max_scroll,
"autoscroll must reach the bottom even though the anchor block \
(the captured node) scrolled out of view early"
);
}
#[test]
fn autoscroll_keeps_scrolling_when_pointer_overshoots_past_the_container() {
use crate::layout::{Display, Overflow};
use crate::node::TuiNodeExt;
let mev = |kind, x: u16, y: u16| {
CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
};
let mut dom = TuiDom::new();
let root = dom.root();
let scroller = dom.create_element("div");
dom.append_child(root, scroller).unwrap();
let body = dom.create_element("p");
let body_text: String = (0..40)
.map(|i| format!("w{}", i % 10))
.collect::<Vec<_>>()
.join(" ");
let t_body = dom.create_text_node(&body_text);
dom.append_child(body, t_body).unwrap();
let body_tail = dom.create_element("span");
dom.append_child(body, body_tail).unwrap();
dom.append_child(scroller, body).unwrap();
let sibling = dom.create_element("aside");
let sib_text = dom.create_text_node("sibling content below the scroller");
dom.append_child(sibling, sib_text).unwrap();
let sib_tail = dom.create_element("span");
dom.append_child(sibling, sib_tail).unwrap();
dom.append_child(root, sibling).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"div",
TuiStyle::new()
.width(Size::Fixed(12))
.height(Size::Fixed(3))
.overflow(Overflow::Auto),
)
.rule_unchecked(
"p",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(12)),
)
.rule_unchecked(
"aside",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(12)),
)
.rule_unchecked("span", TuiStyle::new().display(Display::Inline));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 14, 8));
app.draw_if_dirty().unwrap();
let scroll_y = |app: &App<TestBackend>| -> usize {
app.dom()
.node(scroller)
.tui_ext()
.map(|e| e.scroll_y)
.unwrap_or(0)
};
let max_scroll = app
.dom()
.node(scroller)
.tui_ext()
.map(|e| e.scroll_content_height)
.unwrap_or(0)
.saturating_sub(3);
assert!(max_scroll > 4, "need a tall scroll range, got {max_scroll}");
app.handle_event(mev(MouseEventKind::Down(MouseButton::Left), 1, 0));
app.handle_event(mev(MouseEventKind::Drag(MouseButton::Left), 1, 2));
app.handle_event(mev(MouseEventKind::Drag(MouseButton::Left), 1, 5));
for _ in 0..40 {
app.advance(50).unwrap();
}
assert_eq!(
scroll_y(&app),
max_scroll,
"the drag owns its scroll container — overshooting onto the sibling \
keeps scrolling the scroller to the end"
);
assert!(
app.dom().selection().is_some_and(|s| !s.is_collapsed()),
"the drag over the sibling kept extending the selection"
);
assert_eq!(
app.dom().pointer_capture(),
None,
"a text-selection drag autoscrolls without DOM pointer capture"
);
}
#[test]
fn autoscroll_mid_tick_relayout_cascades_scroll_handler_mutations() {
use crate::layout::{Display, Overflow};
use crate::node::TuiNodeExt;
use std::cell::Cell;
use std::rc::Rc;
let mev = |kind, x: u16, y: u16| {
CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
};
let mut dom = TuiDom::new();
let root = dom.root();
let scroller = dom.create_element("scroller");
dom.append_child(root, scroller).unwrap();
for _ in 0..10 {
let r = dom.create_element("r");
dom.append_child(scroller, r).unwrap();
}
let probe = dom.create_element("probe");
dom.append_child(root, probe).unwrap();
dom.add_event_listener(scroller, "scroll", ListenerOptions::default(), move |ctx| {
let _ = ctx.dom.node_mut(probe).set_attribute("class", "wide");
})
.unwrap();
dom.add_event_listener(
scroller,
"mousedown",
ListenerOptions::default(),
move |ctx| {
ctx.dom.set_pointer_capture(scroller).unwrap();
ctx.dom.set_drag_autoscroll(true);
ctx.event.prevent_default();
},
)
.unwrap();
let seen = Rc::new(Cell::new(0u16));
let seen_w = seen.clone();
dom.add_event_listener(
scroller,
"mousemove",
ListenerOptions::default(),
move |ctx| {
if let Some(w) = ctx.dom.node(probe).layout_rect().map(|r| r.width) {
seen_w.set(w);
}
},
)
.unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"scroller",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(3))
.overflow(Overflow::Auto),
)
.rule_unchecked("r", TuiStyle::new().height(Size::Fixed(1)))
.rule_unchecked(
"probe",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(4))
.height(Size::Fixed(1)),
)
.rule_unchecked(
"probe.wide",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(9))
.height(Size::Fixed(1)),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 12, 8));
app.draw_if_dirty().unwrap();
assert_eq!(
app.dom().node(probe).layout_rect().map(|r| r.width),
Some(4),
"probe starts at the un-tagged width"
);
app.handle_event(mev(MouseEventKind::Down(MouseButton::Left), 2, 1));
app.handle_event(mev(MouseEventKind::Drag(MouseButton::Left), 2, 2));
app.advance(50).unwrap();
assert_eq!(
seen.get(),
9,
"the synthetic move saw the probe re-cascaded to `.wide` (width 9) \
mid-tick — not the stale pre-scroll width 4"
);
}
#[test]
fn relative_cell_glyph_paints_once_after_sibling_hidden_and_abspos_child_dropped() {
use crate::layout::{Direction, Display, Flow, Position, ZIndex};
use crate::node::TuiNodeMutExt;
use crate::render::{Buffer, LayoutExt, PaintExt};
use crate::style::Value;
let vp = Rect::new(0, 0, 40, 6);
let mut dom = TuiDom::new();
let root = dom.root();
let row = dom.create_element("div"); {
let mut s = TuiStyle::new().direction(Direction::Row);
s.display = Some(Value::Specified(Display::Block));
s.flow = Some(Value::Specified(Flow::Flex));
dom.node_mut(row).set_inline_style(s);
}
dom.append_child(root, row).unwrap();
let mk_cell = |dom: &mut TuiDom, parent: NodeId, text: &str| {
let cell = dom.create_element("div");
let t = dom.create_text_node(text);
dom.append_child(cell, t).unwrap();
dom.append_child(parent, cell).unwrap();
cell
};
let a = mk_cell(&mut dom, row, "aa");
let b1 = mk_cell(&mut dom, row, "bb"); let b2 = mk_cell(&mut dom, row, "cc"); let chip = mk_cell(&mut dom, row, "X"); {
let mut s = TuiStyle::new().width(Size::Fixed(3));
s.position = Some(Value::Specified(Position::Relative));
dom.node_mut(chip).set_inline_style(s);
}
for (cell, w) in [(a, 3u16), (b1, 6), (b2, 6), (chip, 3)] {
dom.node_mut(cell).ext_mut().unwrap().table_used_width = Some(w);
}
let sheet = Stylesheet::bare().rule_unchecked("[hide]", TuiStyle::new().display(Display::None));
let mut app = test_app(dom, sheet, vp);
app.draw_if_dirty().unwrap();
app.dom_mut().set_attribute(b1, "hide", "").unwrap();
app.draw_if_dirty().unwrap();
let menu = app.dom_mut().create_element("div");
{
let mut s = TuiStyle::new().width(Size::Fixed(4)).height(Size::Fixed(1));
s.position = Some(Value::Specified(Position::Absolute));
s.z_index = Some(Value::Specified(ZIndex::Value(10)));
app.dom_mut().node_mut(menu).set_inline_style(s);
}
app.dom_mut().append_child(chip, menu).unwrap();
app.draw_if_dirty().unwrap(); let _ = app.dom_mut().drop_subtree(menu);
app.draw_if_dirty().unwrap();
app.dom_mut().set_attribute(b2, "hide", "").unwrap();
app.draw_if_dirty().unwrap();
let dom = app.dom_mut();
dom.layout_dom(vp);
let mut buf = Buffer::empty(vp);
dom.paint_dom(&mut buf, vp);
let mut glyphs = 0;
for y in 0..vp.height {
for x in 0..vp.width {
if buf.cell(x, y).map(|c| c.symbol()) == Some("X") {
glyphs += 1;
}
}
}
assert_eq!(glyphs, 1, "the relative cell's glyph paints exactly once");
}
#[test]
fn scroll_focus_marker_follows_the_nearest_overflowing_scroll_container() {
use crate::accessors::TuiAccessorsMut;
use crate::layout::Overflow;
use crate::runtime::scrollbar::SCROLL_FOCUS_ATTR;
let mut dom = TuiDom::new();
let root = dom.root();
let outer = dom.create_element("outer");
let inner = dom.create_element("inner");
let f = dom.create_element("f");
let g = dom.create_element("g");
dom.set_attribute(f, "tabindex", "0").unwrap();
dom.set_attribute(g, "tabindex", "0").unwrap();
dom.append_child(inner, f).unwrap();
for _ in 0..5 {
let r = dom.create_element("r");
dom.append_child(inner, r).unwrap(); }
dom.append_child(outer, inner).unwrap();
dom.append_child(outer, g).unwrap();
for _ in 0..8 {
let r = dom.create_element("r");
dom.append_child(outer, r).unwrap(); }
dom.append_child(root, outer).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"outer",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(6))
.overflow(Overflow::Auto),
)
.rule_unchecked(
"inner",
TuiStyle::new()
.width(Size::Fixed(8))
.height(Size::Fixed(3))
.overflow(Overflow::Auto),
)
.rule_unchecked("f", TuiStyle::new().height(Size::Fixed(1)))
.rule_unchecked("g", TuiStyle::new().height(Size::Fixed(1)))
.rule_unchecked("r", TuiStyle::new().height(Size::Fixed(1)));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 12, 8));
app.draw_if_dirty().unwrap();
let marked =
|app: &App<TestBackend>, id: NodeId| app.dom().node(id).has_attribute(SCROLL_FOCUS_ATTR);
assert!(
!marked(&app, outer) && !marked(&app, inner),
"nothing focused"
);
app.dom_mut().node_mut(f).focus();
app.draw_if_dirty().unwrap();
assert!(
marked(&app, inner),
"inner is the nearest overflowing scroller of `f`"
);
assert!(
!marked(&app, outer),
"outer also overflows but is not the keyboard target"
);
app.dom_mut().node_mut(g).focus();
app.draw_if_dirty().unwrap();
assert!(marked(&app, outer), "marker moved to outer");
assert!(!marked(&app, inner), "and left inner");
}
#[test]
fn scroll_focus_marker_tolerates_a_removed_carrier() {
use crate::accessors::TuiAccessorsMut;
use crate::layout::Overflow;
use crate::runtime::scrollbar::SCROLL_FOCUS_ATTR;
let mut dom = TuiDom::new();
let root = dom.root();
let outer = dom.create_element("outer");
let inner = dom.create_element("inner");
let f = dom.create_element("f");
let g = dom.create_element("g");
dom.set_attribute(f, "tabindex", "0").unwrap();
dom.set_attribute(g, "tabindex", "0").unwrap();
dom.append_child(inner, f).unwrap();
for _ in 0..5 {
let r = dom.create_element("r");
dom.append_child(inner, r).unwrap();
}
dom.append_child(outer, inner).unwrap();
dom.append_child(outer, g).unwrap();
for _ in 0..8 {
let r = dom.create_element("r");
dom.append_child(outer, r).unwrap();
}
dom.append_child(root, outer).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"outer",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(6))
.overflow(Overflow::Auto),
)
.rule_unchecked(
"inner",
TuiStyle::new()
.width(Size::Fixed(8))
.height(Size::Fixed(3))
.overflow(Overflow::Auto),
)
.rule_unchecked("f", TuiStyle::new().height(Size::Fixed(1)))
.rule_unchecked("g", TuiStyle::new().height(Size::Fixed(1)))
.rule_unchecked("r", TuiStyle::new().height(Size::Fixed(1)));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 12, 8));
app.draw_if_dirty().unwrap();
app.dom_mut().node_mut(f).focus();
app.draw_if_dirty().unwrap();
assert!(app.dom().node(inner).has_attribute(SCROLL_FOCUS_ATTR));
app.dom_mut().remove_child_dropping(outer, inner).unwrap();
app.dom_mut().node_mut(g).focus();
app.draw_if_dirty().unwrap();
assert!(app.dom().node(outer).has_attribute(SCROLL_FOCUS_ATTR));
}
#[test]
fn app_context_stylesheet_intents_apply_after_the_handler() {
use crate::node::TuiNodeExt;
let mut dom = TuiDom::new();
let root = dom.root();
let d = dom.create_element("d");
dom.append_child(root, d).unwrap();
let sheet = Stylesheet::bare().rule_unchecked("d", TuiStyle::new().height(Size::Fixed(1)));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 10, 3));
app.draw_if_dirty().unwrap();
let blue = Color::Rgb(0, 0, 255);
let handle = app.handle();
let pushed: std::sync::Arc<std::sync::Mutex<Option<crate::runtime::app::StylesheetId>>> =
Default::default();
let slot = pushed.clone();
handle.inject(move |ctx| {
let id =
ctx.push_stylesheet(Stylesheet::bare().rule_unchecked("d", TuiStyle::new().bg(blue)));
*slot.lock().unwrap() = Some(id);
});
app.drain_handle_injections();
app.draw_if_dirty().unwrap();
let bg = |app: &App<TestBackend>| {
app.dom()
.node(d)
.tui_ext()
.and_then(|e| e.computed.as_ref())
.map(|c| c.bg)
};
assert_eq!(bg(&app), Some(blue), "the pushed sheet cascaded");
let id = pushed.lock().unwrap().expect("the intent handed out an id");
assert_eq!(app.style_sheets().len(), 2);
let handle = app.handle();
handle.inject(move |ctx| ctx.remove_stylesheet(id));
app.drain_handle_injections();
app.draw_if_dirty().unwrap();
assert_ne!(bg(&app), Some(blue), "the removed sheet no longer applies");
assert_eq!(app.style_sheets().len(), 1);
}
#[test]
fn stylesheet_ids_from_contexts_and_direct_calls_are_the_registered_ids() {
use crate::runtime::app::StylesheetId;
type Ids = std::sync::Arc<std::sync::Mutex<Vec<StylesheetId>>>;
let mut app = test_app(TuiDom::new(), Stylesheet::bare(), Rect::new(0, 0, 10, 3));
let registered = |app: &App<TestBackend>| -> Vec<StylesheetId> {
app.stylesheets.iter().map(|(id, _)| *id).collect()
};
let direct_first = app.push_stylesheet(Stylesheet::bare());
let handed: Ids = Default::default();
let slot = handed.clone();
app.handle().inject(move |ctx| {
let set = ctx.set_stylesheet(Stylesheet::bare());
let dropped = ctx.push_stylesheet(Stylesheet::bare());
let kept = ctx.push_stylesheet(Stylesheet::bare());
ctx.remove_stylesheet(dropped);
slot.lock().unwrap().extend([set, dropped, kept]);
});
app.drain_handle_injections();
let [set, dropped, kept] = handed.lock().unwrap()[..] else {
panic!("three ids handed out");
};
assert_eq!(registered(&app), vec![set, kept]);
let direct = app.push_stylesheet(Stylesheet::bare());
let slot = handed.clone();
app.handle().inject(move |ctx| {
let later = ctx.push_stylesheet(Stylesheet::bare());
slot.lock().unwrap().push(later);
});
app.drain_handle_injections();
let later = handed.lock().unwrap()[3];
assert_eq!(registered(&app), vec![set, kept, direct, later]);
let all = [direct_first, set, dropped, kept, direct, later];
let unique: std::collections::HashSet<_> = all.iter().collect();
assert_eq!(unique.len(), all.len(), "ids are never reused: {all:?}");
}
#[test]
fn caret_reveal_uses_the_layout_of_the_same_frame() {
use crate::layout::Overflow;
use crate::node::TuiNodeExt;
let mut dom = TuiDom::new();
let root = dom.root();
let ta = dom.create_element("textarea");
let t = dom.create_text_node("aa bb cc dd");
dom.append_child(ta, t).unwrap();
dom.append_child(root, ta).unwrap();
let sheet = Stylesheet::bare().rule_unchecked(
"textarea",
TuiStyle::new()
.width(Size::Fixed(6))
.height(Size::Fixed(2))
.overflow_y(Overflow::Auto),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 20, 5));
app.draw_if_dirty().unwrap();
app.dom_mut().set_focused(Some(ta));
app.dom_mut()
.set_selection(Some(Selection::caret(Position::new(t, 11))));
let scroll_y = |app: &App<TestBackend>| app.dom().node(ta).tui_ext().map_or(0, |e| e.scroll_y);
assert_eq!(scroll_y(&app), 0);
for ch in [' ', 'e', 'e'] {
app.handle_event(key(KeyCode::Char(ch)));
app.draw_if_dirty().unwrap();
}
assert_eq!(app.dom().node(t).node_value(), Some("aa bb cc dd ee"));
assert_eq!(
scroll_y(&app),
1,
"the frame that laid out the third line also scrolled it into view"
);
}
fn mouse(kind: MouseEventKind, x: u16, y: u16) -> CtEvent {
CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
}
fn contain_host(gap: u16) -> (TuiDom, [NodeId; 4]) {
let mut dom = TuiDom::new();
let root = dom.root();
let host = dom.create_element("host");
let p1 = dom.create_element("p");
let t1 = dom.create_text_node("first para");
dom.append_child(p1, t1).unwrap();
let p2 = dom.create_element("p");
let t2 = dom.create_text_node("second one");
dom.append_child(p2, t2).unwrap();
dom.append_child(host, p1).unwrap();
if gap > 0 {
let between = dom.create_element("gap");
dom.append_child(host, between).unwrap();
}
dom.append_child(host, p2).unwrap();
dom.append_child(root, host).unwrap();
(dom, [host, t1, t2, root])
}
fn contain_sheet(gap: u16) -> Stylesheet {
use crate::layout::{Direction, UserSelect};
Stylesheet::bare()
.rule_unchecked(
"host",
TuiStyle::new()
.direction(Direction::Column)
.width(Size::Fixed(20))
.user_select(UserSelect::Contain),
)
.rule_unchecked("p", TuiStyle::new().height(Size::Fixed(1)))
.rule_unchecked(
"gap",
TuiStyle::new()
.height(Size::Fixed(gap))
.user_select(UserSelect::None),
)
}
#[test]
fn contain_host_gap_drag_lands_on_the_nearest_paragraph() {
let (dom, [_host, _t1, t2, _]) = contain_host(2);
let mut app = test_app(dom, contain_sheet(2), Rect::new(0, 0, 20, 6));
app.draw_if_dirty().unwrap();
app.handle_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 0));
app.handle_event(mouse(MouseEventKind::Drag(MouseButton::Left), 4, 2));
let sel = app.dom().selection().cloned().expect("a drag selection");
assert_eq!(sel.focus, Position::new(t2, 0));
}
#[test]
fn contain_host_clamps_to_the_line_at_the_pointers_row() {
use crate::layout::Direction;
let (mut dom, [host, t1, t2, root]) = contain_host(0);
let row = dom.create_element("row");
let side = dom.create_element("p");
let t3 = dom.create_text_node("side text");
dom.append_child(side, t3).unwrap();
dom.append_child(row, host).unwrap();
dom.append_child(row, side).unwrap();
dom.append_child(root, row).unwrap();
let sheet = contain_sheet(0).rule_unchecked(
"row",
TuiStyle::new()
.direction(Direction::Row)
.width(Size::Fixed(40)),
);
let mut app = test_app(dom, sheet, Rect::new(0, 0, 40, 6));
app.draw_if_dirty().unwrap();
app.handle_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 0));
app.handle_event(mouse(MouseEventKind::Drag(MouseButton::Left), 25, 0));
let sel = app.dom().selection().cloned().expect("a drag selection");
assert_eq!(sel.focus, Position::new(t1, 10), "end of the line at row 0");
app.handle_event(mouse(MouseEventKind::Drag(MouseButton::Left), 25, 4));
let sel = app.dom().selection().cloned().expect("a drag selection");
assert_eq!(sel.focus, Position::new(t2, 10), "end of the last line");
}
#[test]
fn nested_contain_host_keeps_the_drag_inside_the_inner_host() {
use crate::layout::{Direction, UserSelect};
let mut dom = TuiDom::new();
let root = dom.root();
let panel = dom.create_element("panel");
let card = dom.create_element("card");
let t1 = dom.create_text_node("card text");
dom.append_child(card, t1).unwrap();
let p2 = dom.create_element("p");
let t2 = dom.create_text_node("panel text");
dom.append_child(p2, t2).unwrap();
dom.append_child(panel, card).unwrap();
dom.append_child(panel, p2).unwrap();
dom.append_child(root, panel).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"panel",
TuiStyle::new()
.direction(Direction::Column)
.width(Size::Fixed(20))
.user_select(UserSelect::Contain),
)
.rule_unchecked(
"card",
TuiStyle::new()
.height(Size::Fixed(1))
.user_select(UserSelect::Contain),
)
.rule_unchecked("p", TuiStyle::new().height(Size::Fixed(1)));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 20, 6));
app.draw_if_dirty().unwrap();
app.handle_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 0));
app.handle_event(mouse(MouseEventKind::Drag(MouseButton::Left), 4, 1));
let sel = app.dom().selection().cloned().expect("a drag selection");
assert_eq!(sel.focus, Position::new(t1, 9), "clamped inside the card");
}
#[test]
fn all_host_spans_every_paragraph_of_the_declaring_element() {
use crate::layout::{Direction, UserSelect};
let mut dom = TuiDom::new();
let root = dom.root();
let host = dom.create_element("host");
let p1 = dom.create_element("p");
let t1 = dom.create_text_node("first para");
dom.append_child(p1, t1).unwrap();
let p2 = dom.create_element("p");
let t2 = dom.create_text_node("second one");
dom.append_child(p2, t2).unwrap();
dom.append_child(host, p1).unwrap();
dom.append_child(host, p2).unwrap();
dom.append_child(root, host).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"host",
TuiStyle::new()
.direction(Direction::Column)
.width(Size::Fixed(20))
.user_select(UserSelect::All),
)
.rule_unchecked("p", TuiStyle::new().height(Size::Fixed(1)));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 20, 6));
app.draw_if_dirty().unwrap();
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 3,
row: 1,
modifiers: KeyModifiers::empty(),
}));
let sel = app.dom().selection().cloned().expect("a selection");
assert_eq!(
sel.anchor,
Position::new(t1, 0),
"spans from the host's first text"
);
assert_eq!(
sel.focus,
Position::new(t2, 10),
"to the host's last text end"
);
}
#[test]
fn selection_drag_autoscroll_survives_its_anchor_block_being_dropped() {
use crate::layout::{Display, Overflow};
let mev = |kind, x: u16, y: u16| {
CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
})
};
let mut dom = TuiDom::new();
let root = dom.root();
let scroller = dom.create_element("div");
dom.append_child(root, scroller).unwrap();
let p = dom.create_element("p");
let text: String = (0..40)
.map(|i| format!("w{}", i % 10))
.collect::<Vec<_>>()
.join(" ");
let t = dom.create_text_node(&text);
dom.append_child(p, t).unwrap();
let tail = dom.create_element("span");
dom.append_child(p, tail).unwrap();
dom.append_child(scroller, p).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"div",
TuiStyle::new()
.width(Size::Fixed(12))
.height(Size::Fixed(3))
.overflow(Overflow::Auto),
)
.rule_unchecked(
"p",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(12)),
)
.rule_unchecked("span", TuiStyle::new().display(Display::Inline));
let mut app = test_app(dom, sheet, Rect::new(0, 0, 14, 6));
app.draw_if_dirty().unwrap();
app.handle_event(mev(MouseEventKind::Down(MouseButton::Left), 1, 0));
assert!(app.router.selection_drag.is_some());
app.dom_mut().drop_subtree(p).unwrap();
app.handle_event(mev(MouseEventKind::Drag(MouseButton::Left), 1, 5));
for _ in 0..4 {
app.advance(50).unwrap();
}
assert!(
app.dom().selection().is_none(),
"the selection went with its text"
);
}