diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 0030668..3c8a786 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -41,15 +41,12 @@ pub enum FocusedPane { impl FocusedPane { /// Every focusable pane, in the order `Tab` walks them: the number each - /// carries in its legend. + /// carries in its legend, which is also declaration order. pub const ALL: [Self; 3] = [Self::Searches, Self::Results, Self::Downloads]; + /// Its position in [`Self::ALL`]. const fn index(self) -> usize { - match self { - Self::Searches => 0, - Self::Results => 1, - Self::Downloads => 2, - } + self as usize } } @@ -58,7 +55,7 @@ impl FocusedPane { /// panes while zoomed reads as switching windows. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct PaneLayout { - hidden: [bool; 3], + hidden: [bool; FocusedPane::ALL.len()], pub zoomed: bool, } @@ -238,9 +235,9 @@ pub struct AppState { pub layout: PaneLayout, /// The keys overlay (`?`) is open. pub show_help: bool, - /// Rows the keys overlay is scrolled down, for a terminal too short to - /// show it whole. The renderer clamps it to what there is to scroll. - pub help_scroll: usize, + /// Where the keys overlay is being read, for a terminal too short to + /// show it whole. + pub help_view: LogView, pub should_exit: bool, pub command_bar_active: bool, pub command_bar_input: String, @@ -322,7 +319,7 @@ impl AppState { focused_pane: FocusedPane::Searches, layout: PaneLayout::default(), show_help: false, - help_scroll: 0, + help_view: LogView::default(), should_exit: false, command_bar_active: false, command_bar_input: String::new(), @@ -365,6 +362,16 @@ impl AppState { } } + /// Where `pane` was last drawn; `None` while it is hidden. + #[must_use] + pub const fn pane_area(&self, pane: FocusedPane) -> Option { + match pane { + FocusedPane::Searches => self.searches_pane_area, + FocusedPane::Results => self.results_pane_area, + FocusedPane::Downloads => self.downloads_pane_area, + } + } + /// Put the focus on `pane`, bringing it back on screen if it was hidden: /// pressing a pane's number is also how it comes back. pub const fn focus_pane(&mut self, pane: FocusedPane) { diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 2baa211..2b7038b 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -8,12 +8,10 @@ use crate::ui::panes::{ use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; -use ratatui::layout::Position; +use ratatui::layout::{Position, Rect}; use ratatui::widgets::TableState; const NAME_SCROLL_STEP: isize = 8; -/// Rows a page key moves the keys overlay, which has no table to measure. -const HELP_PAGE: usize = 10; impl MainTui { pub(super) fn handle_key_event(&mut self, key: KeyEvent) { @@ -23,22 +21,18 @@ impl MainTui { } // The keys list closes on the key that opened it, or the usual two, - // and scrolls like any list for a terminal too short to show it all. + // and scrolls like any log for a terminal too short to show it all. if self.state.show_help { - let scroll = &mut self.state.help_scroll; + let view = &mut self.state.help_view; match key.code { KeyCode::Char('?' | 'q') | KeyCode::Esc => { self.state.show_help = false; } - KeyCode::Down | KeyCode::Char('j') => *scroll += 1, - KeyCode::Up | KeyCode::Char('k') => { - *scroll = scroll.saturating_sub(1); + KeyCode::Down | KeyCode::Char('j') => view.scroll(1), + KeyCode::Up | KeyCode::Char('k') => view.scroll(-1), + _ => { + scroll_log(view, key); } - KeyCode::PageDown => *scroll += HELP_PAGE, - KeyCode::PageUp => *scroll = scroll.saturating_sub(HELP_PAGE), - KeyCode::Home | KeyCode::Char('g') => *scroll = 0, - KeyCode::End | KeyCode::Char('G') => *scroll = usize::MAX, - _ => {} } return; } @@ -81,19 +75,13 @@ impl MainTui { } KeyCode::Char('?') => { self.state.show_help = true; - self.state.help_scroll = 0; + self.state.help_view.to_oldest(); return; } - KeyCode::Char('1') => { - self.state.focus_pane(FocusedPane::Searches); - return; - } - KeyCode::Char('2') => { - self.state.focus_pane(FocusedPane::Results); - return; - } - KeyCode::Char('3') => { - self.state.focus_pane(FocusedPane::Downloads); + // A pane's number is its place in the legend order. + KeyCode::Char(digit @ '1'..='3') => { + let index = usize::from(digit as u8 - b'1'); + self.state.focus_pane(FocusedPane::ALL[index]); return; } KeyCode::Tab => { @@ -368,7 +356,7 @@ impl MainTui { /// The list the focus is on and how far one page moves it. fn navigate_focused_list(&mut self, key: KeyEvent) -> bool { let focused = self.state.focused_pane; - let page = self.page_size(focused); + let page = self.page_size(); let (table, len) = match focused { FocusedPane::Searches => ( &mut self.state.searches_table_state, @@ -388,24 +376,14 @@ impl MainTui { /// 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) -> isize { - let rows = self.state.popup_area.map_or(1, |area| { - pane_block(false).inner(area).height.saturating_sub(2) - }); - isize::try_from(rows.max(1)).unwrap_or(isize::MAX) + pub(super) fn popup_page(&self) -> usize { + page_of(self.state.popup_area, 2) } - /// Rows a pane shows at once: its inner height less the table header. - fn page_size(&self, pane: FocusedPane) -> isize { - let area = match pane { - FocusedPane::Searches => self.state.searches_pane_area, - FocusedPane::Results => self.state.results_pane_area, - FocusedPane::Downloads => self.state.downloads_pane_area, - }; - let rows = area.map_or(1, |area| { - pane_block(false).inner(area).height.saturating_sub(1) - }); - isize::try_from(rows.max(1)).unwrap_or(isize::MAX) + /// Rows the focused pane shows at once: its inner height less the table + /// header. + fn page_size(&self) -> usize { + page_of(self.state.pane_area(self.state.focused_pane), 1) } fn handle_searches_input(&mut self, key: KeyEvent) { @@ -518,28 +496,21 @@ impl MainTui { /// How far the highlighted row's long column can scroll before its end /// is in view. Zero when nothing is highlighted or the pane is not drawn. fn highlighted_name_end(&self) -> usize { - match self.state.focused_pane { - FocusedPane::Searches => { - let Some(area) = self.state.searches_pane_area else { - return 0; - }; - self.state - .searches_table_state - .selected() - .and_then(|row| self.state.searches.get(row)) - .map_or(0, |search| query_end_offset(&search.query, area)) - } - FocusedPane::Results => { - let Some(area) = self.state.results_pane_area else { - return 0; - }; - self.highlighted_result() - .map_or(0, |file| name_end_offset(&file.filename, area)) - } + let pane = self.state.focused_pane; + let Some(area) = self.state.pane_area(pane) else { + return 0; + }; + match pane { + FocusedPane::Searches => self + .state + .searches_table_state + .selected() + .and_then(|row| self.state.searches.get(row)) + .map_or(0, |search| query_end_offset(&search.query, area)), + FocusedPane::Results => self + .highlighted_result() + .map_or(0, |file| name_end_offset(&file.filename, area)), FocusedPane::Downloads => { - let Some(area) = self.state.downloads_pane_area else { - return 0; - }; let shown = match selected_transfer( self.state.downloads_table_state.selected(), &self.state.downloads, @@ -589,24 +560,31 @@ impl MainTui { } let clicked = Position::new(mouse.column, mouse.row); - let panes = [ - (self.state.searches_pane_area, FocusedPane::Searches), - (self.state.results_pane_area, FocusedPane::Results), - (self.state.downloads_pane_area, FocusedPane::Downloads), - ]; // The areas are from the last draw. A pane hidden since then, in the // same batch of events, still has one, and is not there to click. - for (area, pane) in panes { - if area.is_some_and(|area| area.contains(clicked)) - && self.state.layout.is_visible(pane) - { - self.state.focused_pane = pane; - return; - } + let hit = FocusedPane::ALL.into_iter().find(|pane| { + self.state.layout.is_visible(*pane) + && self + .state + .pane_area(*pane) + .is_some_and(|area| area.contains(clicked)) + }); + if let Some(pane) = hit { + self.state.focused_pane = pane; } } } +/// 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, 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 /// (negative is up). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -618,8 +596,9 @@ pub(super) enum ListJump { /// The keys every list and log answers alike for moving further than a row: /// the two ends, a page, half a page. `page` is how many rows the list shows. -pub(super) fn list_jump(key: KeyEvent, page: isize) -> Option { +pub(super) fn list_jump(key: KeyEvent, page: usize) -> Option { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let page = isize::try_from(page).unwrap_or(isize::MAX); let half = (page / 2).max(1); Some(match key.code { KeyCode::Home | KeyCode::Char('g') if !ctrl => ListJump::First, @@ -637,8 +616,7 @@ pub(super) fn list_jump(key: KeyEvent, page: isize) -> Option { /// Apply the jump keys to a log, moving by the screenful it last showed. /// Says whether `key` was one of them. pub(super) fn scroll_log(view: &mut LogView, key: KeyEvent) -> bool { - let page = isize::try_from(view.page()).unwrap_or(isize::MAX); - match list_jump(key, page) { + match list_jump(key, view.page()) { Some(ListJump::First) => view.to_oldest(), Some(ListJump::Last) => view.to_newest(), Some(ListJump::Rows(rows)) => view.scroll(rows), @@ -663,19 +641,21 @@ fn navigate_list( key: KeyEvent, table: &mut TableState, len: usize, - page: isize, + page: usize, ) -> bool { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false), KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true), - _ => match list_jump(key, page) { - Some(jump) => { + _ => { + let Some(jump) = list_jump(key, page) else { + return false; + }; + if len > 0 { let current = table.selected().unwrap_or(0); - select_row(table, len, jumped(current, len, jump)); + table.select(Some(jumped(current, len, jump))); } - None => return false, - }, + } } true } @@ -693,10 +673,3 @@ fn cycle(table: &mut TableState, len: usize, forward: bool) { }; table.select(Some(next)); } - -/// Put the selection on `target`, or the last row when that is past the end. -fn select_row(table: &mut TableState, len: usize, target: usize) { - if len > 0 { - table.select(Some(target.min(len - 1))); - } -} diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index c7ca5b2..166283c 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -6,13 +6,14 @@ use crate::ui::panes::{ render_rooms_pane, render_searches_pane, selected_transfer, }; use crate::ui::{ - SIGNAL, accent_style, body_style, dimmed_style, pack_shortcuts, pane_block, - plain_title, primary_style, render_download_stats, warning_style, + accent_style, body_style, dimmed_style, info_style, pack_shortcuts, + pane_block, plain_title, primary_style, render_download_stats, + warning_style, }; use ratatui::{ Frame, layout::{Alignment, Constraint, Layout, Position, Rect}, - style::{Modifier, Style}, + style::Modifier, text::{Line, Span}, widgets::Paragraph, }; @@ -147,10 +148,11 @@ impl MainTui { return; } - let row_panes: Vec = - [FocusedPane::Searches, FocusedPane::Downloads] + // Transfers carry the most columns, so they get the most width. + let row_panes: Vec<(FocusedPane, u16)> = + [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] .into_iter() - .filter(|pane| self.state.layout.is_visible(*pane)) + .filter(|(pane, _)| self.state.layout.is_visible(*pane)) .collect(); let row_area = if self.state.layout.is_visible(FocusedPane::Results) { @@ -168,18 +170,13 @@ impl MainTui { area }; - // Transfers carry the most columns, so they get the most width. let mut constraints: Vec = row_panes .iter() - .map(|pane| match pane { - FocusedPane::Searches => Constraint::Fill(5), - FocusedPane::Downloads => Constraint::Fill(10), - FocusedPane::Results => unreachable!("not a row pane"), - }) + .map(|(_, weight)| Constraint::Fill(*weight)) .collect(); constraints.push(Constraint::Fill(6)); // Info let chunks = Layout::horizontal(constraints).split(row_area); - for (chunk, pane) in chunks.iter().zip(&row_panes) { + for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { self.render_pane(frame, *chunk, *pane); } self.render_info_pane(frame, chunks[row_panes.len()]); @@ -295,8 +292,10 @@ impl MainTui { let screen = frame.area(); // Borders and padding on either side of the text. let frame_width = 4; - let width = screen.width.min(help_width() + frame_width); - let columns = help_columns(width.saturating_sub(frame_width)); + let columns = help_columns(screen.width.saturating_sub(frame_width)); + let gaps = HELP_GAP * (columns.len() as u16 - 1); + let text_width = columns.iter().map(|lines| widest(lines)).sum::(); + let width = screen.width.min(text_width + gaps + frame_width); let tallest = columns.iter().map(Vec::len).max().unwrap_or(0); let height = screen .height @@ -314,10 +313,12 @@ impl MainTui { frame.render_widget(block, area); // Both columns scroll together, and no further than the taller one - // needs; the clamp is written back so the keys cannot run past it. - let furthest = tallest.saturating_sub(usize::from(inner.height)); - self.state.help_scroll = self.state.help_scroll.min(furthest); - let scroll = u16::try_from(self.state.help_scroll).unwrap_or(u16::MAX); + // needs. + let window = self + .state + .help_view + .window(tallest, usize::from(inner.height)); + let scroll = u16::try_from(window.start).unwrap_or(u16::MAX); let areas = Layout::horizontal(vec![Constraint::Fill(1); columns.len()]) @@ -734,19 +735,10 @@ fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize { .unwrap_or(0) } -/// Cells a column of the keys list needs to show every line whole. -fn help_column_width(sections: &[(&str, &[(&str, &str)])]) -> u16 { - let widest = help_lines(sections) - .iter() - .map(Line::width) - .max() - .unwrap_or(0); - u16::try_from(widest).unwrap_or(u16::MAX) -} - -/// The text width of the keys list with both halves side by side. -fn help_width() -> u16 { - help_column_width(HELP_LEFT) + HELP_GAP + help_column_width(HELP_RIGHT) +/// Cells the widest of `lines` takes. +fn widest(lines: &[Line<'_>]) -> u16 { + let cells = lines.iter().map(Line::width).max().unwrap_or(0); + u16::try_from(cells).unwrap_or(u16::MAX) } /// The keys list laid out for `width` cells of text: two columns when both @@ -754,7 +746,7 @@ fn help_width() -> u16 { fn help_columns(width: u16) -> Vec>> { let left = help_lines(HELP_LEFT); let right = help_lines(HELP_RIGHT); - if width >= help_width() { + if width >= widest(&left) + HELP_GAP + widest(&right) { return vec![left, right]; } let mut stacked = left; @@ -776,10 +768,7 @@ fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec> { )); for (key, action) in *keys { lines.push(Line::from(vec![ - Span::styled( - format!("{key: usize { let inner = pane_block(false).inner(area); - let symbol = Line::from(HIGHLIGHT_SYMBOL).width() as u16; + let symbol = HIGHLIGHT_SYMBOL.width() as u16; let [_, columns] = Layout::horizontal([Constraint::Length(symbol), Constraint::Fill(0)]) .areas(inner); @@ -38,7 +38,7 @@ pub fn end_offset( widths: &[Constraint], column: usize, ) -> usize { - overflow(Span::raw(text).width(), column_width(area, widths, column)) + overflow(text.width(), column_width(area, widths, column)) } /// `text` scrolled `offset` cells to the left, with an ellipsis where it was diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index 18624dd..28a938f 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -110,10 +110,14 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { render_tab_bar(frame, chunks[0], rooms); - let user_selected = rooms.user_selected; - let composing = rooms.composing; - let active_index = rooms.active; - let Some(active) = rooms.open.get_mut(active_index) else { + let RoomsState { + open, + active: active_index, + user_selected, + composing, + .. + } = rooms; + let Some(active) = open.get_mut(*active_index) else { frame.render_widget( Paragraph::new("No open rooms. Press l for the room list.") .style(dimmed_style()), @@ -128,10 +132,10 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { .split(chunks[1]); render_messages(frame, body[0], active.lines.as_slice(), &mut active.view); - render_users(frame, body[1], &active.users, user_selected); + render_users(frame, body[1], &active.users, *user_selected); // Compose line or hint. - if composing { + if *composing { let line = Line::from(vec![ Span::styled("› ", accent_style()), Span::styled(active.input.clone(), primary_style()), diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index 930cc02..0acbaac 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -39,7 +39,8 @@ pub const GLYPH_FAILED: &str = "✗"; pub const GLYPH_TIMED_OUT: &str = "⧗"; pub const GLYPH_CURSOR: &str = "▮"; pub const HIGHLIGHT_SYMBOL: &str = "›"; -pub const SHORTCUT_ARROW: &str = "→"; +/// Between a key and its action in the legend, spaced. +pub const SHORTCUT_ARROW: &str = " → "; pub fn header_style() -> Style { Style::default().fg(DUST).add_modifier(Modifier::BOLD) @@ -159,12 +160,15 @@ pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) { } /// One `[key → action]` legend entry. -fn shortcut_spans(key: &str, action: &str) -> Vec> { - vec![ +fn shortcut_spans( + key: &'static str, + action: &'static str, +) -> [Span<'static>; 5] { + [ Span::styled("[", dimmed_style()), - Span::styled(key.to_string(), Style::default().fg(SIGNAL)), - Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style()), - Span::styled(action.to_string(), body_style()), + Span::styled(key, info_style()), + Span::styled(SHORTCUT_ARROW, dimmed_style()), + Span::styled(action, body_style()), Span::styled("]", dimmed_style()), ] } @@ -178,7 +182,7 @@ pub const SHORTCUT_ROWS_MAX: usize = 3; /// that do not fit in [`SHORTCUT_ROWS_MAX`] rows are dropped, so a bar always /// has at least one row and never more than that. pub fn pack_shortcuts( - shortcuts: &[(&str, &str)], + shortcuts: &[(&'static str, &'static str)], width: u16, ) -> Vec> { let width = usize::from(width); @@ -189,24 +193,19 @@ pub fn pack_shortcuts( for (key, action) in shortcuts { let entry = shortcut_spans(key, action); let entry_width: usize = entry.iter().map(Span::width).sum(); - let needed = if row.is_empty() { - entry_width - } else { - row_width + 1 + entry_width - }; - if !row.is_empty() && needed > width { + if !row.is_empty() && row_width + 1 + entry_width > width { rows.push(Line::from(std::mem::take(&mut row))); if rows.len() == SHORTCUT_ROWS_MAX { return rows; } - row_width = entry_width; - } else { - if !row.is_empty() { - row.push(Span::raw(" ")); - } - row_width = needed; + row_width = 0; + } + if !row.is_empty() { + row.push(Span::raw(" ")); + row_width += 1; } row.extend(entry); + row_width += entry_width; } rows.push(Line::from(row));