//! 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!(ui.tree.props(check).bool("active", false)); 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 the_wheel_scrolls_the_list_under_the_pointer_not_the_first_one_painted() { // frq's wide layout: the chats list down the left, the conversation beside // it, and both of them scrolls. A wheel over the conversation used to move // the sidebar — every scroll answered to a pointer anywhere on screen, so // the first one the walk reached took the lot. let mut ui = Ui::new(20, 3); let root = ui.tree.root(); let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]); let sidebar = node(&mut ui, row, "scroll", &[]); ui.tree.set(sidebar, "width-request", Value::Num(10.0)); for i in 0..6 { node( &mut ui, sidebar, "label", &[("label", &format!("chat {i}"))], ); } let backlog = node(&mut ui, row, "scroll", &[]); ui.tree.set(backlog, "width-request", Value::Num(10.0)); for i in 0..6 { node( &mut ui, backlog, "label", &[("label", &format!("line {i}"))], ); } ui.frame(); assert_eq!(ui.screen.line(0), "chat 0 line 0"); ui.wheel(15, 1, 2); ui.frame(); assert_eq!( ui.screen.line(0), "chat 0 line 2", "the wheel was over the backlog, and only the backlog moved" ); ui.wheel(2, 1, 1); ui.frame(); assert_eq!( ui.screen.line(0), "chat 1 line 2", "and over the sidebar it is the sidebar that moves" ); } #[test] fn page_keys_move_the_backlog_while_the_entry_keeps_the_focus() { let mut ui = Ui::new(10, 4); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[]); for i in 0..9 { node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]); } // What a reader is typing into, under the list they are reading. let entry = node(&mut ui, root, "entry", &[("text", "hi")]); ui.frame(); assert_eq!(ui.screen.line(0), "row 0"); assert!(ui.key("page-down"), "the page was taken, not passed on"); ui.frame(); // A page is the viewport less the line that says where you were. assert_eq!(ui.screen.line(0), "row 2"); assert_eq!(ui.focus(), entry, "and the entry still has the keyboard"); ui.key("page-up"); ui.frame(); assert_eq!(ui.screen.line(0), "row 0"); assert!(events(&mut ui).iter().all(|(_, name, _, _)| name != "key")); } #[test] fn a_wheel_between_a_re_render_and_a_frame_moves_from_where_the_list_was() { // A render clears a node's props and writes them again; a wheel or a page // key that landed in that gap used to read an offset of zero and answer // with the top of the buffer. let mut ui = Ui::new(10, 3); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); for i in 0..9 { node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]); } ui.frame(); ui.wheel(1, 1, 4); ui.frame(); assert_eq!(ui.screen.line(0), "row 4"); // The caller re-renders the list: props off, props on, no frame between. ui.tree.clear_props(scroll); ui.tree .set(scroll, "scroll-key", Value::Str("backlog".into())); ui.wheel(1, 1, 1); ui.frame(); assert_eq!(ui.screen.line(0), "row 5"); } #[test] fn a_reaction_paints_its_glyph_and_answers_a_click_on_it() { let mut ui = Ui::new(20, 2); let root = ui.tree.root(); let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]); ui.tree.set(row, "spacing", Value::Num(1.0)); let pill = node(&mut ui, row, "reaction", &[("emoji", "👍")]); ui.tree.set(pill, "count", Value::Num(2.0)); ui.tree.set(pill, "mine", Value::Bool(true)); let chip = node(&mut ui, row, "reaction", &[("emoji", "🙂")]); ui.frame(); // The tally where there is one; the bare glyph where there is not — that // is the chip you press to start one. assert_eq!(ui.screen.line(0), "👍 2 🙂"); // Two cells for the glyph, so the chip after it starts where it looks // like it starts. assert!(ui.click(5, 0), "the chip took the click"); assert_eq!( events(&mut ui), vec![(chip, "click".into(), String::new(), 0.0)] ); // And by keyboard, for a terminal with no pointer at all. ui.key("shift+tab"); ui.key("enter"); assert_eq!( events(&mut ui), vec![(pill, "click".into(), String::new(), 0.0)] ); } #[test] fn a_glyph_keeps_the_selector_that_says_to_draw_it_as_a_picture() { let mut ui = Ui::new(10, 2); let root = ui.tree.root(); // The reply chip: an arrow, and a selector asking for the emoji rather // than the small mono arrow a terminal draws without it. node(&mut ui, root, "reaction", &[("emoji", "↩️")]); ui.frame(); assert_eq!(ui.screen.line(0), "↩\u{fe0f}"); // And it is two columns, like the emoji beside it on the row. assert_eq!(ui.screen.cell(1, 0).map(|c| c.trail), Some(true)); } #[test] fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() { let mut ui = Ui::new(12, 3); let root = ui.tree.root(); let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]); node(&mut ui, row, "label", &[("label", "👍")]); let after = node(&mut ui, row, "button", &[("label", "ok")]); // A label that has to wrap wraps by column, not by character. node(&mut ui, root, "label", &[("label", "👍👍👍👍👍👍👍")]); ui.frame(); assert_eq!(ui.screen.line(0), "👍[ ok ]"); assert_eq!(ui.screen.line(1), "👍👍👍👍👍👍"); // The button starts at column 2, because the glyph before it took two. assert!(ui.click(2, 0)); assert_eq!( events(&mut ui), vec![(after, "click".into(), String::new(), 0.0)] ); } /// A PNG that is only a header: this backend reads the size out of one and /// hands the file to the terminal, so a header is all a test of the layout /// needs. fn png_file(name: &str, w: u32, h: u32) -> String { let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; bytes.extend_from_slice(&13u32.to_be_bytes()); bytes.extend_from_slice(b"IHDR"); bytes.extend_from_slice(&w.to_be_bytes()); bytes.extend_from_slice(&h.to_be_bytes()); let path = std::env::temp_dir().join(name); std::fs::write(&path, &bytes).expect("wrote the picture"); path.to_string_lossy().into_owned() } #[test] fn a_picture_takes_the_cells_its_shape_asks_for_and_says_where_it_is() { crate::graphics::force(Some(true)); crate::graphics::set_cell((8, 16)); let path = png_file("jolt-tui-wide.png", 320, 160); let mut ui = Ui::new(60, 20); let root = ui.tree.root(); node(&mut ui, root, "label", &[("label", "a picture:")]); let image = node(&mut ui, root, "image", &[("src", &path)]); ui.tree.set(image, "max-height", Value::Num(6.0)); ui.frame(); // 320x160 pixels over 8x16 cells is 40 by 10, held to the six rows the // caller allowed — and the width comes down with it, not separately. let placed = ui.images().first().cloned().expect("a placement"); assert_eq!((placed.area.w, placed.area.h), (24, 6)); assert_eq!((placed.area.x, placed.area.y), (0, 1)); assert_eq!(placed.path, path); // The cells themselves stay blank: the terminal draws over them. assert_eq!(ui.screen.line(1), ""); crate::graphics::force(None); } #[test] fn a_picture_scrolling_past_the_edge_is_cropped_rather_than_lost() { crate::graphics::force(Some(true)); crate::graphics::set_cell((8, 16)); let path = png_file("jolt-tui-tall.png", 160, 320); let mut ui = Ui::new(40, 4); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); let image = node(&mut ui, scroll, "image", &[("src", &path)]); ui.tree.set(image, "max-height", Value::Num(8.0)); node(&mut ui, scroll, "label", &[("label", "under it")]); ui.frame(); let placed = ui.images().first().cloned().expect("a placement"); assert_eq!((placed.area.y, placed.area.h), (0, 4), "as much as fits"); assert_eq!((placed.crop_top, placed.crop_bottom), (0, 4)); ui.wheel(1, 1, 2); ui.frame(); let placed = ui.images().first().cloned().expect("still placed"); assert_eq!((placed.area.y, placed.area.h), (0, 4)); assert_eq!( (placed.crop_top, placed.crop_bottom), (2, 2), "two rows of the picture have gone off the top" ); crate::graphics::force(None); } #[test] fn a_terminal_that_draws_no_pictures_says_so_where_one_would_be() { crate::graphics::force(Some(false)); let path = png_file("jolt-tui-note.png", 320, 160); let mut ui = Ui::new(40, 4); let root = ui.tree.root(); node(&mut ui, root, "image", &[("src", &path)]); ui.frame(); assert_eq!(ui.screen.line(0), "[ picture ]"); assert!(ui.images().is_empty()); crate::graphics::force(None); } #[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); } #[test] fn an_unknown_leaf_paints_its_own_text() { // A tag this backend does not know paints as a box so its children still // show — but a leaf has none, and a `:status` badge or a `:link` in the // middle of a message would otherwise be a hole in the sentence. let mut ui = ui(); let root = ui.tree.root(); node(&mut ui, root, "status", &[("label", "joined")]); node(&mut ui, root, "label", &[("label", "after")]); ui.frame(); assert_eq!(ui.screen.line(0), "joined"); assert_eq!(ui.screen.line(1), "after"); } #[test] fn a_nested_column_paints_every_child_and_not_only_the_first() { // Its rows were shared out by measuring each child at the *rows* it had // rather than the columns, so a label wrapped to a paragraph, the overrun // came off the end, and everything after the first child was handed // nothing. Two levels down is where it showed: the top box is as wide as // the screen and as tall, so the two numbers were close enough to hide it. let mut ui = ui(); let root = ui.tree.root(); let col = node(&mut ui, root, "vbox", &[("orientation", "vertical")]); node(&mut ui, col, "label", &[("label", "the first line")]); node(&mut ui, col, "button", &[("label", "Open")]); ui.frame(); assert_eq!(ui.screen.line(0), "the first line"); assert_eq!(ui.screen.line(1), "[ Open ]"); } #[test] fn an_unknown_leaf_that_names_a_picture_paints_nothing() { // An `:avatar`'s label is the nick behind the face — words for something // that cannot be drawn here, and in frq already on the row beside it. let mut ui = ui(); let root = ui.tree.root(); node( &mut ui, root, "avatar", &[("label", "nandi.uk"), ("src", "/tmp/a.png")], ); node(&mut ui, root, "label", &[("label", "nandi.uk")]); ui.frame(); assert_eq!(ui.screen.line(0), "nandi.uk"); assert_eq!(ui.screen.line(1), ""); } #[test] fn a_sticky_viewport_opens_at_the_bottom_and_stays_there() { // A backlog taller than its viewport, in a scroll that follows its own // bottom: the newest line is what a chat client opens on, and a line // arriving must not drag the screen out from under a reader who scrolled // up to read history. let mut ui = Ui::new(20, 3); let root = ui.tree.root(); let scroll = node( &mut ui, root, "scroll", &[("scroll-key", "backlog"), ("stick-to-bottom", "true")], ); for n in 1..=6 { node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]); } ui.frame(); assert_eq!(ui.screen.line(2), "line 6"); // Scrolled up, and it stays where it was put across a re-render — the // position lives under the key, not in a prop the next render clears. ui.wheel(0, 0, -3); ui.frame(); assert_eq!(ui.screen.line(0), "line 1"); ui.tree.clear_props(scroll); ui.tree .set(scroll, "scroll-key", Value::Str("backlog".into())); ui.tree.set(scroll, "stick-to-bottom", Value::Bool(true)); ui.frame(); assert_eq!(ui.screen.line(0), "line 1"); // Back down to the bottom, and it follows again. ui.wheel(0, 0, 9); ui.frame(); node(&mut ui, scroll, "label", &[("label", "line 7")]); ui.frame(); assert_eq!(ui.screen.line(2), "line 7"); } #[test] fn a_column_that_asks_for_a_width_gets_it_and_no_more() { // Two panes in a row, the first with a width of its own. Its content is // one long line, so measured naturally it is wider than the screen and the // pane beside it is left nothing — which is the split view painting a // sidebar and a ten-cell column of wrapped fragments. let mut ui = Ui::new(40, 2); let root = ui.tree.root(); let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]); let side = node(&mut ui, row, "vbox", &[]); // A number, as the ABI sends one: a width read as a string is no width. ui.tree.set(side, "width-request", Value::Num(10.0)); node( &mut ui, side, "label", &[("label", "a preview far longer than ten cells")], ); let main = node(&mut ui, row, "vbox", &[]); node(&mut ui, main, "label", &[("label", "the conversation")]); ui.frame(); assert_eq!(ui.screen.line(0), "a preview the conversation"); } #[test] fn a_backlog_taller_than_the_screen_leaves_the_compose_bar_its_row() { // The shape of frq's chat screen: a viewport holding more than fits, and // under it the things you act with. A column that cannot shrink hands the // scroll every row it asks for and paints the entry off the bottom edge. let mut ui = Ui::new(20, 4); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); for n in 1..=10 { node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]); } node(&mut ui, root, "separator", &[]); node(&mut ui, root, "entry", &[("placeholder", "Message")]); ui.frame(); assert_eq!(ui.screen.line(3), "Message"); } #[test] fn a_message_straddling_the_top_of_a_viewport_shows_the_part_that_is_in_it() { // Culling paints whole children and lets the copy cut them, so the row a // reader is half way through is the row they see — not the next one down. let mut ui = Ui::new(20, 3); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); for n in 1..=6 { let block = node(&mut ui, scroll, "vbox", &[]); node(&mut ui, block, "label", &[("label", &format!("head {n}"))]); node(&mut ui, block, "label", &[("label", &format!("body {n}"))]); } ui.tree.set(scroll, "offset", Value::Num(3.0)); ui.frame(); // Two rows a message, so an offset of three lands mid-way through the // second one: its body, then the third whole. assert_eq!(ui.screen.line(0), "body 2"); assert_eq!(ui.screen.line(1), "head 3"); assert_eq!(ui.screen.line(2), "body 3"); } #[test] fn a_button_below_the_fold_keeps_its_place_in_the_focus_ring() { // Nothing off screen is painted, and the ring is built while painting — // so the ring has to be told about the parts that were skipped. Tabbing // onto something below the fold is how a reader gets to it. let mut ui = Ui::new(20, 2); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); let first = node(&mut ui, scroll, "button", &[("label", "first")]); for n in 1..=20 { node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]); } let below = node(&mut ui, scroll, "button", &[("label", "last")]); ui.frame(); // Twenty lines down and well out of a two-row viewport, but still next in // the ring after the button at the top. assert_eq!(ui.focus(), first); ui.key("tab"); assert_eq!(ui.focus(), below); } #[test] fn a_scrolled_backlog_paints_what_an_unscrolled_one_would_have_shown() { // The check that culling changed nothing: paint a viewport onto the middle // of a long list, and every row is the row that list has there. let mut ui = Ui::new(20, 5); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); for n in 0..40 { node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]); } ui.frame(); let mut offset = 0; for step in [0, 1, 16, 18] { // Through the wheel rather than the prop: the viewport's position is // the library's own state, and it writes it back over anything set // here on the frame after. ui.wheel(0, 0, step); offset += step as usize; ui.frame(); for row in 0..5u16 { assert_eq!( ui.screen.line(row), format!("line {}", offset + row as usize), "row {row} at offset {offset}" ); } } } /// A backlog the shape frq mounts: a scrolling column of messages, each a few /// boxes deep, with a heading row and wrapping text under it. #[cfg(test)] fn backlog(cols: u16, rows: u16, messages: usize) -> Ui { let mut ui = Ui::new(cols, rows); let root = ui.tree.root(); let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]); for n in 0..messages { let row = node(&mut ui, scroll, "hbox", &[("orientation", "horizontal")]); node(&mut ui, row, "spacer", &[]); let body = node(&mut ui, row, "vbox", &[]); let head = node(&mut ui, body, "hbox", &[("orientation", "horizontal")]); node(&mut ui, head, "label", &[("label", "nandi")]); node(&mut ui, head, "dim-label", &[("label", "12:01")]); node( &mut ui, body, "label", &[( "label", &format!("message number {n} with enough words in it to wrap across a line or two"), )], ); } node(&mut ui, root, "separator", &[]); node(&mut ui, root, "entry", &[("placeholder", "Message")]); ui } /// What a frame costs on a backlog, which is what scrolling one costs. Not a /// test — it asserts nothing — so it is `--ignored` and run by hand: /// /// cargo test --release -p jolt-tui -- --ignored --nocapture backlog_cost #[test] #[ignore] fn backlog_cost() { for messages in [25, 50, 100, 200, 400] { let mut ui = backlog(100, 36, messages); ui.frame(); let mut times = Vec::new(); for i in 0..21 { ui.wheel(10, 10, if i % 2 == 0 { 3 } else { -3 }); let at = std::time::Instant::now(); ui.frame(); times.push(at.elapsed().as_secs_f64() * 1000.0); } times.sort_by(|a, b| a.partial_cmp(b).unwrap()); println!( "{messages:5} messages median {:8.2}ms max {:8.2}ms", times[times.len() / 2], times[times.len() - 1] ); } } /// A field of three rows in a screen of 24 columns: the compose bar frq puts /// under its backlog, and the shape every assertion below is about. fn compose(ui: &mut Ui, text: &str) -> u32 { let root = ui.tree.root(); let entry = node(ui, root, "entry", &[("text", text)]); ui.tree.set(entry, "rows", Value::Num(3.0)); ui.frame(); entry } #[test] fn a_multi_line_field_paints_its_text_down_its_own_rows() { let mut ui = ui(); compose(&mut ui, "the tree ABI is the same one libvidya exports"); assert_eq!(ui.screen.line(0), "the tree ABI is the"); assert_eq!(ui.screen.line(1), "same one libvidya"); assert_eq!(ui.screen.line(2), "exports"); } #[test] fn a_click_in_a_wrapped_field_puts_the_caret_on_the_row_it_landed_on() { let mut ui = ui(); let entry = compose(&mut ui, "the tree ABI is the same one libvidya exports"); // The "one" on the second row: "same one libvidya", column 5. ui.click(5, 1); assert_eq!(ui.focus(), entry); ui.key("x"); assert_eq!( ui.tree.props(entry).str("text"), "the tree ABI is the same xone libvidya exports" ); // And a click past the end of the last row is the end of the text. ui.frame(); ui.click(20, 2); ui.key("!"); assert!(ui.tree.props(entry).str("text").ends_with("exports!")); } #[test] fn shift_enter_breaks_the_line_and_enter_still_sends_it() { let mut ui = ui(); let entry = compose(&mut ui, "one"); ui.key("shift+enter"); ui.key("t"); ui.key("w"); ui.key("o"); assert_eq!(ui.tree.props(entry).str("text"), "one\ntwo"); ui.frame(); assert_eq!(ui.screen.line(0), "one"); assert_eq!(ui.screen.line(1), "two"); let _ = events(&mut ui); ui.key("enter"); assert_eq!( events(&mut ui), vec![(entry, "activate".into(), "one\ntwo".into(), 0.0)] ); } #[test] fn up_and_down_step_between_rows_and_keep_the_column_they_left() { let mut ui = ui(); let entry = compose(&mut ui, "hello\nab\nworld"); // The caret is at the end of the text; up twice is column 5 of a row two // characters long, and coming back down has to find column 5 again. ui.key("up"); ui.key("up"); ui.key("down"); ui.key("down"); ui.key("!"); assert_eq!(ui.tree.props(entry).str("text"), "hello\nab\nworld!"); // And up off the first row is not the field's key: it goes to the caller. let _ = events(&mut ui); ui.frame(); ui.key("ctrl+home"); ui.key("up"); assert_eq!( events(&mut ui), vec![(entry, "key".into(), "up".into(), 0.0)] ); } #[test] fn a_field_shorter_than_its_text_scrolls_to_keep_the_caret_in_view() { let mut ui = ui(); let entry = compose(&mut ui, "one\ntwo\nthree\nfour"); // Four rows in three: the caret is at the end, so the first row is off the // top rather than the last off the bottom. assert_eq!(ui.screen.line(0), "two"); assert_eq!(ui.screen.line(2), "four"); // A click on the top row is "two", not "one". ui.click(0, 0); ui.key("!"); assert_eq!(ui.tree.props(entry).str("text"), "one\n!two\nthree\nfour"); // And walking back up brings the row above into view. ui.frame(); ui.key("up"); ui.frame(); assert_eq!(ui.screen.line(0), "one"); } #[test] fn home_and_end_are_about_the_row_the_caret_is_on() { let mut ui = ui(); let entry = compose(&mut ui, "one\ntwo"); ui.key("home"); ui.key("x"); assert_eq!(ui.tree.props(entry).str("text"), "one\nxtwo"); ui.frame(); ui.key("end"); ui.key("ctrl+u"); assert_eq!(ui.tree.props(entry).str("text"), "one\n"); }