diff --git a/CHANGELOG.md b/CHANGELOG.md index bc67a80e..95e1013a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- **Help search highlights its matches**: While filtering the Help menu (search key, `/` by default), every occurrence of your search terms is now highlighted in the visible rows, so you can see at a glance which part of a row matched. Highlighting follows the same smart-case rule as the filter itself ([#408](https://github.com/LargeModGames/spotatui/issues/408)). + ## [v0.40.3] 2026-07-27 ### Added diff --git a/docs/keybindings.md b/docs/keybindings.md index 7fae1201..4bda8b4a 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -1,6 +1,9 @@ # Keybindings -Press `?` in spotatui to see the help menu with all keybindings. +Press `?` in spotatui to see the help menu with all keybindings. Inside the +help menu, press the search key (`/` by default) to filter rows by key, +description, or context; matching text is highlighted in the visible rows. +Press `Enter` to apply the filter and `Esc` to clear it. ## Default Keybindings diff --git a/src/core/app.rs b/src/core/app.rs index f6d23e6c..1afd2516 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -1364,6 +1364,21 @@ pub struct NativePlaybackRestoreAttempt { pub desired_playing: bool, } +/// Formatted Help-menu rows, static between terminal-width, keybinding, or +/// filter changes. Rebuilt outside the draw path (see +/// `tui::ui::popups::ensure_help_menu_model`) so Help rendering reads immutable +/// `App` state instead of rebuilding ~80 owned Strings on every redraw. +pub struct HelpMenuModel { + pub width: usize, + pub keys: crate::core::user_config::KeyBindings, + pub filter: String, + pub header: String, + pub rows: Vec, + /// Per-row filter-match byte ranges, parallel to `rows`. Precomputed here so + /// the render loop does not lowercase every visible row on every frame. + pub match_ranges: Vec>, +} + pub struct App { /// What the user actually wants the volume to be. We keep this around until /// Spotify's API comes back with the same value — otherwise a slow poll @@ -1549,6 +1564,9 @@ pub struct App { pub help_filter: String, /// Whether typed keys are currently editing [`Self::help_filter`]. pub help_filter_editing: bool, + /// Formatted Help rows for the current width/keys/filter; `None` until the + /// Help menu is first prepared for rendering. + pub help_menu_model: Option, pub is_loading: bool, io_tx: Option>, pub is_fetching_current_playback: bool, @@ -1998,6 +2016,7 @@ impl Default for App { help_menu_offset: 0, help_filter: String::new(), help_filter_editing: false, + help_menu_model: None, is_loading: false, io_tx: None, is_fetching_current_playback: false, diff --git a/src/tui/runner.rs b/src/tui/runner.rs index 9551baa6..a19cd441 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -721,6 +721,12 @@ pub async fn start_ui( } }; + // Rebuild the formatted Help rows before drawing so the render path + // below reads immutable App state only. + if app.get_current_route().active_block == ActiveBlock::HelpMenu { + ui::ensure_help_menu_model(&mut app); + } + let current_route = app.get_current_route(); // The banner animates whenever the Home screen is displayed, regardless // of which block has focus (on Home the focused block is usually Empty diff --git a/src/tui/ui/help.rs b/src/tui/ui/help.rs index 6449293e..45a6e131 100644 --- a/src/tui/ui/help.rs +++ b/src/tui/ui/help.rs @@ -31,6 +31,68 @@ fn help_field_contains(field: &str, term: &str) -> bool { } } +/// All byte offsets where `needle` starts in `haystack`, including overlapping +/// occurrences, which `str::match_indices` skips (it resumes after each match). +/// After each hit the search resumes one char later, not one match later. +fn overlapping_match_starts(haystack: &str, needle: &str) -> Vec { + let mut starts = Vec::new(); + let mut from = 0; + while let Some(pos) = haystack[from..].find(needle) { + let start = from + pos; + starts.push(start); + from = start + haystack[start..].chars().next().map_or(1, char::len_utf8); + } + starts +} + +/// Byte ranges within a rendered help line where filter terms match, sorted and +/// merged, so the Help table can highlight why each row survived the filter. +/// Case sensitivity follows the same smart-case rule as [`help_field_contains`]. +pub fn help_match_ranges(line: &str, filter: &str) -> Vec<(usize, usize)> { + let mut ranges: Vec<(usize, usize)> = Vec::new(); + // Lowercasing can change byte lengths for non-ASCII text, so offsets into the + // lowercased haystack do not index `line` directly. Record, for every byte of + // the lowercased haystack, the byte range of the original char it came from, + // and map matches back through that table. Built lazily on the first + // case-insensitive term. + let mut haystack = String::new(); + let mut byte_map: Vec<(usize, usize)> = Vec::new(); + for term in filter.split_whitespace() { + if term.chars().any(|c| c.is_ascii_uppercase()) { + for start in overlapping_match_starts(line, term) { + ranges.push((start, start + term.len())); + } + } else { + if haystack.is_empty() { + for (orig_start, c) in line.char_indices() { + let orig_end = orig_start + c.len_utf8(); + for lowered in c.to_lowercase() { + for _ in 0..lowered.len_utf8() { + byte_map.push((orig_start, orig_end)); + } + haystack.push(lowered); + } + } + } + let needle = term.to_lowercase(); + for start in overlapping_match_starts(&haystack, &needle) { + // A match may start or end inside the multi-char lowering of a single + // original char; widen it to whole original chars. + ranges.push((byte_map[start].0, byte_map[start + needle.len() - 1].1)); + } + } + } + ranges.sort_unstable(); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (start, end) in ranges { + match merged.last_mut() { + Some(last) if start <= last.1 => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + merged +} + pub fn get_help_docs(app: &App) -> Vec> { let key_bindings = &app.user_config.keys; vec![ @@ -519,4 +581,56 @@ mod tests { assert_eq!(filter_help_docs(rows.clone(), " "), rows); } + + #[test] + fn help_match_ranges_finds_every_occurrence_of_every_term() { + let ranges = help_match_ranges("Increase volume by 10% + General", "volume general"); + + assert_eq!(ranges, vec![(9, 15), (27, 34)]); + } + + #[test] + fn help_match_ranges_uses_smart_case() { + assert!(help_match_ranges("Increase volume", "VOLUME").is_empty()); + assert_eq!( + help_match_ranges("Increase volume", "Increase"), + vec![(0, 8)] + ); + } + + #[test] + fn help_match_ranges_merges_overlapping_matches() { + assert_eq!(help_match_ranges("aaaa", "aa aaa"), vec![(0, 4)]); + } + + #[test] + fn help_match_ranges_finds_overlapping_occurrences_of_one_term() { + // `str::match_indices` alone would stop at (0, 2), leaving the last 'a' + // unhighlighted. + assert_eq!(help_match_ranges("aaa", "aa"), vec![(0, 3)]); + assert_eq!(help_match_ranges("AAA", "AA"), vec![(0, 3)]); + } + + #[test] + fn help_match_ranges_maps_offsets_when_lowercasing_changes_byte_lengths() { + // 'İ' (2 bytes) lowercases to "i\u{307}" (3 bytes), shifting every + // lowercased offset after it by one. + assert_eq!(help_match_ranges("İ volume", "volume"), vec![(3, 9)]); + + // 'ẞ' (3 bytes) lowercases to 'ß' (2 bytes), so with a leading 'İ' the + // total byte length is unchanged while offsets in between are still + // shifted — a length check alone cannot catch this. + let line = "İ volume ẞ"; + assert_eq!(line.len(), line.to_lowercase().len()); + let ranges = help_match_ranges(line, "volume"); + assert_eq!(ranges, vec![(3, 9)]); + assert_eq!(&line[3..9], "volume"); + } + + #[test] + fn help_match_ranges_widens_matches_to_original_char_boundaries() { + // "i\u{307}" matches inside the lowering of 'İ'; the highlight covers the + // whole original char rather than slicing mid-char. + assert_eq!(help_match_ranges("İx", "i\u{307}"), vec![(0, 2)]); + } } diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index f06f6420..cfe5e75e 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -36,6 +36,7 @@ pub use self::plugin_screen::draw_plugin_screen; pub use self::popups::{ draw_announcement_prompt, draw_dialog, draw_error_screen, draw_exit_prompt, draw_help_menu, draw_party, draw_plugin_popup, draw_queue, draw_recap_prompt, draw_sort_menu, + ensure_help_menu_model, }; pub use self::search::{draw_input_and_help_box, draw_search_results}; pub use self::stats::draw_stats; diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs index dfb5bf21..48bc8607 100644 --- a/src/tui/ui/popups.rs +++ b/src/tui/ui/popups.rs @@ -1,4 +1,6 @@ -use crate::core::app::{ActiveBlock, AnnouncementLevel, App, DialogContext, PlaylistPickerRow}; +use crate::core::app::{ + ActiveBlock, AnnouncementLevel, App, DialogContext, HelpMenuModel, PlaylistPickerRow, +}; use crate::core::plugin_api::PlayableInfo; use crate::core::plugin_api::PopupLine; use crate::infra::network::sync::PartyStatus; @@ -10,21 +12,63 @@ use ratatui::{ Frame, }; -use super::help::get_filtered_help_docs; - -/// Formatted help rows are static between terminal-width, keybinding, or filter -/// changes, so cache them instead of rebuilding ~80 owned Strings (plus -/// per-cell char-count truncation) on every redraw while Help is open. -struct HelpMenuCache { - width: usize, - keys: crate::core::user_config::KeyBindings, - filter: String, - header: String, - rows: Vec, +use super::help::{get_filtered_help_docs, help_match_ranges}; + +/// Rebuild [`App::help_menu_model`] if the terminal width, keybindings, or +/// filter changed since the last build. Called from the event loop (and tests) +/// before drawing, so [`draw_help_menu`] renders from immutable `App` state +/// instead of rebuilding ~80 owned Strings (plus per-cell char-count +/// truncation) on every redraw while Help is open. +pub fn ensure_help_menu_model(app: &mut App) { + // Mirrors draw_help_menu's layout: a margin of 2 on each side of the frame. + let total_width = (app.size.width as usize).saturating_sub(4); + let stale = app.help_menu_model.as_ref().is_none_or(|m| { + m.width != total_width || m.keys != app.user_config.keys || m.filter != app.help_filter + }); + if !stale { + return; + } + let (header, rows) = build_help_rows(app, total_width); + let match_ranges = rows + .iter() + .map(|row| help_match_ranges(row, &app.help_filter)) + .collect(); + app.help_menu_model = Some(HelpMenuModel { + width: total_width, + keys: app.user_config.keys.clone(), + filter: app.help_filter.clone(), + header, + rows, + match_ranges, + }); } -static HELP_MENU_CACHE: std::sync::OnceLock>> = - std::sync::OnceLock::new(); +/// Split a formatted help row into spans so every filter-term match renders in +/// the highlight style, making it obvious why the row survived the filter. +/// `ranges` are the row's precomputed [`help_match_ranges`] from the model. +fn highlighted_help_line<'a>( + row: &'a str, + ranges: &[(usize, usize)], + base: Style, + highlight: Style, +) -> Line<'a> { + if ranges.is_empty() { + return Line::from(Span::styled(row, base)); + } + let mut spans = Vec::with_capacity(ranges.len() * 2 + 1); + let mut cursor = 0; + for &(start, end) in ranges { + if cursor < start { + spans.push(Span::styled(&row[cursor..start], base)); + } + spans.push(Span::styled(&row[start..end], highlight)); + cursor = end; + } + if cursor < row.len() { + spans.push(Span::styled(&row[cursor..], base)); + } + Line::from(spans) +} fn build_help_rows(app: &App, total_width: usize) -> (String, Vec) { // Create a one-column table to avoid flickering due to non-determinism when @@ -78,45 +122,42 @@ pub fn draw_help_menu(f: &mut Frame<'_>, app: &App) { Constraint::Length(1), ])); - let total_width = table_area.width as usize; - - let cache_slot = HELP_MENU_CACHE.get_or_init(|| std::sync::Mutex::new(None)); - let mut cache = cache_slot - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let stale = cache.as_ref().is_none_or(|c| { - c.width != total_width || c.keys != app.user_config.keys || c.filter != app.help_filter - }); - if stale { - let (header, rows) = build_help_rows(app, total_width); - *cache = Some(HelpMenuCache { - width: total_width, - keys: app.user_config.keys.clone(), - filter: app.help_filter.clone(), - header, - rows, - }); - } - let cache = cache.as_ref().expect("help cache populated above"); + // The runner (and tests) call `ensure_help_menu_model` before drawing; + // rendering itself never rebuilds the model, only reads it. + let Some(model) = app.help_menu_model.as_ref() else { + return; + }; let help_menu_style = app.user_config.theme.base_style(); - let header = &cache.header; - let start = (app.help_menu_offset as usize).min(cache.rows.len()); + let header = &model.header; + let start = (app.help_menu_offset as usize).min(model.rows.len()); // Two border rows plus the table header leave this many data rows. This is // also the value used by the runner for page-size calculations. let visible_count = table_area.height.saturating_sub(3) as usize; - let end = start.saturating_add(visible_count).min(cache.rows.len()); - let help_docs = &cache.rows[start..end]; + let end = start.saturating_add(visible_count).min(model.rows.len()); + let help_docs = &model.rows[start..end]; - let rows: Vec> = if cache.rows.is_empty() && !app.help_filter.is_empty() { + let rows: Vec> = if model.rows.is_empty() && !app.help_filter.is_empty() { vec![ Row::new([format!("No help rows match '{}'", app.help_filter)]) .style(Style::default().fg(app.user_config.theme.inactive)), ] } else { + let highlight_style = Style::default() + .fg(app.user_config.theme.active) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)); help_docs .iter() - .map(|item| Row::new([item.as_str()]).style(help_menu_style)) + .zip(&model.match_ranges[start..end]) + .map(|(item, ranges)| { + Row::new([highlighted_help_line( + item, + ranges, + help_menu_style, + highlight_style, + )]) + .style(help_menu_style) + }) .collect() }; @@ -150,7 +191,7 @@ pub fn draw_help_menu(f: &mut Frame<'_>, app: &App) { Style::default().fg(theme.active), ), Span::styled( - format!(" ({}) : clear filter", cache.rows.len()), + format!(" ({}) : clear filter", model.rows.len()), Style::default().fg(theme.inactive), ), ]) @@ -177,7 +218,12 @@ mod help_menu_tests { use super::*; use ratatui::{backend::TestBackend, Terminal}; - fn rendered_help(app: &App) -> String { + fn rendered_help(app: &mut App) -> String { + app.size = ratatui::layout::Size { + width: 100, + height: 30, + }; + ensure_help_menu_model(app); let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); terminal.draw(|f| draw_help_menu(f, app)).unwrap(); let buffer = terminal.backend().buffer(); @@ -198,7 +244,7 @@ mod help_menu_tests { app.help_filter = "volume".to_string(); app.help_filter_editing = true; - let rendered = rendered_help(&app); + let rendered = rendered_help(&mut app); assert!(rendered.contains("Increase volume by 10%")); assert!(rendered.contains("Decrease volume by 10%")); @@ -209,19 +255,56 @@ mod help_menu_tests { #[test] fn help_menu_shows_back_hint_when_filter_is_inactive() { - let rendered = rendered_help(&App::default()); + let rendered = rendered_help(&mut App::default()); assert!(rendered.contains(": go back")); assert!(!rendered.contains("press to go back")); } + #[test] + fn help_menu_highlights_matched_text_in_filtered_rows() { + let mut app = App::default(); + app.help_filter = "volume".to_string(); + app.size = ratatui::layout::Size { + width: 100, + height: 30, + }; + ensure_help_menu_model(&mut app); + + let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + terminal.draw(|f| draw_help_menu(f, &app)).unwrap(); + let buffer = terminal.backend().buffer(); + + // Locate the row and column of the first "volume" match in the buffer. + // `find` returns a byte offset, but border symbols are multi-byte, so + // convert to a cell column by counting the chars before the match. + let (match_x, match_y) = (0..30) + .find_map(|y| { + let line: String = (0..100) + .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) + .collect(); + line.find("Increase volume").map(|byte_idx| { + let cell_x = line[..byte_idx].chars().count() as u16; + (cell_x + "Increase ".len() as u16, y) + }) + }) + .expect("filtered help row should be rendered"); + + let matched = buffer.cell((match_x, match_y)).unwrap(); + let unmatched = buffer + .cell((match_x - "Increase ".len() as u16, match_y)) + .unwrap(); + assert_eq!(matched.style().fg, Some(app.user_config.theme.active)); + assert_ne!(unmatched.style().fg, Some(app.user_config.theme.active)); + } + #[test] fn help_menu_renders_no_matches_and_clamps_a_stale_offset() { let mut app = App::default(); app.help_filter = "not-a-real-help-row".to_string(); app.help_menu_offset = u32::MAX; - let rendered = rendered_help(&app); + let rendered = rendered_help(&mut app); assert!(rendered.contains("No help rows match 'not-a-real-help-row'")); assert!(rendered.contains("matches for 'not-a-real-help-row' (0)"));