fix: TUI selection underflow and filtered-results desync

Three related TUI correctness bugs:

- download_selector navigation (select_previous/next/last) computed
  items.len() - 1, panicking with an arithmetic underflow when the list was
  empty (e.g. a filter matched nothing). Index math is now in wrap-safe
  helpers that return no selection for an empty list.

- update_search_results overwrote the filtered results view with the full
  unfiltered list on every ~100ms poll, so an active filter silently stopped
  working as new results streamed in. It now re-derives the filtered view
  from the current query.

- The results pane rendered selection checkboxes by testing the display-row
  index against a selection set that stores unfiltered indices, so under an
  active filter the wrong rows showed as checked. It now maps the display
  index back through the filtered-index table.

Filtering and index-mapping logic extracted into pure, unit-tested helpers.
This commit is contained in:
michel
2026-07-18 14:30:02 +02:00
parent 16af88eb9d
commit bdaeb33a4f
4 changed files with 219 additions and 49 deletions
+59 -5
View File
@@ -19,12 +19,33 @@ pub struct ResultsPaneParams<'a> {
pub items: &'a [FileDisplayData],
pub table_state: &'a mut TableState,
pub selected_indices: &'a HashSet<usize>,
/// Maps a rendered row index to its index in the unfiltered results list.
/// `None` means the rendered rows are the unfiltered list (identity map).
pub original_indices: Option<&'a [usize]>,
pub filter_query: &'a str,
pub is_filtering: bool,
pub focused: bool,
pub active_search_query: Option<&'a str>,
}
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
/// indices into the *unfiltered* results, so under an active filter the display
/// index must be translated through `original_indices` first.
fn row_is_selected(
display_idx: usize,
original_indices: Option<&[usize]>,
selected_indices: &HashSet<usize>,
) -> bool {
let original = match original_indices {
Some(map) => match map.get(display_idx) {
Some(&o) => o,
None => return false,
},
None => display_idx,
};
selected_indices.contains(&original)
}
pub fn render_results_pane(
frame: &mut Frame,
area: Rect,
@@ -34,6 +55,7 @@ pub fn render_results_pane(
items,
table_state,
selected_indices,
original_indices,
filter_query,
is_filtering,
focused,
@@ -83,11 +105,12 @@ No results. Select a search from the Searches pane [1]. Or start new search [s
.iter()
.enumerate()
.map(|(idx, file)| {
let checkbox = if selected_indices.contains(&idx) {
"[✓]"
} else {
"[ ]"
};
let checkbox =
if row_is_selected(idx, original_indices, selected_indices) {
"[✓]"
} else {
"[ ]"
};
let bitrate_str = file
.bitrate
@@ -147,3 +170,34 @@ No results. Select a search from the Searches pane [1]. Or start new search [s
frame.render_stateful_widget(table, area, table_state);
}
#[cfg(test)]
mod tests {
use super::row_is_selected;
use std::collections::HashSet;
#[test]
fn identity_mapping_used_when_no_filter() {
let selected: HashSet<usize> = [1].into_iter().collect();
assert!(row_is_selected(1, None, &selected));
assert!(!row_is_selected(0, None, &selected));
}
#[test]
fn filtered_rows_resolve_through_original_indices() {
// Filter shows original rows 2, 5, 7 as display rows 0, 1, 2.
// The user selected original row 5.
let original_indices = [2usize, 5, 7];
let selected: HashSet<usize> = [5].into_iter().collect();
assert!(!row_is_selected(0, Some(&original_indices), &selected));
assert!(row_is_selected(1, Some(&original_indices), &selected));
assert!(!row_is_selected(2, Some(&original_indices), &selected));
}
#[test]
fn out_of_range_display_index_is_not_selected() {
let original_indices = [2usize, 5];
let selected: HashSet<usize> = [2, 5].into_iter().collect();
assert!(!row_is_selected(9, Some(&original_indices), &selected));
}
}