diff --git a/.gitignore b/.gitignore index ea8c4bf..0592392 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 05ad54c..f8b8152 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,6 +616,7 @@ dependencies = [ "syntect", "toml", "two-face", + "unicode-width", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 140647c..053893b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ serde = { version = "1.0.228", features = ["derive"] } syntect = { version = "5.3.0", default-features = false, features = ["default-themes", "regex-fancy"] } two-face = { version = "0.5.1", default-features = false, features = ["syntect-default-fancy"] } toml = "1.0.6" +unicode-width = "0.2" [profile.release] codegen-units = 1 diff --git a/src/ui/render.rs b/src/ui/render.rs index 3dfb8c1..c90f595 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -9,6 +9,8 @@ use ratatui::{ Frame, }; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + use crate::diff::LineChange; use super::rebase::render_rebase_ui; @@ -173,6 +175,10 @@ pub fn render_file_list(f: &mut Frame, app: &App, area: Rect) { return; } + // Borders (2) + highlight symbol "▌ " (2) + const FILE_LIST_CHROME_WIDTH: u16 = 4; + let inner_width = area.width.saturating_sub(FILE_LIST_CHROME_WIDTH) as usize; + let items: Vec = app .file_names .iter() @@ -188,25 +194,12 @@ pub fn render_file_list(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(t.fg_normal) }; - let mut spans = vec![Span::styled(file.clone(), name_style)]; - if adds > 0 || dels > 0 { - spans.push(Span::styled(" ", Style::default())); - if adds > 0 { - spans.push(Span::styled( - format!("+{}", adds), - Style::default().fg(t.fg_added), - )); - } - if adds > 0 && dels > 0 { - spans.push(Span::styled(" ", Style::default())); - } - if dels > 0 { - spans.push(Span::styled( - format!("-{}", dels), - Style::default().fg(t.fg_removed), - )); - } - } + let (stat_spans, stats_width) = build_file_stats(adds, dels, t); + let max_name_width = inner_width.saturating_sub(stats_width); + let display_name = truncate_path(file, max_name_width); + + let mut spans = vec![Span::styled(display_name, name_style)]; + spans.extend(stat_spans); ListItem::new(Line::from(spans)) }) @@ -823,6 +816,163 @@ fn clamp_scroll(app: &mut App, content_area_height: u16) { } } +/// Build the styled stats spans (e.g. " +3 -1") and return their total display width. +fn build_file_stats<'a>(adds: usize, dels: usize, theme: &Theme) -> (Vec>, usize) { + if adds == 0 && dels == 0 { + return (vec![], 0); + } + + let mut spans = Vec::new(); + let mut width = 1; // leading space + spans.push(Span::styled(" ", Style::default())); + + if adds > 0 { + let s = format!("+{}", adds); + width += UnicodeWidthStr::width(s.as_str()); + spans.push(Span::styled(s, Style::default().fg(theme.fg_added))); + } + if adds > 0 && dels > 0 { + width += 1; + spans.push(Span::styled(" ", Style::default())); + } + if dels > 0 { + let s = format!("-{}", dels); + width += UnicodeWidthStr::width(s.as_str()); + spans.push(Span::styled(s, Style::default().fg(theme.fg_removed))); + } + + (spans, width) +} + +/// Truncate a path from the left so it fits within `max_width` display columns, +/// preserving the filename (tail). Uses unicode display widths so East Asian +/// full-width characters are measured correctly. +fn truncate_path(path: &str, max_width: usize) -> String { + let display_width = UnicodeWidthStr::width(path); + if display_width <= max_width { + return path.to_string(); + } + if max_width <= 1 { + return "\u{2026}".to_string(); + } + // Reserve 1 column for the "…" prefix, keep as much of the tail as possible + let target = max_width - 1; + let mut width = 0; + let mut start_byte = path.len(); + for (idx, ch) in path.char_indices().rev() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > target { + break; + } + width += ch_width; + start_byte = idx; + } + format!("\u{2026}{}", &path[start_byte..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_path_no_truncation_needed() { + assert_eq!(truncate_path("src/main.rs", 20), "src/main.rs"); + } + + #[test] + fn test_truncate_path_exact_fit() { + assert_eq!(truncate_path("abcde", 5), "abcde"); + } + + #[test] + fn test_truncate_path_truncates_from_left() { + // 10 chars, max 6 → "…" + last 5 chars + assert_eq!(truncate_path("abcdefghij", 6), "\u{2026}fghij"); + } + + #[test] + fn test_truncate_path_very_narrow() { + assert_eq!(truncate_path("abcdefghij", 1), "\u{2026}"); + assert_eq!(truncate_path("abcdefghij", 0), "\u{2026}"); + } + + #[test] + fn test_truncate_path_width_2() { + // max_width=2 → "…" + 1 char + assert_eq!(truncate_path("abcdef", 2), "\u{2026}f"); + } + + #[test] + fn test_truncate_path_cjk_characters() { + // Each CJK char is 2 display columns wide + // "日本語" = 6 columns, max 5 → "…" + "本語" (4 cols) = 5 + assert_eq!(truncate_path("日本語", 5), "\u{2026}本語"); + } + + #[test] + fn test_truncate_path_mixed_ascii_cjk() { + // "src/日本語.rs" — test that mixed content truncates correctly + let path = "src/日本語.rs"; + let truncated = truncate_path(path, 8); + // Should end with the tail that fits in 7 cols (8 - 1 for "…") + assert!(truncated.starts_with('\u{2026}')); + assert!(UnicodeWidthStr::width(truncated.as_str()) <= 8); + } + + // ── build_file_stats ──────────────────────────────────────────────── + + fn stats_content_width(spans: &[Span]) -> usize { + spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum() + } + + #[test] + fn test_build_file_stats_no_changes() { + let t = Theme::dark(); + let (spans, width) = build_file_stats(0, 0, &t); + assert!(spans.is_empty()); + assert_eq!(width, 0); + } + + #[test] + fn test_build_file_stats_adds_only() { + let t = Theme::dark(); + let (spans, width) = build_file_stats(42, 0, &t); + // " +42" → 1 + 3 = 4 + assert_eq!(width, 4); + assert_eq!(stats_content_width(&spans), width); + } + + #[test] + fn test_build_file_stats_dels_only() { + let t = Theme::dark(); + let (spans, width) = build_file_stats(0, 7, &t); + // " -7" → 1 + 2 = 3 + assert_eq!(width, 3); + assert_eq!(stats_content_width(&spans), width); + } + + #[test] + fn test_build_file_stats_adds_and_dels() { + let t = Theme::dark(); + let (spans, width) = build_file_stats(3, 1, &t); + // " +3 -1" → 1 + 2 + 1 + 2 = 6 + assert_eq!(width, 6); + assert_eq!(stats_content_width(&spans), width); + } + + #[test] + fn test_build_file_stats_large_numbers() { + let t = Theme::dark(); + let (spans, width) = build_file_stats(1000, 99999, &t); + // " +1000 -99999" → 1 + 5 + 1 + 6 = 13 + assert_eq!(width, 13); + assert_eq!(stats_content_width(&spans), width); + } +} + fn count_file_changes(app: &App, file: &str) -> (usize, usize) { if let Some((base, head)) = app.file_changes.get(file) { let dels = base.iter().filter(|(_, l)| l.starts_with('-')).count();