refactor(tui): one page window and one filter editor for every list
Four panes had grown the same dance around ratatui's table: clamp the selection, take the visible page, hand the table a page-local state, keep the page start. It is now page_window and render_page in a paging module beside page_of, and the two popups page by the same count they draw, tab bar or header included. Five typed filters had grown the same key handling too. edit_filter now takes a key for all of them, which also stops a control or alt chord from typing its letter into a filter, stops Enter, which keeps a filter, from resetting the view it is over, and lets the results and room-list filters page like the others. Their titles and the typing shortcuts come from one place as well.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
use crate::models::{BrowseState, BrowseStatus, BrowseTabs};
|
||||
use crate::ui::{
|
||||
HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, format_bytes,
|
||||
get_spinner_char, highlight_style, page_of, pane_block, primary_style,
|
||||
row_highlight_style, visible_range, warning_style,
|
||||
HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, filter_title,
|
||||
format_bytes, get_spinner_char, highlight_style, page_window, pane_block,
|
||||
primary_style, render_page, row_highlight_style, warning_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@@ -62,22 +62,22 @@ fn render_browse_one(
|
||||
spinner_state: usize,
|
||||
) {
|
||||
let title = match browse.status {
|
||||
BrowseStatus::Loaded
|
||||
if browse.filtering || !browse.filter().is_empty() =>
|
||||
{
|
||||
format!(
|
||||
" Browse {} — {} files, {} folders · filter: {}{} (Enter: keep, Esc: clear) ",
|
||||
browse.username,
|
||||
browse.file_count,
|
||||
browse.folder_count,
|
||||
BrowseStatus::Loaded => {
|
||||
let shown =
|
||||
browse.rows().iter().filter(|row| !row.is_folder).count();
|
||||
filter_title(
|
||||
&format!(
|
||||
"Browse {} — {shown}/{} files",
|
||||
browse.username, browse.file_count
|
||||
),
|
||||
browse.filter(),
|
||||
if browse.filtering { "_" } else { "" }
|
||||
browse.filtering,
|
||||
&format!(
|
||||
" Browse {} — {} files, {} folders (Enter/d: download, /: filter, Tab: user, w: close, Esc: hide) ",
|
||||
browse.username, browse.file_count, browse.folder_count
|
||||
),
|
||||
)
|
||||
}
|
||||
BrowseStatus::Loaded => format!(
|
||||
" Browse {} — {} files, {} folders (Enter/d: download, /: filter, Tab: user, w: close, Esc: hide) ",
|
||||
browse.username, browse.file_count, browse.folder_count
|
||||
),
|
||||
_ => format!(" Browse {} ", browse.username),
|
||||
};
|
||||
let block = pane_block(true).title(title);
|
||||
@@ -128,12 +128,8 @@ fn render_browse_one(
|
||||
}
|
||||
BrowseStatus::Loaded => {
|
||||
let all = browse.rows();
|
||||
let window = visible_range(
|
||||
table_state.offset(),
|
||||
Some(browse.selected_row),
|
||||
all.len(),
|
||||
page_of(Some(area), 0),
|
||||
);
|
||||
table_state.select(Some(browse.selected_row));
|
||||
let window = page_window(table_state, all.len(), area, 0);
|
||||
let start = window.start;
|
||||
let rows: Vec<Row> = all[window]
|
||||
.iter()
|
||||
@@ -170,13 +166,7 @@ fn render_browse_one(
|
||||
.highlight_spacing(HighlightSpacing::Always)
|
||||
.block(block);
|
||||
|
||||
// The table holds one page, so the state it gets is shifted
|
||||
// onto it.
|
||||
let mut page_state = TableState::default().with_selected(Some(
|
||||
browse.selected_row.min(all.len().saturating_sub(1)) - start,
|
||||
));
|
||||
frame.render_stateful_widget(table, area, &mut page_state);
|
||||
*table_state.offset_mut() = start;
|
||||
render_page(frame, table, area, table_state, start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::models::{AppState, MessageDirection, contains_filter};
|
||||
use crate::ui::{
|
||||
accent_style, border_style, dimmed_style, highlight_style, info_style,
|
||||
primary_style, wrap_chat_line,
|
||||
accent_style, border_style, dimmed_style, filter_title, highlight_style,
|
||||
info_style, primary_style, wrap_chat_line,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@@ -22,19 +22,19 @@ pub fn render_chat_pane(
|
||||
) {
|
||||
let peer = state.active_chat_peer().map(str::to_string);
|
||||
let peer = peer.as_deref();
|
||||
let title = match peer {
|
||||
None => " Messages (m: compose, i/Esc: close) ".to_string(),
|
||||
Some(peer) if state.chat_filtering || !state.chat_filter.is_empty() => {
|
||||
format!(
|
||||
" {peer} · filter: {}{} (Enter: keep, Esc: clear) ",
|
||||
state.chat_filter,
|
||||
if state.chat_filtering { "_" } else { "" }
|
||||
let title = peer.map_or_else(
|
||||
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|
||||
|peer| {
|
||||
filter_title(
|
||||
peer,
|
||||
&state.chat_filter,
|
||||
state.chat_filtering,
|
||||
&format!(
|
||||
" {peer} (↑↓/Tab: switch, /: find, m: to…, i/Esc: close) "
|
||||
),
|
||||
)
|
||||
}
|
||||
Some(peer) => {
|
||||
format!(" {peer} (↑↓/Tab: switch, /: find, m: to…, i/Esc: close) ")
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style(true))
|
||||
|
||||
@@ -4,8 +4,8 @@ use crate::ui::{
|
||||
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
|
||||
accent_style, body_style, dimmed_style, download_status_glyph, error_style,
|
||||
format_bytes, format_speed, header_style, inactive_style, info_style,
|
||||
page_of, pane_block, pane_title, primary_style, row_highlight_style,
|
||||
success_style, visible_range, warning_style,
|
||||
page_window, pane_block, pane_title, primary_style, render_page,
|
||||
row_highlight_style, success_style, warning_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@@ -83,15 +83,7 @@ pub fn render_downloads_pane(
|
||||
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||
// Downloads come first, uploads after; one page of the two together.
|
||||
let total = downloads.len() + uploads.len();
|
||||
if let Some(selected) = table_state.selected_mut() {
|
||||
*selected = (*selected).min(total - 1);
|
||||
}
|
||||
let window = visible_range(
|
||||
table_state.offset(),
|
||||
table_state.selected(),
|
||||
total,
|
||||
page_of(Some(area), 1),
|
||||
);
|
||||
let window = page_window(table_state, total, area, 1);
|
||||
let start = window.start;
|
||||
let split = downloads.len();
|
||||
let shown_downloads = &downloads[start.min(split)..window.end.min(split)];
|
||||
@@ -229,9 +221,5 @@ pub fn render_downloads_pane(
|
||||
focused,
|
||||
)));
|
||||
|
||||
// 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;
|
||||
render_page(frame, table, area, table_state, start);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::name_scroll::{column_width, end_offset, scroll_text};
|
||||
use crate::models::FileDisplayData;
|
||||
use crate::ui::{
|
||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
||||
header_style, info_style, page_of, pane_block, pane_title,
|
||||
row_highlight_style, success_style, visible_range, warning_style,
|
||||
header_style, info_style, page_window, pane_block, pane_title, render_page,
|
||||
row_highlight_style, success_style, warning_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@@ -149,17 +149,7 @@ pub fn render_results_pane(
|
||||
|
||||
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||
let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN);
|
||||
// A selection past the end, after a filter shrank the list, lands on the
|
||||
// last row, as the table would have put it.
|
||||
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 window = page_window(table_state, items.len(), area, 1);
|
||||
let start = window.start;
|
||||
let rows: Vec<Row> = (start..)
|
||||
.zip(&items[window])
|
||||
@@ -221,11 +211,7 @@ pub fn render_results_pane(
|
||||
.highlight_spacing(HighlightSpacing::Always)
|
||||
.block(pane_block(focused).title(pane_title("2", &title, focused)));
|
||||
|
||||
// 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;
|
||||
render_page(frame, table, area, table_state, start);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -363,16 +349,6 @@ mod tests {
|
||||
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]
|
||||
fn identity_mapping_used_when_no_filter() {
|
||||
let selected: HashSet<usize> = std::iter::once(1).collect();
|
||||
|
||||
@@ -3,9 +3,9 @@ use crate::models::{
|
||||
contains_filter, matching_users,
|
||||
};
|
||||
use crate::ui::{
|
||||
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
|
||||
highlight_style, info_style, page_of, pane_block, plain_title,
|
||||
primary_style, row_highlight_style, visible_range, wrap_chat_line,
|
||||
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, filter_title,
|
||||
highlight_style, info_style, page_window, pane_block, plain_title,
|
||||
primary_style, render_page, row_highlight_style, wrap_chat_line,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@@ -64,12 +64,8 @@ fn render_list(
|
||||
Cell::from("room").style(dimmed_style()),
|
||||
Cell::from("users").style(dimmed_style()),
|
||||
]);
|
||||
let window = visible_range(
|
||||
table_state.offset(),
|
||||
Some(rooms.list_selected),
|
||||
filtered.len(),
|
||||
page_of(Some(area), 1),
|
||||
);
|
||||
table_state.select(Some(rooms.list_selected));
|
||||
let window = page_window(table_state, filtered.len(), area, 1);
|
||||
let start = window.start;
|
||||
let table_rows: Vec<Row> = filtered[window]
|
||||
.iter()
|
||||
@@ -100,26 +96,17 @@ fn render_list(
|
||||
.highlight_spacing(HighlightSpacing::Always)
|
||||
.block(block);
|
||||
|
||||
// The table holds one page, so the state it gets is shifted onto it.
|
||||
let mut page_state = TableState::default().with_selected(Some(
|
||||
rooms.list_selected.min(filtered.len() - 1) - start,
|
||||
));
|
||||
frame.render_stateful_widget(table, area, &mut page_state);
|
||||
*table_state.offset_mut() = start;
|
||||
render_page(frame, table, area, table_state, start);
|
||||
}
|
||||
|
||||
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
|
||||
let typing_log = rooms.filtering == Some(ChatFilter::Log);
|
||||
let title = if typing_log || !rooms.log_filter.is_empty() {
|
||||
format!(
|
||||
" Chat rooms · filter: {}{} (Enter: keep, Esc: clear) ",
|
||||
rooms.log_filter,
|
||||
if typing_log { "_" } else { "" }
|
||||
)
|
||||
} else {
|
||||
" Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) "
|
||||
.to_string()
|
||||
};
|
||||
let title = filter_title(
|
||||
"Chat rooms",
|
||||
&rooms.log_filter,
|
||||
typing_log,
|
||||
" Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) ",
|
||||
);
|
||||
let block = pane_block(true).title(title);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
Reference in New Issue
Block a user