perf(tui): build only the visible page of result rows
Scrolling a long results list was laggy: every frame built a table row for every result, and ratatui then drew the forty-odd that fit. At the 10,000-file cap that cost 25-32ms a frame in release (69ms in debug), in attached and standalone mode alike, so a held key stuttered. The pane now slices to the page ratatui would show, by its own rule: stay where the last frame left off, pulled along only as far as keeps the selection in view. The table gets a page-local state and the page start is written back into the real one. The page is counted with the same page_of that PgUp/PgDn use, so paging and drawing agree, and a selection past the end lands on the last row as ratatui put it. Idle frames drop to 1.5ms and a keypress to 0.6ms at the same size, measured live in both modes. ratatui has no lazy rows of its own: the lazy_rows proposal was not merged and moved to the experimental ratatui-table crate, so windowing in the caller is the recommended approach today.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
use super::MainTui;
|
use super::MainTui;
|
||||||
use crate::models::{CommandBarMode, FocusedPane, LogView};
|
use crate::models::{CommandBarMode, FocusedPane, LogView};
|
||||||
use crate::ui::pane_block;
|
use crate::ui::page_of;
|
||||||
use crate::ui::panes::{
|
use crate::ui::panes::{
|
||||||
InfoSubject, name_end_offset, query_end_offset, selected_transfer,
|
InfoSubject, name_end_offset, query_end_offset, selected_transfer,
|
||||||
transfer_name_end_offset, upload_display_name,
|
transfer_name_end_offset, upload_display_name,
|
||||||
@@ -8,7 +8,7 @@ use crate::ui::panes::{
|
|||||||
use ratatui::crossterm::event::{
|
use ratatui::crossterm::event::{
|
||||||
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||||||
};
|
};
|
||||||
use ratatui::layout::{Position, Rect};
|
use ratatui::layout::Position;
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
|
|
||||||
const NAME_SCROLL_STEP: isize = 8;
|
const NAME_SCROLL_STEP: isize = 8;
|
||||||
@@ -575,16 +575,6 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a
|
|
||||||
/// table header, a popup's tab bar and compose line. At least one, so a page
|
|
||||||
/// key always moves, even before the first draw.
|
|
||||||
fn page_of(area: Option<Rect>, chrome: u16) -> usize {
|
|
||||||
area.map_or(0, |area| {
|
|
||||||
usize::from(pane_block(false).inner(area).height.saturating_sub(chrome))
|
|
||||||
})
|
|
||||||
.max(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A move over a whole list at once: to either end, or by a number of rows
|
/// A move over a whole list at once: to either end, or by a number of rows
|
||||||
/// (negative is up).
|
/// (negative is up).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use super::name_scroll::{column_width, end_offset, scroll_text};
|
|||||||
use crate::models::FileDisplayData;
|
use crate::models::FileDisplayData;
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
||||||
header_style, info_style, pane_block, pane_title, row_highlight_style,
|
header_style, info_style, page_of, pane_block, pane_title,
|
||||||
success_style, warning_style,
|
row_highlight_style, success_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -11,7 +11,7 @@ use ratatui::{
|
|||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::{collections::HashSet, ops::Range};
|
||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
@@ -85,6 +85,26 @@ fn row_is_selected(
|
|||||||
selected_indices.contains(&original)
|
selected_indices.contains(&original)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The page a `height`-tall table shows, by ratatui's rule (its
|
||||||
|
/// `visible_rows` is private): the offset, moved only as far as keeps the
|
||||||
|
/// selection in view. A selection past the end counts as the last row.
|
||||||
|
fn visible_range(
|
||||||
|
offset: usize,
|
||||||
|
selected: Option<usize>,
|
||||||
|
len: usize,
|
||||||
|
height: usize,
|
||||||
|
) -> Range<usize> {
|
||||||
|
let height = height.max(1);
|
||||||
|
let last = len.saturating_sub(1);
|
||||||
|
let start = match selected.map(|selected| selected.min(last)) {
|
||||||
|
Some(selected) => {
|
||||||
|
offset.clamp((selected + 1).saturating_sub(height), selected)
|
||||||
|
}
|
||||||
|
None => offset.min(last),
|
||||||
|
};
|
||||||
|
start..(start + height).min(len)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_results_pane(
|
pub fn render_results_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -149,9 +169,20 @@ pub fn render_results_pane(
|
|||||||
|
|
||||||
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||||
let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN);
|
let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN);
|
||||||
let rows: Vec<Row> = items
|
// A selection past the end, after a filter shrank the list, lands on the
|
||||||
.iter()
|
// last row, as the table would have put it.
|
||||||
.enumerate()
|
if let Some(selected) = table_state.selected_mut() {
|
||||||
|
*selected = (*selected).min(items.len() - 1);
|
||||||
|
}
|
||||||
|
let window = visible_range(
|
||||||
|
table_state.offset(),
|
||||||
|
table_state.selected(),
|
||||||
|
items.len(),
|
||||||
|
page_of(Some(area), 1),
|
||||||
|
);
|
||||||
|
let start = window.start;
|
||||||
|
let rows: Vec<Row> = (start..)
|
||||||
|
.zip(&items[window])
|
||||||
.map(|(idx, file)| {
|
.map(|(idx, file)| {
|
||||||
let (folder, name) = split_path(&file.filename);
|
let (folder, name) = split_path(&file.filename);
|
||||||
let checkbox =
|
let checkbox =
|
||||||
@@ -210,12 +241,18 @@ pub fn render_results_pane(
|
|||||||
.highlight_spacing(HighlightSpacing::Always)
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
.block(pane_block(focused).title(pane_title("2", &title, focused)));
|
.block(pane_block(focused).title(pane_title("2", &title, focused)));
|
||||||
|
|
||||||
frame.render_stateful_widget(table, area, table_state);
|
// The table holds one page, so the state it gets is shifted onto it.
|
||||||
|
let mut page_state = TableState::default()
|
||||||
|
.with_selected(table_state.selected().map(|selected| selected - start));
|
||||||
|
frame.render_stateful_widget(table, area, &mut page_state);
|
||||||
|
*table_state.offset_mut() = start;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ResultsPaneParams, render_results_pane, row_is_selected};
|
use super::{
|
||||||
|
ResultsPaneParams, render_results_pane, row_is_selected, visible_range,
|
||||||
|
};
|
||||||
use crate::models::FileDisplayData;
|
use crate::models::FileDisplayData;
|
||||||
use ratatui::{Terminal, backend::TestBackend, widgets::TableState};
|
use ratatui::{Terminal, backend::TestBackend, widgets::TableState};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -223,8 +260,16 @@ mod tests {
|
|||||||
fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String {
|
fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String {
|
||||||
let mut state = TableState::default();
|
let mut state = TableState::default();
|
||||||
state.select(Some(0));
|
state.select(Some(0));
|
||||||
|
render_with(items, &mut state, name_offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_with(
|
||||||
|
items: &[FileDisplayData],
|
||||||
|
state: &mut TableState,
|
||||||
|
name_offset: usize,
|
||||||
|
) -> String {
|
||||||
// Wide enough for the name column to hold 11 cells, with 7 for the
|
// Wide enough for the name column to hold 11 cells, with 7 for the
|
||||||
// folder beside it.
|
// folder beside it, and tall enough for three rows.
|
||||||
let mut terminal =
|
let mut terminal =
|
||||||
Terminal::new(TestBackend::new(88, 6)).expect("backend");
|
Terminal::new(TestBackend::new(88, 6)).expect("backend");
|
||||||
terminal
|
terminal
|
||||||
@@ -234,7 +279,7 @@ mod tests {
|
|||||||
frame.area(),
|
frame.area(),
|
||||||
ResultsPaneParams {
|
ResultsPaneParams {
|
||||||
items,
|
items,
|
||||||
table_state: &mut state,
|
table_state: state,
|
||||||
selected_indices: &HashSet::new(),
|
selected_indices: &HashSet::new(),
|
||||||
original_indices: None,
|
original_indices: None,
|
||||||
filter_query: "",
|
filter_query: "",
|
||||||
@@ -326,6 +371,39 @@ mod tests {
|
|||||||
assert!(!screen.contains("…"), "{screen}");
|
assert!(!screen.contains("…"), "{screen}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_page_follows_the_selection_and_is_never_longer_than_the_pane() {
|
||||||
|
assert_eq!(visible_range(500, Some(505), 10_000, 10), 500..510);
|
||||||
|
assert_eq!(visible_range(0, Some(9_999), 10_000, 10), 9_990..10_000);
|
||||||
|
assert_eq!(visible_range(500, Some(499), 10_000, 10), 499..509);
|
||||||
|
assert_eq!(visible_range(9_995, None, 10_000, 10), 9_995..10_000);
|
||||||
|
assert_eq!(visible_range(0, Some(9), 3, 2), 1..3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_far_selection_is_drawn_from_its_own_page() {
|
||||||
|
let items: Vec<FileDisplayData> =
|
||||||
|
(0..10_000).map(|i| file(&format!("{i}.mp3"))).collect();
|
||||||
|
let mut state = TableState::default();
|
||||||
|
state.select(Some(9_999));
|
||||||
|
let screen = render_with(&items, &mut state, 0);
|
||||||
|
assert!(screen.contains("›[ ] 9999.mp3"), "{screen}");
|
||||||
|
assert!(screen.contains(" [ ] 9997.mp3"), "{screen}");
|
||||||
|
assert!(!screen.contains(" [ ] 0.mp3"), "{screen}");
|
||||||
|
assert_eq!(state.offset(), 9_997, "the next frame starts here");
|
||||||
|
assert_eq!(state.selected(), Some(9_999));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_selection_off_the_end_lands_on_the_last_row() {
|
||||||
|
let items = [file("a.mp3"), file("b.mp3")];
|
||||||
|
let mut state = TableState::default();
|
||||||
|
state.select(Some(7));
|
||||||
|
let screen = render_with(&items, &mut state, 0);
|
||||||
|
assert!(screen.contains("›[ ] b.mp3"), "{screen}");
|
||||||
|
assert_eq!(state.selected(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn identity_mapping_used_when_no_filter() {
|
fn identity_mapping_used_when_no_filter() {
|
||||||
let selected: HashSet<usize> = std::iter::once(1).collect();
|
let selected: HashSet<usize> = std::iter::once(1).collect();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Reusable styles and colors for consistent UI appearance
|
// Reusable styles and colors for consistent UI appearance
|
||||||
|
|
||||||
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, BorderType, Borders, Padding};
|
use ratatui::widgets::{Block, BorderType, Borders, Padding};
|
||||||
@@ -147,6 +148,18 @@ pub fn pane_block(focused: bool) -> Block<'static> {
|
|||||||
.padding(PANE_PADDING)
|
.padding(PANE_PADDING)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a
|
||||||
|
/// table header, a popup's tab bar and compose line. At least one, so a page
|
||||||
|
/// key always moves, even before the first draw. Paging and drawing both
|
||||||
|
/// count rows with this, so they agree on what a page is.
|
||||||
|
#[must_use]
|
||||||
|
pub fn page_of(area: Option<Rect>, chrome: u16) -> usize {
|
||||||
|
area.map_or(0, |area| {
|
||||||
|
usize::from(pane_block(false).inner(area).height.saturating_sub(chrome))
|
||||||
|
})
|
||||||
|
.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) {
|
pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) {
|
||||||
match status {
|
match status {
|
||||||
DownloadStatus::Queued => (GLYPH_QUEUED, warning_style()),
|
DownloadStatus::Queued => (GLYPH_QUEUED, warning_style()),
|
||||||
|
|||||||
Reference in New Issue
Block a user