diff --git a/src/app/model/dagruns/popup/trigger/table.rs b/src/app/model/dagruns/popup/trigger/table.rs index 5b5063e2..ea7e4c87 100644 --- a/src/app/model/dagruns/popup/trigger/table.rs +++ b/src/app/model/dagruns/popup/trigger/table.rs @@ -8,8 +8,9 @@ use ratatui::{ use crate::ui::theme::theme; use super::params::{ParamEntry, ParamKind}; -use super::text::{truncate_cols, value_window, wrap_text}; +use super::text::{value_window, wrap_text}; use super::TriggerDagRunPopUp; +use crate::ui::common::truncate_cols; /// Total inter-column padding the table reserves (1 col between each of 3 columns). const COLUMN_GAPS: usize = 2; diff --git a/src/app/model/dagruns/popup/trigger/text.rs b/src/app/model/dagruns/popup/trigger/text.rs index 484134a6..40c541df 100644 --- a/src/app/model/dagruns/popup/trigger/text.rs +++ b/src/app/model/dagruns/popup/trigger/text.rs @@ -63,21 +63,9 @@ pub(super) fn value_window(value: &str, cursor_pos: usize, width: usize) -> (Str (before, cursor_char, after) } -/// Truncate a string to at most `max_cols` columns, appending `…` if clipped. -pub(super) fn truncate_cols(s: &str, max_cols: usize) -> String { - if s.chars().count() <= max_cols { - return s.to_string(); - } - if max_cols == 0 { - return String::new(); - } - let kept: String = s.chars().take(max_cols - 1).collect(); - format!("{kept}…") -} - #[cfg(test)] mod tests { - use super::{truncate_cols, value_window, wrap_text}; + use super::{value_window, wrap_text}; #[test] fn wrap_text_breaks_on_word_boundaries() { @@ -88,13 +76,6 @@ mod tests { assert_eq!(wrap_text("abcdefgh", 3), vec!["abc", "def", "gh"]); } - #[test] - fn truncate_appends_ellipsis_when_clipped() { - assert_eq!(truncate_cols("hello", 10), "hello"); - assert_eq!(truncate_cols("hello world", 5), "hell…"); - assert_eq!(truncate_cols("hello", 0), ""); - } - #[test] fn window_shows_whole_value_when_it_fits() { let (before, cursor, after) = value_window("abc", 1, 20); diff --git a/src/app/state/breadcrumb.rs b/src/app/state/breadcrumb.rs index 458e931d..f4526743 100644 --- a/src/app/state/breadcrumb.rs +++ b/src/app/state/breadcrumb.rs @@ -3,6 +3,7 @@ use std::sync::LazyLock; use time::format_description::BorrowedFormatItem; use super::{App, Panel}; +use crate::ui::common::truncate_cols; /// Cached date format for breadcrumb display (YYYY-MM-DD) static BREADCRUMB_DATE_FORMAT: LazyLock>> = LazyLock::new(|| { @@ -20,7 +21,7 @@ impl App { if self.active_panel != Panel::Config && self.active_panel != Panel::Dag { if let Some(dag_id) = self.nav_context.dag_id() { - let truncated = Self::truncate_breadcrumb_part(dag_id, 25); + let truncated = truncate_cols(dag_id, 25); parts.push(truncated); } } @@ -33,18 +34,18 @@ impl App { .unwrap_or_else(|_| "unknown".to_string()); parts.push(formatted); } else if let Some(dag_run_id) = self.nav_context.dag_run_id() { - let truncated = Self::truncate_breadcrumb_part(dag_run_id, 20); + let truncated = truncate_cols(dag_run_id, 20); parts.push(truncated); } } else if let Some(dag_run_id) = self.nav_context.dag_run_id() { - let truncated = Self::truncate_breadcrumb_part(dag_run_id, 20); + let truncated = truncate_cols(dag_run_id, 20); parts.push(truncated); } } if self.active_panel == Panel::Logs { if let Some(task_id) = self.nav_context.task_id() { - let truncated = Self::truncate_breadcrumb_part(task_id, 20); + let truncated = truncate_cols(task_id, 20); parts.push(truncated); } } @@ -57,18 +58,4 @@ impl App { None } } - - fn truncate_breadcrumb_part(s: &str, max_chars: usize) -> String { - let char_count = s.chars().count(); - if char_count <= max_chars { - s.to_string() - } else { - let truncate_at = max_chars.saturating_sub(3); - let byte_index = s - .char_indices() - .nth(truncate_at) - .map_or(s.len(), |(idx, _)| idx); - format!("{}...", &s[..byte_index]) - } - } } diff --git a/src/ui/common.rs b/src/ui/common.rs index fa4d77ad..90402b89 100644 --- a/src/ui/common.rs +++ b/src/ui/common.rs @@ -3,10 +3,38 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, BorderType, Borders}, }; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::constants::AirflowStateColor; use super::theme::theme; +/// Truncate `s` to at most `max_cols` terminal columns, appending `…` if clipped. +/// +/// Measures display width rather than counting `char`s, so wide characters +/// (CJK, emoji) cannot overflow the cell they are rendered into. +pub fn truncate_cols(s: &str, max_cols: usize) -> String { + if s.width() <= max_cols { + return s.to_string(); + } + if max_cols == 0 { + return String::new(); + } + // Reserve one column for the ellipsis. + let budget = max_cols - 1; + let mut out = String::new(); + let mut used = 0; + for ch in s.chars() { + let w = ch.width().unwrap_or(0); + if used + w > budget { + break; + } + out.push(ch); + used += w; + } + out.push('…'); + out +} + /// Builds a modal popup `Block` with the shared popup chrome: rounded, fully /// bordered, themed border and background, and a title padded with a single /// space on each side (`" title "`) styled bold in `accent`. @@ -36,3 +64,25 @@ pub fn create_headers<'a>( pub fn state_to_colored_square<'a>(color: AirflowStateColor) -> Span<'a> { Span::styled("■", Style::default().fg(color.into())) } + +#[cfg(test)] +mod tests { + use super::truncate_cols; + + #[test] + fn truncate_appends_ellipsis_when_clipped() { + assert_eq!(truncate_cols("hello", 10), "hello"); + assert_eq!(truncate_cols("hello world", 5), "hell…"); + assert_eq!(truncate_cols("hello", 0), ""); + } + + #[test] + fn truncate_measures_display_width_not_char_count() { + // Each CJK glyph occupies two columns, so only two fit alongside the + // ellipsis in a six-column cell. Counting chars would have kept five + // and overflowed to ten columns. + assert_eq!(truncate_cols("日本語テスト", 6), "日本…"); + // A string of wide chars that exactly fills the cell is left alone. + assert_eq!(truncate_cols("日本語", 6), "日本語"); + } +}