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::cell::RefCell;
use std::rc::Rc;
use crate::TuiDom;
use crate::layout::Size;
use crate::render::{Terminal, TestBackend};
use crate::runtime::app::App;
use crate::style::{Stylesheet, TuiStyle};
fn test_app(dom: TuiDom, sheet: Stylesheet) -> App<TestBackend> {
let backend = TestBackend::new(40, 5);
let terminal = Terminal::new(backend).unwrap();
App::with_backend(dom, sheet, terminal).unwrap()
}
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(),
}));
}
}
fn key_press(code: KeyCode, modifiers: KeyModifiers) -> CtEvent {
CtEvent::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
})
}
fn checkbox_app() -> (App<TestBackend>, NodeId) {
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 sheet = Stylesheet::new().rule_unchecked(
"input[type=checkbox]",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(1)),
);
let app = test_app(dom, sheet);
(app, cb)
}
fn radio_group_app() -> (App<TestBackend>, [NodeId; 3]) {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let make = |dom: &mut TuiDom, i: usize| {
let r = dom.create_element("input");
dom.set_attribute(r, "type", "radio").unwrap();
dom.set_attribute(r, "name", "g").unwrap();
dom.set_attribute(r, "id", &format!("r{}", i)).unwrap();
dom.append_child(root, r).unwrap();
r
};
let ids = [make(&mut dom, 0), make(&mut dom, 1), make(&mut dom, 2)];
let sheet = Stylesheet::new().rule_unchecked(
"input[type=radio]",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(1)),
);
let app = test_app(dom, sheet);
(app, ids)
}
#[test]
fn click_on_checkbox_toggles_checked_attribute() {
let (mut app, cb) = checkbox_app();
app.draw_if_dirty().unwrap();
assert!(!app.dom().node(cb).has_attribute("checked"));
click_at(&mut app, 1, 0);
assert!(app.dom().node(cb).has_attribute("checked"));
click_at(&mut app, 1, 0);
assert!(!app.dom().node(cb).has_attribute("checked"));
}
#[test]
fn click_on_checkbox_fires_input_then_change() {
let (mut app, cb) = checkbox_app();
let order = Rc::new(RefCell::new(Vec::<&'static str>::new()));
for ty in ["input", "change"] {
let o = order.clone();
app.dom_mut()
.add_event_listener(cb, ty, ListenerOptions::default(), move |_| {
o.borrow_mut().push(ty);
})
.unwrap();
}
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 0);
assert_eq!(*order.borrow(), vec!["input", "change"]);
}
#[test]
fn prevent_default_on_click_blocks_checkbox_toggle() {
let (mut app, cb) = checkbox_app();
app.dom_mut()
.add_event_listener(cb, "click", ListenerOptions::default(), |ctx| {
ctx.event.prevent_default();
})
.unwrap();
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 0);
assert!(!app.dom().node(cb).has_attribute("checked"));
}
#[test]
fn disabled_checkbox_does_not_toggle_on_click() {
let (mut app, cb) = checkbox_app();
app.dom_mut().set_attribute(cb, "disabled", "").unwrap();
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 0);
assert!(!app.dom().node(cb).has_attribute("checked"));
}
#[test]
fn checkbox_in_a_disabled_fieldset_does_not_toggle_but_one_in_its_legend_does() {
use crate::accessors::TuiAccessorsMut;
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let fs = dom.create_element("fieldset");
dom.set_attribute(fs, "disabled", "").unwrap();
dom.append_child(root, fs).unwrap();
let legend = dom.create_element("legend");
dom.append_child(fs, legend).unwrap();
let in_legend = dom.create_element("input");
dom.set_attribute(in_legend, "type", "checkbox").unwrap();
dom.append_child(legend, in_legend).unwrap();
let inside = dom.create_element("input");
dom.set_attribute(inside, "type", "checkbox").unwrap();
dom.append_child(fs, inside).unwrap();
let mut app = test_app(dom, Stylesheet::new());
app.draw_if_dirty().unwrap();
app.dom_mut().node_mut(inside).click();
assert!(!app.dom().node(inside).has_attribute("checked"));
app.dom_mut().node_mut(in_legend).click();
assert!(app.dom().node(in_legend).has_attribute("checked"));
}
#[test]
fn initially_checked_checkbox_unchecks_on_click() {
let (mut app, cb) = checkbox_app();
app.dom_mut().set_attribute(cb, "checked", "").unwrap();
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 0);
assert!(!app.dom().node(cb).has_attribute("checked"));
}
#[test]
fn space_on_focused_checkbox_toggles() {
let (mut app, cb) = checkbox_app();
app.dom_mut().set_focused(Some(cb));
app.handle_event(key_press(KeyCode::Char(' '), KeyModifiers::empty()));
assert!(app.dom().node(cb).has_attribute("checked"));
}
#[test]
fn enter_on_focused_checkbox_does_not_toggle() {
let (mut app, cb) = checkbox_app();
app.dom_mut().set_focused(Some(cb));
app.handle_event(key_press(KeyCode::Enter, KeyModifiers::empty()));
assert!(!app.dom().node(cb).has_attribute("checked"));
}
#[test]
fn ctrl_space_does_not_toggle_checkbox() {
let (mut app, cb) = checkbox_app();
app.dom_mut().set_focused(Some(cb));
app.handle_event(key_press(KeyCode::Char(' '), KeyModifiers::CONTROL));
assert!(!app.dom().node(cb).has_attribute("checked"));
}
#[test]
fn click_on_radio_checks_it_and_unchecks_siblings() {
let (mut app, [r1, r2, r3]) = radio_group_app();
app.dom_mut().set_attribute(r1, "checked", "").unwrap();
app.dom_mut().set_focused(Some(r2));
app.handle_event(key_press(KeyCode::Char(' '), KeyModifiers::empty()));
assert!(!app.dom().node(r1).has_attribute("checked"));
assert!(app.dom().node(r2).has_attribute("checked"));
assert!(!app.dom().node(r3).has_attribute("checked"));
}
#[test]
fn re_clicking_already_checked_radio_is_noop() {
let (mut app, [r1, _r2, _r3]) = radio_group_app();
app.dom_mut().set_attribute(r1, "checked", "").unwrap();
let fired = Rc::new(Cell::new(0u32));
let f = fired.clone();
app.dom_mut()
.add_event_listener(r1, "change", ListenerOptions::default(), move |_| {
f.set(f.get() + 1);
})
.unwrap();
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 0);
assert!(app.dom().node(r1).has_attribute("checked"));
assert_eq!(fired.get(), 0, "no change event when state didn't change");
}
#[test]
fn radio_groups_with_different_names_are_independent() {
let (mut app, [r1, _, _]) = radio_group_app();
let r_other = app.dom_mut().create_element("input");
app.dom_mut()
.set_attribute(r_other, "type", "radio")
.unwrap();
app.dom_mut()
.set_attribute(r_other, "name", "other")
.unwrap();
app.dom_mut().set_attribute(r_other, "checked", "").unwrap();
let root = app.dom().root();
app.dom_mut().append_child(root, r_other).unwrap();
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 0);
assert!(app.dom().node(r1).has_attribute("checked"));
assert!(app.dom().node(r_other).has_attribute("checked"));
}
fn radio_under(dom: &mut TuiDom, parent: NodeId, attrs: &[(&str, &str)]) -> NodeId {
let r = dom.create_element("input");
dom.set_attribute(r, "type", "radio").unwrap();
for (k, v) in attrs {
dom.set_attribute(r, k, v).unwrap();
}
dom.append_child(parent, r).unwrap();
r
}
fn form_under(dom: &mut TuiDom, parent: NodeId, id: &str) -> NodeId {
let f = dom.create_element("form");
dom.set_attribute(f, "id", id).unwrap();
dom.append_child(parent, f).unwrap();
f
}
fn press_space_on(app: &mut App<TestBackend>, radio: NodeId) {
app.dom_mut().set_focused(Some(radio));
app.handle_event(key_press(KeyCode::Char(' '), KeyModifiers::empty()));
}
#[test]
fn same_named_radios_in_two_forms_toggle_independently() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let fa = form_under(&mut dom, root, "a");
let fb = form_under(&mut dom, root, "b");
let a1 = radio_under(&mut dom, fa, &[("name", "g"), ("checked", "")]);
let b1 = radio_under(&mut dom, fb, &[("name", "g")]);
let mut app = test_app(dom, Stylesheet::new());
press_space_on(&mut app, b1);
assert!(app.dom().node(b1).has_attribute("checked"));
assert!(
app.dom().node(a1).has_attribute("checked"),
"a radio in another form is in another group"
);
}
#[test]
fn a_radio_outside_a_form_with_a_form_attribute_groups_with_that_form() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let f = form_under(&mut dom, root, "f");
let inside = radio_under(&mut dom, f, &[("name", "g"), ("checked", "")]);
let pointing = radio_under(&mut dom, root, &[("name", "g"), ("form", "f")]);
let unowned = radio_under(&mut dom, root, &[("name", "g"), ("checked", "")]);
let mut app = test_app(dom, Stylesheet::new());
press_space_on(&mut app, pointing);
assert!(app.dom().node(pointing).has_attribute("checked"));
assert!(
!app.dom().node(inside).has_attribute("checked"),
"form=f puts the radio in f's group"
);
assert!(
app.dom().node(unowned).has_attribute("checked"),
"an unowned radio is in the no-owner group"
);
}
#[test]
fn radio_names_that_differ_in_case_are_different_groups() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let upper = radio_under(&mut dom, root, &[("name", "G"), ("checked", "")]);
let lower = radio_under(&mut dom, root, &[("name", "g")]);
let mut app = test_app(dom, Stylesheet::new());
press_space_on(&mut app, lower);
assert!(app.dom().node(upper).has_attribute("checked"));
}
#[test]
fn arrow_navigation_stays_inside_the_form_owners_group() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let fa = form_under(&mut dom, root, "a");
let fb = form_under(&mut dom, root, "b");
let a1 = radio_under(&mut dom, fa, &[("name", "g")]);
let a2 = radio_under(&mut dom, fa, &[("name", "g")]);
let _b1 = radio_under(&mut dom, fb, &[("name", "g")]);
let mut app = test_app(dom, Stylesheet::new());
app.dom_mut().set_focused(Some(a2));
app.handle_event(key_press(KeyCode::Down, KeyModifiers::empty()));
assert_eq!(app.dom().focused(), Some(a1), "wraps within form a");
}
#[test]
fn down_arrow_moves_focus_to_next_radio_in_group() {
let (mut app, [r1, r2, _r3]) = radio_group_app();
app.dom_mut().set_focused(Some(r1));
app.handle_event(key_press(KeyCode::Down, KeyModifiers::empty()));
assert_eq!(app.dom().focused(), Some(r2));
}
#[test]
fn right_arrow_also_moves_to_next_radio() {
let (mut app, [r1, r2, _r3]) = radio_group_app();
app.dom_mut().set_focused(Some(r1));
app.handle_event(key_press(KeyCode::Right, KeyModifiers::empty()));
assert_eq!(app.dom().focused(), Some(r2));
}
#[test]
fn up_arrow_moves_focus_to_previous_radio_in_group() {
let (mut app, [r1, r2, _r3]) = radio_group_app();
app.dom_mut().set_focused(Some(r2));
app.handle_event(key_press(KeyCode::Up, KeyModifiers::empty()));
assert_eq!(app.dom().focused(), Some(r1));
}
#[test]
fn down_arrow_at_last_radio_wraps_to_first() {
let (mut app, [r1, _r2, r3]) = radio_group_app();
app.dom_mut().set_focused(Some(r3));
app.handle_event(key_press(KeyCode::Down, KeyModifiers::empty()));
assert_eq!(app.dom().focused(), Some(r1));
}
#[test]
fn up_arrow_at_first_radio_wraps_to_last() {
let (mut app, [r1, _r2, r3]) = radio_group_app();
app.dom_mut().set_focused(Some(r1));
app.handle_event(key_press(KeyCode::Up, KeyModifiers::empty()));
assert_eq!(app.dom().focused(), Some(r3));
}
#[test]
fn arrow_with_modifier_does_not_navigate() {
let (mut app, [r1, _, _]) = radio_group_app();
app.dom_mut().set_focused(Some(r1));
app.handle_event(key_press(KeyCode::Down, KeyModifiers::SHIFT));
assert_eq!(app.dom().focused(), Some(r1));
}
#[test]
fn arrow_on_radio_without_name_is_noop() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let r = dom.create_element("input");
dom.set_attribute(r, "type", "radio").unwrap();
dom.append_child(root, r).unwrap();
let mut app = test_app(dom, Stylesheet::new());
app.dom_mut().set_focused(Some(r));
app.handle_event(key_press(KeyCode::Down, KeyModifiers::empty()));
assert_eq!(app.dom().focused(), Some(r));
}
#[test]
fn space_on_focused_radio_selects_it() {
let (mut app, [_r1, r2, _r3]) = radio_group_app();
app.dom_mut().set_focused(Some(r2));
app.handle_event(key_press(KeyCode::Char(' '), KeyModifiers::empty()));
assert!(app.dom().node(r2).has_attribute("checked"));
}
#[test]
fn click_on_checkbox_beside_text_toggles_and_selects_no_text() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let p = dom.create_element("p");
let t = dom.create_text_node("some prose");
dom.append_child(p, t).unwrap();
let cb = dom.create_element("input");
dom.set_attribute(cb, "type", "checkbox").unwrap();
dom.append_child(root, p).unwrap();
dom.append_child(root, cb).unwrap();
let sheet = Stylesheet::new()
.rule_unchecked("p", TuiStyle::new().height(Size::Fixed(1)))
.rule_unchecked(
"input[type=checkbox]",
TuiStyle::new()
.display(crate::layout::Display::Block)
.width(Size::Fixed(10))
.height(Size::Fixed(1)),
);
let mut app = test_app(dom, sheet);
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 1);
assert!(
app.dom().node(cb).has_attribute("checked"),
"the click reached the box"
);
assert!(
app.dom().selection().is_none_or(|s| s.is_collapsed()),
"no text was selected"
);
}
#[test]
fn click_on_user_select_auto_checkbox_beside_text_still_toggles() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let p = dom.create_element("p");
let t = dom.create_text_node("some prose");
dom.append_child(p, t).unwrap();
let cb = dom.create_element("input");
dom.set_attribute(cb, "type", "checkbox").unwrap();
dom.append_child(root, p).unwrap();
dom.append_child(root, cb).unwrap();
let sheet = Stylesheet::new()
.rule_unchecked("p", TuiStyle::new().height(Size::Fixed(1)))
.rule_unchecked(
"input[type=checkbox]",
TuiStyle::new()
.display(crate::layout::Display::Block)
.width(Size::Fixed(10))
.height(Size::Fixed(1))
.user_select(crate::layout::UserSelect::Auto),
);
let mut app = test_app(dom, sheet);
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 1);
assert!(
app.dom().node(cb).has_attribute("checked"),
"the click reached the box"
);
click_at(&mut app, 1, 1);
assert!(
!app.dom().node(cb).has_attribute("checked"),
"and a second click flips it back"
);
}
#[test]
fn uppercase_checkbox_type_toggles_and_gets_the_ua_glyph() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let cb = dom.create_element("input");
dom.set_attribute(cb, "type", "CHECKBOX").unwrap();
dom.set_attribute(cb, "class", "box").unwrap();
dom.append_child(root, cb).unwrap();
let sheet = Stylesheet::new().rule_unchecked(
".box",
TuiStyle::new()
.width(Size::Fixed(10))
.height(Size::Fixed(1)),
);
let mut app = test_app(dom, sheet);
app.draw_if_dirty().unwrap();
click_at(&mut app, 1, 0);
assert!(app.dom().node(cb).has_attribute("checked"));
assert!(
app.dom()
.matches(cb, "input[type=checkbox]:checked")
.unwrap(),
"the UA `input[type=checkbox]` rules match an uppercase type"
);
}