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::render::{Terminal, TestBackend};
use crate::runtime::app::App;
use crate::style::Stylesheet;
const ACTIVE: &str = "data-rdom-active";
fn test_app(dom: TuiDom) -> App<TestBackend> {
let backend = TestBackend::new(40, 12);
let terminal = Terminal::new(backend).unwrap();
App::with_backend(dom, Stylesheet::new(), terminal).unwrap()
}
fn ti(dom: &mut TuiDom, label: &str, attrs: &[(&str, &str)]) -> NodeId {
let li = dom.create_element("li");
dom.set_attribute(li, "role", "treeitem").unwrap();
for (k, v) in attrs {
dom.set_attribute(li, k, v).unwrap();
}
let t = dom.create_text_node(label);
dom.append_child(li, t).unwrap();
li
}
fn group(dom: &mut TuiDom, parent: NodeId) -> NodeId {
let g = dom.create_element("ul");
dom.set_attribute(g, "role", "group").unwrap();
dom.append_child(parent, g).unwrap();
g
}
struct Fix {
app: App<TestBackend>,
tree: NodeId,
cluster: NodeId,
pods: NodeId,
nodes: NodeId,
node1: NodeId,
node2: NodeId,
}
fn fixture() -> Fix {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let tree = dom.create_element("ul");
dom.set_attribute(tree, "role", "tree").unwrap();
dom.append_child(root, tree).unwrap();
let cluster = ti(&mut dom, "Cluster", &[("aria-expanded", "true")]);
dom.append_child(tree, cluster).unwrap();
let cg = group(&mut dom, cluster);
let pods = ti(&mut dom, "Pods", &[]);
dom.append_child(cg, pods).unwrap();
let nodes = ti(&mut dom, "Nodes", &[("aria-expanded", "true")]);
dom.append_child(cg, nodes).unwrap();
let ng = group(&mut dom, nodes);
let node1 = ti(&mut dom, "node-1", &[]);
let node2 = ti(&mut dom, "node-2", &[]);
dom.append_child(ng, node1).unwrap();
dom.append_child(ng, node2).unwrap();
let mut app = test_app(dom);
app.dom_mut().set_focused(Some(tree));
app.draw_if_dirty().unwrap();
Fix {
app,
tree,
cluster,
pods,
nodes,
node1,
node2,
}
}
fn key(code: KeyCode) -> CtEvent {
CtEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
})
}
fn active(app: &App<TestBackend>, tree: NodeId) -> Option<NodeId> {
fn walk(app: &App<TestBackend>, id: NodeId) -> Option<NodeId> {
for child in app.dom().node(id).child_nodes() {
if child.has_attribute(ACTIVE) {
return Some(child.id());
}
if let Some(f) = walk(app, child.id()) {
return Some(f);
}
}
None
}
walk(app, tree)
}
#[test]
fn arrow_down_seeds_then_advances_cursor() {
let mut f = fixture();
f.app.handle_event(key(KeyCode::Down));
assert_eq!(
active(&f.app, f.tree),
Some(f.cluster),
"first Down → first item"
);
f.app.handle_event(key(KeyCode::Down));
assert_eq!(
active(&f.app, f.tree),
Some(f.pods),
"second Down → next visible"
);
}
#[test]
fn arrow_up_clamps_at_top() {
let mut f = fixture();
f.app.handle_event(key(KeyCode::Down)); f.app.handle_event(key(KeyCode::Up)); assert_eq!(active(&f.app, f.tree), Some(f.cluster));
}
#[test]
fn home_and_end_jump() {
let mut f = fixture();
f.app.handle_event(key(KeyCode::End));
assert_eq!(active(&f.app, f.tree), Some(f.node2), "End → last visible");
f.app.handle_event(key(KeyCode::Home));
assert_eq!(active(&f.app, f.tree), Some(f.cluster), "Home → first");
}
#[test]
fn down_skips_collapsed_subtree() {
let mut f = fixture();
f.app
.dom_mut()
.set_attribute(f.cluster, "aria-expanded", "false")
.unwrap();
f.app.handle_event(key(KeyCode::Down)); f.app.handle_event(key(KeyCode::Down)); assert_eq!(active(&f.app, f.tree), Some(f.cluster));
}
#[test]
fn right_expands_collapsed_branch_and_fires_toggle() {
let mut f = fixture();
f.app
.dom_mut()
.set_attribute(f.cluster, "aria-expanded", "false")
.unwrap();
let fires = Rc::new(Cell::new(0u32));
let fc = fires.clone();
f.app
.dom_mut()
.add_event_listener(f.cluster, "toggle", ListenerOptions::default(), move |_| {
fc.set(fc.get() + 1)
})
.unwrap();
f.app.handle_event(key(KeyCode::Down)); f.app.handle_event(key(KeyCode::Right)); assert_eq!(
f.app.dom().node(f.cluster).get_attribute("aria-expanded"),
Some("true")
);
assert_eq!(fires.get(), 1, "expand fires toggle");
}
#[test]
fn right_on_expanded_branch_descends_to_first_child() {
let mut f = fixture();
f.app.handle_event(key(KeyCode::Down)); f.app.handle_event(key(KeyCode::Right)); assert_eq!(active(&f.app, f.tree), Some(f.pods));
}
#[test]
fn left_collapses_then_ascends_to_parent() {
let mut f = fixture();
f.app.handle_event(key(KeyCode::End)); f.app.handle_event(key(KeyCode::Up)); assert_eq!(active(&f.app, f.tree), Some(f.node1));
f.app.handle_event(key(KeyCode::Up)); assert_eq!(active(&f.app, f.tree), Some(f.nodes));
f.app.handle_event(key(KeyCode::Left)); assert_eq!(
f.app.dom().node(f.nodes).get_attribute("aria-expanded"),
Some("false")
);
f.app.handle_event(key(KeyCode::Left)); assert_eq!(active(&f.app, f.tree), Some(f.cluster));
}
#[test]
fn right_on_unloaded_branch_fires_toggle_for_lazy_load() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let tree = dom.create_element("ul");
dom.set_attribute(tree, "role", "tree").unwrap();
dom.append_child(root, tree).unwrap();
let lazy = ti(&mut dom, "Lazy", &[("aria-expanded", "false")]);
dom.append_child(tree, lazy).unwrap();
let mut app = test_app(dom);
app.dom_mut().set_focused(Some(tree));
app.draw_if_dirty().unwrap();
let fires = Rc::new(Cell::new(0u32));
let fc = fires.clone();
app.dom_mut()
.add_event_listener(lazy, "toggle", ListenerOptions::default(), move |_| {
fc.set(fc.get() + 1)
})
.unwrap();
app.handle_event(key(KeyCode::Down));
app.handle_event(key(KeyCode::Right));
assert_eq!(
fires.get(),
1,
"expanding an unloaded branch fires the load hook"
);
}
#[test]
fn enter_activates_and_fires_click() {
let mut f = fixture();
f.app.handle_event(key(KeyCode::Down)); f.app.handle_event(key(KeyCode::Down)); let fires = Rc::new(Cell::new(0u32));
let fc = fires.clone();
f.app
.dom_mut()
.add_event_listener(f.pods, "click", ListenerOptions::default(), move |_| {
fc.set(fc.get() + 1)
})
.unwrap();
f.app.handle_event(key(KeyCode::Enter));
assert_eq!(fires.get(), 1, "Enter fires click on the active item");
}
fn click_at(app: &mut App<TestBackend>, x: u16, y: u16) {
for kind in [
MouseEventKind::Down(MouseButton::Left),
MouseEventKind::Up(MouseButton::Left),
] {
app.handle_event(CtEvent::Mouse(CtMouseEvent {
kind,
column: x,
row: y,
modifiers: KeyModifiers::empty(),
}));
}
}
#[test]
fn click_on_leaf_sets_cursor() {
let mut f = fixture();
click_at(&mut f.app, 8, 1);
assert_eq!(active(&f.app, f.tree), Some(f.pods));
}
#[test]
fn click_anywhere_on_branch_row_toggles() {
let mut f = fixture();
click_at(&mut f.app, 5, 0);
assert_eq!(active(&f.app, f.tree), Some(f.cluster));
assert_eq!(
f.app.dom().node(f.cluster).get_attribute("aria-expanded"),
Some("false"),
"branch-row click collapses it"
);
click_at(&mut f.app, 5, 0);
assert_eq!(
f.app.dom().node(f.cluster).get_attribute("aria-expanded"),
Some("true")
);
}
#[test]
fn prevent_default_suppresses_builtin_navigation() {
let mut f = fixture();
f.app
.dom_mut()
.add_event_listener(f.tree, "keydown", ListenerOptions::default(), move |ctx| {
ctx.event.prevent_default();
})
.unwrap();
f.app.handle_event(key(KeyCode::Down));
assert_eq!(
active(&f.app, f.tree),
None,
"no cursor moved — default prevented"
);
}