From 06bc48fcd91acebcdaf2adf1e311f1023a3ee08f Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Sun, 6 Sep 2026 21:42:36 +0200 Subject: [PATCH] perf(tui): bound the name scroll walk by the column width Scrolled rows cost 31.7ms per frame at 30k results against 13.3ms on develop, because every row segmented its whole filename into grapheme clusters. Walk back from the end for at most one column of cells and forward for at most the offset, so a row costs the column width rather than the filename, and drop unicode-truncate for unicode-width. A tail never starts on a combining mark. --- Cargo.lock | 2 +- soulseek-rs/Cargo.toml | 2 +- soulseek-rs/src/ui/panes/results_pane.rs | 45 ++++++++++++++++++++---- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 426dbe4..3ab3e88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ "soulseek-rs-lib", "tempfile", "toml", - "unicode-truncate", + "unicode-width", ] [[package]] diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 4821441..d04ccc3 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -30,7 +30,7 @@ soulseek-rs-lib = { version = "14.1.2", path = "../soulseek-rs-lib" } clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } clap_complete = "4.6.7" ratatui = "0.30.2" -unicode-truncate = "2" +unicode-width = "0.2" color-eyre = "0.6.5" dotenv = "0.15.0" igd-next = "0.17" diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 37915ad..1634047 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -11,7 +11,7 @@ use ratatui::{ widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; use std::collections::HashSet; -use unicode_truncate::UnicodeTruncateStr; +use unicode_width::UnicodeWidthChar; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -61,13 +61,37 @@ fn scroll_name(name: &str, offset: usize, width: usize) -> Line<'_> { if offset == 0 { return Line::from(name); } - let cells = Span::raw(name).width(); - let skip = offset.min(overflow(cells, width)); - if skip == 0 { + let mut tail_cells = 0; + let mut furthest = name.len(); + for (index, ch) in name.char_indices().rev() { + let cells = ch.width().unwrap_or(0); + if tail_cells + cells >= width { + if index == 0 && tail_cells + cells == width { + return Line::from(name); + } + break; + } + tail_cells += cells; + furthest = index; + } + if furthest == 0 { return Line::from(name); } - let (tail, _) = name.unicode_truncate_start(cells - skip); - Line::from(vec![Span::raw("…"), Span::raw(tail)]) + let mut skipped = 0; + let mut start = furthest; + for (index, ch) in name.char_indices() { + if index >= furthest || skipped >= offset { + start = index; + break; + } + skipped += ch.width().unwrap_or(0); + } + while let Some(ch) = name[start..].chars().next() + && ch.width() == Some(0) + { + start += ch.len_utf8(); + } + Line::from(vec![Span::raw("…"), Span::raw(&name[start..])]) } /// Whether the rendered row `display_idx` is selected. `selected_indices` holds @@ -281,6 +305,15 @@ mod tests { 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 an_unscrolled_name_starts_at_its_beginning() { let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")];