//! Line and string utility functions for ratatui text manipulation. use ratatui::text::{Line, Span}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; pub use super::tool_paths::{path_basename, path_for_tool_header, shorten_path}; /// Clone a borrowed ratatui `Line` into an owned `'static` line. pub fn line_to_static(line: &Line<'_>) -> Line<'static> { Line { style: line.style, alignment: line.alignment, spans: line .spans .iter() .map(|s| Span { style: s.style, content: std::borrow::Cow::Owned(s.content.to_string()), }) .collect(), } } /// Append owned copies of borrowed lines to `out`. pub fn push_owned_lines(src: &[Line<'_>], out: &mut Vec>) { for l in src { out.push(line_to_static(l)); } } /// True for a character unsafe to render from untrusted/server text: /// C0/C1 controls (the terminal-escape-injection vector) plus the Unicode /// bidi-control and zero-width/format set (Trojan-Source spoofing) — U+061C, /// U+200B–200F, U+202A–202E, U+2060–206F, U+FEFF. /// /// Shared by every untrusted-text strip/scrub site (chip labels, toast error /// scrub, settings editor input) so the set never drifts between them. pub fn is_unsafe_display_char(c: char) -> bool { c.is_control() || matches!( c, '\u{061C}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{FEFF}' ) } /// Polyfill for nightly-only [`str::floor_char_boundary`]. /// Snaps a byte index down to the nearest char boundary. pub fn floor_char_boundary(s: &str, index: usize) -> usize { let index = index.min(s.len()); let mut i = index; while i > 0 && !s.is_char_boundary(i) { i -= 1; } i } /// Byte offset at which cumulative display width exceeds `max_width`. /// Returns `s.len()` when the entire string fits. pub fn byte_offset_at_width(s: &str, max_width: usize) -> usize { let mut width = 0; for (i, ch) in s.char_indices() { let cw = UnicodeWidthChar::width(ch).unwrap_or(0); if width + cw > max_width { return i; } width += cw; } s.len() } /// Truncate a string to fit within `max_width` display columns. /// /// Uses Unicode-aware width measurement (handles CJK wide chars, /// multi-byte UTF-8 like em-dash, etc.). If truncated, the last character /// is replaced with `…` so the result fits within `max_width`. /// /// Returns the original string (owned) if it already fits. pub fn truncate_str(s: &str, max_width: usize) -> String { if max_width == 0 { return String::new(); } let end = byte_offset_at_width(s, max_width); let needs_ellipsis = end < s.len(); if needs_ellipsis && max_width > 1 { // Back up one char to make room for '…' (1 display column). let truncated_end = s[..end] .char_indices() .next_back() .map(|(i, _)| i) .unwrap_or(0); format!("{}…", &s[..truncated_end]) } else if needs_ellipsis { "…".to_string() } else { s[..end].to_string() } } /// Truncate a styled `Line` (multiple spans) to fit within `max_width` display columns. /// /// Walks spans left-to-right, consuming width budget. When the budget is /// exhausted mid-span, that span is truncated and `…` is appended. Spans /// beyond the budget are dropped. All styles are preserved. /// /// Returns the line `unchanged` if it already fits. pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> { if max_width == 0 { return Line::from(vec![]); } let total: usize = line.spans.iter().map(|s| s.content.width()).sum(); if total <= max_width { return line; } // Need room for the ellipsis (1 column). let budget = max_width.saturating_sub(1); let mut used = 0usize; let mut out: Vec> = Vec::new(); for span in line.spans { let sw = span.content.width(); if used + sw <= budget { // Entire span fits. used += sw; out.push(span); } else { // Partial fit — truncate this span. let remaining = budget - used; if remaining > 0 { let truncated = take_width(&span.content, remaining); out.push(Span::styled(truncated, span.style)); } // Append ellipsis with the same style as the last span. let ellipsis_style = out.last().map(|s| s.style).unwrap_or_default(); out.push(Span::styled("\u{2026}", ellipsis_style)); return Line::from(out); } } // Shouldn't reach here (total > max_width checked above), but be safe. Line::from(out) } /// Clip or pad a styled `Line` to exactly `width` display columns. /// /// Wider lines are clipped on grapheme boundaries (a multi-`char` grapheme like /// `⚠\u{FE0F}` is never split) with no ellipsis; narrower lines are `padded` with /// trailing spaces. This keeps a rendered row "self-owning" — the app writes a /// real cell in every column, so a terminal drawing a glyph wider than the app /// measured cannot strand a stale cell past the row (the markdown-table ghost /// glyph bug). Width uses [`UnicodeWidthStr`], matching the table layout. /// /// `width` must be a bounded display width: the pad branch allocates /// `width - total` spaces. pub fn fit_line_to_width<'a>(line: Line<'a>, width: usize) -> Line<'a> { let total: usize = line.spans.iter().map(|s| s.content.width()).sum(); if total == width { return line; } let Line { style, alignment, mut spans, } = line; if total < width { spans.push(Span::raw(" ".repeat(width - total))); return Line { style, alignment, spans, }; } // Wider than width: clip on grapheme boundaries, no ellipsis. let mut out: Vec> = Vec::new(); let mut used = 0usize; for span in spans { let sw = span.content.width(); if used + sw <= width { used += sw; out.push(span); if used == width { break; } continue; } // This span straddles the boundary — take whole graphemes that fit. let remaining = width - used; let mut taken = String::new(); let mut taken_width = 0usize; for g in span.content.graphemes(true) { let gw = g.width(); if taken_width + gw > remaining { break; } taken_width += gw; taken.push_str(g); } if !taken.is_empty() { out.push(Span::styled(taken, span.style)); used += taken_width; } // A straddling wide grapheme leaves a 1-column gap; pad it. if used < width { out.push(Span::raw(" ".repeat(width - used))); } break; } Line { style, alignment, spans: out, } } /// Take the first `n` display columns from a string. fn take_width(s: &str, n: usize) -> String { let mut width = 0; let mut end = s.len(); for (i, ch) in s.char_indices() { let cw = ch.width().unwrap_or(0); if width + cw > n { end = i; break; } width += cw; } s[..end].to_string() } /// Cascade-truncate multiple text elements to fit within `avail` display columns. /// /// Returns `(type, description, activity, meta)` truncated to fit. /// Priority (highest first): type, activity, meta. Description is truncated /// first. If overhead (type + activity + meta) >= avail, description is dropped /// and the remaining elements are cascaded: meta is dropped first, then /// activity is truncated, then type. pub fn cascade_truncate( avail: usize, type_text: &str, description: &str, activity_text: &str, meta_text: &str, ) -> (String, String, String, String) { let overhead = type_text.width() + activity_text.width() + meta_text.width(); if overhead <= avail { let desc_max = avail - overhead; ( type_text.to_string(), truncate_str(description, desc_max), activity_text.to_string(), meta_text.to_string(), ) } else { let mut budget = avail; let td = if type_text.width() <= budget { budget -= type_text.width(); type_text.to_string() } else { let s = truncate_str(type_text, budget); budget = 0; s }; let ad = if budget == 0 { String::new() } else if activity_text.width() <= budget { budget -= activity_text.width(); activity_text.to_string() } else { let s = truncate_str(activity_text, budget); budget = 0; s }; let md = if budget == 0 { String::new() } else if meta_text.width() <= budget { meta_text.to_string() } else { truncate_str(meta_text, budget) }; (td, String::new(), ad, md) } } #[cfg(test)] mod tests { use super::*; #[test] fn is_unsafe_display_char_covers_controls_and_bidi_format() { // Safe: ordinary printable text (incl. legitimate RTL letters). for c in ['a', ' ', '/', '\u{00e9}', '\u{05d0}'] { assert!(!is_unsafe_display_char(c), "{c:?} must be safe"); } // Unsafe: C0/C1 controls + the full bidi-control / zero-width set. for c in [ '\u{1b}', '\n', '\t', '\u{061C}', '\u{200B}', '\u{200F}', '\u{202E}', '\u{2066}', '\u{2069}', '\u{206F}', '\u{FEFF}', ] { assert!( is_unsafe_display_char(c), "{:#06x} must be unsafe", c as u32 ); } } #[test] fn truncate_str_fits() { assert_eq!(truncate_str("hello", 10), "hello"); assert_eq!(truncate_str("hello", 5), "hello"); } #[test] fn truncate_str_truncates() { assert_eq!(truncate_str("hello world!", 5), "hell…"); assert_eq!(truncate_str("abcdef", 4), "abc…"); } #[test] fn truncate_str_empty_and_zero() { assert_eq!(truncate_str("hello", 0), ""); assert_eq!(truncate_str("", 5), ""); } #[test] fn truncate_str_width_1() { assert_eq!(truncate_str("hello", 1), "…"); assert_eq!(truncate_str("x", 1), "x"); } #[test] fn truncate_str_multibyte() { // em-dash is 1 display column but 3 bytes let s = "hello — world"; let result = truncate_str(s, 8); assert!(result.ends_with('…')); // safe byte length assert!(result.len() <= 12); } // truncate_line tests #[test] fn truncate_line_fits() { let line = Line::from(vec![Span::raw("Hello "), Span::raw("world")]); let result = truncate_line(line, 20); assert_eq!(result.spans.len(), 2); assert_eq!(result.spans[0].content.as_ref(), "Hello "); assert_eq!(result.spans[1].content.as_ref(), "world"); } #[test] fn truncate_line_cuts_mid_span() { let line = Line::from(vec![ Span::raw("Edit "), Span::raw("very/long/path/to/file.rs"), ]); // Total = 29, budget = 15 → "Edit very/long…" let result = truncate_line(line, 15); let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.ends_with('\u{2026}')); assert!(text.width() <= 15); } #[test] fn truncate_line_drops_later_spans() { let line = Line::from(vec![ Span::raw("Search "), Span::raw("pattern"), Span::raw(" in "), Span::raw("path"), Span::raw(" (5 matches)"), ]); let result = truncate_line(line, 18); let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.ends_with('\u{2026}')); assert!(text.width() <= 18); } #[test] fn truncate_line_zero_width() { let line = Line::from(vec![Span::raw("hello")]); let result = truncate_line(line, 0); assert!(result.spans.is_empty()); } // fit_line_to_width tests fn line_text(line: &Line<'static>) -> String { line.spans.iter().map(|s| s.content.as_ref()).collect() } #[test] fn fit_line_pads_short_line() { let line = Line::from(vec![Span::raw("│ a │")]); let out = fit_line_to_width(line, 10); assert_eq!(line_text(&out).width(), 10); assert_eq!(line_text(&out), "│ a │ "); } #[test] fn fit_line_exact_width_unchanged() { let line = Line::from(vec![Span::raw("hello")]); let out = fit_line_to_width(line, 5); assert_eq!(out.spans.len(), 1); assert_eq!(line_text(&out), "hello"); } #[test] fn fit_line_clips_long_line_no_ellipsis() { let line = Line::from(vec![Span::raw("│ Column A │ Column B │")]); let out = fit_line_to_width(line, 8); assert_eq!(line_text(&out).width(), 8); assert_eq!(line_text(&out), "│ Column"); } #[test] fn fit_line_does_not_split_emoji_grapheme() { // a(1)+b(1)+⚠️(2) = 4. Clipping to 3 must drop the width-2 grapheme // whole (never split it) and pad → "ab" + 1 space. let line = Line::from(vec![Span::raw("ab\u{26A0}\u{FE0F}")]); let out = fit_line_to_width(line, 3); assert_eq!(line_text(&out).width(), 3); assert_eq!(line_text(&out), "ab "); } #[test] fn fit_line_clips_grapheme_straddle_in_later_span() { // The straddle happens in a later span: keep "ab", then 1 col left → // ⚠️ (width 2) won't fit → dropped whole and `padded`. let line = Line::from(vec![Span::raw("ab"), Span::raw("\u{26A0}\u{FE0F}cd")]); let out = fit_line_to_width(line, 3); assert_eq!(line_text(&out).width(), 3); assert_eq!(line_text(&out), "ab "); } #[test] fn fit_line_drops_subsequent_spans_after_clip() { let line = Line::from(vec![ Span::raw("hello"), Span::raw(" world"), Span::raw("!!!"), ]); let out = fit_line_to_width(line, 5); assert_eq!(line_text(&out), "hello"); // The straddling/later spans must be dropped entirely. assert_eq!(out.spans.len(), 1); } #[test] fn fit_line_takes_partial_of_later_span() { let line = Line::from(vec![Span::raw("ab"), Span::raw("cdef")]); let out = fit_line_to_width(line, 4); assert_eq!(line_text(&out), "abcd"); assert_eq!(line_text(&out).width(), 4); } #[test] fn fit_line_zero_width_returns_empty() { let line = Line::from(vec![Span::raw("│ a │")]); let out = fit_line_to_width(line, 0); assert_eq!(line_text(&out), ""); assert_eq!(line_text(&out).width(), 0); } #[test] fn fit_line_preserves_span_styles_when_padding() { let bold = ratatui::style::Style::new().add_modifier(ratatui::style::Modifier::BOLD); let line = Line::from(vec![Span::styled("hi", bold)]); let out = fit_line_to_width(line, 5); assert_eq!(line_text(&out).width(), 5); assert!( out.spans[0] .style .add_modifier .contains(ratatui::style::Modifier::BOLD) ); } #[test] fn legacy_tool_path_api_remains_available_at_line_utils_path() { assert_eq!(shorten_path("verylongfilename.rs", 10), "verylongf…"); assert_eq!(path_basename("/repo/src/main.rs", 80), "main.rs"); assert_eq!( path_for_tool_header("/repo/src/main.rs", Some(80), "Read ".len()), "main.rs" ); assert_eq!( path_for_tool_header("/repo/src/main.rs", None, "Read ".len()), "/repo/src/main.rs" ); } // cascade_truncate tests #[test] fn cascade_truncate_all_fit() { let (t, d, a, m) = cascade_truncate(50, "type ", "description", " \u{2014} running", " meta"); assert_eq!(t, "type "); assert_eq!(d, "description"); assert_eq!(a, " \u{2014} running"); assert_eq!(m, " meta"); } #[test] fn cascade_truncate_desc_truncated() { let (t, d, a, m) = cascade_truncate( 25, "type ", "long description here", " \u{2014} running", " meta", ); assert_eq!(t, "type "); assert_eq!(d, "lo\u{2026}"); assert_eq!(a, " \u{2014} running"); assert_eq!(m, " meta"); } #[test] fn cascade_truncate_desc_gone_meta_truncated() { // overhead = 6+10+6 = 22 > avail 20 → desc gone, type 6 + activity 10 + meta truncated to 4 let (t, d, a, m) = cascade_truncate(20, "type ", "desc", " \u{2014} running", " meta"); assert_eq!(t, "type "); assert_eq!(d, ""); assert_eq!(a, " \u{2014} running"); assert_eq!(m, " m\u{2026}"); } #[test] fn cascade_truncate_meta_and_activity_gone() { // avail=8, type=6 fits (budget=2), activity truncated to 2, meta gone let (t, d, a, m) = cascade_truncate(8, "type ", "desc", " \u{2014} running", " meta"); assert_eq!(t, "type "); assert_eq!(d, ""); assert_eq!(a, " \u{2026}"); assert_eq!(m, ""); } #[test] fn cascade_truncate_type_truncated() { let (t, d, a, m) = cascade_truncate(3, "type ", "desc", " \u{2014} running", " meta"); assert_eq!(t, "ty\u{2026}"); assert_eq!(d, ""); assert_eq!(a, ""); assert_eq!(m, ""); } #[test] fn cascade_truncate_zero_avail() { let (t, d, a, m) = cascade_truncate(0, "type ", "desc", " \u{2014} running", " meta"); assert_eq!(t, ""); assert_eq!(d, ""); assert_eq!(a, ""); assert_eq!(m, ""); } #[test] fn cascade_truncate_unicode() { // ✗ = 1 display column; — = 1 display column let (t, d, a, m) = cascade_truncate(10, "\u{2717} ", "description", " \u{2014} run", ""); assert_eq!(t, "\u{2717} "); assert_eq!(d, "\u{2026}"); assert_eq!(a, " \u{2014} run"); assert_eq!(m, ""); } #[test] fn cascade_truncate_overhead_equals_avail() { // overhead exactly equals avail → desc empty, everything else fits let (t, d, a, m) = cascade_truncate(22, "type ", "desc", " \u{2014} running", " meta"); assert_eq!(t, "type "); assert_eq!(d, ""); assert_eq!(a, " \u{2014} running"); assert_eq!(m, " meta"); } #[test] fn cascade_truncate_avail_one() { let (t, d, a, m) = cascade_truncate(1, "type", "desc", "act", "meta"); assert_eq!(t, "\u{2026}"); assert_eq!((d.as_str(), a.as_str(), m.as_str()), ("", "", "")); } #[test] fn cascade_truncate_all_empty() { let (t, d, a, m) = cascade_truncate(10, "", "", "", ""); assert_eq!( (t.as_str(), d.as_str(), a.as_str(), m.as_str()), ("", "", "", "") ); } }