Skip to main content

photon_ui/
utils.rs

1use unicode_width::UnicodeWidthChar;
2
3/// Compute the visible display width of a string.
4///
5/// ANSI escape sequences (CSI `\x1b[…` and OSC `\x1b]…`) do not contribute to
6/// the width. Full-width characters (e.g. CJK) count as 2 columns.
7pub fn visible_width(s: &str) -> usize {
8    let mut width = 0;
9    let mut chars = s.chars().peekable();
10    while let Some(ch) = chars.next() {
11        if ch == '\x1b' {
12            match chars.peek() {
13                | Some(&'[') => {
14                    chars.next();
15                    while let Some(&c) = chars.peek() {
16                        chars.next();
17                        if c.is_alphabetic() {
18                            break;
19                        }
20                    }
21                    continue;
22                },
23                | Some(&']') => {
24                    chars.next();
25                    while let Some(&c) = chars.peek() {
26                        chars.next();
27                        if c == '\x07' {
28                            break;
29                        }
30                        if c == '\x1b' &&
31                            let Some(&'\\') = chars.peek()
32                        {
33                            chars.next();
34                            break;
35                        }
36                    }
37                    continue;
38                },
39                | _ => {},
40            }
41        }
42        width += ch.width().unwrap_or(0);
43    }
44    width
45}
46
47/// Return the byte index in `s` that corresponds to visual position
48/// `target_pos`.
49///
50/// ANSI escape sequences are skipped (they contribute 0 width). If `target_pos`
51/// is beyond the visible width of `s`, the byte index after the last visible
52/// character is returned.
53pub fn byte_index_at_visual_pos(s: &str, target_pos: usize) -> usize {
54    let mut width = 0;
55    let mut byte_idx = 0;
56    let mut chars = s.chars().peekable();
57
58    while let Some(&ch) = chars.peek() {
59        let ch_len = ch.len_utf8();
60        if ch == '\x1b' {
61            chars.next();
62            byte_idx += ch_len;
63            match chars.peek() {
64                | Some(&'[') => {
65                    chars.next();
66                    byte_idx += '['.len_utf8();
67                    while let Some(&c) = chars.peek() {
68                        chars.next();
69                        byte_idx += c.len_utf8();
70                        if c.is_alphabetic() {
71                            break;
72                        }
73                    }
74                },
75                | Some(&']') => {
76                    chars.next();
77                    byte_idx += ']'.len_utf8();
78                    while let Some(&c) = chars.peek() {
79                        chars.next();
80                        byte_idx += c.len_utf8();
81                        if c == '\x07' {
82                            break;
83                        }
84                        if c == '\x1b' &&
85                            let Some(&'\\') = chars.peek()
86                        {
87                            chars.next();
88                            byte_idx += '\\'.len_utf8();
89                            break;
90                        }
91                    }
92                },
93                | _ => {},
94            }
95            continue;
96        }
97        if width >= target_pos {
98            return byte_idx;
99        }
100        chars.next();
101        width += ch.width().unwrap_or(0);
102        byte_idx += ch_len;
103        if width >= target_pos {
104            return byte_idx;
105        }
106    }
107    byte_idx
108}
109
110/// Truncate a string so its visible width does not exceed `max_width`.
111///
112/// If truncation is necessary, `ellipsis` is appended at the end. The result
113/// always satisfies `visible_width(result) <= max_width`.
114///
115/// # Example
116///
117/// ```
118/// use photon_ui::utils::truncate_to_width;
119///
120/// assert_eq!(truncate_to_width("hello world", 8, "…"), "hello w…");
121/// assert_eq!(truncate_to_width("hello", 10, "…"), "hello");
122/// ```
123pub fn truncate_to_width(s: &str, max_width: u16, ellipsis: &str) -> String {
124    let max = max_width as usize;
125    let ellip_width = visible_width(ellipsis);
126    let total = visible_width(s);
127    if total <= max {
128        return s.to_string();
129    }
130    let target = max.saturating_sub(ellip_width);
131    let mut result = String::new();
132    let mut w = 0;
133    let mut chars = s.chars().peekable();
134    while let Some(ch) = chars.next() {
135        // Skip ANSI escape sequences (CSI and OSC) — they contribute 0 width.
136        if ch == '\x1b' {
137            match chars.peek() {
138                | Some(&'[') => {
139                    result.push(ch);
140                    chars.next(); // consume '['
141                    result.push('[');
142                    while let Some(&c) = chars.peek() {
143                        chars.next();
144                        result.push(c);
145                        if c.is_alphabetic() {
146                            break;
147                        }
148                    }
149                    continue;
150                },
151                | Some(&']') => {
152                    result.push(ch);
153                    chars.next(); // consume ']'
154                    result.push(']');
155                    while let Some(&c) = chars.peek() {
156                        chars.next();
157                        result.push(c);
158                        if c == '\x07' {
159                            break;
160                        }
161                        if c == '\x1b' &&
162                            let Some(&'\\') = chars.peek()
163                        {
164                            chars.next();
165                            result.push('\\');
166                            break;
167                        }
168                    }
169                    continue;
170                },
171                | _ => {},
172            }
173        }
174        let cw = ch.width().unwrap_or(0);
175        if w + cw > target {
176            break;
177        }
178        result.push(ch);
179        w += cw;
180    }
181    result.push_str(ellipsis);
182    // If the original string contained ANSI codes, append a reset so that
183    // truncated strings don't leave active attributes (e.g. background colours)
184    // dangling.
185    if s.contains('\x1b') {
186        result.push_str("\x1b[0m");
187    }
188    result
189}
190
191/// An active OSC 8 hyperlink tracked by [`AnsiCodeTracker`].
192#[derive(Debug, Clone, PartialEq)]
193pub struct ActiveHyperlink {
194    /// Hyperlink parameters (e.g. `id` or empty string).
195    pub params: String,
196    /// The target URL.
197    pub url: String,
198    /// The original terminator sequence (`\x1b\\` or `\x07`).
199    pub terminator: String,
200}
201
202/// Tracks active ANSI SGR and OSC 8 state across line breaks.
203///
204/// When wrapping styled text, styles must be closed at the end of each
205/// physical line and reopened at the start of the next. This struct records
206/// which attributes are currently active and can emit the corresponding
207/// escape sequences.
208///
209/// # Example
210///
211/// ```
212/// use photon_ui::utils::AnsiCodeTracker;
213///
214/// let mut tracker = AnsiCodeTracker::new();
215/// tracker.process("\x1b[1m"); // bold on
216/// tracker.process("\x1b[31m"); // red fg
217/// assert_eq!(tracker.current_codes(), "\x1b[1;31m");
218/// ```
219#[derive(Debug, Default, Clone, PartialEq)]
220pub struct AnsiCodeTracker {
221    /// Bold (SGR 1) is active.
222    pub bold: bool,
223    /// Italic (SGR 3) is active.
224    pub italic: bool,
225    /// Underline (SGR 4) is active.
226    pub underline: bool,
227    /// Faint / dim (SGR 2) is active.
228    pub faint: bool,
229    /// Reverse video (SGR 7) is active.
230    pub reverse: bool,
231    /// Active foreground color SGR parameter, e.g. `"31"` or `"38;5;240"`.
232    pub fg_color: Option<String>,
233    /// Active background color SGR parameter, e.g. `"41"` or `"48;5;240"`.
234    pub bg_color: Option<String>,
235    /// Active OSC 8 hyperlink, if any.
236    pub hyperlink: Option<ActiveHyperlink>,
237}
238
239impl AnsiCodeTracker {
240    /// Create a tracker with no active codes.
241    pub fn new() -> Self {
242        Self::default()
243    }
244
245    /// Parse an OSC 8 hyperlink sequence.
246    ///
247    /// Returns `Some(Some(link))` on open, `Some(None)` on close, and
248    /// `None` if the sequence is not a valid OSC 8 hyperlink.
249    fn parse_osc8(seq: &str) -> Option<Option<ActiveHyperlink>> {
250        let body = match seq.strip_prefix("\x1b]") {
251            | Some(b) => b,
252            | None => return None,
253        };
254        let (body, terminator) = if let Some(body) = body.strip_suffix("\x1b\\") {
255            (body, "\x1b\\".to_string())
256        } else if let Some(body) = body.strip_suffix('\x07') {
257            (body, "\x07".to_string())
258        } else {
259            return None;
260        };
261        let rest = match body.strip_prefix("8;") {
262            | Some(r) => r,
263            | None => return None,
264        };
265        let sep = match rest.find(';') {
266            | Some(s) => s,
267            | None => return None,
268        };
269        let params = rest[..sep].to_string();
270        let url = rest[sep + 1..].to_string();
271        if url.is_empty() {
272            Some(None)
273        } else {
274            Some(Some(ActiveHyperlink {
275                params,
276                url,
277                terminator,
278            }))
279        }
280    }
281
282    /// Process an ANSI escape sequence, updating internal state.
283    ///
284    /// Supports:
285    /// - OSC 8 hyperlink open / close (`\x1b]8;;URL\x1b\\`, `\x1b]8;;\x1b\\`)
286    /// - SGR codes (`\x1b[…m`) for bold, italic, underline, and colors,
287    ///   including 256-color (`38;5;N` / `48;5;N`) and 24-bit truecolor
288    ///   (`38;2;R;G;B` / `48;2;R;G;B`) forms.
289    pub fn process(&mut self, seq: &str) {
290        if let Some(parsed) = Self::parse_osc8(seq) {
291            self.hyperlink = parsed;
292            return;
293        }
294
295        let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
296        let body = body.strip_suffix('m').unwrap_or(body);
297        let codes: Vec<&str> = body.split(';').collect();
298        let mut i = 0;
299        while i < codes.len() {
300            let code = codes[i];
301            match code {
302                | "1" => self.bold = true,
303                | "2" => self.faint = true,
304                | "3" => self.italic = true,
305                | "4" => self.underline = true,
306                | "7" => self.reverse = true,
307                | "22" => {
308                    self.bold = false;
309                    self.faint = false;
310                },
311                | "23" => self.italic = false,
312                | "24" => self.underline = false,
313                | "27" => self.reverse = false,
314                | "39" => self.fg_color = None,
315                | "49" => self.bg_color = None,
316                | "38" => {
317                    self.fg_color = Self::parse_extended_color(&codes, &mut i, "38");
318                    continue;
319                },
320                | "48" => {
321                    self.bg_color = Self::parse_extended_color(&codes, &mut i, "48");
322                    continue;
323                },
324                | c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
325                | c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
326                | _ => {},
327            }
328            i += 1;
329        }
330    }
331
332    /// Parse an extended color specification that follows `38` or `48`.
333    ///
334    /// The `prefix` is `"38"` for foreground or `"48"` for background. The
335    /// returned string includes the prefix so it can be emitted directly as an
336    /// SGR parameter (e.g. `"38;2;250;82;15"`). `i` is advanced past the
337    /// consumed codes; incomplete specifications return `None`.
338    fn parse_extended_color(codes: &[&str], i: &mut usize, prefix: &str) -> Option<String> {
339        *i += 1;
340        if *i >= codes.len() {
341            return None;
342        }
343        match codes[*i] {
344            | "5" => {
345                *i += 1;
346                if *i >= codes.len() {
347                    return None;
348                }
349                let idx = codes[*i];
350                *i += 1;
351                Some(format!("{};5;{}", prefix, idx))
352            },
353            | "2" => {
354                *i += 1;
355                if *i + 2 >= codes.len() {
356                    return None;
357                }
358                let r = codes[*i];
359                let g = codes[*i + 1];
360                let b = codes[*i + 2];
361                *i += 3;
362                Some(format!("{};2;{};{};{}", prefix, r, g, b))
363            },
364            | _ => None,
365        }
366    }
367
368    /// Return the escape sequences needed to restore all active codes.
369    ///
370    /// This is used to reopen styles at the beginning of a continuation line.
371    pub fn current_codes(&self) -> String {
372        let mut parts = Vec::new();
373        if self.bold {
374            parts.push("1");
375        }
376        if self.faint {
377            parts.push("2");
378        }
379        if self.italic {
380            parts.push("3");
381        }
382        if self.underline {
383            parts.push("4");
384        }
385        if self.reverse {
386            parts.push("7");
387        }
388        if let Some(ref fg) = self.fg_color {
389            parts.push(fg.as_str());
390        }
391        if let Some(ref bg) = self.bg_color {
392            parts.push(bg.as_str());
393        }
394        let mut result = if parts.is_empty() {
395            String::new()
396        } else {
397            format!("\x1b[{}m", parts.join(";"))
398        };
399        if let Some(ref link) = self.hyperlink {
400            result.push_str(&format!(
401                "\x1b]8;{};{}{}",
402                link.params, link.url, link.terminator
403            ));
404        }
405        result
406    }
407
408    /// Return the escape sequences needed to close active codes at a line end.
409    ///
410    /// Unlike a full SGR reset, this only closes attributes that would bleed
411    /// into padding or subsequent lines (underline and hyperlinks). The caller
412    /// is responsible for emitting `\x1b[0m` when a full SGR reset is needed.
413    pub fn line_end_reset(&self) -> String {
414        let mut result = String::new();
415        if self.underline {
416            result.push_str("\x1b[24m");
417        }
418        if self.reverse {
419            result.push_str("\x1b[27m");
420        }
421        if let Some(ref link) = self.hyperlink {
422            result.push_str(&format!("\x1b]8;;{}", link.terminator));
423        }
424        result
425    }
426
427    /// Returns `true` if any SGR or OSC 8 code is currently active.
428    pub fn has_active_codes(&self) -> bool {
429        self.bold ||
430            self.faint ||
431            self.italic ||
432            self.underline ||
433            self.reverse ||
434            self.fg_color.is_some() ||
435            self.bg_color.is_some() ||
436            self.hyperlink.is_some()
437    }
438}
439
440/// Wrap text into lines that fit within `width` columns, preserving ANSI codes.
441///
442/// ANSI SGR sequences (`\x1b[…m`) and OSC 8 hyperlink sequences (`\x1b]8;…`)
443/// are parsed and carried across line boundaries so that styles remain
444/// continuous. Newlines in the input produce new lines in the output.
445///
446/// # Example
447///
448/// ```
449/// use photon_ui::utils::wrap_text_with_ansi;
450///
451/// let lines = wrap_text_with_ansi("hello world", 6);
452/// assert_eq!(lines, vec!["hello ", "world"]);
453/// ```
454pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
455    let w = width as usize;
456    let mut lines: Vec<String> = Vec::new();
457    let mut current = String::new();
458    let mut current_width = 0;
459    let mut tracker = AnsiCodeTracker::new();
460
461    let mut chars = text.chars().peekable();
462    while let Some(ch) = chars.next() {
463        if ch == '\x1b' {
464            match chars.peek() {
465                | Some(&'[') => {
466                    chars.next();
467                    let mut seq = String::from("\x1b[");
468                    while let Some(&c) = chars.peek() {
469                        seq.push(c);
470                        chars.next();
471                        if c.is_alphabetic() {
472                            break;
473                        }
474                    }
475                    tracker.process(&seq);
476                    current.push_str(&seq);
477                    continue;
478                },
479                | Some(&']') => {
480                    chars.next();
481                    let mut seq = String::from("\x1b]");
482                    while let Some(&c) = chars.peek() {
483                        seq.push(c);
484                        chars.next();
485                        if c == '\x07' {
486                            break;
487                        }
488                        if c == '\x1b' &&
489                            let Some(&'\\') = chars.peek()
490                        {
491                            seq.push('\\');
492                            chars.next();
493                            break;
494                        }
495                    }
496                    tracker.process(&seq);
497                    current.push_str(&seq);
498                    continue;
499                },
500                | _ => {},
501            }
502        }
503
504        if ch == '\n' {
505            if tracker.bold ||
506                tracker.italic ||
507                tracker.underline ||
508                tracker.fg_color.is_some() ||
509                tracker.bg_color.is_some()
510            {
511                current.push_str("\x1b[0m");
512            }
513            let reset = tracker.line_end_reset();
514            if !reset.is_empty() {
515                current.push_str(&reset);
516            }
517            lines.push(current);
518            current = tracker.current_codes();
519            current_width = 0;
520            continue;
521        }
522
523        let cw = ch.width().unwrap_or(0);
524        if current_width + cw > w && !current.is_empty() {
525            if tracker.bold ||
526                tracker.italic ||
527                tracker.underline ||
528                tracker.fg_color.is_some() ||
529                tracker.bg_color.is_some()
530            {
531                current.push_str("\x1b[0m");
532            }
533            let reset = tracker.line_end_reset();
534            if !reset.is_empty() {
535                current.push_str(&reset);
536            }
537            lines.push(current);
538            current = tracker.current_codes();
539            current_width = 0;
540        }
541        current.push(ch);
542        current_width += cw;
543    }
544
545    if !current.is_empty() {
546        lines.push(current);
547    }
548    lines
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn tracker_tracks_hyperlink() {
557        let mut tracker = AnsiCodeTracker::new();
558        tracker.process("\x1b]8;;https://example.com\x1b\\");
559        assert!(tracker.hyperlink.is_some());
560        assert_eq!(
561            tracker.hyperlink.as_ref().unwrap().url,
562            "https://example.com"
563        );
564        assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
565    }
566
567    #[test]
568    fn tracker_hyperlink_bel_terminator() {
569        let mut tracker = AnsiCodeTracker::new();
570        tracker.process("\x1b]8;;https://example.com\x07");
571        assert!(tracker.hyperlink.is_some());
572        assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
573    }
574
575    #[test]
576    fn tracker_hyperlink_close() {
577        let mut tracker = AnsiCodeTracker::new();
578        tracker.process("\x1b]8;;https://example.com\x1b\\");
579        assert!(tracker.hyperlink.is_some());
580        tracker.process("\x1b]8;;\x1b\\");
581        assert!(tracker.hyperlink.is_none());
582    }
583
584    #[test]
585    fn current_codes_includes_hyperlink() {
586        let mut tracker = AnsiCodeTracker::new();
587        tracker.process("\x1b]8;;https://example.com\x1b\\");
588        let codes = tracker.current_codes();
589        assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
590    }
591
592    #[test]
593    fn line_end_reset_closes_hyperlink() {
594        let mut tracker = AnsiCodeTracker::new();
595        tracker.process("\x1b]8;;https://example.com\x1b\\");
596        let reset = tracker.line_end_reset();
597        assert!(reset.contains("\x1b]8;;\x1b\\"));
598    }
599
600    #[test]
601    fn wrap_preserves_hyperlink_across_lines() {
602        let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
603        let lines = wrap_text_with_ansi(text, 6);
604        assert_eq!(lines.len(), 2);
605        // First line should close hyperlink at end
606        assert!(lines[0].contains("\x1b]8;;\x1b\\"));
607        // Second line should reopen hyperlink
608        assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
609    }
610
611    #[test]
612    fn has_active_codes_with_hyperlink() {
613        let mut tracker = AnsiCodeTracker::new();
614        assert!(!tracker.has_active_codes());
615        tracker.process("\x1b]8;;https://example.com\x1b\\");
616        assert!(tracker.has_active_codes());
617    }
618
619    #[test]
620    fn line_end_reset_with_underline() {
621        let mut tracker = AnsiCodeTracker::new();
622        tracker.process("\x1b[4m");
623        let reset = tracker.line_end_reset();
624        assert!(reset.contains("\x1b[24m"));
625    }
626
627    #[test]
628    fn wrap_hyperlink_bel_terminator() {
629        let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
630        let lines = wrap_text_with_ansi(text, 6);
631        assert_eq!(lines.len(), 2);
632        assert!(lines[0].contains("\x1b]8;;\x07"));
633        assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
634    }
635
636    #[test]
637    fn wrap_newline_with_active_sgr() {
638        let text = "\x1b[31mhello\nworld\x1b[0m";
639        let lines = wrap_text_with_ansi(text, 20);
640        assert_eq!(lines.len(), 2);
641        // First line should have SGR reset and hyperlink reset at end
642        assert!(lines[0].contains("\x1b[0m"));
643        // Second line should reopen the SGR code
644        assert!(lines[1].starts_with("\x1b[31m"));
645    }
646
647    #[test]
648    fn tracker_invalid_osc_ignored() {
649        let mut tracker = AnsiCodeTracker::new();
650        tracker.process("\x1b]8;;url");
651        assert!(tracker.hyperlink.is_none());
652    }
653
654    #[test]
655    fn tracker_invalid_osc_no_prefix() {
656        let mut tracker = AnsiCodeTracker::new();
657        tracker.process("\x1b]9;;url\x1b\\");
658        assert!(tracker.hyperlink.is_none());
659    }
660
661    #[test]
662    fn has_active_codes_with_sgr() {
663        let mut tracker = AnsiCodeTracker::new();
664        tracker.process("\x1b[1m");
665        assert!(tracker.has_active_codes());
666    }
667
668    /// Regression: 24-bit truecolor foreground must be preserved in full.
669    #[test]
670    fn tracker_preserves_truecolor_foreground() {
671        let mut tracker = AnsiCodeTracker::new();
672        tracker.process("\x1b[38;2;250;82;15m");
673        assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
674        assert_eq!(tracker.current_codes(), "\x1b[38;2;250;82;15m");
675    }
676
677    /// Regression: 24-bit truecolor background must be preserved in full.
678    #[test]
679    fn tracker_preserves_truecolor_background() {
680        let mut tracker = AnsiCodeTracker::new();
681        tracker.process("\x1b[48;2;42;42;42m");
682        assert_eq!(tracker.bg_color, Some("48;2;42;42;42".to_string()));
683        assert_eq!(tracker.current_codes(), "\x1b[48;2;42;42;42m");
684    }
685
686    /// Regression: 256-color foreground must be preserved.
687    #[test]
688    fn tracker_preserves_256_foreground() {
689        let mut tracker = AnsiCodeTracker::new();
690        tracker.process("\x1b[38;5;196m");
691        assert_eq!(tracker.fg_color, Some("38;5;196".to_string()));
692    }
693
694    /// Regression: mixed truecolor and attribute codes must all be tracked.
695    #[test]
696    fn tracker_mixed_truecolor_and_attributes() {
697        let mut tracker = AnsiCodeTracker::new();
698        tracker.process("\x1b[1;38;2;250;82;15;48;2;0;0;0m");
699        assert!(tracker.bold);
700        assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
701        assert_eq!(tracker.bg_color, Some("48;2;0;0;0".to_string()));
702        assert_eq!(tracker.current_codes(), "\x1b[1;38;2;250;82;15;48;2;0;0;0m");
703    }
704
705    /// Regression: default foreground/background codes must still clear state.
706    #[test]
707    fn tracker_default_colors_clear_state() {
708        let mut tracker = AnsiCodeTracker::new();
709        tracker.process("\x1b[38;2;250;82;15;48;2;0;0;0m");
710        tracker.process("\x1b[39;49m");
711        assert!(tracker.fg_color.is_none());
712        assert!(tracker.bg_color.is_none());
713    }
714
715    #[test]
716    fn truncate_jk_text_demo() {
717        let text = "  j/k = navigate list   Tab = switch focus   i = insert mode   Esc = normal mode   q = quit";
718        let truncated = truncate_to_width(text, 80, "…");
719        let vw = visible_width(&truncated);
720        eprintln!("original vw: {}", visible_width(text));
721        eprintln!("truncated: {:?}", truncated);
722        eprintln!("truncated vw: {}", vw);
723        assert!(vw <= 80, "truncated width {} exceeds 80", vw);
724        assert!(truncated.ends_with("…"));
725    }
726
727    #[test]
728    fn truncate_to_width_preserves_ansi_prefix() {
729        let s = "\x1b[44mhello\x1b[0m";
730        let truncated = truncate_to_width(s, 3, "…");
731        // Should preserve the ANSI prefix, truncate visible text, add ellipsis,
732        // and append a reset so attributes don't bleed.
733        assert!(truncated.starts_with("\x1b[44m"));
734        assert!(truncated.contains("…"));
735        assert!(truncated.ends_with("\x1b[0m"));
736        assert_eq!(visible_width(&truncated), 3);
737    }
738
739    #[test]
740    fn truncate_to_width_preserves_ansi_infix() {
741        let s = "hi\x1b[31mred\x1b[0mlo";
742        let truncated = truncate_to_width(s, 4, "…");
743        assert_eq!(visible_width(&truncated), 4);
744        // The ANSI sequence should be fully preserved, not split mid-sequence.
745        assert!(truncated.contains("\x1b[31m"));
746        assert!(truncated.contains("\x1b[0m"));
747    }
748
749    #[test]
750    fn truncate_to_width_no_truncation_when_fits() {
751        let s = "\x1b[44mhi\x1b[0m";
752        let truncated = truncate_to_width(s, 5, "…");
753        // visible width is 2, which fits in 5, so return as-is
754        assert_eq!(truncated, s);
755    }
756
757    #[test]
758    fn byte_index_at_visual_pos_plain() {
759        assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
760        assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
761        assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
762        assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
763    }
764
765    #[test]
766    fn byte_index_at_visual_pos_with_ansi_prefix() {
767        let s = "\x1b[31mhello\x1b[0m";
768        // "\x1b[31m" is 5 bytes, visible width 0
769        assert_eq!(byte_index_at_visual_pos(s, 0), 5);
770        assert_eq!(byte_index_at_visual_pos(s, 3), 8);
771        assert_eq!(byte_index_at_visual_pos(s, 5), 10);
772        // Past end → byte index after last visible char (including trailing ANSI)
773        assert_eq!(byte_index_at_visual_pos(s, 10), 14);
774    }
775
776    #[test]
777    fn byte_index_at_visual_pos_with_ansi_infix() {
778        let s = "hi\x1b[31mred\x1b[0mlo";
779        // visible: h i r e d l o = 7
780        assert_eq!(byte_index_at_visual_pos(s, 0), 0);
781        assert_eq!(byte_index_at_visual_pos(s, 2), 2);
782        // Position 3 is 'e' which starts at byte 8 (after "hi\x1b[31mr")
783        assert_eq!(byte_index_at_visual_pos(s, 3), 8);
784        // Past end
785        assert_eq!(byte_index_at_visual_pos(s, 7), 16);
786    }
787
788    #[test]
789    fn byte_index_at_visual_pos_with_hyperlink() {
790        let s = "\x1b]8;;https://example.com\x07hello";
791        // OSC hyperlink is 25 bytes, visible width 0
792        assert_eq!(byte_index_at_visual_pos(s, 0), 25);
793        assert_eq!(byte_index_at_visual_pos(s, 3), 28);
794    }
795}