Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,19 @@ Goal: 0 draws-per-second at idle. Implementation:
buffer because the emoji arrives from flashes, file names and agent
output alike. Its tests replay ratatui's real diff and backend bytes
through libghostty-vt and compare the screen to the buffer.
- Caching: `build_rows()` and grid stabilization keyed by a
- Caching: `build_rows()` and the page settle keyed by a
`list_generation` counter that increments on any listing /
cursor / pick / mask change.
- Pages are counted from the top (#679): the first starts at row 0
and each starts where the one before it ends. A page holds as many
columns as fit and a column is as wide as its longest name, so pages
differ in length, and `ui::list_view::Pages` finds their starts by
walking from the top, once per listing and list size and only as far
as asked. A row's page therefore never depends on how the cursor got
there, and PageDown / PageUp step through the same pages. Snapping
`view_top` to a multiple of the current page's length instead
alternated between two starts whenever neighbouring pages differed,
and could settle on one that didn't hold the cursor.

The activity overlay (`A` toggle) reports dps and bytes/sec for
ongoing tuning.
Expand Down
6 changes: 2 additions & 4 deletions src/app/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,8 @@ impl App {
view: View::Dir,
cursor: Cursor::new(),
rows: Vec::new(),
grid_dims: crate::ui::list_view::GridDims {
cols: 1,
rows_per_col: 1,
},
grid_dims: crate::ui::list_view::GridDims::new(1, 1),
page: crate::ui::list_view::PageSpan::default(),
list_generation: 0,
// Per-column git: the startup branch string + (empty) markers,
// and empty cache/worker plumbing until the first chdir +
Expand Down
43 changes: 41 additions & 2 deletions src/app/render/grid_settle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ fn page_cols(app: &App, view_top: usize, (w, h): (u16, u16)) -> u16 {
/// 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);
Expand All @@ -139,7 +138,6 @@ fn a_search_jump_draws_the_cursor_bar_on_the_match() {
/// 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);
Expand Down Expand Up @@ -170,3 +168,44 @@ fn l_off_the_last_column_opens_the_next_page_at_its_first_column() {
Some((first_column, row, names[522].clone()))
);
}

/// PageDown moves to the same column and row on the next page, or to its last
/// row when it has no such column. The case: from the last of three columns on
/// the page 348-521, the next page, 522-599, has two.
#[test]
fn page_down_to_a_narrower_page_lands_on_its_last_row() {
let (mut app, names) = listing();
draw(&mut app, BARE);
search(&mut app, "n0464-", BARE);
assert_eq!(
app.state.left.cursor.view_top, 348,
"the cursor starts on the page 348-521"
);

press(&mut app, KeyCode::PageDown, BARE);
let buf = draw(&mut app, BARE);
let bar = cursor_bar(&app, &buf).map(|(_, _, name)| name);
assert_eq!(bar.as_deref(), Some(names[599].as_str()));
}

/// PageUp moves to the same column and row on the page before. The case: from
/// the top of the two-column page at 522 to the top of the three-column page
/// at 348.
#[test]
fn page_up_from_a_pages_first_row_lands_on_the_previous_pages_first_row() {
let (mut app, names) = listing();
draw(&mut app, BARE);
search(&mut app, "n0522-", BARE);
let buf = draw(&mut app, BARE);
let first_column = app.frame_layout(buf.area).list.x;
assert_eq!(
cursor_bar(&app, &buf).map(|(x, _, _)| x),
Some(first_column),
"the cursor starts at the top of its page",
);

press(&mut app, KeyCode::PageUp, BARE);
let buf = draw(&mut app, BARE);
let (x, _, name) = cursor_bar(&app, &buf).unwrap();
assert_eq!((x, name.as_str()), (first_column, names[348].as_str()));
}
174 changes: 52 additions & 122 deletions src/app/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ use ratatui::Frame;
#[cfg(test)]
use crate::config::StatusPosition;
use crate::spyc_debug;
use crate::ui::list_view::{ListView, Row};
use crate::ui::list_view::Row;

use super::{App, FrameLayout, View, state};
use super::{App, FrameLayout, state};

mod activity_hud;
mod agent_hud;
Expand Down Expand Up @@ -434,36 +434,33 @@ impl App {
self.build_rows_for(&self.state.left)
}

/// Rebuild the list-rows cache and run the `view_top`↔grid stabilization.
/// Pure derived state: reads the listing/cursor + the list rect, writes
/// `cached_rows`, `grid_dims`, and `cursor.view_top`. No drawing, no IO —
/// extracted from the draw path (MVU Stage 2) so `render` stays
/// mutation-free.
/// Rebuild the list-rows cache and place each column on the page holding
/// its cursor. Pure derived state: reads the listing/cursor + the list rect,
/// writes `cached_rows`, the page caches, `grid_dims`, `page` and
/// `cursor.view_top`. No drawing, no IO — extracted from the draw path
/// (MVU Stage 2) so `render` stays mutation-free.
fn settle_list_grid(&mut self, layout: &FrameLayout) {
if self.view.cached_rows_gen != self.state.left.list_generation {
self.view.cached_rows = self.build_rows();
self.view.cached_rows_gen = self.state.left.list_generation;
}
let focused = !self.state.pane_focused();
// Disjoint field borrows: cached_rows/theme (read) + cached_grid_key
// Disjoint field borrows: cached_rows (read) + pages/cached_grid_key
// (write) on `view`, and `state.left` (write) — all distinct fields, so
// this passes the borrow checker without an accessor that locks all of
// `self`. PR C settles the right column the same way against its own
// commander + caches.
stabilize_grid(
// `self`.
settle_page(
&self.view.cached_rows,
self.view.cached_rows_gen,
&mut self.state.left,
layout.list,
focused,
&self.view.theme,
&mut self.view.pages,
&mut self.view.cached_grid_key,
);

// The right column's second commander (`state.right`) settles the same
// way against its own row cache + grid key + rect. No-op until `^s`
// way against its own row cache, pages and grid key. No-op until `^s`
// opens one.
if self.state.right.is_some() {
let right_focused = self.right_column_focused();
let right_gen = self
.state
.right
Expand All @@ -476,130 +473,63 @@ impl App {
self.view.right_cached_rows_gen = right_gen;
}
if let (Some(rect), Some(right)) = (layout.right, self.state.right.as_mut()) {
stabilize_grid(
settle_page(
&self.view.right_cached_rows,
self.view.right_cached_rows_gen,
right,
rect,
right_focused,
&self.view.theme,
&mut self.view.right_pages,
&mut self.view.right_cached_grid_key,
);
}
}
}
}

/// The `view_top` ↔ grid stabilization for one commander column. The grid
/// depends on `view_top` (entry name lengths → column count → items-per-page)
/// and `view_top` depends on the grid, so this iterates to a fixpoint (≤4
/// rounds), breaking a 2-cycle by picking the lower `view_top` (more context,
/// deterministic across frames). Skips the loop when nothing changed since the
/// last frame (the `cached_grid_key` guard). Pure derived state — mutates only
/// the commander's `grid_dims`/`cursor.view_top` and the caller's cache key, no
/// drawing or IO. Free fn (not an `App` method) so the borrows on the caller's
/// `view`/`state` fields stay disjoint; called once per visible column.
fn stabilize_grid(
rows: &[crate::ui::list_view::Row],
/// Put one commander column on the fixed page holding its cursor
/// ([`crate::ui::list_view::Pages`]), and record that page's grid and its
/// neighbours for PageDown / PageUp. A row's page depends only on the listing
/// and the list's size, never on where the view was before.
///
/// Skips the work when nothing changed since the last frame (the
/// `cached_grid_key` guard). Pure derived state: mutates only the commander's
/// `cursor.view_top` / `grid_dims` / `page` and the caller's caches, no drawing
/// or IO. A free fn (not an `App` method) so the borrows on the caller's
/// `view` / `state` fields stay disjoint; called once per visible column.
fn settle_page(
rows: &[Row],
rows_gen: u64,
commander: &mut state::Commander,
rect: ratatui::layout::Rect,
focused: bool,
theme: &crate::ui::theme::Theme,
pages: &mut crate::ui::list_view::Pages,
cached_grid_key: &mut (u64, usize, usize, u16, u16),
) {
let grid_key = (
commander.list_generation,
commander.cursor.view_top,
commander.cursor.index,
rect.width,
rect.height,
);
if grid_key == *cached_grid_key {
let key = |c: &state::Commander| {
(
c.list_generation,
c.cursor.view_top,
c.cursor.index,
rect.width,
rect.height,
)
};
if key(commander) == *cached_grid_key {
return;
}
*cached_grid_key = grid_key;
let mut prev_vt: Option<usize> = None; // for 2-cycle detection
let mut settled = false;
for round in 0..4 {
let probe = ListView {
rows,
cursor: commander.cursor.index,
view_top: commander.cursor.view_top,
empty_marker: commander.view == View::Dir,
focused,
theme,
selection: None,
};
commander.grid_dims = probe.grid(rect).dims();
let old_vt = commander.cursor.view_top;
let pp = commander.grid_dims.items_per_page();
commander.ensure_cursor_visible();
if commander.cursor.view_top == old_vt {
spyc_debug!(
"grid settled round {}: vt={} cursor={} grid={}x{} pp={}",
round + 1,
old_vt,
commander.cursor.index,
commander.grid_dims.cols,
commander.grid_dims.rows_per_col,
pp,
);
settled = true;
break;
}
spyc_debug!(
"grid unstable round {}: vt {} -> {} cursor={} grid={}x{} pp={}",
round + 1,
old_vt,
commander.cursor.view_top,
commander.cursor.index,
commander.grid_dims.cols,
commander.grid_dims.rows_per_col,
pp,
);
// 2-cycle: new vt equals the vt from two rounds ago.
if Some(commander.cursor.view_top) == prev_vt {
// Always pick the lower vt — deterministic across frames.
let forced = old_vt.min(commander.cursor.view_top);
commander.cursor.view_top = forced;
// Recompute grid for the forced view_top.
let probe = ListView {
rows,
cursor: commander.cursor.index,
view_top: commander.cursor.view_top,
empty_marker: commander.view == View::Dir,
focused,
theme,
selection: None,
};
commander.grid_dims = probe.grid(rect).dims();
spyc_debug!(
"grid 2-cycle broken: forcing vt={} (cursor={} grid={}x{} pp={})",
forced,
commander.cursor.index,
commander.grid_dims.cols,
commander.grid_dims.rows_per_col,
commander.grid_dims.items_per_page(),
);
settled = true;
break;
}
prev_vt = Some(old_vt);
}
if !settled {
spyc_debug!(
"grid did NOT settle after 4 rounds: vt={} cursor={}",
commander.cursor.view_top,
commander.cursor.index,
);
}
// Update cache key in case the stabilization loop changed view_top.
*cached_grid_key = (
commander.list_generation,
commander.cursor.view_top,
let page = pages.around(rows, rect, rows_gen, commander.cursor.index);
commander.cursor.view_top = page.start;
commander.page = page;
commander.grid_dims = crate::ui::list_view::grid_at(rows, page.start, rect).dims();
spyc_debug!(
"page settled: vt={} cursor={} grid={}x{} prev={:?} next={:?}",
page.start,
commander.cursor.index,
rect.width,
rect.height,
commander.grid_dims.cols,
commander.grid_dims.rows_per_col,
page.prev,
page.next,
);
*cached_grid_key = key(commander);
}

#[cfg(test)]
Expand Down
5 changes: 2 additions & 3 deletions src/app/state/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ impl AppState {
pub fn apply(&mut self, action: &Action) -> ApplyResult {
let len = self.cur().rows.len();
let rows_per_col = self.cur().grid_dims.rows_per_col as usize;
let per_page = self.cur().grid_dims.items_per_page();

match action {
// -- Cursor motion --
Expand All @@ -56,8 +55,8 @@ impl AppState {
self.flash_info("~");
}
}
Action::PageUp => self.cursor_move_global(-(per_page as isize), len),
Action::PageDown => self.cursor_move_global(per_page as isize, len),
Action::PageUp => self.page_move(false),
Action::PageDown => self.page_move(true),
Action::GotoFirst => self.goto_col_top(rows_per_col),
Action::GotoLast => self.goto_col_bottom(rows_per_col, len),

Expand Down
11 changes: 6 additions & 5 deletions src/app/state/commander.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use super::{GitCache, GitState, RowData, View};
use crate::config::Config;
use crate::fs::Listing;
use crate::state::{Cursor, IgnoreMasks, Picks};
use crate::ui::list_view::GridDims;
use crate::ui::list_view::{GridDims, PageSpan};
use std::path::{Path, PathBuf};

/// One file-commander's worth of pure Model state — the per-browser fields a
Expand Down Expand Up @@ -44,6 +44,9 @@ pub struct Commander {
/// The geometry slice of this browser's last rendered grid (cols ×
/// rows-per-col), written by render and read by cursor/page-math.
pub grid_dims: GridDims,
/// The cursor's page and its neighbours, as the last frame placed them:
/// what PageDown / PageUp step to.
pub page: PageSpan,
/// Monotonic counter bumped whenever this browser's display row list
/// changes. Used by App to skip redundant `build_rows()` calls.
pub list_generation: u64,
Expand Down Expand Up @@ -101,10 +104,8 @@ impl Commander {
view: View::Dir,
cursor: Cursor::new(),
rows: Vec::new(),
grid_dims: GridDims {
cols: 1,
rows_per_col: 1,
},
grid_dims: GridDims::new(1, 1),
page: PageSpan::default(),
list_generation: 0,
git: GitState::default(),
git_cache: GitCache::default(),
Expand Down
6 changes: 2 additions & 4 deletions src/app/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -999,10 +999,8 @@ impl AppState {
view: View::Dir,
cursor: Cursor::new(),
rows: Vec::new(),
grid_dims: GridDims {
cols: 1,
rows_per_col: 20,
},
grid_dims: GridDims::new(1, 20),
page: crate::ui::list_view::PageSpan::default(),
list_generation: 0,
git: GitState::default(),
git_cache: GitCache::default(),
Expand Down
Loading
Loading