nandi/jolt-nativepublic Fork 0
e98a184f21222aa11f115f428f758f6b5d9b6cfa
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

tests.rs · 872 lines · 30.0 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago1//! The backend end to end, with no terminal.
2//!
3//! Every one of these mounts real nodes through the same calls the ABI makes,
4//! paints a real frame, and asserts on the lines that came out — which is the
5//! whole reason painting goes through a grid. Nothing here needs a TTY, a
6//! display or raw mode, so it all runs in CI.
7
8use crate::screen::attr;
9use crate::tree::Value;
10use crate::ui::Ui;
11
12fn ui() -> Ui {
13 Ui::new(24, 8)
14}
15
16/// Mount `tag` under `parent` with the string props given.
17fn node(ui: &mut Ui, parent: u32, tag: &str, props: &[(&str, &str)]) -> u32 {
18 let id = ui.tree.new_node(tag);
19 for (key, value) in props {
20 ui.tree.set(id, key, Value::Str((*value).to_owned()));
21 }
22 ui.tree.append(parent, id);
23 id
24}
25
26fn events(ui: &mut Ui) -> Vec<(u32, String, String, f64)> {
27 let mut out = Vec::new();
28 while ui.tree.poll() {
29 let event = ui.tree.current().unwrap();
30 out.push((
31 event.node,
32 event.name.to_owned(),
33 event.text.clone(),
34 event.num,
35 ));
36 }
37 out
38}
39
40#[test]
41fn a_column_paints_its_children_down_the_page() {
42 let mut ui = ui();
43 let root = ui.tree.root();
44 node(&mut ui, root, "label", &[("label", "first")]);
45 node(&mut ui, root, "label", &[("label", "second")]);
46 ui.frame();
47 assert_eq!(ui.screen.line(0), "first");
48 assert_eq!(ui.screen.line(1), "second");
49}
50
51#[test]
52fn a_row_paints_its_children_across_with_its_spacing_between_them() {
53 let mut ui = ui();
54 let root = ui.tree.root();
55 let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
56 ui.tree.set(row, "spacing", Value::Num(2.0));
57 node(&mut ui, row, "label", &[("label", "aa")]);
58 node(&mut ui, row, "label", &[("label", "bb")]);
59 ui.frame();
60 assert_eq!(ui.screen.line(0), "aa bb");
61}
62
63#[test]
64fn a_label_wraps_to_the_width_it_was_given() {
65 let mut ui = Ui::new(10, 4);
66 let root = ui.tree.root();
67 node(&mut ui, root, "label", &[("label", "one two three four")]);
68 ui.frame();
69 assert_eq!(ui.screen.line(0), "one two");
70 assert_eq!(ui.screen.line(1), "three four");
71}
72
73#[test]
74fn a_frame_draws_a_border_with_its_label_in_the_top_edge() {
75 let mut ui = Ui::new(12, 4);
76 let root = ui.tree.root();
77 let frame = node(&mut ui, root, "frame", &[("label", "Task")]);
78 node(&mut ui, frame, "label", &[("label", "hi")]);
79 ui.frame();
80 assert_eq!(ui.screen.line(0), "┌ Task ────┐");
81 assert_eq!(ui.screen.line(1), "│hi │");
82 assert_eq!(ui.screen.line(2), "└──────────┘");
83}
84
85#[test]
86fn the_first_focusable_widget_takes_focus_and_tab_walks_the_ring() {
87 let mut ui = ui();
88 let root = ui.tree.root();
89 let first = node(&mut ui, root, "button", &[("label", "one")]);
90 let second = node(&mut ui, root, "button", &[("label", "two")]);
91 ui.frame();
92 assert_eq!(ui.focus(), first);
93 ui.key("tab");
94 assert_eq!(ui.focus(), second);
95 ui.key("tab");
96 assert_eq!(ui.focus(), first, "the ring wraps");
97 ui.key("shift+tab");
98 assert_eq!(ui.focus(), second);
99}
100
101#[test]
102fn autofocus_beats_paint_order() {
103 let mut ui = ui();
104 let root = ui.tree.root();
105 node(&mut ui, root, "button", &[("label", "one")]);
106 let wanted = node(&mut ui, root, "entry", &[]);
107 ui.tree.set(wanted, "autofocus", Value::Bool(true));
108 ui.frame();
109 assert_eq!(ui.focus(), wanted);
110}
111
112#[test]
113fn a_focused_button_is_drawn_in_reverse_and_activates_on_enter() {
114 let mut ui = ui();
115 let root = ui.tree.root();
116 let button = node(&mut ui, root, "button", &[("label", "go")]);
117 ui.frame();
118 assert_eq!(ui.screen.line(0), "[ go ]");
119 assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::REVERSE));
120 ui.key("enter");
121 assert_eq!(
122 events(&mut ui),
123 vec![(button, "click".into(), String::new(), 0.0)]
124 );
125}
126
127#[test]
128fn a_checkbutton_writes_its_new_state_back_as_well_as_reporting_it() {
129 let mut ui = ui();
130 let root = ui.tree.root();
131 let check = node(&mut ui, root, "checkbutton", &[("label", "live")]);
132 ui.frame();
133 assert_eq!(ui.screen.line(0), "[ ] live");
134 ui.key("space");
135 assert_eq!(
136 events(&mut ui),
137 vec![(check, "toggled".into(), String::new(), 1.0)]
138 );
139 // A caller that ignores the event still has a working control.
Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago140 assert!(ui.tree.props(check).bool("active", false));
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago141 ui.frame();
142 assert_eq!(ui.screen.line(0), "[x] live");
143}
144
145#[test]
146fn typing_in_an_entry_edits_its_text_and_reports_every_change() {
147 let mut ui = ui();
148 let root = ui.tree.root();
149 let entry = node(&mut ui, root, "entry", &[("placeholder", "name")]);
150 ui.frame();
151 assert_eq!(
152 ui.screen.line(0),
153 "name",
154 "the placeholder shows until it is typed in"
155 );
156 for key in ["h", "i", "space", "there"] {
157 for name in [key] {
158 if name.chars().count() > 1 && name != "space" {
159 for ch in name.chars() {
160 ui.key(&ch.to_string());
161 }
162 } else {
163 ui.key(name);
164 }
165 }
166 }
167 assert_eq!(ui.tree.props(entry).str("text"), "hi there");
168 let changes = events(&mut ui);
169 assert_eq!(changes.len(), 8);
170 assert_eq!(changes.last().unwrap().2, "hi there");
171 ui.frame();
172 assert_eq!(ui.screen.line(0), "hi there");
173}
174
175#[test]
176fn readline_keys_edit_where_the_caret_is() {
177 let mut ui = ui();
178 let root = ui.tree.root();
179 let entry = node(&mut ui, root, "entry", &[("text", "one two three")]);
180 ui.frame();
181 ui.key("ctrl+w");
182 assert_eq!(ui.tree.props(entry).str("text"), "one two ");
183 ui.key("ctrl+a");
184 ui.key("delete");
185 assert_eq!(ui.tree.props(entry).str("text"), "ne two ");
186 ui.key("ctrl+e");
187 ui.key("backspace");
188 assert_eq!(ui.tree.props(entry).str("text"), "ne two");
189 ui.key("ctrl+u");
190 assert_eq!(ui.tree.props(entry).str("text"), "");
191}
192
193#[test]
194fn enter_in_an_entry_activates_rather_than_typing() {
195 let mut ui = ui();
196 let root = ui.tree.root();
197 let entry = node(&mut ui, root, "entry", &[("text", "search")]);
198 ui.frame();
199 ui.key("enter");
200 assert_eq!(
201 events(&mut ui),
202 vec![(entry, "activate".into(), "search".into(), 0.0)]
203 );
204 assert_eq!(ui.tree.props(entry).str("text"), "search");
205}
206
207#[test]
208fn a_listbox_moves_its_cursor_with_the_arrows_and_with_j_and_k() {
209 let mut ui = ui();
210 let root = ui.tree.root();
211 let list = node(&mut ui, root, "listbox", &[]);
212 for name in ["alpha", "beta", "gamma"] {
213 node(&mut ui, list, "label", &[("label", name)]);
214 }
215 ui.frame();
216 assert_eq!(ui.screen.line(0), "› alpha");
217 ui.key("j");
218 assert_eq!(
219 events(&mut ui),
220 vec![(list, "select".into(), "beta".into(), 1.0)]
221 );
222 ui.key("G");
223 assert_eq!(ui.tree.props(list).num("selected", -1.0), 2.0);
224 ui.key("enter");
225 assert_eq!(
226 events(&mut ui),
227 vec![
228 (list, "select".into(), "gamma".into(), 2.0),
229 (list, "activate".into(), "gamma".into(), 2.0)
230 ]
231 );
232 ui.frame();
233 assert_eq!(ui.screen.line(2), "› gamma");
234}
235
236#[test]
237fn a_key_nothing_wanted_reaches_the_caller_as_an_event() {
238 let mut ui = ui();
239 let root = ui.tree.root();
240 let button = node(&mut ui, root, "button", &[("label", "go")]);
241 ui.frame();
242 assert!(!ui.key("f5"));
243 assert_eq!(
244 events(&mut ui),
245 vec![(button, "key".into(), "f5".into(), 0.0)]
246 );
247}
248
249#[test]
250fn ctrl_c_asks_the_loop_to_stop() {
251 let mut ui = ui();
252 assert!(!ui.should_close());
253 ui.key("ctrl+c");
254 assert!(ui.should_close());
255}
256
257#[test]
258fn an_insensitive_subtree_is_dimmed_and_out_of_the_focus_ring() {
259 let mut ui = ui();
260 let root = ui.tree.root();
261 let column = node(&mut ui, root, "vbox", &[]);
262 ui.tree.set(column, "sensitive", Value::Bool(false));
263 node(&mut ui, column, "button", &[("label", "go")]);
264 let live = node(&mut ui, root, "button", &[("label", "live")]);
265 ui.frame();
266 assert!(ui.screen.cell(0, 0).unwrap().style.has(attr::DIM));
267 assert_eq!(ui.focus(), live);
268}
269
270#[test]
271fn a_click_focuses_and_activates_what_is_under_it() {
272 let mut ui = ui();
273 let root = ui.tree.root();
274 node(&mut ui, root, "button", &[("label", "one")]);
275 let second = node(&mut ui, root, "button", &[("label", "two")]);
276 ui.frame();
277 assert!(ui.click(2, 1));
278 assert_eq!(ui.focus(), second);
279 assert_eq!(
280 events(&mut ui),
281 vec![(second, "click".into(), String::new(), 0.0)]
282 );
283 assert!(!ui.click(20, 7), "a click on nothing is not an event");
284}
285
286#[test]
287fn a_scroll_shows_a_window_of_its_content_and_will_not_go_past_the_end() {
288 let mut ui = Ui::new(10, 3);
289 let root = ui.tree.root();
290 let scroll = node(&mut ui, root, "scroll", &[]);
291 for i in 0..6 {
292 node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
293 }
294 ui.frame();
295 assert_eq!(ui.screen.lines(), vec!["row 0", "row 1", "row 2"]);
296 ui.wheel(1, 1, 2);
297 ui.frame();
298 assert_eq!(ui.screen.lines(), vec!["row 2", "row 3", "row 4"]);
299 ui.wheel(1, 1, 40);
300 ui.frame();
301 assert_eq!(ui.screen.lines(), vec!["row 3", "row 4", "row 5"]);
302 // The clamped viewport is written back, so the next scroll up starts from
303 // where the reader actually is.
304 assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0);
305}
306
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago307#[test]
308fn the_wheel_scrolls_the_list_under_the_pointer_not_the_first_one_painted() {
309 // frq's wide layout: the chats list down the left, the conversation beside
310 // it, and both of them scrolls. A wheel over the conversation used to move
311 // the sidebar — every scroll answered to a pointer anywhere on screen, so
312 // the first one the walk reached took the lot.
313 let mut ui = Ui::new(20, 3);
314 let root = ui.tree.root();
315 let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
316 let sidebar = node(&mut ui, row, "scroll", &[]);
317 ui.tree.set(sidebar, "width-request", Value::Num(10.0));
318 for i in 0..6 {
Run the formatter over the tree 3e8c6f0 nandi 13d ago319 node(
320 &mut ui,
321 sidebar,
322 "label",
323 &[("label", &format!("chat {i}"))],
324 );
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago325 }
326 let backlog = node(&mut ui, row, "scroll", &[]);
327 ui.tree.set(backlog, "width-request", Value::Num(10.0));
328 for i in 0..6 {
Run the formatter over the tree 3e8c6f0 nandi 13d ago329 node(
330 &mut ui,
331 backlog,
332 "label",
333 &[("label", &format!("line {i}"))],
334 );
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago335 }
336 ui.frame();
337 assert_eq!(ui.screen.line(0), "chat 0 line 0");
338
339 ui.wheel(15, 1, 2);
340 ui.frame();
341 assert_eq!(
342 ui.screen.line(0),
343 "chat 0 line 2",
344 "the wheel was over the backlog, and only the backlog moved"
345 );
346
347 ui.wheel(2, 1, 1);
348 ui.frame();
349 assert_eq!(
350 ui.screen.line(0),
351 "chat 1 line 2",
352 "and over the sidebar it is the sidebar that moves"
353 );
354}
355
356#[test]
357fn page_keys_move_the_backlog_while_the_entry_keeps_the_focus() {
358 let mut ui = Ui::new(10, 4);
359 let root = ui.tree.root();
360 let scroll = node(&mut ui, root, "scroll", &[]);
361 for i in 0..9 {
362 node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
363 }
364 // What a reader is typing into, under the list they are reading.
365 let entry = node(&mut ui, root, "entry", &[("text", "hi")]);
366 ui.frame();
367 assert_eq!(ui.screen.line(0), "row 0");
368
369 assert!(ui.key("page-down"), "the page was taken, not passed on");
370 ui.frame();
371 // A page is the viewport less the line that says where you were.
372 assert_eq!(ui.screen.line(0), "row 2");
373 assert_eq!(ui.focus(), entry, "and the entry still has the keyboard");
374
375 ui.key("page-up");
376 ui.frame();
377 assert_eq!(ui.screen.line(0), "row 0");
378 assert!(events(&mut ui).iter().all(|(_, name, _, _)| name != "key"));
379}
380
381#[test]
382fn a_wheel_between_a_re_render_and_a_frame_moves_from_where_the_list_was() {
383 // A render clears a node's props and writes them again; a wheel or a page
384 // key that landed in that gap used to read an offset of zero and answer
385 // with the top of the buffer.
386 let mut ui = Ui::new(10, 3);
387 let root = ui.tree.root();
388 let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
389 for i in 0..9 {
390 node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
391 }
392 ui.frame();
393 ui.wheel(1, 1, 4);
394 ui.frame();
395 assert_eq!(ui.screen.line(0), "row 4");
396
397 // The caller re-renders the list: props off, props on, no frame between.
398 ui.tree.clear_props(scroll);
399 ui.tree
400 .set(scroll, "scroll-key", Value::Str("backlog".into()));
401 ui.wheel(1, 1, 1);
402 ui.frame();
403 assert_eq!(ui.screen.line(0), "row 5");
404}
405
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago406#[test]
407fn a_reaction_paints_its_glyph_and_answers_a_click_on_it() {
408 let mut ui = Ui::new(20, 2);
409 let root = ui.tree.root();
410 let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
411 ui.tree.set(row, "spacing", Value::Num(1.0));
412 let pill = node(&mut ui, row, "reaction", &[("emoji", "👍")]);
413 ui.tree.set(pill, "count", Value::Num(2.0));
414 ui.tree.set(pill, "mine", Value::Bool(true));
415 let chip = node(&mut ui, row, "reaction", &[("emoji", "🙂")]);
416 ui.frame();
417 // The tally where there is one; the bare glyph where there is not — that
418 // is the chip you press to start one.
419 assert_eq!(ui.screen.line(0), "👍 2 🙂");
420
421 // Two cells for the glyph, so the chip after it starts where it looks
422 // like it starts.
423 assert!(ui.click(5, 0), "the chip took the click");
424 assert_eq!(
425 events(&mut ui),
426 vec![(chip, "click".into(), String::new(), 0.0)]
427 );
428
429 // And by keyboard, for a terminal with no pointer at all.
430 ui.key("shift+tab");
431 ui.key("enter");
432 assert_eq!(
433 events(&mut ui),
434 vec![(pill, "click".into(), String::new(), 0.0)]
435 );
436}
437
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago438#[test]
439fn a_glyph_keeps_the_selector_that_says_to_draw_it_as_a_picture() {
440 let mut ui = Ui::new(10, 2);
441 let root = ui.tree.root();
442 // The reply chip: an arrow, and a selector asking for the emoji rather
443 // than the small mono arrow a terminal draws without it.
444 node(&mut ui, root, "reaction", &[("emoji", "↩️")]);
445 ui.frame();
446 assert_eq!(ui.screen.line(0), "\u{fe0f}");
447 // And it is two columns, like the emoji beside it on the row.
448 assert_eq!(ui.screen.cell(1, 0).map(|c| c.trail), Some(true));
449}
450
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago451#[test]
452fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() {
453 let mut ui = Ui::new(12, 3);
454 let root = ui.tree.root();
455 let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
456 node(&mut ui, row, "label", &[("label", "👍")]);
457 let after = node(&mut ui, row, "button", &[("label", "ok")]);
458 // A label that has to wrap wraps by column, not by character.
459 node(&mut ui, root, "label", &[("label", "👍👍👍👍👍👍👍")]);
460 ui.frame();
461 assert_eq!(ui.screen.line(0), "👍[ ok ]");
462 assert_eq!(ui.screen.line(1), "👍👍👍👍👍👍");
463
464 // The button starts at column 2, because the glyph before it took two.
465 assert!(ui.click(2, 0));
466 assert_eq!(
467 events(&mut ui),
468 vec![(after, "click".into(), String::new(), 0.0)]
469 );
470}
471
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago472/// A PNG that is only a header: this backend reads the size out of one and
473/// hands the file to the terminal, so a header is all a test of the layout
474/// needs.
475fn png_file(name: &str, w: u32, h: u32) -> String {
476 let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
477 bytes.extend_from_slice(&13u32.to_be_bytes());
478 bytes.extend_from_slice(b"IHDR");
479 bytes.extend_from_slice(&w.to_be_bytes());
480 bytes.extend_from_slice(&h.to_be_bytes());
481 let path = std::env::temp_dir().join(name);
482 std::fs::write(&path, &bytes).expect("wrote the picture");
483 path.to_string_lossy().into_owned()
484}
485
486#[test]
487fn a_picture_takes_the_cells_its_shape_asks_for_and_says_where_it_is() {
488 crate::graphics::force(Some(true));
489 crate::graphics::set_cell((8, 16));
490 let path = png_file("jolt-tui-wide.png", 320, 160);
491
492 let mut ui = Ui::new(60, 20);
493 let root = ui.tree.root();
494 node(&mut ui, root, "label", &[("label", "a picture:")]);
495 let image = node(&mut ui, root, "image", &[("src", &path)]);
496 ui.tree.set(image, "max-height", Value::Num(6.0));
497 ui.frame();
498
499 // 320x160 pixels over 8x16 cells is 40 by 10, held to the six rows the
500 // caller allowed — and the width comes down with it, not separately.
501 let placed = ui.images().first().cloned().expect("a placement");
502 assert_eq!((placed.area.w, placed.area.h), (24, 6));
503 assert_eq!((placed.area.x, placed.area.y), (0, 1));
504 assert_eq!(placed.path, path);
505 // The cells themselves stay blank: the terminal draws over them.
506 assert_eq!(ui.screen.line(1), "");
507 crate::graphics::force(None);
508}
509
510#[test]
511fn a_picture_scrolling_past_the_edge_is_cropped_rather_than_lost() {
512 crate::graphics::force(Some(true));
513 crate::graphics::set_cell((8, 16));
514 let path = png_file("jolt-tui-tall.png", 160, 320);
515
516 let mut ui = Ui::new(40, 4);
517 let root = ui.tree.root();
518 let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
519 let image = node(&mut ui, scroll, "image", &[("src", &path)]);
520 ui.tree.set(image, "max-height", Value::Num(8.0));
521 node(&mut ui, scroll, "label", &[("label", "under it")]);
522 ui.frame();
523 let placed = ui.images().first().cloned().expect("a placement");
524 assert_eq!((placed.area.y, placed.area.h), (0, 4), "as much as fits");
525 assert_eq!((placed.crop_top, placed.crop_bottom), (0, 4));
526
527 ui.wheel(1, 1, 2);
528 ui.frame();
529 let placed = ui.images().first().cloned().expect("still placed");
530 assert_eq!((placed.area.y, placed.area.h), (0, 4));
531 assert_eq!(
532 (placed.crop_top, placed.crop_bottom),
533 (2, 2),
534 "two rows of the picture have gone off the top"
535 );
536 crate::graphics::force(None);
537}
538
539#[test]
540fn a_terminal_that_draws_no_pictures_says_so_where_one_would_be() {
541 crate::graphics::force(Some(false));
542 let path = png_file("jolt-tui-note.png", 320, 160);
543 let mut ui = Ui::new(40, 4);
544 let root = ui.tree.root();
545 node(&mut ui, root, "image", &[("src", &path)]);
546 ui.frame();
547 assert_eq!(ui.screen.line(0), "[ picture ]");
548 assert!(ui.images().is_empty());
549 crate::graphics::force(None);
550}
551
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago552#[test]
553fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {
554 let mut ui = Ui::new(14, 5);
555 let root = ui.tree.root();
556 node(&mut ui, root, "label", &[("label", "beneath")]);
557 let overlay = node(&mut ui, root, "overlay", &[("label", "Sure?")]);
558 node(&mut ui, overlay, "label", &[("label", "yes")]);
559 ui.frame();
560 assert_eq!(ui.screen.line(1), " ┌ Sure? ┐");
561 assert_eq!(ui.screen.line(2), " │yes │");
562 ui.key("esc");
563 assert_eq!(
564 events(&mut ui),
565 vec![(overlay, "close".into(), String::new(), 0.0)]
566 );
567}
568
569#[test]
570fn a_progress_bar_fills_the_share_of_its_width_it_was_given() {
571 let mut ui = Ui::new(10, 2);
572 let root = ui.tree.root();
573 let bar = node(&mut ui, root, "progress", &[]);
574 ui.tree.set(bar, "value", Value::Num(0.5));
575 ui.frame();
576 assert_eq!(ui.screen.line(0), "█████░░░░░");
577}
578
579#[test]
580fn colours_and_attributes_are_inherited_by_a_subtree() {
581 let mut ui = ui();
582 let root = ui.tree.root();
583 let column = node(&mut ui, root, "vbox", &[("color", "red")]);
584 ui.tree.set(column, "bold", Value::Bool(true));
585 node(&mut ui, column, "label", &[("label", "hi")]);
586 ui.frame();
587 let cell = ui.screen.cell(0, 0).unwrap();
588 assert_eq!(cell.style.fg, crate::screen::Color::Indexed(1));
589 assert!(cell.style.has(attr::BOLD));
590}
591
592#[test]
593fn an_unknown_tag_still_shows_its_children() {
594 let mut ui = ui();
595 let root = ui.tree.root();
596 let odd = node(&mut ui, root, "sparkline", &[]);
597 node(&mut ui, odd, "label", &[("label", "inside")]);
598 ui.frame();
599 assert_eq!(ui.screen.line(0), "inside");
600}
601
602#[test]
603fn focus_survives_a_repaint_and_lands_somewhere_when_its_widget_is_unmounted() {
604 let mut ui = ui();
605 let root = ui.tree.root();
606 let first = node(&mut ui, root, "button", &[("label", "one")]);
607 let second = node(&mut ui, root, "button", &[("label", "two")]);
608 ui.frame();
609 ui.key("tab");
610 assert_eq!(ui.focus(), second);
611 ui.frame();
612 assert_eq!(ui.focus(), second, "a repaint does not move the focus");
613 ui.tree.remove(root, second);
614 ui.frame();
615 assert_eq!(ui.focus(), first);
616}
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 17d ago617
618#[test]
619fn an_unknown_leaf_paints_its_own_text() {
620 // A tag this backend does not know paints as a box so its children still
621 // show — but a leaf has none, and a `:status` badge or a `:link` in the
622 // middle of a message would otherwise be a hole in the sentence.
623 let mut ui = ui();
624 let root = ui.tree.root();
625 node(&mut ui, root, "status", &[("label", "joined")]);
626 node(&mut ui, root, "label", &[("label", "after")]);
627 ui.frame();
628 assert_eq!(ui.screen.line(0), "joined");
629 assert_eq!(ui.screen.line(1), "after");
630}
631
632#[test]
633fn a_nested_column_paints_every_child_and_not_only_the_first() {
634 // Its rows were shared out by measuring each child at the *rows* it had
635 // rather than the columns, so a label wrapped to a paragraph, the overrun
636 // came off the end, and everything after the first child was handed
637 // nothing. Two levels down is where it showed: the top box is as wide as
638 // the screen and as tall, so the two numbers were close enough to hide it.
639 let mut ui = ui();
640 let root = ui.tree.root();
641 let col = node(&mut ui, root, "vbox", &[("orientation", "vertical")]);
642 node(&mut ui, col, "label", &[("label", "the first line")]);
643 node(&mut ui, col, "button", &[("label", "Open")]);
644 ui.frame();
645 assert_eq!(ui.screen.line(0), "the first line");
646 assert_eq!(ui.screen.line(1), "[ Open ]");
647}
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago648
649#[test]
650fn an_unknown_leaf_that_names_a_picture_paints_nothing() {
651 // An `:avatar`'s label is the nick behind the face — words for something
652 // that cannot be drawn here, and in frq already on the row beside it.
653 let mut ui = ui();
654 let root = ui.tree.root();
655 node(
656 &mut ui,
657 root,
658 "avatar",
659 &[("label", "nandi.uk"), ("src", "/tmp/a.png")],
660 );
661 node(&mut ui, root, "label", &[("label", "nandi.uk")]);
662 ui.frame();
663 assert_eq!(ui.screen.line(0), "nandi.uk");
664 assert_eq!(ui.screen.line(1), "");
665}
666
667#[test]
668fn a_sticky_viewport_opens_at_the_bottom_and_stays_there() {
669 // A backlog taller than its viewport, in a scroll that follows its own
670 // bottom: the newest line is what a chat client opens on, and a line
671 // arriving must not drag the screen out from under a reader who scrolled
672 // up to read history.
673 let mut ui = Ui::new(20, 3);
674 let root = ui.tree.root();
675 let scroll = node(
676 &mut ui,
677 root,
678 "scroll",
679 &[("scroll-key", "backlog"), ("stick-to-bottom", "true")],
680 );
681 for n in 1..=6 {
682 node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
683 }
684 ui.frame();
685 assert_eq!(ui.screen.line(2), "line 6");
686
687 // Scrolled up, and it stays where it was put across a re-render — the
688 // position lives under the key, not in a prop the next render clears.
689 ui.wheel(0, 0, -3);
690 ui.frame();
691 assert_eq!(ui.screen.line(0), "line 1");
692 ui.tree.clear_props(scroll);
693 ui.tree
Run the formatter over the tree 3e8c6f0 nandi 13d ago694 .set(scroll, "scroll-key", Value::Str("backlog".into()));
695 ui.tree.set(scroll, "stick-to-bottom", Value::Bool(true));
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago696 ui.frame();
697 assert_eq!(ui.screen.line(0), "line 1");
698
699 // Back down to the bottom, and it follows again.
700 ui.wheel(0, 0, 9);
701 ui.frame();
702 node(&mut ui, scroll, "label", &[("label", "line 7")]);
703 ui.frame();
704 assert_eq!(ui.screen.line(2), "line 7");
705}
706
707#[test]
708fn a_column_that_asks_for_a_width_gets_it_and_no_more() {
709 // Two panes in a row, the first with a width of its own. Its content is
710 // one long line, so measured naturally it is wider than the screen and the
711 // pane beside it is left nothing — which is the split view painting a
712 // sidebar and a ten-cell column of wrapped fragments.
713 let mut ui = Ui::new(40, 2);
714 let root = ui.tree.root();
715 let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
716 let side = node(&mut ui, row, "vbox", &[]);
717 // A number, as the ABI sends one: a width read as a string is no width.
718 ui.tree.set(side, "width-request", Value::Num(10.0));
719 node(
720 &mut ui,
721 side,
722 "label",
723 &[("label", "a preview far longer than ten cells")],
724 );
725 let main = node(&mut ui, row, "vbox", &[]);
726 node(&mut ui, main, "label", &[("label", "the conversation")]);
727 ui.frame();
728 assert_eq!(ui.screen.line(0), "a preview the conversation");
729}
730
731#[test]
732fn a_backlog_taller_than_the_screen_leaves_the_compose_bar_its_row() {
733 // The shape of frq's chat screen: a viewport holding more than fits, and
734 // under it the things you act with. A column that cannot shrink hands the
735 // scroll every row it asks for and paints the entry off the bottom edge.
736 let mut ui = Ui::new(20, 4);
737 let root = ui.tree.root();
738 let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
739 for n in 1..=10 {
740 node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
741 }
742 node(&mut ui, root, "separator", &[]);
743 node(&mut ui, root, "entry", &[("placeholder", "Message")]);
744 ui.frame();
745 assert_eq!(ui.screen.line(3), "Message");
746}
Measure a node once, and paint only what is on screen c41903b nandi 16d ago747
748#[test]
749fn a_message_straddling_the_top_of_a_viewport_shows_the_part_that_is_in_it() {
750 // Culling paints whole children and lets the copy cut them, so the row a
751 // reader is half way through is the row they see — not the next one down.
752 let mut ui = Ui::new(20, 3);
753 let root = ui.tree.root();
754 let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
755 for n in 1..=6 {
756 let block = node(&mut ui, scroll, "vbox", &[]);
757 node(&mut ui, block, "label", &[("label", &format!("head {n}"))]);
758 node(&mut ui, block, "label", &[("label", &format!("body {n}"))]);
759 }
760 ui.tree.set(scroll, "offset", Value::Num(3.0));
761 ui.frame();
762 // Two rows a message, so an offset of three lands mid-way through the
763 // second one: its body, then the third whole.
764 assert_eq!(ui.screen.line(0), "body 2");
765 assert_eq!(ui.screen.line(1), "head 3");
766 assert_eq!(ui.screen.line(2), "body 3");
767}
768
769#[test]
770fn a_button_below_the_fold_keeps_its_place_in_the_focus_ring() {
771 // Nothing off screen is painted, and the ring is built while painting —
772 // so the ring has to be told about the parts that were skipped. Tabbing
773 // onto something below the fold is how a reader gets to it.
774 let mut ui = Ui::new(20, 2);
775 let root = ui.tree.root();
776 let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
777 let first = node(&mut ui, scroll, "button", &[("label", "first")]);
778 for n in 1..=20 {
779 node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
780 }
781 let below = node(&mut ui, scroll, "button", &[("label", "last")]);
782 ui.frame();
783 // Twenty lines down and well out of a two-row viewport, but still next in
784 // the ring after the button at the top.
785 assert_eq!(ui.focus(), first);
786 ui.key("tab");
787 assert_eq!(ui.focus(), below);
788}
789
790#[test]
791fn a_scrolled_backlog_paints_what_an_unscrolled_one_would_have_shown() {
792 // The check that culling changed nothing: paint a viewport onto the middle
793 // of a long list, and every row is the row that list has there.
794 let mut ui = Ui::new(20, 5);
795 let root = ui.tree.root();
796 let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
797 for n in 0..40 {
798 node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
799 }
800 ui.frame();
801 let mut offset = 0;
802 for step in [0, 1, 16, 18] {
803 // Through the wheel rather than the prop: the viewport's position is
804 // the library's own state, and it writes it back over anything set
805 // here on the frame after.
806 ui.wheel(0, 0, step);
807 offset += step as usize;
808 ui.frame();
809 for row in 0..5u16 {
810 assert_eq!(
811 ui.screen.line(row),
812 format!("line {}", offset + row as usize),
813 "row {row} at offset {offset}"
814 );
815 }
816 }
817}
818
819/// A backlog the shape frq mounts: a scrolling column of messages, each a few
820/// boxes deep, with a heading row and wrapping text under it.
821#[cfg(test)]
822fn backlog(cols: u16, rows: u16, messages: usize) -> Ui {
823 let mut ui = Ui::new(cols, rows);
824 let root = ui.tree.root();
825 let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
826 for n in 0..messages {
827 let row = node(&mut ui, scroll, "hbox", &[("orientation", "horizontal")]);
828 node(&mut ui, row, "spacer", &[]);
829 let body = node(&mut ui, row, "vbox", &[]);
830 let head = node(&mut ui, body, "hbox", &[("orientation", "horizontal")]);
831 node(&mut ui, head, "label", &[("label", "nandi")]);
832 node(&mut ui, head, "dim-label", &[("label", "12:01")]);
833 node(
834 &mut ui,
835 body,
836 "label",
837 &[(
838 "label",
839 &format!("message number {n} with enough words in it to wrap across a line or two"),
840 )],
841 );
842 }
843 node(&mut ui, root, "separator", &[]);
844 node(&mut ui, root, "entry", &[("placeholder", "Message")]);
845 ui
846}
847
848/// What a frame costs on a backlog, which is what scrolling one costs. Not a
849/// test — it asserts nothing — so it is `--ignored` and run by hand:
850///
851/// cargo test --release -p jolt-tui -- --ignored --nocapture backlog_cost
852#[test]
853#[ignore]
854fn backlog_cost() {
855 for messages in [25, 50, 100, 200, 400] {
856 let mut ui = backlog(100, 36, messages);
857 ui.frame();
858 let mut times = Vec::new();
859 for i in 0..21 {
860 ui.wheel(10, 10, if i % 2 == 0 { 3 } else { -3 });
861 let at = std::time::Instant::now();
862 ui.frame();
863 times.push(at.elapsed().as_secs_f64() * 1000.0);
864 }
865 times.sort_by(|a, b| a.partial_cmp(b).unwrap());
866 println!(
867 "{messages:5} messages median {:8.2}ms max {:8.2}ms",
868 times[times.len() / 2],
869 times[times.len() - 1]
870 );
871 }
872}