Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 77 additions & 29 deletions src/interactive/app/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<String>,
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)]
Expand All @@ -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}"
);
}
}
}
93 changes: 80 additions & 13 deletions src/interactive/widgets/mark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use tui::{
Widget,
},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

#[derive(Clone, Copy)]
pub enum MarkMode {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading