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