diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index 9588f13..e9fc1bd 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -1,5 +1,5 @@ use super::MainTui; -use super::input::{jumped, list_jump}; +use super::input::{FilterEdit, edit_filter, jumped, list_jump}; use crate::models::BrowseStatus; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::{thread, time::Duration}; @@ -27,7 +27,9 @@ impl MainTui { self.state.show_browse = false; 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. if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { return; @@ -173,24 +175,12 @@ impl MainTui { let Some(browse) = self.state.browse.active_tab_mut() else { return false; }; - match key.code { - KeyCode::Esc => { - browse.filtering = false; - browse.set_filter(String::new()); - } - KeyCode::Enter => browse.filtering = false, - 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, - } + let mut filter = browse.filter().to_string(); + let Some(edit) = edit_filter(&mut filter, key) else { + return false; + }; + browse.filtering = edit == FilterEdit::Changed; + browse.set_filter(filter); self.sync_browse_selection(); true } diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index b894c5a..c9c20e8 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -192,30 +192,16 @@ impl MainTui { // Typing a filter captures the keys that edit it; the rest still // move around. - if self.state.chat_filtering { - match key.code { - KeyCode::Esc => { - self.state.chat_filtering = false; - self.state.chat_filter.clear(); - 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 self.state.chat_filtering + && let Some(edit) = edit_filter(&mut self.state.chat_filter, key) + { + if edit != FilterEdit::Changed { + self.state.chat_filtering = false; } + if edit != FilterEdit::Kept { + self.state.chat_view.to_newest(); + } + return; } // Paging keys move through the conversation's history. @@ -364,30 +350,16 @@ impl MainTui { } fn handle_filter_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.results_is_filtering = false; - self.state.results_filter_query.clear(); - self.state.results_filtered_items = - self.state.results_items.clone(); - self.state.results_filtered_indices = - (0..self.state.results_items.len()).collect(); - } - KeyCode::Char(c) => { - 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), + let Some(edit) = edit_filter(&mut self.state.results_filter_query, key) + else { + return self.handle_results_input(key); + }; + // A kept query stays: the normal Results keys act on the filtered + // list. An empty one shows everything without a rebuild. + if edit == FilterEdit::Changed { + self.apply_filter(); + } else { + self.state.results_is_filtering = false; } } @@ -412,10 +384,10 @@ impl MainTui { navigate_list(key, table, len, page) } - /// Rows a popup shows at once: its inner height less a tab bar and a - /// compose line, which is what its list or log has left. - pub(super) fn popup_page(&self) -> usize { - page_of(self.state.popup_area, 2) + /// Rows a popup's list shows at once: its inner height less the `chrome` + /// rows above the list, a tab bar or a header. + pub(super) fn popup_page(&self, chrome: u16) -> usize { + page_of(self.state.popup_area, chrome) } /// 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); } } - KeyCode::Char('/') => { - self.state.results_is_filtering = true; - self.state.results_filter_query.clear(); - } + KeyCode::Char('/') => self.state.results_is_filtering = true, KeyCode::Char('a') => { let indices: Vec = 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 { + 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 /// (negative is up). #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 0152f88..fa39086 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -1738,6 +1738,12 @@ mod tests { assert_eq!(rows(&tui), 3, "music, alpha, a2.flac"); let screen = screen_of(&mut tui); 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::Char('j')); press(&mut tui, KeyCode::Char('j')); @@ -1775,6 +1781,8 @@ mod tests { assert!(screen.contains("line 010"), "{screen}"); assert!(!screen.contains("line 029"), "{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::Esc); assert!(tui.state.rooms.log_filter.is_empty()); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 73841d8..a9389e0 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -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 { pub(super) fn render(&mut self, frame: &mut Frame) { let shortcuts = self.shortcut_rows(frame.area().width); @@ -497,13 +508,7 @@ impl MainTui { } } RoomsView::Chat if self.state.rooms.filtering.is_some() => { - vec![ - ("Type", "filter"), - ("PgUp/PgDn", "scroll"), - ("↑↓", "pick user"), - ("Enter", "keep filter"), - ("Esc", "clear filter"), - ] + filter_keys(&[("PgUp/PgDn", "scroll"), ("↑↓", "pick user")]) } RoomsView::Chat => vec![ ("Enter", "say"), @@ -565,12 +570,7 @@ impl MainTui { ("Esc", "stop typing"), ] } else if self.state.chat_filtering { - vec![ - ("Type", "filter"), - ("PgUp/PgDn", "scroll"), - ("Enter", "keep filter"), - ("Esc", "clear filter"), - ] + filter_keys(&[("PgUp/PgDn", "scroll")]) } else { vec![ ("Enter", "type"), @@ -586,13 +586,7 @@ impl MainTui { } else if self.state.show_browse { if self.state.browse.active_tab().is_some_and(|b| b.filtering) { // Typing goes to the filter, so no letter keys are on offer. - vec![ - ("Type", "filter"), - ("↑/↓", "navigate"), - ("PgUp/PgDn", "page"), - ("Enter", "keep filter"), - ("Esc", "clear filter"), - ] + filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")]) } else { vec![ ("↑↓", "move"), @@ -632,13 +626,7 @@ impl MainTui { && self.state.focused_pane == FocusedPane::Results { // Typing goes to the filter, so no letter keys are on offer here. - vec![ - ("Type", "filter"), - ("↑/↓", "navigate"), - ("PgUp/PgDn", "page"), - ("Enter", "keep filter"), - ("Esc", "clear filter"), - ] + filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")]) } else { let mut keys = match self.state.focused_pane { FocusedPane::Searches => vec![ diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs index d309831..a5cc811 100644 --- a/soulseek-rs/src/ui/main_tui/rooms.rs +++ b/soulseek-rs/src/ui/main_tui/rooms.rs @@ -1,5 +1,5 @@ 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 ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -47,7 +47,7 @@ impl MainTui { fn handle_rooms_list_input(&mut self, key: KeyEvent) { 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 = jumped(self.state.rooms.list_selected, len, jump); return; @@ -88,25 +88,18 @@ impl MainTui { } fn handle_room_filter_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.rooms.list_is_filtering = false; - self.state.rooms.list_filter.clear(); + let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key) + else { + return self.handle_rooms_list_input(key); + }; + match edit { + FilterEdit::Changed | FilterEdit::Cleared => { self.state.rooms.list_selected = 0; } - KeyCode::Enter => { - self.state.rooms.list_is_filtering = false; - self.join_selected_room(); - } - 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; - } - _ => {} + FilterEdit::Kept => self.join_selected_room(), + } + if edit != FilterEdit::Changed { + self.state.rooms.list_is_filtering = false; } } @@ -163,33 +156,24 @@ impl MainTui { fn handle_chat_filter_input(&mut self, key: KeyEvent) -> bool { let rooms = &mut self.state.rooms; let over_log = rooms.filtering == Some(ChatFilter::Log); - let current = if over_log { + let mut filter = if over_log { rooms.log_filter.clone() } else { rooms.user_filter.clone() }; - let next = match key.code { - KeyCode::Esc => String::new(), - 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, + let Some(edit) = edit_filter(&mut filter, key) else { + return false; }; - if matches!(key.code, KeyCode::Esc | KeyCode::Enter) { + if edit != FilterEdit::Changed { rooms.filtering = None; } - if over_log { - rooms.set_log_filter(next); - } else { - rooms.set_user_filter(next); + // Keeping a filter leaves the view where it is. + if edit != FilterEdit::Kept { + if over_log { + rooms.set_log_filter(filter); + } else { + rooms.set_user_filter(filter); + } } true } diff --git a/soulseek-rs/src/ui/mod.rs b/soulseek-rs/src/ui/mod.rs index dbafefe..d84b850 100644 --- a/soulseek-rs/src/ui/mod.rs +++ b/soulseek-rs/src/ui/mod.rs @@ -1,11 +1,13 @@ mod downloads; pub mod login; mod main_tui; +mod paging; mod panes; mod styles; mod utils; pub use downloads::render_download_stats; pub use main_tui::launch_main_tui; +pub use paging::*; pub use styles::*; pub use utils::*; diff --git a/soulseek-rs/src/ui/paging.rs b/soulseek-rs/src/ui/paging.rs new file mode 100644 index 0000000..d91cb4a --- /dev/null +++ b/soulseek-rs/src/ui/paging.rs @@ -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, 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 { + 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, + len: usize, + height: usize, +) -> Range { + 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)); + } +} diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index 6c0d7f3..6da44c4 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -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 = 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); } } } diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs index b3610f7..92b716b 100644 --- a/soulseek-rs/src/ui/panes/chat_pane.rs +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -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)) diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs index 365627f..98a7878 100644 --- a/soulseek-rs/src/ui/panes/downloads_pane.rs +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -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); } diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 0c1c1d7..1b71f59 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -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 = (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 = std::iter::once(1).collect(); diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index 28b501e..dd95ed2 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -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 = 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); diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index 35b30ca..b807b88 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -1,11 +1,9 @@ // Reusable styles and colors for consistent UI appearance -use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Padding}; use soulseek_rs::DownloadStatus; -use std::ops::Range; /// 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. @@ -140,6 +138,23 @@ pub fn plain_title(title: impl Into, 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. pub fn pane_block(focused: bool) -> Block<'static> { Block::default() @@ -149,41 +164,6 @@ pub fn pane_block(focused: bool) -> Block<'static> { .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, 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, - len: usize, - height: usize, -) -> Range { - 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) { match status { 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[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); - } }