| 1 | //! Text that is safe to measure and paint. |
| 2 | //! |
| 3 | //! Every string a caller passes can come from a model, a tool or a file name. |
| 4 | //! ratatui 0.30 already drops control characters when it writes cells, so an |
| 5 | //! escape sequence cannot drive the terminal. Bidirectional overrides pass, |
| 6 | //! though, and can make `rm -rf ~/x` display as something else, so every |
| 7 | //! component runs caller text through [`display_safe`] first. |
| 8 | |
| 9 | use std::borrow::Cow; |
| 10 | |
| 11 | use unicode_segmentation::UnicodeSegmentation; |
| 12 | use unicode_width::UnicodeWidthStr; |
| 13 | |
| 14 | use crate::glyphs; |
| 15 | |
| 16 | /// Characters that reorder or hide text without drawing anything. |
| 17 | fn is_hidden_control(c: char) -> bool { |
| 18 | matches!(c, |
| 19 | '\u{202A}'..='\u{202E}' // LRE RLE PDF LRO RLO |
| 20 | | '\u{2066}'..='\u{2069}' // LRI RLI FSI PDI |
| 21 | | '\u{200E}' | '\u{200F}' | '\u{061C}' // LRM RLM ALM |
| 22 | ) || c.is_control() |
| 23 | } |
| 24 | |
| 25 | /// `text` without bidi controls or other control characters. |
| 26 | #[must_use] |
| 27 | pub fn display_safe(text: &str) -> Cow<'_, str> { |
| 28 | if text.chars().any(is_hidden_control) { |
| 29 | Cow::Owned(text.chars().filter(|c| !is_hidden_control(*c)).collect()) |
| 30 | } else { |
| 31 | Cow::Borrowed(text) |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// Display width in terminal cells. |
| 36 | #[must_use] |
| 37 | pub fn width(text: &str) -> usize { |
| 38 | UnicodeWidthStr::width(text) |
| 39 | } |
| 40 | |
| 41 | /// Fit `text` into `max` cells, ending with `…` (`...` when ASCII-safe) |
| 42 | /// when anything was cut. Cuts between graphemes, so a wide character or a |
| 43 | /// combining sequence is never split. Use it for names, paths and IDs. |
| 44 | #[must_use] |
| 45 | pub fn truncate(text: &str, max: usize, ascii: bool) -> Cow<'_, str> { |
| 46 | cut(text, max, ascii, false) |
| 47 | } |
| 48 | |
| 49 | /// Like [`truncate`], but for prose: the cut lands between words where it |
| 50 | /// can, because a clipped clause reads as a sentence and a clipped word |
| 51 | /// reads as a bug. From the engine's `ui_text::semantic_truncate`. |
| 52 | #[must_use] |
| 53 | pub fn truncate_words(text: &str, max: usize, ascii: bool) -> Cow<'_, str> { |
| 54 | cut(text, max, ascii, true) |
| 55 | } |
| 56 | |
| 57 | fn cut(text: &str, max: usize, ascii: bool, at_word: bool) -> Cow<'_, str> { |
| 58 | if width(text) <= max { |
| 59 | return Cow::Borrowed(text); |
| 60 | } |
| 61 | if max == 0 { |
| 62 | return Cow::Borrowed(""); |
| 63 | } |
| 64 | // ASCII has no one-cell ellipsis, and a lone `.` makes a clipped |
| 65 | // sentence read as finished. Spend three cells where there are four. |
| 66 | let ellipsis = match (ascii, max) { |
| 67 | (false, _) => glyphs::ELLIPSIS, |
| 68 | (true, 4..) => "...", |
| 69 | (true, _) => ".", |
| 70 | }; |
| 71 | let budget = max.saturating_sub(width(ellipsis)); |
| 72 | let mut used = 0; |
| 73 | let mut end = 0; |
| 74 | let mut word_end = None; |
| 75 | let mut in_word = false; |
| 76 | for (at, g) in text.grapheme_indices(true) { |
| 77 | let w = width(g); |
| 78 | if used + w > budget { |
| 79 | break; |
| 80 | } |
| 81 | used += w; |
| 82 | end = at + g.len(); |
| 83 | if g.chars().all(char::is_whitespace) { |
| 84 | if in_word { |
| 85 | word_end = Some(at); |
| 86 | } |
| 87 | in_word = false; |
| 88 | } else { |
| 89 | in_word = true; |
| 90 | } |
| 91 | } |
| 92 | let body = match word_end { |
| 93 | Some(word_end) if at_word => text[..word_end].trim_end(), |
| 94 | _ => text[..end].trim_end(), |
| 95 | }; |
| 96 | let body = if body.is_empty() { |
| 97 | text[..end].trim_end() |
| 98 | } else { |
| 99 | body |
| 100 | }; |
| 101 | Cow::Owned(format!("{body}{ellipsis}")) |
| 102 | } |
| 103 | |
| 104 | /// Pad `text` with spaces to exactly `cells` wide (truncating first). |
| 105 | #[must_use] |
| 106 | pub fn pad(text: &str, cells: usize, ascii: bool) -> String { |
| 107 | let fitted = truncate(text, cells, ascii); |
| 108 | let gap = cells.saturating_sub(width(&fitted)); |
| 109 | format!("{fitted}{}", " ".repeat(gap)) |
| 110 | } |
| 111 | |
| 112 | #[cfg(test)] |
| 113 | mod tests { |
| 114 | use super::*; |
| 115 | |
| 116 | #[test] |
| 117 | fn bidi_overrides_are_stripped() { |
| 118 | let spoof = "rm -rf ~/\u{202E}txt.exe"; |
| 119 | assert_eq!(display_safe(spoof), "rm -rf ~/txt.exe"); |
| 120 | assert!(matches!(display_safe("plain"), Cow::Borrowed(_))); |
| 121 | assert_eq!(display_safe("a\u{1b}[31mb"), "a[31mb"); |
| 122 | } |
| 123 | |
| 124 | #[test] |
| 125 | fn truncate_respects_cell_width() { |
| 126 | assert_eq!(truncate("Shoreline", 20, false), "Shoreline"); |
| 127 | assert_eq!(truncate("Shoreline light", 10, false), "Shoreline…"); |
| 128 | // In ASCII a cut says so: `Shoreline.` would read as a full stop. |
| 129 | assert_eq!(truncate("Shoreline light", 10, true), "Shoreli..."); |
| 130 | assert_eq!(truncate("Shoreline light", 3, true), "Sh."); |
| 131 | // A wide character is never split. |
| 132 | assert_eq!(truncate("鲸鱼鲸鱼", 5, false), "鲸鱼…"); |
| 133 | assert_eq!(width(&pad("ab", 4, false)), 4); |
| 134 | // A combining sequence stays whole. |
| 135 | assert_eq!(truncate("cafe\u{301} au lait", 6, false), "cafe\u{301}…"); |
| 136 | } |
| 137 | |
| 138 | #[test] |
| 139 | fn prose_is_cut_between_words() { |
| 140 | let hint = "Works in this session; asks before edits and shell commands"; |
| 141 | assert_eq!( |
| 142 | truncate_words(hint, 40, false), |
| 143 | "Works in this session; asks before…" |
| 144 | ); |
| 145 | assert_eq!( |
| 146 | truncate(hint, 40, false), |
| 147 | "Works in this session; asks before edit…" |
| 148 | ); |
| 149 | // One long word still fits by cutting inside it. |
| 150 | assert_eq!(truncate_words("supercalifragilistic", 8, false), "superca…"); |
| 151 | } |
| 152 | } |
| 153 |