//! The backend end to end, with no terminal. //! //! Every one of these mounts real nodes through the same calls the ABI makes, //! paints a real frame, and asserts on the lines that came out — which is the //! whole reason painting goes through a grid. Nothing here needs a TTY, a //! display or raw mode, so it all runs in CI. use crate::screen::attr; use crate::tree::Value; use crate::ui::Ui; fn ui() -> Ui { Ui::new(24, 8) } /// Mount `tag` under `parent` with the string props given. fn node(ui: &mut Ui, parent: u32, tag: &str, props: &[(&str, &str)]) -> u32 { let id = ui.tree.new_node(tag); for (key, value) in props { ui.tree.set(id, key, Value::Str((*value).to_owned())); } ui.tree.append(parent, id); id } fn events(ui: &mut Ui) -> Vec<(u32, String, String, f64)> { let mut out = Vec::new(); while ui.tree.poll() { let event = ui.tree.current().unwrap(); out.push(( event.node, event.name.to_owned(), event.text.clone(), event.num, )); } out } #[test] fn a_column_paints_its_children_down_the_page() { let mut ui = ui(); let root = ui.tree.root(); node(&mut ui, root, "label", &[("label", "first")]); node(&mut ui, root, "label", &[("label", "second")]); ui.frame(); assert_eq!(ui.screen.line(0), "first"); assert_eq!(ui.screen.line(1), "second"); } #[test] fn a_row_paints_its_children_across_with_its_spacing_between_them() { let mut ui = ui(); let root = ui.tree.root(); let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]); ui.tree.set(row, "spacing", Value::Num(2.0)); node(&mut ui, row, "label", &[("label", "aa")]); node(&mut ui, row, "label", &[("label", "bb")]); ui.frame(); assert_eq!(ui.screen.line(0), "aa bb"); } #[test] fn a_label_wraps_to_the_width_it_was_given() { let mut ui = Ui::new(10, 4); let root = ui.tree.root(); node(&mut ui, root, "label", &[("label", "one two three four")]); ui.frame(); assert_eq!(ui.screen.line(0), "one two"); assert_eq!(ui.screen.line(1), "three four"); } #[test] fn a_frame_draws_a_border_with_its_label_in_the_top_edge() { let mut ui = Ui::new(12, 4); let root = ui.tree.root(); let frame = node(&mut ui, root, "frame", &[("label", "Task")]); node(&mut ui, frame, "label", &[("label", "hi")]); ui.frame(); assert_eq!(ui.screen.line(0), "┌ Task ────┐"); assert_eq!(ui.screen.line(1), "│hi │"); assert_eq!(ui.screen.line(2), "└──────────┘"); } #[test] fn the_first_focusable_widget_takes_focus_and_tab_walks_the_ring() { let mut ui = ui(); let root = ui.tree.root(); let first = node(&mut ui, root, "button", &[("label", "one")]); let second = node(&mut ui, root, "button", &[("label", "two")]); ui.frame(); assert_eq!(ui.focus(), first); ui.key("tab"); assert_eq!(ui.focus(), second); ui.key("tab"); assert_eq!(ui.focus(), first, "the ring wraps"); ui.key("shift+tab"); assert_eq!(ui.focus(), second); } #[test] fn autofocus_beats_paint_order() { let mut ui = ui(); let root = ui.tree.root(); node(&mut ui, root, "button", &[("label", "one")]); let wanted = node(&mut ui, root, "entry", &[]); ui.tree.set(wanted, "autofocus", Value::Bool(true)); ui.frame(); assert_eq!(ui.focus(), wanted); } #[test] fn a_focused_button_is_drawn_in_reverse_and_activates_on_enter() { let mut ui = ui(); let root = ui.tree.root(); let button = node(&mut ui, root, "button", &[("label", "go")]); ui.frame(); assert_eq!(ui.screen.line(0), "[ go ]"); assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); ui.key("enter"); assert_eq!( events(&mut ui), vec![(button, "click".into(), String::new(), 0.0)] ); } #[test] fn a_checkbutton_writes_its_new_state_back_as_well_as_reporting_it() { let mut ui = ui(); let root = ui.tree.root(); let check = node(&mut ui, root, "checkbutton", &[("label", "live")]); ui.frame(); assert_eq!(ui.screen.line(0), "[ ] live"); ui.key("space"); assert_eq!( events(&mut ui), vec![(check, "toggled".into(), String::new(), 1.0)] ); // A caller that ignores the event still has a working control. assert_eq!(ui.tree.props(check).bool("active", false), true); ui.frame(); assert_eq!(ui.screen.line(0), "[x] live"); } #[test] fn typing_in_an_entry_edits_its_text_and_reports_every_change() { let mut ui = ui(); let root = ui.tree.root(); let entry = node(&mut ui, root, "entry", &[("placeholder", "name")]); ui.frame(); assert_eq!( ui.screen.line(0), "name", "the placeholder shows until it is typed in" ); for key in ["h", "i", "space", "there"] { for name in [key] { if name.chars().count() > 1 && name != "space" { for ch in name.chars() { ui.key(&ch.to_string()); } } else { ui.key(name); } } } assert_eq!(ui.tree.props(entry).str("text"), "hi there"); let changes = events(&mut ui); assert_eq!(changes.len(), 8); assert_eq!(changes.last().unwrap().2, "hi there"); ui.frame(); assert_eq!(ui.screen.line(0), "hi there"); } #[test] fn readline_keys_edit_where_the_caret_is() { let mut ui = ui(); let root = ui.tree.root(); let entry = node(&mut ui, root, "entry", &[("text", "one two three")]); ui.frame(); ui.key("ctrl+w"); assert_eq!(ui.tree.props(entry).str("text"), "one two "); ui.key("ctrl+a"); ui.key("delete"); assert_eq!(ui.tree.props(entry).str("text"), "ne two "); ui.key("ctrl+e"); ui.key("backspace"); assert_eq!(ui.tree.props(entry).str("text"), "ne two"); ui.key("ctrl+u"); assert_eq!(ui.tree.props(entry).str("text"), ""); } #[test] fn enter_in_an_entry_activates_rather_than_typing() { let mut ui = ui(); let root = ui.tree.root(); let entry = node(&mut ui, root, "entry", &[("text", "search")]); ui.frame(); ui.key("enter"); assert_eq!( events(&mut ui), vec![(entry, "activate".into(), "search".into(), 0.0)] ); assert_eq!(ui.tree.props(entry).str("text"), "search"); } #[test] fn a_listbox_moves_its_cursor_with_the_arrows_and_with_j_and_k() { let mut ui = ui(); let root = ui.tree.root(); let list = node(&mut ui, root, "listbox", &[]); for name in ["alpha", "beta", "gamma"] { node(&mut ui, list, "label", &[("label", name)]); } ui.frame(); assert_eq!(ui.screen.line(0), "› alpha"); ui.key("j"); assert_eq!( events(&mut ui), vec![(list, "select".into(), "beta".into(), 1.0)] ); ui.key("G"); assert_eq!(ui.tree.props(list).num("selected", -1.0), 2.0); ui.key("enter"); assert_eq!( events(&mut ui), vec![ (list, "select".into(), "gamma".into(), 2.0), (list, "activate".into(), "gamma".into(), 2.0) ] ); ui.frame(); assert_eq!(ui.screen.line(2), "› gamma"); } #[test] fn a_key_nothing_wanted_reaches_the_caller_as_an_event() { let mut ui = ui(); let root = ui.tree.root(); let button = node(&mut ui, root, "button", &[("label", "go")]); ui.frame(); assert!(!ui.key("f5")); assert_eq!( events(&mut ui), vec![(button, "key".into(), "f5".into(), 0.0)] ); } #[test] fn ctrl_c_asks_the_loop_to_stop() { let mut ui = ui(); assert!(!ui.should_close()); ui.key("ctrl+c"); assert!(ui.should_close()); } #[test] fn an_insensitive_subtree_is_dimmed_and_out_of_the_focus_ring() { let mut ui = ui(); let root = ui.tree.root(); let column = node(&mut ui, root, "vbox", &[]); ui.tree.set(column, "sensitive", Value::Bool(false)); node(&mut ui, column, "button", &[("label", "go")]); let live = node(&mut ui, root, "button", &[("label", "live")]); ui.frame(); assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::DIM)); assert_eq!(ui.focus(), live); } #[test] fn a_click_focuses_and_activates_what_is_under_it() { let mut ui = ui(); let root = ui.tree.root(); node(&mut ui, root, "button", &[("label", "one")]); let second = node(&mut ui, root, "button", &[("label", "two")]); ui.frame(); assert!(ui.click(2, 1)); assert_eq!(ui.focus(), second); assert_eq!( events(&mut ui), vec![(second, "click".into(), String::new(), 0.0)] ); assert!(!ui.click(20, 7), "a click on nothing is not an event"); } #[test] fn a_scroll_shows_a_window_of_its_content_and_will_not_go_past_the_end() { let mut ui = Ui::new(10, 3); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[]); for i in 0..6 { node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]); } ui.frame(); assert_eq!(ui.screen.lines(), vec!["row 0", "row 1", "row 2"]); ui.wheel(1, 1, 2); ui.frame(); assert_eq!(ui.screen.lines(), vec!["row 2", "row 3", "row 4"]); ui.wheel(1, 1, 40); ui.frame(); assert_eq!(ui.screen.lines(), vec!["row 3", "row 4", "row 5"]); // The clamped viewport is written back, so the next scroll up starts from // where the reader actually is. assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0); } #[test] fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() { let mut ui = Ui::new(14, 5); let root = ui.tree.root(); node(&mut ui, root, "label", &[("label", "beneath")]); let overlay = node(&mut ui, root, "overlay", &[("label", "Sure?")]); node(&mut ui, overlay, "label", &[("label", "yes")]); ui.frame(); assert_eq!(ui.screen.line(1), " ┌ Sure? ┐"); assert_eq!(ui.screen.line(2), " │yes │"); ui.key("esc"); assert_eq!( events(&mut ui), vec![(overlay, "close".into(), String::new(), 0.0)] ); } #[test] fn a_progress_bar_fills_the_share_of_its_width_it_was_given() { let mut ui = Ui::new(10, 2); let root = ui.tree.root(); let bar = node(&mut ui, root, "progress", &[]); ui.tree.set(bar, "value", Value::Num(0.5)); ui.frame(); assert_eq!(ui.screen.line(0), "█████░░░░░"); } #[test] fn colours_and_attributes_are_inherited_by_a_subtree() { let mut ui = ui(); let root = ui.tree.root(); let column = node(&mut ui, root, "vbox", &[("color", "red")]); ui.tree.set(column, "bold", Value::Bool(true)); node(&mut ui, column, "label", &[("label", "hi")]); ui.frame(); let cell = ui.screen.cell(0, 0).unwrap(); assert_eq!(cell.style.fg, crate::screen::Color::Indexed(1)); assert!(cell.style.has(attr::BOLD)); } #[test] fn an_unknown_tag_still_shows_its_children() { let mut ui = ui(); let root = ui.tree.root(); let odd = node(&mut ui, root, "sparkline", &[]); node(&mut ui, odd, "label", &[("label", "inside")]); ui.frame(); assert_eq!(ui.screen.line(0), "inside"); } #[test] fn focus_survives_a_repaint_and_lands_somewhere_when_its_widget_is_unmounted() { let mut ui = ui(); let root = ui.tree.root(); let first = node(&mut ui, root, "button", &[("label", "one")]); let second = node(&mut ui, root, "button", &[("label", "two")]); ui.frame(); ui.key("tab"); assert_eq!(ui.focus(), second); ui.frame(); assert_eq!(ui.focus(), second, "a repaint does not move the focus"); ui.tree.remove(root, second); ui.frame(); assert_eq!(ui.focus(), first); }