From d8a9376a98e2f5510a8eca7df0c8ea3310978595 Mon Sep 17 00:00:00 2001 From: rajvira10 Date: Fri, 16 Oct 2026 22:02:08 +0600 Subject: [PATCH 1/2] Fix marked-pane alignment for wide Unicode paths --- src/interactive/app/common.rs | 73 ++++++++++++++++++++------------- src/interactive/widgets/mark.rs | 69 +++++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 42 deletions(-) diff --git a/src/interactive/app/common.rs b/src/interactive/app/common.rs index ed8d3991..743f48c2 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,31 @@ 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 mut suffix_start = s.len(); + let mut width = 1; // ellipsis + for (index, grapheme) in s.grapheme_indices(true).rev() { + let grapheme_width = grapheme.width(); + if width + grapheme_width > desired_width { + break; + } + suffix_start = index; + width += grapheme_width; + } + (format!("…{}", &s[suffix_start..]), width) } #[cfg(test)] @@ -282,22 +278,41 @@ 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_string_respects_display_width_and_grapheme_boundaries() { + for (input, budget, 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), + ] { + assert_eq!( + fit_string_graphemes_with_ellipsis(input, budget), + (expected.to_string(), width), + "{input:?} at width {budget}" + ); + } + } } diff --git a/src/interactive/widgets/mark.rs b/src/interactive/widgets/mark.rs index ed3fa8ae..635886e3 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,54 @@ mod mark_pane_tests { use crossterm::event::{KeyCode, KeyModifiers}; use tui::buffer::Cell; + #[test] + fn unicode_paths_keep_the_size_column_aligned() { + 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:?}" + ); + } + #[test] fn title_shows_percentage_of_root_size() { let area = Rect::new(0, 0, 80, 4); From 3ab91b4cdd791c76c2d85e7956ab9eabe968a241 Mon Sep 17 00:00:00 2001 From: Byron Date: Sat, 17 Oct 2026 04:20:35 +0200 Subject: [PATCH 2/2] review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P2] Return the actual width of the truncated string — src/interactive/app/common.rs:267-270 For truncated paths containing cross-grapheme ligatures, individual grapheme widths do not sum to the whole-string width used by Ratatui. For example, `fit_string_graphemes_with_ellipsis("xxxلا📁", 5)` returns `("…لا📁", 5)`, although the returned string’s `.width()` is 4. `MarkPane::render` consequently computes a spacer one cell too short, shifting the size column left relative to ASCII rows. Compute the returned width from the completed string and use a consistent whole-string metric when selecting the suffix. Assisted-by: GPT 6.1 Sol --- src/interactive/app/common.rs | 61 +++++++++++++++++++++++++-------- src/interactive/widgets/mark.rs | 24 +++++++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/interactive/app/common.rs b/src/interactive/app/common.rs index 743f48c2..6c085f30 100644 --- a/src/interactive/app/common.rs +++ b/src/interactive/app/common.rs @@ -257,17 +257,19 @@ pub fn fit_string_graphemes_with_ellipsis( return (String::new(), 0); } - let mut suffix_start = s.len(); - let mut width = 1; // ellipsis - for (index, grapheme) in s.grapheme_indices(true).rev() { - let grapheme_width = grapheme.width(); - if width + grapheme_width > desired_width { - break; - } - suffix_start = index; - width += grapheme_width; - } - (format!("…{}", &s[suffix_start..]), width) + 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)] @@ -297,9 +299,29 @@ mod tests { ); } + #[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, width) in [ + for (input, budget, expected_str, expected_width) in [ ("資料abc", 6, "…料abc", 6), ("資料abc", 5, "…abc", 4), ("👩‍💻abc", 4, "…abc", 4), @@ -307,10 +329,21 @@ mod tests { ("資料", 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!( - fit_string_graphemes_with_ellipsis(input, budget), - (expected.to_string(), width), + (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 635886e3..518d7735 100644 --- a/src/interactive/widgets/mark.rs +++ b/src/interactive/widgets/mark.rs @@ -527,6 +527,14 @@ mod mark_pane_tests { #[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); @@ -571,6 +579,22 @@ mod mark_pane_tests { 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]