use compact_str::CompactString;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use htui::events::EventBindings;
use htui::html::{css_parse, html_parse};
use htui::layout::layout::TermMap;
use htui::script::ScriptEngine;
use htui::tdom::node::{NodeArena, NodeId, NodeType};
use htui::tui::graph_api::Block;
use htui::tui::graph_api::{
InputRenderState, InputState, TermLayout, apply_input_key, apply_input_word_key,
collect_input_ids, is_input_field_key, key_event_info, next_word_boundary,
number_input_allows_char, prev_word_boundary, selection_range,
};
fn init() {
let _ = env_logger::builder().is_test(true).try_init();
}
fn setup(html: &str) -> (NodeId, NodeArena) {
let mut html_parser = html_parse::Parser::new(html);
let (root_id, mut arena, embedded) = html_parser.parse();
if !embedded.trim().is_empty() {
let mut css_parser = css_parse::Parser::new(&embedded);
let rules = css_parser.parse();
css_parse::css_into_node(&mut arena, root_id, &rules);
}
(root_id, arena)
}
const COLS: u16 = 80;
const ROWS: u16 = 24;
fn mock_term_map() -> TermMap {
TermMap {
columns: COLS,
rows: ROWS,
}
}
fn render_rows(html: &str) -> (Vec<String>, TermLayout) {
let (root_id, arena) = setup(html);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let cols = term_map.columns as usize;
let mut rows: Vec<String> = Vec::new();
for chunk in tl.layout.chunks(cols) {
let s: String = chunk.iter().map(|b| b.word.as_str()).collect();
rows.push(s);
}
(rows, tl)
}
fn layout_boxes(arena: &NodeArena, root: NodeId) -> Vec<htui::layout::LayoutBox> {
use htui::layout::{BoxConstraint, Cursor, layout_tree};
let containing = BoxConstraint::root(&mock_term_map());
let mut cursor = Cursor::new();
layout_tree(
arena,
root,
containing,
&mut cursor,
COLS as usize,
ROWS as usize,
)
}
fn find_box(
boxes: &[htui::layout::LayoutBox],
node_id: NodeId,
) -> Option<&htui::layout::LayoutBox> {
for b in boxes {
if b.node_id == node_id {
return Some(b);
}
if let Some(found) = find_box(&b.children, node_id) {
return Some(found);
}
}
None
}
#[test]
fn select_renders_as_field_with_current_label() {
init();
let (rows, _) = render_rows(
r#"<div><select value="b"><option>A</option><option value="b">BB</option></select></div>"#,
);
let r = &rows[0];
assert!(
r.contains("BB"),
"select must show the selected option; row={r:?}"
);
}
#[test]
fn select_renders_first_option_without_value() {
init();
let (rows, _) =
render_rows(r#"<div><select><option>Alpha</option><option>Beta</option></select></div>"#);
let r = &rows[0];
assert!(
r.contains("Alpha"),
"select must fall back to the first option; row={r:?}"
);
}
fn find_input(arena: &NodeArena, root: NodeId) -> NodeId {
let mut stack = vec![root];
while let Some(id) = stack.pop() {
let node = &arena[id];
if node.node_type == NodeType::Input {
return id;
}
stack.extend(node.children.iter().copied());
}
panic!("no <input> in document");
}
fn find_textarea(arena: &NodeArena, root: NodeId) -> NodeId {
let mut stack = vec![root];
while let Some(id) = stack.pop() {
let node = &arena[id];
if node.node_type == NodeType::Textarea {
return id;
}
stack.extend(node.children.iter().copied());
}
panic!("no <textarea> in document");
}
#[test]
fn selection_range_orders_anchor_and_caret() {
assert_eq!(selection_range(1, 4), (1, 4));
assert_eq!(selection_range(4, 1), (1, 4));
assert_eq!(selection_range(2, 2), (2, 2));
}
#[test]
fn input_renders_selection_highlight() {
init();
let (root_id, arena) = setup(r#"<div><input></div>"#);
let input_id = find_input(&arena, root_id);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.input_states.insert(
input_id,
InputRenderState {
value: "hello".into(),
cursor: 4,
focused: true,
selection: Some((1, 4)),
},
);
tl.draw(&arena, root_id, &term_map);
let cell = |x: usize| -> &Block { &tl.layout[x] };
assert_eq!(cell(0).word, "h");
assert_eq!(
cell(0).bcolor,
Some(crossterm::style::Color::DarkGrey),
"unselected char sits on the default control background"
);
assert_eq!(
cell(1).bcolor,
Some(crossterm::style::Color::Rgb {
r: 0x3b,
g: 0x42,
b: 0x61,
}),
"selected char gets the highlight bg"
);
assert_eq!(cell(3).bcolor, cell(1).bcolor, "selection is contiguous");
assert_eq!(
cell(4).bcolor,
Some(crossterm::style::Color::White),
"caret cell stays inverted"
);
}
#[test]
fn input_renders_empty_field_without_panicking() {
init();
let (rows, _) = render_rows(r#"<div><input></div>"#);
assert!(
rows[0].chars().take(8).all(|c| c == ' '),
"empty field paints 8 blank cells; row={:?}",
rows[0]
);
}
#[test]
fn input_long_value_windows_around_the_caret() {
init();
let (root_id, arena) = setup(r#"<div><input width="8"></div>"#);
let input_id = find_input(&arena, root_id);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.input_states.insert(
input_id,
InputRenderState {
value: "abcdefghijkl".into(),
cursor: 12,
focused: true,
selection: None,
},
);
tl.draw(&arena, root_id, &term_map);
let row: String = tl.layout.iter().take(8).map(|b| b.word.as_str()).collect();
assert_eq!(row, "fghijkl ");
}
#[test]
fn input_renders_multibyte_values_at_display_width() {
init();
let (rows, _) = render_rows(r#"<div><input value="你好"></div>"#);
let r = &rows[0];
assert_eq!(r.chars().next(), Some('你'));
assert_eq!(r.chars().nth(1), Some('好'));
assert_eq!(r.chars().nth(2), Some(' '), "filler after the wide glyphs");
assert_eq!(r.chars().nth(7), Some(' '), "field stays 8 cells wide");
}
#[test]
fn set_value_commits_edited_value_to_the_dom_and_marks_dirty() {
init();
let mut engine = ScriptEngine::new(
r#"<div><input value="initial"></div>"#,
"",
EventBindings::default(),
)
.expect("engine");
let input_id = find_input(&engine.arena(), engine.root_id());
engine.set_value(input_id, "edited");
assert!(
engine.take_dirty().nodes.contains(&input_id),
"set_value must mark the node dirty"
);
let stored = engine
.arena()
.get(input_id)
.and_then(|n| n.attributes.get("value"))
.cloned();
assert_eq!(stored.as_deref(), Some("edited"));
engine.set_value(input_id, "edited");
assert!(
engine.take_dirty().is_empty(),
"committing the same value again must not mark dirty"
);
}
#[test]
fn layout_drops_unfocused_input_state_but_keeps_focused() {
init();
let (root_id, arena) = setup(r#"<div><input value="hi"></div>"#);
let input_id = find_input(&arena, root_id);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.input_states.insert(
input_id,
InputRenderState {
value: "stale".into(),
cursor: 0,
focused: false,
selection: None,
},
);
tl.draw(&arena, root_id, &term_map);
assert!(
!tl.input_states.contains_key(&input_id),
"layout must drop stale unfocused entries"
);
let row: String = tl.layout.iter().take(8).map(|b| b.word.as_str()).collect();
assert_eq!(row, "hi ", "unfocused field paints the DOM value");
tl.input_states.insert(
input_id,
InputRenderState {
value: "typing".into(),
cursor: 5,
focused: true,
selection: None,
},
);
tl.draw(&arena, root_id, &term_map);
assert!(
tl.input_states.contains_key(&input_id),
"focused edit state must survive layout"
);
let row: String = tl.layout.iter().take(8).map(|b| b.word.as_str()).collect();
assert_eq!(row, "typing ", "focused field paints the live edit buffer");
}
#[test]
fn input_renders_as_field_with_value() {
init();
let (rows, _) = render_rows(r#"<div><input value="hi"></div>"#);
assert_eq!(rows[0].chars().next(), Some('h'));
assert_eq!(rows[0].chars().nth(1), Some('i'));
assert_eq!(rows[0].chars().nth(7), Some(' '), "field is 8 cells wide");
assert_eq!(rows[0].chars().nth(8), Some(' '), "field must not overflow");
}
#[test]
fn input_respects_width_attribute() {
init();
let (rows, _) = render_rows(r#"<div><input width="4" value="abc"></div>"#);
assert_eq!(rows[0].chars().next(), Some('a'));
assert_eq!(rows[0].chars().nth(1), Some('b'));
assert_eq!(rows[0].chars().nth(2), Some('c'));
assert_eq!(rows[0].chars().nth(3), Some(' '), "field is 4 cells wide");
}
#[test]
fn input_field_key_classifies_editing_keys() {
assert!(is_input_field_key(KeyCode::Char('a')));
assert!(is_input_field_key(KeyCode::Backspace));
assert!(is_input_field_key(KeyCode::Delete));
assert!(is_input_field_key(KeyCode::Left));
assert!(is_input_field_key(KeyCode::Right));
assert!(is_input_field_key(KeyCode::Home));
assert!(is_input_field_key(KeyCode::End));
assert!(!is_input_field_key(KeyCode::Down));
assert!(!is_input_field_key(KeyCode::Up));
assert!(!is_input_field_key(KeyCode::PageDown));
assert!(!is_input_field_key(KeyCode::PageUp));
assert!(!is_input_field_key(KeyCode::Enter));
assert!(!is_input_field_key(KeyCode::F(1)));
}
#[test]
fn apply_input_key_inserts_removes_and_moves() {
let mut value = CompactString::from("hello");
let mut cursor = 5;
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Char('!')));
assert_eq!(value, "hello!");
assert_eq!(cursor, 6);
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Backspace));
assert_eq!(value, "hello");
assert_eq!(cursor, 5);
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Left));
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Char('X')));
assert_eq!(value, "hellXo");
assert_eq!(cursor, 5);
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Delete));
assert_eq!(value, "hellX");
}
#[test]
fn apply_input_key_home_end_and_limits() {
let mut value = CompactString::from("ab");
let mut cursor = 2;
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Home));
assert_eq!(cursor, 0);
assert!(!apply_input_key(&mut value, &mut cursor, KeyCode::Left));
assert!(!apply_input_key(
&mut value,
&mut cursor,
KeyCode::Backspace
));
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::End));
assert_eq!(cursor, 2);
assert!(!apply_input_key(&mut value, &mut cursor, KeyCode::Right));
assert!(!apply_input_key(&mut value, &mut cursor, KeyCode::Delete));
assert!(!apply_input_key(&mut value, &mut cursor, KeyCode::Enter));
assert!(!apply_input_key(&mut value, &mut cursor, KeyCode::Esc));
}
#[test]
fn apply_input_key_handles_multibyte_chars() {
let mut value = CompactString::from("你好");
let mut cursor = 0;
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Char('!')));
assert_eq!(value, "!你好");
assert_eq!(cursor, 1);
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Backspace));
assert_eq!(value, "你好");
assert_eq!(cursor, 0);
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::End));
assert!(apply_input_key(&mut value, &mut cursor, KeyCode::Backspace));
assert_eq!(value, "你");
assert_eq!(cursor, 1);
}
#[test]
fn engine_input_state_renders_value_and_caret() {
init();
let (root_id, arena) = setup(r#"<div><input value="initial"></div>"#);
let input_id = find_input(&arena, root_id);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.input_states.insert(
input_id,
InputRenderState {
value: "abc".into(),
cursor: 1,
focused: true,
selection: None,
},
);
tl.draw(&arena, root_id, &term_map);
let cell = |x: usize| -> &Block { &tl.layout[x] };
assert_eq!(cell(0).word, "a");
assert_eq!(cell(1).word, "b");
assert_eq!(cell(2).word, "c");
assert_eq!(cell(7).word, " ");
assert_eq!(cell(0).fcolor, None);
assert_eq!(cell(0).bcolor, Some(crossterm::style::Color::DarkGrey));
assert_eq!(cell(1).bcolor, Some(crossterm::style::Color::White));
}
#[test]
fn input_caret_color_paints_the_caret_cell() {
init();
let (root_id, arena) =
setup(r#"<div><input value="initial"></div><style>input { caret-color: red; }</style>"#);
let input_id = find_input(&arena, root_id);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.input_states.insert(
input_id,
InputRenderState {
value: "abc".into(),
cursor: 1,
focused: true,
selection: None,
},
);
tl.draw(&arena, root_id, &term_map);
let cell = |x: usize| -> &Block { &tl.layout[x] };
assert_eq!(
cell(1).bcolor,
Some(crossterm::style::Color::Rgb { r: 255, g: 0, b: 0 }),
"caret cell must take the caret-color"
);
assert_eq!(cell(0).bcolor, Some(crossterm::style::Color::DarkGrey));
}
#[test]
fn textarea_caret_color_paints_the_caret_cell() {
init();
let (root_id, arena) = setup(
r#"<div><textarea rows="3">initial</textarea></div>
<style>textarea { caret-color: blue; }</style>"#,
);
let textarea_id = find_textarea(&arena, root_id);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.input_states.insert(
textarea_id,
InputRenderState {
value: "hi".into(),
cursor: 1,
focused: true,
selection: None,
},
);
tl.draw(&arena, root_id, &term_map);
let cols = term_map.columns as usize;
let cell = |x: usize, y: usize| -> &Block { &tl.layout[y * cols + x] };
assert_eq!(
cell(1, 0).bcolor,
Some(crossterm::style::Color::Rgb { r: 0, g: 0, b: 255 }),
"caret cell must take the caret-color"
);
}
#[test]
fn word_boundaries_match_browser_semantics() {
let s = "hello brave new world";
assert_eq!(
prev_word_boundary(s, 11),
6,
"back to the previous word start"
);
assert_eq!(next_word_boundary(s, 0), 5, "forward to the next word end");
assert_eq!(
next_word_boundary(s, 5),
11,
"whitespace + the next word are covered in one step (browser semantics)"
);
assert_eq!(prev_word_boundary(s, 6), 0, "back over a leading word");
assert_eq!(prev_word_boundary("a,b", 2), 1, "stop before the comma");
assert_eq!(next_word_boundary("a,b", 0), 1, "stop after `a`");
assert_eq!(prev_word_boundary("ab", 0), 0);
assert_eq!(next_word_boundary("ab", 2), 2);
assert_eq!(prev_word_boundary("", 0), 0);
assert_eq!(next_word_boundary("", 0), 0);
}
#[test]
fn apply_word_key_moves_caret_by_word() {
let mut value = CompactString::from("hello brave new world");
let mut cursor = 0;
let mut anchor = None;
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Right,
KeyModifiers::CONTROL,
));
assert_eq!(cursor, 5);
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Left,
KeyModifiers::CONTROL,
));
assert_eq!(cursor, 0);
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Right,
KeyModifiers::ALT,
));
assert_eq!(cursor, 5);
cursor = 0;
assert!(!apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Left,
KeyModifiers::ALT,
));
assert_eq!(cursor, 0);
}
#[test]
fn apply_word_key_deletes_by_word() {
let mut value = CompactString::from("hello world");
let mut cursor = 11;
let mut anchor = None;
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Backspace,
KeyModifiers::CONTROL,
));
assert_eq!(value, "hello ");
assert_eq!(cursor, 6, "caret lands on the deleted word's start");
let mut value = CompactString::from("hello world");
let mut cursor = 6;
let mut anchor = None;
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Delete,
KeyModifiers::CONTROL,
));
assert_eq!(value, "hello ");
assert_eq!(cursor, 6);
let mut value = CompactString::from("abc def");
let mut cursor = 3;
let mut anchor = None;
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Home,
KeyModifiers::CONTROL,
));
assert_eq!(cursor, 0);
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::End,
KeyModifiers::CONTROL,
));
assert_eq!(cursor, 7);
}
#[test]
fn apply_word_key_shift_extends_the_selection() {
let mut value = CompactString::from("hello brave");
let mut cursor = 0;
let mut anchor = None;
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(cursor, 5);
assert_eq!(anchor, Some(0), "selection anchor stays at the start");
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(cursor, 11);
assert_eq!(anchor, Some(0));
assert!(apply_input_word_key(
&mut value,
&mut cursor,
&mut anchor,
KeyCode::Left,
KeyModifiers::CONTROL,
));
assert_eq!(anchor, None, "plain navigation clears the selection");
}
#[test]
fn collect_input_ids_returns_document_order() {
init();
let (root_id, arena) =
setup(r#"<div><input id="first"><p><input id="second"><input id="third"></p></div>"#);
let ids = collect_input_ids(&arena);
let labels: Vec<&str> = ids
.iter()
.map(|&id| {
arena[id]
.attributes
.get("id")
.map(String::as_str)
.unwrap_or("?")
})
.collect();
assert_eq!(
labels,
vec!["first", "second", "third"],
"Tab order follows DOM order"
);
assert!(ids.iter().all(|&id| id != root_id), "root is not an input");
}
#[test]
fn key_event_info_maps_every_key_to_browser_fields() {
let mut info = key_event_info(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert_eq!(info.key, "a");
assert_eq!(info.code, "KeyA");
assert_eq!(info.key_code, 'a' as u32);
assert!(!info.ctrl && !info.shift && !info.alt && !info.meta && !info.repeat);
info = key_event_info(&KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(info.key, " ");
assert_eq!(info.code, "Space");
assert_eq!(info.key_code, 32);
info = key_event_info(&KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE));
assert_eq!(info.code, "Digit5");
info = key_event_info(&KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT));
assert_eq!(info.key, "ArrowUp");
assert_eq!(info.key_code, 38);
assert!(info.shift);
info = key_event_info(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!((info.key.as_str(), info.key_code), ("Enter", 13));
info = key_event_info(&KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
assert_eq!(
(info.key.as_str(), info.code.as_str(), info.key_code),
("F5", "F5", 116)
);
info = key_event_info(&KeyEvent::new(KeyCode::Esc, KeyModifiers::CONTROL));
assert_eq!(info.key, "Escape");
assert!(info.ctrl);
let ev = KeyEvent {
code: KeyCode::Char('x'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Repeat,
state: KeyEventState::empty(),
};
info = key_event_info(&ev);
assert!(
info.repeat,
"repeat presses must be visible to JS listeners"
);
}
#[test]
fn input_state_clamps_scroll_to_the_content_ceiling() {
let mut state = InputState::new();
state.scroll_y = 60; state.clamp_scroll_to(76); assert_eq!(state.scroll_y, 60);
state.clamp_scroll_to(60); assert_eq!(state.scroll_y, 60);
state.clamp_scroll_to(59); assert_eq!(state.scroll_y, 59);
let mut fresh = InputState::new();
fresh.scroll_y = 5;
fresh.clamp_scroll_to(u32::MAX);
assert_eq!(fresh.scroll_y, 5);
}
#[test]
fn input_button_types_render_as_buttons() {
init();
let (rows, _) = render_rows(
r#"<div><input type="button" value="X"><input type="submit" value="Save"><input type="reset" value="Clear"></div>"#,
);
assert_eq!(
rows[0].trim(),
"X",
"button input paints its label centred; row={:?}",
rows[0]
);
assert_eq!(
rows[1].trim(),
"Save",
"submit input paints its value label; row={:?}",
rows[1]
);
assert_eq!(
rows[2].trim(),
"Clear",
"reset input paints its value label; row={:?}",
rows[2]
);
}
#[test]
fn input_button_default_labels_submit_reset() {
init();
let (rows, _) = render_rows(r#"<div><input type="submit"><input type="reset"></div>"#);
assert_eq!(
rows[0].trim(),
"Submit",
"default submit label; row={:?}",
rows[0]
);
assert_eq!(
rows[1].trim(),
"Reset",
"default reset label; row={:?}",
rows[1]
);
}
#[test]
fn input_button_geometry_sizes_from_label() {
init();
let (root_id, arena) = setup(
r#"<div><input id="sub" type="submit" value="Go"><input id="txt" type="text" width="20"></div>"#,
);
let boxes = layout_boxes(&arena, root_id);
let id_of = |id: &str| {
arena
.iter()
.find(|(_, n)| n.attributes.get("id").map(String::as_str) == Some(id))
.map(|(nid, _)| nid)
.expect(id)
};
let submit = find_box(&boxes, id_of("sub")).expect("submit box");
assert_eq!(
submit.rect.width, 6,
"`Go` button is 6 columns (label + padding)"
);
let text = find_box(&boxes, id_of("txt")).expect("text box");
assert_eq!(text.rect.width, 20, "text field keeps the width attribute");
}
#[test]
fn input_button_skipped_by_tab_focus_cycle() {
init();
let (_, arena) = setup(
r#"<div><input id="a" type="text"><input type="submit"><input type="button" value="X"><input id="b" type="text"></div>"#,
);
let ids = collect_input_ids(&arena);
let labels: Vec<&str> = ids
.iter()
.map(|&id| {
arena[id]
.attributes
.get("id")
.map(String::as_str)
.unwrap_or("?")
})
.collect();
assert_eq!(
labels,
vec!["a", "b"],
"button inputs must not enter the text-field tab order"
);
}
#[test]
fn input_placeholder_shows_dimmed_hint_when_empty() {
init();
let (_, tl) = render_rows(r#"<div><input placeholder="Type here" width="20"></div>"#);
let hint_cells: Vec<&Block> = tl.layout.iter().filter(|b| !b.word.is_empty()).collect();
let text: String = hint_cells.iter().map(|b| b.word.as_str()).collect();
let text = text.trim();
assert_eq!(
text, "Type here",
"placeholder must render in the empty field"
);
assert!(
hint_cells
.iter()
.filter(|b| b.word != " ")
.all(|b| b.fcolor == Some(crossterm::style::Color::DarkGrey)),
"placeholder hint must be dimmed grey"
);
}
#[test]
fn input_placeholder_hidden_when_value_present() {
init();
let (_, tl) = render_rows(r#"<div><input placeholder="hint" value="val"></div>"#);
let text: String = tl
.layout
.iter()
.filter(|b| !b.word.is_empty() && b.word != " ")
.map(|b| b.word.as_str())
.collect();
assert_eq!(text, "val", "the value renders, not the placeholder");
}
#[test]
fn input_disabled_field_is_dimmed() {
init();
let (_, tl) = render_rows(r#"<div><input disabled value="off"></div>"#);
let cells: Vec<&Block> = tl
.layout
.iter()
.filter(|b| !b.word.is_empty() && b.word != " ")
.collect();
let text: String = cells.iter().map(|b| b.word.as_str()).collect();
assert_eq!(text, "off", "disabled field keeps its value");
assert!(
cells
.iter()
.all(|b| b.fcolor == Some(crossterm::style::Color::DarkGrey)),
"disabled field must be dimmed grey"
);
}
#[test]
fn number_input_character_filter_matches_browser() {
init();
for ok in ['0', '9', '-', '+', '.', 'e', 'E'] {
assert!(number_input_allows_char(ok), "{ok:?} must be accepted");
}
for bad in ['a', 'x', '_', ' ', ',', '#', '你'] {
assert!(!number_input_allows_char(bad), "{bad:?} must be rejected");
}
}
#[test]
fn input_checkbox_radio_disabled_is_dimmed() {
init();
let (rows, tl) = render_rows(
r#"<div><input id="c" type="checkbox" disabled><input id="r" type="radio" disabled checked></div>"#,
);
let dark = crossterm::style::Color::DarkGrey;
let cols = COLS as usize;
let head = |row: &str| row.chars().take(2).collect::<String>();
assert_eq!(head(&rows[0]), "□ ");
assert_eq!(head(&rows[1]), "● ");
let glyph_idx: [usize; 4] = [0, 1, cols, cols + 1];
assert!(
glyph_idx.iter().all(|&i| tl.layout[i].fcolor == Some(dark)),
"disabled toggles must be dimmed grey"
);
}
#[test]
fn input_image_renders_as_button() {
init();
let (rows, _) = render_rows(
r#"<div><input type="image" value="Go"><input type="image" alt="Logo"><input type="image"></div>"#,
);
assert_eq!(
rows[0].trim(),
"Go",
"image input uses its value label; row={:?}",
rows[0]
);
assert_eq!(
rows[1].trim(),
"Logo",
"image input falls back to alt; row={:?}",
rows[1]
);
assert_eq!(
rows[2].trim(),
"Submit Query",
"image input defaults to `Submit Query`; row={:?}",
rows[2]
);
}
#[test]
fn input_honours_css_size_and_margin_auto() {
init();
let (root_id, arena) =
setup(r#"<style>input { width: 16px; height: 24px; margin: 0 auto; }</style><input>"#);
let boxes = layout_boxes(&arena, root_id);
let input = find_input(&arena, root_id);
let r = find_box(&boxes, input).expect("input box");
assert_eq!(r.rect.width, 2, "16px resolves to 2 cells");
assert_eq!(r.rect.height, 3, "24px resolves to 3 rows");
assert_eq!(
r.rect.x,
(COLS as usize - 2) / 2,
"auto horizontal margins must centre the field"
);
}
#[test]
fn textarea_css_size_overrides_cols_rows() {
init();
let (root_id, arena) = setup(
r#"<style>textarea { width: 40px; height: 16px; }</style><textarea cols="60" rows="8"></textarea>"#,
);
let boxes = layout_boxes(&arena, root_id);
let textarea = find_textarea(&arena, root_id);
let r = find_box(&boxes, textarea).expect("textarea box");
assert_eq!(r.rect.width, 5, "40px resolves to 5 cells");
assert_eq!(r.rect.height, 2, "16px resolves to 2 rows");
}
#[test]
fn input_min_width_beats_max_width_when_they_conflict() {
init();
let (root_id, arena) =
setup(r#"<style>input { width: 8px; max-width: 8px; min-width: 24px; }</style><input>"#);
let boxes = layout_boxes(&arena, root_id);
let input = find_input(&arena, root_id);
let r = find_box(&boxes, input).expect("input box");
assert_eq!(
r.rect.width, 3,
"24px min-width must win over 8px max-width"
);
}
#[test]
fn input_without_css_keeps_attribute_or_default_size() {
init();
let (root_id, arena) = setup(r#"<input width="12">"#);
let boxes = layout_boxes(&arena, root_id);
let input = find_input(&arena, root_id);
let r = find_box(&boxes, input).expect("input box");
assert_eq!(r.rect.width, 12);
assert_eq!(r.rect.height, 1);
assert_eq!(r.rect.x, 0);
let (root_id, arena) = setup(r#"<input>"#);
let boxes = layout_boxes(&arena, root_id);
let input = find_input(&arena, root_id);
let r = find_box(&boxes, input).expect("default input box");
assert_eq!(r.rect.width, 8);
assert_eq!(r.rect.x, 0);
}
#[test]
fn collect_input_ids_includes_range_sliders() {
init();
let (root, arena) = setup(
r#"<input value="text"><input type="range" min="0" max="4" value="2"><input type="checkbox">"#,
);
let ids = collect_input_ids(&arena);
assert_eq!(
ids.len(),
2,
"text and range are focusable; checkbox is not"
);
assert_ne!(ids[0], ids[1]);
let _ = root;
}
#[test]
fn range_input_renders_track_and_thumb() {
init();
let (rows, _) =
render_rows(r#"<input type="range" min="0" max="4" step="1" value="2" width="9">"#);
let slider: String = rows[0].chars().take(9).collect();
assert_eq!(
slider, "\u{2501}\u{2501}\u{2501}\u{2501}\u{25CF}\u{2500}\u{2500}\u{2500}\u{2500}",
"value 2 of 4 places the thumb halfway across a 9-cell track"
);
}
#[test]
fn range_pseudo_element_webkit_thumb_color() {
init();
let html = r#"<style>
input::-webkit-slider-thumb { background-color: #ff0000; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9">"#;
let (_, tl) = render_rows(html);
let cell = |x: usize| &tl.layout[x]; assert_eq!(
cell(4).fcolor,
Some(crossterm::style::Color::Rgb {
r: 0xff,
g: 0x00,
b: 0x00
}),
"::-webkit-slider-thumb background-color colours the thumb"
);
}
#[test]
fn range_pseudo_element_moz_thumb_color() {
init();
let html = r#"<style>
input::-moz-range-thumb { background-color: #00ff00; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9">"#;
let (_, tl) = render_rows(html);
let cell = |x: usize| &tl.layout[x];
assert_eq!(
cell(4).fcolor,
Some(crossterm::style::Color::Rgb {
r: 0x00,
g: 0xff,
b: 0x00
}),
"::-moz-range-thumb background-color colours the thumb"
);
}
#[test]
fn range_unfilled_track_stays_neutral_with_accent_color() {
init();
let html = r#"<style>
input { accent-color: #43e5a0; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9">"#;
let (_, tl) = render_rows(html);
let accent = crossterm::style::Color::Rgb {
r: 0x43,
g: 0xe5,
b: 0xa0,
};
assert_eq!(
tl.layout[0].fcolor,
Some(accent),
"the filled track takes accent-color"
);
assert_ne!(
tl.layout[8].fcolor,
Some(accent),
"the unfilled track stays neutral so filled and unfilled remain distinguishable"
);
}
#[test]
fn range_pseudo_element_moz_progress_color() {
init();
let html = r#"<style>
input::-moz-range-progress { background-color: #0000ff; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9">"#;
let (_, tl) = render_rows(html);
let cell = |x: usize| &tl.layout[x];
assert_eq!(
cell(0).fcolor,
Some(crossterm::style::Color::Rgb {
r: 0x00,
g: 0x00,
b: 0xff
}),
"::-moz-range-progress background-color colours the filled track"
);
assert_eq!(
cell(6).fcolor,
Some(crossterm::style::Color::DarkGrey),
"unfilled track without ::-moz-range-track stays DarkGrey"
);
}
#[test]
fn range_pseudo_element_track_color() {
init();
let html = r#"<style>
input::-webkit-slider-runnable-track { background-color: #ffff00; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9">"#;
let (_, tl) = render_rows(html);
let cell = |x: usize| &tl.layout[x];
assert_eq!(
cell(6).fcolor,
Some(crossterm::style::Color::Rgb {
r: 0xff,
g: 0xff,
b: 0x00
}),
"::-webkit-slider-runnable-track background-color colours the unfilled track"
);
}
#[test]
fn range_pseudo_element_moz_track_color() {
init();
let html = r#"<style>
input::-moz-range-track { color: #00ffff; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9">"#;
let (_, tl) = render_rows(html);
let cell = |x: usize| &tl.layout[x];
assert_eq!(
cell(6).fcolor,
Some(crossterm::style::Color::Rgb {
r: 0x00,
g: 0xff,
b: 0xff
}),
"::-moz-range-track color colours the unfilled track"
);
}
#[test]
fn range_accent_color_fallback_for_thumb_and_fill() {
init();
let html = r#"<style>
input { accent-color: #ff8800; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9">"#;
let (_, tl) = render_rows(html);
let cell = |x: usize| &tl.layout[x];
let orange = Some(crossterm::style::Color::Rgb {
r: 0xff,
g: 0x88,
b: 0x00,
});
assert_eq!(cell(4).fcolor, orange, "accent-color colours the thumb");
assert_eq!(
cell(0).fcolor,
orange,
"accent-color colours the filled track"
);
}
#[test]
fn range_pseudo_element_overrides_accent_color() {
init();
let html = r#"<style>
input { accent-color: #ff8800; }
input::-webkit-slider-thumb { background-color: #00ff00; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9">"#;
let (_, tl) = render_rows(html);
let cell = |x: usize| &tl.layout[x];
assert_eq!(
cell(4).fcolor,
Some(crossterm::style::Color::Rgb {
r: 0x00,
g: 0xff,
b: 0x00
}),
"pseudo-element thumb overrides accent-color"
);
assert_eq!(
cell(0).fcolor,
Some(crossterm::style::Color::Rgb {
r: 0xff,
g: 0x88,
b: 0x00
}),
"filled track without a pseudo-element uses accent-color"
);
}
#[test]
fn checkbox_accent_color() {
init();
let html = r#"<style>input { accent-color: red; }</style>
<input type="checkbox" checked>"#;
let (_, tl) = render_rows(html);
let cell = &tl.layout[0];
assert_eq!(
cell.fcolor,
Some(crossterm::style::Color::Rgb {
r: 0xff,
g: 0x00,
b: 0x00
}),
"accent-color themes the checked checkbox glyph"
);
}
#[test]
fn range_pseudo_element_disabled_overrides_colors() {
init();
let html = r#"<style>
input::-webkit-slider-thumb { background-color: #ff0000; }
input::-moz-range-progress { background-color: #0000ff; }
</style>
<input type="range" min="0" max="4" step="1" value="2" width="9" disabled>"#;
let (_, tl) = render_rows(html);
let cell = |x: usize| &tl.layout[x];
assert_eq!(
cell(4).fcolor,
Some(crossterm::style::Color::DarkGrey),
"disabled slider ignores pseudo-element thumb colour"
);
assert_eq!(
cell(0).fcolor,
Some(crossterm::style::Color::DarkGrey),
"disabled slider ignores pseudo-element progress colour"
);
}
#[test]
fn clipped_range_control_does_not_paint_over_parent_border() {
init();
let html = r#"<style>
#shell { width: 10ch; height: 3ch; overflow: hidden; border: 1ch solid #888; }
input { width: 20ch; margin-left: 1ch; }
</style>
<div id="shell"><input type="range" min="0" max="9" step="1" value="4"></div>"#;
let (_, tl) = render_rows(html);
let cols = 80;
let word = |x: usize, y: usize| tl.layout[y * cols + x].word.as_str().to_string();
assert_eq!(word(0, 1), "\u{2502}", "left border stays intact");
assert_eq!(word(9, 1), "\u{2502}", "right border stays intact");
assert_eq!(word(10, 1), " ", "control must stop at its inherited clip");
assert_ne!(
word(2, 1),
" ",
"the visible track starts inside the parent"
);
assert_ne!(word(8, 1), " ", "the visible track ends inside the parent");
}