Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/app/model/dagruns/popup/trigger/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 1 addition & 20 deletions src/app/model/dagruns/popup/trigger/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand Down
23 changes: 5 additions & 18 deletions src/app/state/breadcrumb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<BorrowedFormatItem<'static>>> = LazyLock::new(|| {
Expand All @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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])
}
}
}
50 changes: 50 additions & 0 deletions src/ui/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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), "日本語");
}
}
Loading