diff --git a/src/app/render/grid_settle_tests.rs b/src/app/render/grid_settle_tests.rs new file mode 100644 index 00000000..192356a2 --- /dev/null +++ b/src/app/render/grid_settle_tests.rs @@ -0,0 +1,172 @@ +//! The list's page settle (`stabilize_grid`). After the cursor moves, the page +//! drawn must hold it, and `l` off a page's last column must open the next page +//! at its first column. Reported in a large downloads folder at 238×60; +//! reproduced here with synthetic names. +//! +//! Both cases need neighbouring pages that fit different numbers of columns: +//! `ListView::grid` sizes a page from its own names, so a long name narrows the +//! page it lands on. +#![allow(clippy::unwrap_used)] // test fixtures; the module deny is for production + +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend, buffer::Buffer, layout::Rect}; + +use crate::app::App; +use crate::ui::list_view::ListView; + +/// The owner's terminal with no pane: a 58-row list. +const BARE: (u16, u16) = (238, 60); +/// The 16-row list the owner had beside their pane. +const BESIDE_PANE: (u16, u16) = (238, 18); + +/// Mostly short names with an occasional long one. Each starts with a unique +/// `nNNNN-` to search for. +fn mixed_length_names() -> Vec { + let mut seed: u64 = 0x9E37_79B9_7F4A_7C15; + (0..600) + .map(|i| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let len = if seed.is_multiple_of(12) { + 40 + seed / 12 % 20 + } else { + 8 + seed % 23 + }; + format!("n{i:04}-{}", "x".repeat(len as usize)) + }) + .collect() +} + +/// A paneless `App` listing [`mixed_length_names`]. +fn listing() -> (App, Vec) { + let names = mixed_length_names(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut app = App::test_app(std::env::temp_dir()); + app.state.left.listing.dir = PathBuf::from("/projects/demo"); + app.seed_rows(&refs); + // `seed_rows` doesn't bump `list_generation`, which the row cache is keyed on. + app.view.cached_rows_gen = app.state.left.list_generation.wrapping_sub(1); + (app, names) +} + +fn draw(app: &mut App, (w, h): (u16, u16)) -> Buffer { + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal.draw(|f| app.render(f)).unwrap(); + terminal.backend().buffer().clone() +} + +/// One key, then a frame, as the terminal draws between keystrokes. +fn press(app: &mut App, code: KeyCode, size: (u16, u16)) { + app.handle_key(KeyEvent::new(code, KeyModifiers::empty())) + .unwrap(); + draw(app, size); +} + +/// `/`, a key at a time. +fn search(app: &mut App, query: &str, size: (u16, u16)) { + press(app, KeyCode::Char('/'), size); + for c in query.chars() { + press(app, KeyCode::Char(c), size); + } + press(app, KeyCode::Enter, size); +} + +/// Where the list draws its cursor bar, and the name in it. +fn cursor_bar(app: &App, buf: &Buffer) -> Option<(u16, u16, String)> { + let list = app.frame_layout(buf.area).list; + let bar = Some(app.view.theme.cursor_bg); + for y in list.y..list.bottom() { + let lit: Vec = (list.x..list.right()) + .filter(|&x| buf.cell((x, y)).map(|c| c.bg) == bar) + .collect(); + if let (Some(&first), Some(&last)) = (lit.first(), lit.last()) { + let text: String = (first..=last) + .map(|x| buf.cell((x, y)).map_or(" ", |c| c.symbol())) + .collect(); + return Some((first, y, text.trim().to_string())); + } + } + None +} + +/// How many columns the page starting at `view_top` fits. +fn page_cols(app: &App, view_top: usize, (w, h): (u16, u16)) -> u16 { + let list = app.frame_layout(Rect::new(0, 0, w, h)).list; + ListView { + rows: &app.view.cached_rows, + cursor: 0, + view_top, + empty_marker: false, + focused: true, + theme: &app.view.theme, + selection: None, + } + .grid(list) + .cols +} + +/// `/` jumps the cursor to the match, and the list must draw the cursor bar on +/// it. The case: two pages could hold row 240, one from 192 (three columns, +/// ending at 239) and one from 240 (four). +#[test] +#[ignore = "red: the settle alternates between 192 and 240 and keeps 192, which ends before the cursor"] +fn a_search_jump_draws_the_cursor_bar_on_the_match() { + let (mut app, names) = listing(); + draw(&mut app, BESIDE_PANE); + search(&mut app, "n0240-", BESIDE_PANE); + assert_eq!( + app.state.left.cursor.index, 240, + "the search found its match" + ); + assert_eq!( + ( + page_cols(&app, 192, BESIDE_PANE), + page_cols(&app, 240, BESIDE_PANE) + ), + (3, 4), + "the case: 16-row columns, three of them from 192 and four from 240", + ); + + let buf = draw(&mut app, BESIDE_PANE); + let bar = cursor_bar(&app, &buf).map(|(_, _, name)| name); + assert_eq!(bar.as_deref(), Some(names[240].as_str())); +} + +/// `l` from a page's last column moves to the next page, whose first column is +/// the cursor's new one. The case: the page after 348-521 starts at 522 and +/// fits two columns, and a page from 464 would fit three. +#[test] +#[ignore = "red: the settle alternates between 522 and 464 and keeps 464, so the cursor lands in the second column"] +fn l_off_the_last_column_opens_the_next_page_at_its_first_column() { + let (mut app, names) = listing(); + draw(&mut app, BARE); + search(&mut app, "n0464-", BARE); + let buf = draw(&mut app, BARE); + let left = &app.state.left; + assert_eq!( + ( + left.cursor.view_top, + left.grid_dims.cols, + left.grid_dims.rows_per_col + ), + (348, 3, 58), + "the cursor starts in the last of three 58-row columns, on the page 348-521", + ); + assert_eq!( + (page_cols(&app, 522, BARE), page_cols(&app, 464, BARE)), + (2, 3), + "the case: the next page, from 522, fits two columns; one from 464 fits three", + ); + let (_, row, _) = cursor_bar(&app, &buf).unwrap(); + + press(&mut app, KeyCode::Char('l'), BARE); + let buf = draw(&mut app, BARE); + let first_column = app.frame_layout(buf.area).list.x; + assert_eq!( + cursor_bar(&app, &buf), + Some((first_column, row, names[522].clone())) + ); +} diff --git a/src/app/render/mod.rs b/src/app/render/mod.rs index 30e91430..406ea630 100644 --- a/src/app/render/mod.rs +++ b/src/app/render/mod.rs @@ -37,6 +37,8 @@ mod agent_hud; mod chrome; #[cfg(test)] mod divider_tests; +#[cfg(test)] +mod grid_settle_tests; mod inner; mod layout; mod overlays;