Files
soulseek-rs-modded/soulseek-rs/src/ui/panes/results_pane.rs
T
Michel de Graaf 2733ee26f7 refactor(tui): trim the folder column change to what it needs
The path splitter is a one-line stdlib call with a single caller, so it
lives beside that caller as a private fn, without its own tests. The pane
tests assert the two columns in one screen fragment each, the end-offset
test keeps only the two cases the existing name tests do not cover, and
the key-handling test that repeated one of them goes. The website mock
splits on the backslash the same way its Info pane already does.
2026-09-08 05:59:39 +00:00

354 lines
12 KiB
Rust

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, pane_block, pane_title, row_highlight_style,
success_style, warning_style,
};
use ratatui::{
Frame,
layout::{Constraint, Rect},
text::{Line, Span},
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
};
use std::collections::HashSet;
const VERSION: &str = env!("CARGO_PKG_VERSION");
// The file name and its folder share whatever width the fixed columns leave,
// the name getting the larger part: it is what a search is after, and a
// folder's tail is a scroll away.
const WIDTHS: [Constraint; 8] = [
Constraint::Length(3),
Constraint::Fill(3),
Constraint::Fill(2),
Constraint::Length(12),
Constraint::Length(15),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Length(6),
];
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>,
pub name_offset: usize,
}
/// The file name is the second column, the folder it sits in the third.
const NAME_COLUMN: usize = 1;
const FOLDER_COLUMN: usize = 2;
/// A shared path as `(folder, name)`. Peers send backslashes, but a slash
/// counts too.
fn split_path(path: &str) -> (&str, &str) {
path.rsplit_once(['\\', '/']).unwrap_or(("", path))
}
/// The offset that brings the end of a result's longer column into view.
/// One offset scrolls the name and the folder together, so it runs until
/// both tails are showing.
#[must_use]
pub fn name_end_offset(path: &str, area: Rect) -> usize {
let (folder, name) = split_path(path);
end_offset(name, area, &WIDTHS, NAME_COLUMN).max(end_offset(
folder,
area,
&WIDTHS,
FOLDER_COLUMN,
))
}
/// 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,
params: ResultsPaneParams,
) {
let ResultsPaneParams {
items,
table_state,
selected_indices,
original_indices,
filter_query,
is_filtering,
focused,
active_search_query,
name_offset,
} = params;
if items.is_empty() {
let title = match active_search_query {
Some(query) => format!("Results: {query}"),
None => "Results".to_string(),
};
let empty_block =
pane_block(focused).title(pane_title("2", &title, focused));
let message = if is_filtering {
vec![Line::from(Span::styled(
format!("No results match filter: '{filter_query}'"),
dimmed_style(),
))]
} else {
vec![
Line::from(vec![
Span::styled("soulseek-rs", body_style()),
Span::styled(format!(" v{VERSION}"), dimmed_style()),
]),
Line::from(""),
Line::from(Span::styled("No results yet.", body_style())),
Line::from(Span::styled(
"Pick a search in [1] Searches, or start a new one.",
dimmed_style(),
)),
]
};
let paragraph = Paragraph::new(message).block(empty_block);
frame.render_widget(paragraph, area);
return;
}
let header = Row::new(vec![
Cell::from("✓").style(header_style()),
Cell::from("Filename").style(header_style()),
Cell::from("Folder").style(header_style()),
Cell::from("Size").style(header_style()),
Cell::from("User").style(header_style()),
Cell::from("Bitrate").style(header_style()),
Cell::from("Speed").style(header_style()),
Cell::from("Slots").style(header_style()),
])
.height(1);
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN);
let rows: Vec<Row> = items
.iter()
.enumerate()
.map(|(idx, file)| {
let (folder, name) = split_path(&file.filename);
let checkbox =
if row_is_selected(idx, original_indices, selected_indices) {
"[✓]"
} else {
"[ ]"
};
let bitrate_str = file
.bitrate
.map_or_else(|| "-".to_string(), |br| format!("{br} kbps"));
let speed_str = if file.speed > 0 {
let speed_mb = (f64::from(file.speed) / BYTES_PER_MB * 100.0)
.round()
/ 100.0;
format!("{speed_mb} MB/s")
} else {
"-".to_string()
};
let checkbox_style = if checkbox == "[✓]" {
success_style()
} else {
dimmed_style()
};
Row::new(vec![
Cell::from(checkbox).style(checkbox_style),
Cell::from(scroll_text(name, name_offset, name_width))
.style(body_style()),
Cell::from(scroll_text(folder, name_offset, folder_width))
.style(dimmed_style()),
Cell::from(format_bytes(file.size)).style(warning_style()),
Cell::from(file.username.clone()).style(info_style()),
Cell::from(bitrate_str).style(dimmed_style()),
Cell::from(speed_str).style(dimmed_style()),
Cell::from(file.slots.to_string()).style(dimmed_style()),
])
})
.collect();
let title = if is_filtering {
format!("Results · filter: '{filter_query}'")
} else if let Some(query) = active_search_query {
format!("Results: {query}")
} else {
"Results".to_string()
};
let table = Table::new(rows, WIDTHS)
.header(header)
.row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
.highlight_spacing(HighlightSpacing::Always)
.block(pane_block(focused).title(pane_title("2", &title, focused)));
frame.render_stateful_widget(table, area, table_state);
}
#[cfg(test)]
mod tests {
use super::{ResultsPaneParams, render_results_pane, row_is_selected};
use crate::models::FileDisplayData;
use ratatui::{Terminal, backend::TestBackend, widgets::TableState};
use std::collections::HashSet;
fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String {
let mut state = TableState::default();
state.select(Some(0));
// Wide enough for the name column to hold 11 cells, with 7 for the
// folder beside it.
let mut terminal =
Terminal::new(TestBackend::new(88, 6)).expect("backend");
terminal
.draw(|frame| {
render_results_pane(
frame,
frame.area(),
ResultsPaneParams {
items,
table_state: &mut state,
selected_indices: &HashSet::new(),
original_indices: None,
filter_query: "",
is_filtering: false,
focused: true,
active_search_query: None,
name_offset,
},
);
})
.expect("draw");
terminal.backend().to_string()
}
fn file(filename: &str) -> FileDisplayData {
FileDisplayData {
filename: filename.to_string(),
username: "bob".to_string(),
..Default::default()
}
}
#[test]
fn a_scrolled_name_shows_its_tail_up_to_the_column_edge() {
let items = [
file("abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
file("short.mp3"),
];
let screen = render_rows(&items, usize::MAX);
assert!(screen.contains("…456789ABCD"), "{screen}");
assert!(!screen.contains("3456789ABCD"), "{screen}");
assert!(screen.contains(" short.mp3 "), "{screen}");
let screen = render_rows(&items, 8);
assert!(screen.contains("…ijklmnopqr"), "{screen}");
assert!(!screen.contains("hijklmnopqr"), "{screen}");
}
#[test]
fn a_wide_glyph_name_scrolls_by_cells_not_chars() {
let items = [file("零一二三四五六七八九十百千万亿東西南北中")];
let screen = render_rows(&items, usize::MAX);
assert!(screen.contains("…東西南北中"), "{screen}");
assert!(!screen.contains("亿東"), "{screen}");
}
#[test]
fn a_scrolled_name_never_starts_with_a_combining_mark() {
let name = format!("{}e\u{301}{}", "x".repeat(5), "y".repeat(20));
let items = [file(&name)];
let screen = render_rows(&items, 6);
assert!(screen.contains("…yyyyyyyyyy"), "{screen}");
assert!(!screen.contains("…\u{301}"), "{screen}");
}
#[test]
fn a_path_shows_its_name_before_its_folder() {
let items = [file("@@abc\\Music\\Album\\01.flac"), file("cover.jpg")];
let screen = render_rows(&items, 0);
assert!(screen.contains("01.flac @@abc\\M"), "{screen}");
assert!(screen.contains("cover.jpg "), "{screen}");
}
#[test]
fn one_offset_scrolls_the_name_and_the_folder_together() {
let items = [file("@@abc\\Music\\Album\\abcdefghijklmnopqrstuvwxyz")];
let screen = render_rows(&items, 4);
assert!(screen.contains("…efghijklmn …c\\Musi"), "{screen}");
}
#[test]
fn the_end_offset_is_the_longer_columns() {
// 88 cells wide: 11 for the name, 7 for the folder.
let area = ratatui::layout::Rect::new(0, 0, 88, 6);
let folder = "f".repeat(20);
assert_eq!(super::name_end_offset(&format!("{folder}\\a"), area), 14);
let name = "x".repeat(40);
assert_eq!(
super::name_end_offset(&format!("{folder}\\{name}"), area),
30
);
}
#[test]
fn an_unscrolled_name_starts_at_its_beginning() {
let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")];
let screen = render_rows(&items, 0);
assert!(screen.contains("abcdefghijk"), "{screen}");
assert!(!screen.contains("…"), "{screen}");
}
#[test]
fn identity_mapping_used_when_no_filter() {
let selected: HashSet<usize> = std::iter::once(1).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> = std::iter::once(5).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));
}
}