Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
19 changes: 19 additions & 0 deletions src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<Vec<(usize, usize)>>,
}

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
Expand Down Expand Up @@ -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<HelpMenuModel>,
pub is_loading: bool,
io_tx: Option<Sender<IoEvent>>,
pub is_fetching_current_playback: bool,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/tui/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions src/tui/ui/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
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));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
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<Vec<String>> {
let key_bindings = &app.user_config.keys;
vec![
Expand Down Expand Up @@ -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)]);
}
}
1 change: 1 addition & 0 deletions src/tui/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading