diff --git a/src/interactive/app/common.rs b/src/interactive/app/common.rs index ed8d3991..6c085f30 100644 --- a/src/interactive/app/common.rs +++ b/src/interactive/app/common.rs @@ -2,6 +2,7 @@ use dua::traverse::{Tree, TreeIndex}; use std::time::SystemTime; use std::{cmp::Ordering, path::PathBuf}; use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; /// Controls which modification time is used for mtime sorting. #[derive(Default, Debug, Copy, Clone, PartialOrd, PartialEq, Eq)] @@ -242,36 +243,33 @@ fn mtime_of_descendants_with_ordering( selected_mtime } +/// Keep a grapheme-aligned suffix within a terminal-cell budget, adding an ellipsis if shortened. pub fn fit_string_graphemes_with_ellipsis( s: impl Into, - path_graphemes_count: usize, - mut desired_graphemes: usize, + desired_width: usize, ) -> (String, usize) { - const ELLIPSIS: usize = 1; - const MIN_GRAPHEMES_ON_SIDE: usize = 1; - const MIN_LEN: usize = ELLIPSIS + MIN_GRAPHEMES_ON_SIDE; - const USE_EXTENDED: bool = true; - let s = s.into(); - desired_graphemes = desired_graphemes.max(MIN_LEN); - - debug_assert!( - path_graphemes_count == s.graphemes(USE_EXTENDED).count(), - "input grapheme count is actually correct" - ); - - let gc = path_graphemes_count; - if gc <= desired_graphemes { - return (s, gc); + let width = s.width(); + if width <= desired_width { + return (s, width); + } + if desired_width == 0 { + return (String::new(), 0); } - let mut n = String::with_capacity(desired_graphemes); - let to_be_removed = gc - desired_graphemes + ELLIPSIS; - let gmi = s.graphemes(USE_EXTENDED); - - n.push('…'); - n.extend(gmi.skip(to_be_removed)); - (n, desired_graphemes) + let suffix_start = if s.bytes().all(|byte| (b' '..=b'~').contains(&byte)) { + // Printable ASCII has one cell per byte and no multi-byte graphemes. + s.len() - (desired_width - 1) + } else { + // Ligatures can span grapheme boundaries, so measure complete suffixes in O(n2) + s.grapheme_indices(true) + .map(|(index, _)| index) + .find(|&index| s[index..].width() < desired_width) + .unwrap_or(s.len()) + }; + let fitted = format!("…{}", &s[suffix_start..]); + let width = fitted.width(); + (fitted, width) } #[cfg(test)] @@ -282,22 +280,72 @@ mod tests { fn fit_string_inputs() { assert_eq!( ("aaa".into(), 3), - fit_string_graphemes_with_ellipsis("aaa", 3, 4) + fit_string_graphemes_with_ellipsis("aaa", 4) ); assert_eq!( - ("…a".to_string(), 2), - fit_string_graphemes_with_ellipsis("abbbba", 6, 1), + ("…".to_string(), 1), + fit_string_graphemes_with_ellipsis("abbbba", 1), "even amount of chars, desired too small" ); assert_eq!( ("…ca".to_string(), 3), - fit_string_graphemes_with_ellipsis("abbbbca", 7, 3), + fit_string_graphemes_with_ellipsis("abbbbca", 3), "uneven amount of chars, desired too small" ); assert_eq!( ("… a".to_string(), 3), - fit_string_graphemes_with_ellipsis("a a", 6, 3), + fit_string_graphemes_with_ellipsis("a a", 3), "spaces are counted as graphemes, too" ); } + + #[test] + fn fit_long_ascii_string_in_narrow_widths() { + let input = "path/to/report.txt ".repeat(1_000); + for budget in [0, 1, 2, 5, 20, input.len(), input.len() + 1] { + let expected = if input.len() <= budget { + input.clone() + } else if budget == 0 { + String::new() + } else { + format!("…{}", &input[input.len() - (budget - 1)..]) + }; + let expected_width = expected.width(); + assert_eq!( + fit_string_graphemes_with_ellipsis(input.as_str(), budget), + (expected, expected_width), + "ASCII path at width {budget}" + ); + } + } + + #[test] + fn fit_string_respects_display_width_and_grapheme_boundaries() { + for (input, budget, expected_str, expected_width) in [ + ("資料abc", 6, "…料abc", 6), + ("資料abc", 5, "…abc", 4), + ("👩‍💻abc", 4, "…abc", 4), + ("cafe\u{301}", 3, "…fe\u{301}", 3), + ("資料", 1, "…", 1), + ("資料", 0, "", 0), + ("資料", 4, "資料", 4), + ("xxxx\r\nab", 4, "…\r\nab", 4), + ("xxxلا📁", 5, "…xلا📁", 5), + ("xxxلا📁", 4, "…لا📁", 4), + ("xxxلا📁", 3, "…📁", 3), + ("xxxⵏ⵿ⴾ📁", 4, "…ⵏ⵿ⴾ📁", 4), + ] { + let (fitted, actual_width) = fit_string_graphemes_with_ellipsis(input, budget); + assert_eq!( + actual_width, + fitted.width(), + "reported width must match the whole string: {input:?} at width {budget}" + ); + assert_eq!( + (fitted, actual_width), + (expected_str.to_string(), expected_width), + "{input:?} at width {budget}" + ); + } + } } diff --git a/src/interactive/widgets/mark.rs b/src/interactive/widgets/mark.rs index ed3fa8ae..518d7735 100644 --- a/src/interactive/widgets/mark.rs +++ b/src/interactive/widgets/mark.rs @@ -27,7 +27,7 @@ use tui::{ Widget, }, }; -use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; #[derive(Clone, Copy)] pub enum MarkMode { @@ -304,18 +304,13 @@ impl MarkPane { String::new() } ); - let num_path_graphemes = path.graphemes(true).count(); - match num_path_graphemes + format.total_width() { - n if n > area.width as usize => { - let desired_size = - num_path_graphemes.saturating_sub(n - area.width as usize); - fit_string_graphemes_with_ellipsis( - path, - num_path_graphemes, - desired_size, - ) - } - _ => (path, num_path_graphemes), + let path_width = path.width(); + let available_width = + (area.width as usize).saturating_sub(format.total_width()); + if path_width > available_width { + fit_string_graphemes_with_ellipsis(path, available_width) + } else { + (path, path_width) } }; let fg_path = entry_color(None, !v.is_dir, true); @@ -530,6 +525,78 @@ mod mark_pane_tests { use crossterm::event::{KeyCode, KeyModifiers}; use tui::buffer::Cell; + #[test] + fn unicode_paths_keep_the_size_column_aligned() { + fn buffer_debug_without_styles(buffer: &Buffer) -> String { + let debug = format!("{buffer:?}"); + let (content, _) = debug + .split_once("\n styles: [") + .expect("buffer debug output includes styles"); + content.to_owned() + } + + let long_path = "資料/".repeat(30); + let paths = ["report.txt", "資料.txt", "📁.txt", long_path.as_str()]; + let area = Rect::new(0, 0, 80, 6); + let mut buffer = Buffer::empty(area); + let mut pane = MarkPane::default(); + for (index, path) in paths.iter().enumerate() { + pane.marked.insert( + TreeIndex::new(index), + EntryMark { + index, + path: PathBuf::from(path), + size: 1_000_000, + ..Default::default() + }, + ); + } + pane.render( + MarkPaneProps { + border_style: Style::default(), + format: ByteFormat::Metric, + keys: &KeysConfig::default(), + root_total_size: 4_000_000, + safety_notice: None, + allow_changes: true, + language: Language::English, + }, + area, + &mut buffer, + ); + + let mut size_columns = Vec::new(); + for y in 1..=4 { + let row: String = (0..area.width).map(|x| buffer[(x, y)].symbol()).collect(); + assert!(row.contains("1.00 MB"), "size must remain visible: {row}"); + size_columns.push( + (0..area.width) + .find(|&x| buffer[(x, y)].fg == Color::Green) + .expect("size column"), + ); + } + assert!( + size_columns.iter().all(|x| *x == size_columns[0]), + "{size_columns:?}" + ); + + insta::assert_snapshot!( + buffer_debug_without_styles(&buffer), + "marked item count, size and percentage of root size", + @r#" + Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 6 }, + content: [ + "┌Marked 0 items (0 B, 0.00% of 4.00 MB) ──────────────────────────────────────┐", + "│ report.txt 1.00 MB █", + "│ 資料.txt 1.00 MB █", // hidden by multi-width symbols: [(3, " "), (5, " ")] + "│ 📁.txt 1.00 MB ║", // hidden by multi-width symbols: [(3, " ")] + "│…料/資料/資料/資料/資料/資料/資料/資料/資料/資料/資料/資料/資料/ 1.00 MB ║", // hidden by multi-width symbols: [(3, " "), (6, " "), (8, " "), (11, " "), (13, " "), (16, " "), (18, " "), (21, " "), (23, " "), (26, " "), (28, " "), (31, " "), (33, " "), (36, " "), (38, " "), (41, " "), (43, " "), (46, " "), (48, " "), (51, " "), (53, " "), (56, " "), (58, " "), (61, " "), (63, " ")] + "└──────────────────────────────────────────────────────────────────────────────┘", + ], + "#); + } + #[test] fn title_shows_percentage_of_root_size() { let area = Rect::new(0, 0, 80, 4);