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,5 +1,5 @@
|
|||||||
use super::MainTui;
|
use super::MainTui;
|
||||||
use super::input::{jumped, list_jump};
|
use super::input::{FilterEdit, edit_filter, jumped, list_jump};
|
||||||
use crate::models::BrowseStatus;
|
use crate::models::BrowseStatus;
|
||||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use std::{thread, time::Duration};
|
use std::{thread, time::Duration};
|
||||||
@@ -27,7 +27,9 @@ impl MainTui {
|
|||||||
self.state.show_browse = false;
|
self.state.show_browse = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let jump = list_jump(key, self.popup_page());
|
// The tab bar takes a row once more than one user is open.
|
||||||
|
let chrome = u16::from(self.state.browse.tabs.len() > 1);
|
||||||
|
let jump = list_jump(key, self.popup_page(chrome));
|
||||||
// Control combinations are only ever jumps: ctrl-d pages, d downloads.
|
// Control combinations are only ever jumps: ctrl-d pages, d downloads.
|
||||||
if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
|
if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
return;
|
return;
|
||||||
@@ -173,24 +175,12 @@ impl MainTui {
|
|||||||
let Some(browse) = self.state.browse.active_tab_mut() else {
|
let Some(browse) = self.state.browse.active_tab_mut() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
match key.code {
|
let mut filter = browse.filter().to_string();
|
||||||
KeyCode::Esc => {
|
let Some(edit) = edit_filter(&mut filter, key) else {
|
||||||
browse.filtering = false;
|
return false;
|
||||||
browse.set_filter(String::new());
|
};
|
||||||
}
|
browse.filtering = edit == FilterEdit::Changed;
|
||||||
KeyCode::Enter => browse.filtering = false,
|
browse.set_filter(filter);
|
||||||
KeyCode::Char(c) => {
|
|
||||||
let mut filter = browse.filter().to_string();
|
|
||||||
filter.push(c);
|
|
||||||
browse.set_filter(filter);
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
let mut filter = browse.filter().to_string();
|
|
||||||
filter.pop();
|
|
||||||
browse.set_filter(filter);
|
|
||||||
}
|
|
||||||
_ => return false,
|
|
||||||
}
|
|
||||||
self.sync_browse_selection();
|
self.sync_browse_selection();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,30 +192,16 @@ impl MainTui {
|
|||||||
|
|
||||||
// Typing a filter captures the keys that edit it; the rest still
|
// Typing a filter captures the keys that edit it; the rest still
|
||||||
// move around.
|
// move around.
|
||||||
if self.state.chat_filtering {
|
if self.state.chat_filtering
|
||||||
match key.code {
|
&& let Some(edit) = edit_filter(&mut self.state.chat_filter, key)
|
||||||
KeyCode::Esc => {
|
{
|
||||||
self.state.chat_filtering = false;
|
if edit != FilterEdit::Changed {
|
||||||
self.state.chat_filter.clear();
|
self.state.chat_filtering = false;
|
||||||
self.state.chat_view.to_newest();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
self.state.chat_filtering = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
self.state.chat_filter.push(c);
|
|
||||||
self.state.chat_view.to_newest();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
self.state.chat_filter.pop();
|
|
||||||
self.state.chat_view.to_newest();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
if edit != FilterEdit::Kept {
|
||||||
|
self.state.chat_view.to_newest();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paging keys move through the conversation's history.
|
// Paging keys move through the conversation's history.
|
||||||
@@ -364,30 +350,16 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_filter_input(&mut self, key: KeyEvent) {
|
fn handle_filter_input(&mut self, key: KeyEvent) {
|
||||||
match key.code {
|
let Some(edit) = edit_filter(&mut self.state.results_filter_query, key)
|
||||||
KeyCode::Esc => {
|
else {
|
||||||
self.state.results_is_filtering = false;
|
return self.handle_results_input(key);
|
||||||
self.state.results_filter_query.clear();
|
};
|
||||||
self.state.results_filtered_items =
|
// A kept query stays: the normal Results keys act on the filtered
|
||||||
self.state.results_items.clone();
|
// list. An empty one shows everything without a rebuild.
|
||||||
self.state.results_filtered_indices =
|
if edit == FilterEdit::Changed {
|
||||||
(0..self.state.results_items.len()).collect();
|
self.apply_filter();
|
||||||
}
|
} else {
|
||||||
KeyCode::Char(c) => {
|
self.state.results_is_filtering = false;
|
||||||
self.state.results_filter_query.push(c);
|
|
||||||
self.apply_filter();
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
self.state.results_filter_query.pop();
|
|
||||||
self.apply_filter();
|
|
||||||
}
|
|
||||||
// Confirm the filter: leave typing mode but keep the query, so
|
|
||||||
// the normal Results keys (j/k, space, enter) act on the
|
|
||||||
// filtered list.
|
|
||||||
KeyCode::Enter => {
|
|
||||||
self.state.results_is_filtering = false;
|
|
||||||
}
|
|
||||||
_ => self.handle_results_input(key),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,10 +384,10 @@ impl MainTui {
|
|||||||
navigate_list(key, table, len, page)
|
navigate_list(key, table, len, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows a popup shows at once: its inner height less a tab bar and a
|
/// Rows a popup's list shows at once: its inner height less the `chrome`
|
||||||
/// compose line, which is what its list or log has left.
|
/// rows above the list, a tab bar or a header.
|
||||||
pub(super) fn popup_page(&self) -> usize {
|
pub(super) fn popup_page(&self, chrome: u16) -> usize {
|
||||||
page_of(self.state.popup_area, 2)
|
page_of(self.state.popup_area, chrome)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows the focused pane shows at once: its inner height less the table
|
/// Rows the focused pane shows at once: its inner height less the table
|
||||||
@@ -478,10 +450,7 @@ impl MainTui {
|
|||||||
self.state.results_selected_indices.insert(index);
|
self.state.results_selected_indices.insert(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('/') => {
|
KeyCode::Char('/') => self.state.results_is_filtering = true,
|
||||||
self.state.results_is_filtering = true;
|
|
||||||
self.state.results_filter_query.clear();
|
|
||||||
}
|
|
||||||
KeyCode::Char('a') => {
|
KeyCode::Char('a') => {
|
||||||
let indices: Vec<usize> =
|
let indices: Vec<usize> =
|
||||||
if self.state.results_filter_query.is_empty() {
|
if self.state.results_filter_query.is_empty() {
|
||||||
@@ -613,6 +582,43 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a key did to a filter being typed.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum FilterEdit {
|
||||||
|
Changed,
|
||||||
|
Kept,
|
||||||
|
Cleared,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `key` to `filter` the way every typed filter takes it: characters
|
||||||
|
/// and Backspace edit it, Enter keeps it, Esc clears it. A control or alt
|
||||||
|
/// chord is not typing. `None` when the key was none of those.
|
||||||
|
pub(super) fn edit_filter(
|
||||||
|
filter: &mut String,
|
||||||
|
key: KeyEvent,
|
||||||
|
) -> Option<FilterEdit> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
filter.clear();
|
||||||
|
Some(FilterEdit::Cleared)
|
||||||
|
}
|
||||||
|
KeyCode::Enter => Some(FilterEdit::Kept),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
filter.pop();
|
||||||
|
Some(FilterEdit::Changed)
|
||||||
|
}
|
||||||
|
KeyCode::Char(c)
|
||||||
|
if !key
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||||
|
{
|
||||||
|
filter.push(c);
|
||||||
|
Some(FilterEdit::Changed)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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)]
|
||||||
|
|||||||
@@ -1738,6 +1738,12 @@ mod tests {
|
|||||||
assert_eq!(rows(&tui), 3, "music, alpha, a2.flac");
|
assert_eq!(rows(&tui), 3, "music, alpha, a2.flac");
|
||||||
let screen = screen_of(&mut tui);
|
let screen = screen_of(&mut tui);
|
||||||
assert!(screen.contains("filter: flac_"), "{screen}");
|
assert!(screen.contains("filter: flac_"), "{screen}");
|
||||||
|
ctrl(&mut tui, 'd');
|
||||||
|
assert_eq!(
|
||||||
|
tui.state.browse.active_tab().expect("tab").filter(),
|
||||||
|
"flac",
|
||||||
|
"a control chord pages, it does not type"
|
||||||
|
);
|
||||||
press(&mut tui, KeyCode::Enter);
|
press(&mut tui, KeyCode::Enter);
|
||||||
press(&mut tui, KeyCode::Char('j'));
|
press(&mut tui, KeyCode::Char('j'));
|
||||||
press(&mut tui, KeyCode::Char('j'));
|
press(&mut tui, KeyCode::Char('j'));
|
||||||
@@ -1775,6 +1781,8 @@ mod tests {
|
|||||||
assert!(screen.contains("line 010"), "{screen}");
|
assert!(screen.contains("line 010"), "{screen}");
|
||||||
assert!(!screen.contains("line 029"), "{screen}");
|
assert!(!screen.contains("line 029"), "{screen}");
|
||||||
assert!(screen.contains("filter: line 01_"), "{screen}");
|
assert!(screen.contains("filter: line 01_"), "{screen}");
|
||||||
|
ctrl(&mut tui, 'u');
|
||||||
|
assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls");
|
||||||
press(&mut tui, KeyCode::Enter);
|
press(&mut tui, KeyCode::Enter);
|
||||||
press(&mut tui, KeyCode::Esc);
|
press(&mut tui, KeyCode::Esc);
|
||||||
assert!(tui.state.rooms.log_filter.is_empty());
|
assert!(tui.state.rooms.log_filter.is_empty());
|
||||||
|
|||||||
@@ -114,6 +114,17 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// What the bar offers while a filter is being typed, wherever that is,
|
||||||
|
/// with the keys that still move around there.
|
||||||
|
fn filter_keys(
|
||||||
|
moves: &[(&'static str, &'static str)],
|
||||||
|
) -> Vec<(&'static str, &'static str)> {
|
||||||
|
let mut keys = vec![("Type", "filter")];
|
||||||
|
keys.extend_from_slice(moves);
|
||||||
|
keys.extend([("Enter", "keep filter"), ("Esc", "clear filter")]);
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
impl MainTui {
|
impl MainTui {
|
||||||
pub(super) fn render(&mut self, frame: &mut Frame) {
|
pub(super) fn render(&mut self, frame: &mut Frame) {
|
||||||
let shortcuts = self.shortcut_rows(frame.area().width);
|
let shortcuts = self.shortcut_rows(frame.area().width);
|
||||||
@@ -497,13 +508,7 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RoomsView::Chat if self.state.rooms.filtering.is_some() => {
|
RoomsView::Chat if self.state.rooms.filtering.is_some() => {
|
||||||
vec![
|
filter_keys(&[("PgUp/PgDn", "scroll"), ("↑↓", "pick user")])
|
||||||
("Type", "filter"),
|
|
||||||
("PgUp/PgDn", "scroll"),
|
|
||||||
("↑↓", "pick user"),
|
|
||||||
("Enter", "keep filter"),
|
|
||||||
("Esc", "clear filter"),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
RoomsView::Chat => vec![
|
RoomsView::Chat => vec![
|
||||||
("Enter", "say"),
|
("Enter", "say"),
|
||||||
@@ -565,12 +570,7 @@ impl MainTui {
|
|||||||
("Esc", "stop typing"),
|
("Esc", "stop typing"),
|
||||||
]
|
]
|
||||||
} else if self.state.chat_filtering {
|
} else if self.state.chat_filtering {
|
||||||
vec![
|
filter_keys(&[("PgUp/PgDn", "scroll")])
|
||||||
("Type", "filter"),
|
|
||||||
("PgUp/PgDn", "scroll"),
|
|
||||||
("Enter", "keep filter"),
|
|
||||||
("Esc", "clear filter"),
|
|
||||||
]
|
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
("Enter", "type"),
|
("Enter", "type"),
|
||||||
@@ -586,13 +586,7 @@ impl MainTui {
|
|||||||
} else if self.state.show_browse {
|
} else if self.state.show_browse {
|
||||||
if self.state.browse.active_tab().is_some_and(|b| b.filtering) {
|
if self.state.browse.active_tab().is_some_and(|b| b.filtering) {
|
||||||
// Typing goes to the filter, so no letter keys are on offer.
|
// Typing goes to the filter, so no letter keys are on offer.
|
||||||
vec![
|
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
|
||||||
("Type", "filter"),
|
|
||||||
("↑/↓", "navigate"),
|
|
||||||
("PgUp/PgDn", "page"),
|
|
||||||
("Enter", "keep filter"),
|
|
||||||
("Esc", "clear filter"),
|
|
||||||
]
|
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
("↑↓", "move"),
|
("↑↓", "move"),
|
||||||
@@ -632,13 +626,7 @@ impl MainTui {
|
|||||||
&& self.state.focused_pane == FocusedPane::Results
|
&& self.state.focused_pane == FocusedPane::Results
|
||||||
{
|
{
|
||||||
// Typing goes to the filter, so no letter keys are on offer here.
|
// Typing goes to the filter, so no letter keys are on offer here.
|
||||||
vec![
|
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
|
||||||
("Type", "filter"),
|
|
||||||
("↑/↓", "navigate"),
|
|
||||||
("PgUp/PgDn", "page"),
|
|
||||||
("Enter", "keep filter"),
|
|
||||||
("Esc", "clear filter"),
|
|
||||||
]
|
|
||||||
} else {
|
} else {
|
||||||
let mut keys = match self.state.focused_pane {
|
let mut keys = match self.state.focused_pane {
|
||||||
FocusedPane::Searches => vec![
|
FocusedPane::Searches => vec![
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::MainTui;
|
use super::MainTui;
|
||||||
use super::input::{jumped, list_jump, scroll_log};
|
use super::input::{FilterEdit, edit_filter, jumped, list_jump, scroll_log};
|
||||||
use crate::models::{ChatFilter, RoomsView};
|
use crate::models::{ChatFilter, RoomsView};
|
||||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ impl MainTui {
|
|||||||
|
|
||||||
fn handle_rooms_list_input(&mut self, key: KeyEvent) {
|
fn handle_rooms_list_input(&mut self, key: KeyEvent) {
|
||||||
let len = self.state.rooms.filtered_rooms().len();
|
let len = self.state.rooms.filtered_rooms().len();
|
||||||
if let Some(jump) = list_jump(key, self.popup_page()) {
|
if let Some(jump) = list_jump(key, self.popup_page(1)) {
|
||||||
self.state.rooms.list_selected =
|
self.state.rooms.list_selected =
|
||||||
jumped(self.state.rooms.list_selected, len, jump);
|
jumped(self.state.rooms.list_selected, len, jump);
|
||||||
return;
|
return;
|
||||||
@@ -88,25 +88,18 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_room_filter_input(&mut self, key: KeyEvent) {
|
fn handle_room_filter_input(&mut self, key: KeyEvent) {
|
||||||
match key.code {
|
let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key)
|
||||||
KeyCode::Esc => {
|
else {
|
||||||
self.state.rooms.list_is_filtering = false;
|
return self.handle_rooms_list_input(key);
|
||||||
self.state.rooms.list_filter.clear();
|
};
|
||||||
|
match edit {
|
||||||
|
FilterEdit::Changed | FilterEdit::Cleared => {
|
||||||
self.state.rooms.list_selected = 0;
|
self.state.rooms.list_selected = 0;
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
FilterEdit::Kept => self.join_selected_room(),
|
||||||
self.state.rooms.list_is_filtering = false;
|
}
|
||||||
self.join_selected_room();
|
if edit != FilterEdit::Changed {
|
||||||
}
|
self.state.rooms.list_is_filtering = false;
|
||||||
KeyCode::Char(c) => {
|
|
||||||
self.state.rooms.list_filter.push(c);
|
|
||||||
self.state.rooms.list_selected = 0;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
self.state.rooms.list_filter.pop();
|
|
||||||
self.state.rooms.list_selected = 0;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,33 +156,24 @@ impl MainTui {
|
|||||||
fn handle_chat_filter_input(&mut self, key: KeyEvent) -> bool {
|
fn handle_chat_filter_input(&mut self, key: KeyEvent) -> bool {
|
||||||
let rooms = &mut self.state.rooms;
|
let rooms = &mut self.state.rooms;
|
||||||
let over_log = rooms.filtering == Some(ChatFilter::Log);
|
let over_log = rooms.filtering == Some(ChatFilter::Log);
|
||||||
let current = if over_log {
|
let mut filter = if over_log {
|
||||||
rooms.log_filter.clone()
|
rooms.log_filter.clone()
|
||||||
} else {
|
} else {
|
||||||
rooms.user_filter.clone()
|
rooms.user_filter.clone()
|
||||||
};
|
};
|
||||||
let next = match key.code {
|
let Some(edit) = edit_filter(&mut filter, key) else {
|
||||||
KeyCode::Esc => String::new(),
|
return false;
|
||||||
KeyCode::Enter => current,
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
let mut next = current;
|
|
||||||
next.push(c);
|
|
||||||
next
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
let mut next = current;
|
|
||||||
next.pop();
|
|
||||||
next
|
|
||||||
}
|
|
||||||
_ => return false,
|
|
||||||
};
|
};
|
||||||
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
|
if edit != FilterEdit::Changed {
|
||||||
rooms.filtering = None;
|
rooms.filtering = None;
|
||||||
}
|
}
|
||||||
if over_log {
|
// Keeping a filter leaves the view where it is.
|
||||||
rooms.set_log_filter(next);
|
if edit != FilterEdit::Kept {
|
||||||
} else {
|
if over_log {
|
||||||
rooms.set_user_filter(next);
|
rooms.set_log_filter(filter);
|
||||||
|
} else {
|
||||||
|
rooms.set_user_filter(filter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
mod downloads;
|
mod downloads;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
mod main_tui;
|
mod main_tui;
|
||||||
|
mod paging;
|
||||||
mod panes;
|
mod panes;
|
||||||
mod styles;
|
mod styles;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use downloads::render_download_stats;
|
pub use downloads::render_download_stats;
|
||||||
pub use main_tui::launch_main_tui;
|
pub use main_tui::launch_main_tui;
|
||||||
|
pub use paging::*;
|
||||||
pub use styles::*;
|
pub use styles::*;
|
||||||
pub use utils::*;
|
pub use utils::*;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//! How a pane's table shows one page of a list that may run to tens of
|
||||||
|
//! thousands of rows: which rows those are, and how ratatui is handed just
|
||||||
|
//! those. Paging and drawing both count rows here, so they agree on what a
|
||||||
|
//! page is.
|
||||||
|
|
||||||
|
use crate::ui::pane_block;
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::Rect,
|
||||||
|
widgets::{Table, TableState},
|
||||||
|
};
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a
|
||||||
|
/// table header, a popup's tab bar. At least one, so a page key always
|
||||||
|
/// moves, even before the first draw.
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page a table over `area` shows of `len` rows, `chrome` rows of the
|
||||||
|
/// area being above them: where the last frame left off, moved only as far
|
||||||
|
/// as keeps the selection in view. A selection past the end lands on the
|
||||||
|
/// last row, as the table would have put it.
|
||||||
|
pub fn page_window(
|
||||||
|
state: &mut TableState,
|
||||||
|
len: usize,
|
||||||
|
area: Rect,
|
||||||
|
chrome: u16,
|
||||||
|
) -> Range<usize> {
|
||||||
|
if let Some(selected) = state.selected_mut() {
|
||||||
|
*selected = (*selected).min(len.saturating_sub(1));
|
||||||
|
}
|
||||||
|
visible_range(
|
||||||
|
state.offset(),
|
||||||
|
state.selected(),
|
||||||
|
len,
|
||||||
|
page_of(Some(area), chrome),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw `table`, holding the rows of the page that starts at `start`, with
|
||||||
|
/// `state` shifted onto that page. The page start is kept for the next
|
||||||
|
/// frame.
|
||||||
|
pub fn render_page(
|
||||||
|
frame: &mut Frame,
|
||||||
|
table: Table,
|
||||||
|
area: Rect,
|
||||||
|
state: &mut TableState,
|
||||||
|
start: usize,
|
||||||
|
) {
|
||||||
|
let mut page_state = TableState::default()
|
||||||
|
.with_selected(state.selected().map(|selected| selected - start));
|
||||||
|
frame.render_stateful_widget(table, area, &mut page_state);
|
||||||
|
*state.offset_mut() = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{page_window, visible_range};
|
||||||
|
use ratatui::{layout::Rect, widgets::TableState};
|
||||||
|
|
||||||
|
#[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_window_pulls_a_selection_past_the_end_onto_the_last_row() {
|
||||||
|
// 6 rows tall: 2 border, 1 header, 3 of the list.
|
||||||
|
let area = Rect::new(0, 0, 40, 6);
|
||||||
|
let mut state = TableState::default().with_selected(Some(9));
|
||||||
|
assert_eq!(page_window(&mut state, 5, area, 1), 2..5);
|
||||||
|
assert_eq!(state.selected(), Some(4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::models::{BrowseState, BrowseStatus, BrowseTabs};
|
use crate::models::{BrowseState, BrowseStatus, BrowseTabs};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, format_bytes,
|
HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, filter_title,
|
||||||
get_spinner_char, highlight_style, page_of, pane_block, primary_style,
|
format_bytes, get_spinner_char, highlight_style, page_window, pane_block,
|
||||||
row_highlight_style, visible_range, warning_style,
|
primary_style, render_page, row_highlight_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -62,22 +62,22 @@ fn render_browse_one(
|
|||||||
spinner_state: usize,
|
spinner_state: usize,
|
||||||
) {
|
) {
|
||||||
let title = match browse.status {
|
let title = match browse.status {
|
||||||
BrowseStatus::Loaded
|
BrowseStatus::Loaded => {
|
||||||
if browse.filtering || !browse.filter().is_empty() =>
|
let shown =
|
||||||
{
|
browse.rows().iter().filter(|row| !row.is_folder).count();
|
||||||
format!(
|
filter_title(
|
||||||
" Browse {} — {} files, {} folders · filter: {}{} (Enter: keep, Esc: clear) ",
|
&format!(
|
||||||
browse.username,
|
"Browse {} — {shown}/{} files",
|
||||||
browse.file_count,
|
browse.username, browse.file_count
|
||||||
browse.folder_count,
|
),
|
||||||
browse.filter(),
|
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),
|
_ => format!(" Browse {} ", browse.username),
|
||||||
};
|
};
|
||||||
let block = pane_block(true).title(title);
|
let block = pane_block(true).title(title);
|
||||||
@@ -128,12 +128,8 @@ fn render_browse_one(
|
|||||||
}
|
}
|
||||||
BrowseStatus::Loaded => {
|
BrowseStatus::Loaded => {
|
||||||
let all = browse.rows();
|
let all = browse.rows();
|
||||||
let window = visible_range(
|
table_state.select(Some(browse.selected_row));
|
||||||
table_state.offset(),
|
let window = page_window(table_state, all.len(), area, 0);
|
||||||
Some(browse.selected_row),
|
|
||||||
all.len(),
|
|
||||||
page_of(Some(area), 0),
|
|
||||||
);
|
|
||||||
let start = window.start;
|
let start = window.start;
|
||||||
let rows: Vec<Row> = all[window]
|
let rows: Vec<Row> = all[window]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -170,13 +166,7 @@ fn render_browse_one(
|
|||||||
.highlight_spacing(HighlightSpacing::Always)
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
.block(block);
|
.block(block);
|
||||||
|
|
||||||
// The table holds one page, so the state it gets is shifted
|
render_page(frame, table, area, table_state, start);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::models::{AppState, MessageDirection, contains_filter};
|
use crate::models::{AppState, MessageDirection, contains_filter};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
accent_style, border_style, dimmed_style, highlight_style, info_style,
|
accent_style, border_style, dimmed_style, filter_title, highlight_style,
|
||||||
primary_style, wrap_chat_line,
|
info_style, primary_style, wrap_chat_line,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -22,19 +22,19 @@ pub fn render_chat_pane(
|
|||||||
) {
|
) {
|
||||||
let peer = state.active_chat_peer().map(str::to_string);
|
let peer = state.active_chat_peer().map(str::to_string);
|
||||||
let peer = peer.as_deref();
|
let peer = peer.as_deref();
|
||||||
let title = match peer {
|
let title = peer.map_or_else(
|
||||||
None => " Messages (m: compose, i/Esc: close) ".to_string(),
|
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|
||||||
Some(peer) if state.chat_filtering || !state.chat_filter.is_empty() => {
|
|peer| {
|
||||||
format!(
|
filter_title(
|
||||||
" {peer} · filter: {}{} (Enter: keep, Esc: clear) ",
|
peer,
|
||||||
state.chat_filter,
|
&state.chat_filter,
|
||||||
if state.chat_filtering { "_" } else { "" }
|
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()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(border_style(true))
|
.border_style(border_style(true))
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use crate::ui::{
|
|||||||
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
|
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
|
||||||
accent_style, body_style, dimmed_style, download_status_glyph, error_style,
|
accent_style, body_style, dimmed_style, download_status_glyph, error_style,
|
||||||
format_bytes, format_speed, header_style, inactive_style, info_style,
|
format_bytes, format_speed, header_style, inactive_style, info_style,
|
||||||
page_of, pane_block, pane_title, primary_style, row_highlight_style,
|
page_window, pane_block, pane_title, primary_style, render_page,
|
||||||
success_style, visible_range, warning_style,
|
row_highlight_style, success_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -83,15 +83,7 @@ pub fn render_downloads_pane(
|
|||||||
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||||
// Downloads come first, uploads after; one page of the two together.
|
// Downloads come first, uploads after; one page of the two together.
|
||||||
let total = downloads.len() + uploads.len();
|
let total = downloads.len() + uploads.len();
|
||||||
if let Some(selected) = table_state.selected_mut() {
|
let window = page_window(table_state, total, area, 1);
|
||||||
*selected = (*selected).min(total - 1);
|
|
||||||
}
|
|
||||||
let window = visible_range(
|
|
||||||
table_state.offset(),
|
|
||||||
table_state.selected(),
|
|
||||||
total,
|
|
||||||
page_of(Some(area), 1),
|
|
||||||
);
|
|
||||||
let start = window.start;
|
let start = window.start;
|
||||||
let split = downloads.len();
|
let split = downloads.len();
|
||||||
let shown_downloads = &downloads[start.min(split)..window.end.min(split)];
|
let shown_downloads = &downloads[start.min(split)..window.end.min(split)];
|
||||||
@@ -229,9 +221,5 @@ pub fn render_downloads_pane(
|
|||||||
focused,
|
focused,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// The table holds one page, so the state it gets is shifted onto it.
|
render_page(frame, table, area, table_state, start);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, page_of, pane_block, pane_title,
|
header_style, info_style, page_window, pane_block, pane_title, render_page,
|
||||||
row_highlight_style, success_style, visible_range, warning_style,
|
row_highlight_style, success_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -149,17 +149,7 @@ 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);
|
||||||
// A selection past the end, after a filter shrank the list, lands on the
|
let window = page_window(table_state, items.len(), area, 1);
|
||||||
// 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 start = window.start;
|
let start = window.start;
|
||||||
let rows: Vec<Row> = (start..)
|
let rows: Vec<Row> = (start..)
|
||||||
.zip(&items[window])
|
.zip(&items[window])
|
||||||
@@ -221,11 +211,7 @@ 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)));
|
||||||
|
|
||||||
// The table holds one page, so the state it gets is shifted onto it.
|
render_page(frame, table, area, table_state, start);
|
||||||
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)]
|
||||||
@@ -363,16 +349,6 @@ mod tests {
|
|||||||
assert_eq!(state.selected(), Some(9_999));
|
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();
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use crate::models::{
|
|||||||
contains_filter, matching_users,
|
contains_filter, matching_users,
|
||||||
};
|
};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
|
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, filter_title,
|
||||||
highlight_style, info_style, page_of, pane_block, plain_title,
|
highlight_style, info_style, page_window, pane_block, plain_title,
|
||||||
primary_style, row_highlight_style, visible_range, wrap_chat_line,
|
primary_style, render_page, row_highlight_style, wrap_chat_line,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -64,12 +64,8 @@ fn render_list(
|
|||||||
Cell::from("room").style(dimmed_style()),
|
Cell::from("room").style(dimmed_style()),
|
||||||
Cell::from("users").style(dimmed_style()),
|
Cell::from("users").style(dimmed_style()),
|
||||||
]);
|
]);
|
||||||
let window = visible_range(
|
table_state.select(Some(rooms.list_selected));
|
||||||
table_state.offset(),
|
let window = page_window(table_state, filtered.len(), area, 1);
|
||||||
Some(rooms.list_selected),
|
|
||||||
filtered.len(),
|
|
||||||
page_of(Some(area), 1),
|
|
||||||
);
|
|
||||||
let start = window.start;
|
let start = window.start;
|
||||||
let table_rows: Vec<Row> = filtered[window]
|
let table_rows: Vec<Row> = filtered[window]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -100,26 +96,17 @@ fn render_list(
|
|||||||
.highlight_spacing(HighlightSpacing::Always)
|
.highlight_spacing(HighlightSpacing::Always)
|
||||||
.block(block);
|
.block(block);
|
||||||
|
|
||||||
// The table holds one page, so the state it gets is shifted onto it.
|
render_page(frame, table, area, table_state, start);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
|
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
|
||||||
let typing_log = rooms.filtering == Some(ChatFilter::Log);
|
let typing_log = rooms.filtering == Some(ChatFilter::Log);
|
||||||
let title = if typing_log || !rooms.log_filter.is_empty() {
|
let title = filter_title(
|
||||||
format!(
|
"Chat rooms",
|
||||||
" Chat rooms · filter: {}{} (Enter: keep, Esc: clear) ",
|
&rooms.log_filter,
|
||||||
rooms.log_filter,
|
typing_log,
|
||||||
if typing_log { "_" } else { "" }
|
" Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) ",
|
||||||
)
|
);
|
||||||
} else {
|
|
||||||
" Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) "
|
|
||||||
.to_string()
|
|
||||||
};
|
|
||||||
let block = pane_block(true).title(title);
|
let block = pane_block(true).title(title);
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
// 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};
|
||||||
use soulseek_rs::DownloadStatus;
|
use soulseek_rs::DownloadStatus;
|
||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
/// One column of breathing room on each side, so content never touches the
|
/// One column of breathing room on each side, so content never touches the
|
||||||
/// border. Vertical rows stay unpadded — they are too scarce in a terminal.
|
/// border. Vertical rows stay unpadded — they are too scarce in a terminal.
|
||||||
@@ -140,6 +138,23 @@ pub fn plain_title(title: impl Into<String>, focused: bool) -> Line<'static> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A popup's title: `idle` until its list is filtered, then what it is
|
||||||
|
/// filtered by, a caret after it while that is still being typed, and how
|
||||||
|
/// the filter ends.
|
||||||
|
#[must_use]
|
||||||
|
pub fn filter_title(
|
||||||
|
head: &str,
|
||||||
|
filter: &str,
|
||||||
|
typing: bool,
|
||||||
|
idle: &str,
|
||||||
|
) -> String {
|
||||||
|
if !typing && filter.is_empty() {
|
||||||
|
return idle.to_string();
|
||||||
|
}
|
||||||
|
let caret = if typing { "_" } else { "" };
|
||||||
|
format!(" {head} · filter: {filter}{caret} (Enter: keep, Esc: clear) ")
|
||||||
|
}
|
||||||
|
|
||||||
/// The standard bordered pane. Chain `.title(..)` for a legend.
|
/// The standard bordered pane. Chain `.title(..)` for a legend.
|
||||||
pub fn pane_block(focused: bool) -> Block<'static> {
|
pub fn pane_block(focused: bool) -> Block<'static> {
|
||||||
Block::default()
|
Block::default()
|
||||||
@@ -149,41 +164,6 @@ 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
/// Building rows for this page alone keeps a frame's cost flat however long
|
|
||||||
/// the list is.
|
|
||||||
#[must_use]
|
|
||||||
pub 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 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()),
|
||||||
@@ -373,13 +353,4 @@ mod tests {
|
|||||||
assert_eq!(bar.spans[1].style.fg, Some(SIGNAL));
|
assert_eq!(bar.spans[1].style.fg, Some(SIGNAL));
|
||||||
assert_eq!(bar.spans[2].content.chars().count(), 15);
|
assert_eq!(bar.spans[2].content.chars().count(), 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user