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    pub fn process(&mut self, seq: &str) {
288        if let Some(parsed) = Self::parse_osc8(seq) {
289            self.hyperlink = parsed;
290            return;
291        }
292
293        let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
294        let body = body.strip_suffix('m').unwrap_or(body);
295        for code in body.split(';') {
296            match code {
297                | "1" => self.bold = true,
298                | "2" => self.faint = true,
299                | "3" => self.italic = true,
300                | "4" => self.underline = true,
301                | "7" => self.reverse = true,
302                | "22" => {
303                    self.bold = false;
304                    self.faint = false;
305                },
306                | "23" => self.italic = false,
307                | "24" => self.underline = false,
308                | "27" => self.reverse = false,
309                | "39" => self.fg_color = None,
310                | "49" => self.bg_color = None,
311                | c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
312                | c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
313                | _ => {},
314            }
315        }
316    }
317
318    /// Return the escape sequences needed to restore all active codes.
319    ///
320    /// This is used to reopen styles at the beginning of a continuation line.
321    pub fn current_codes(&self) -> String {
322        let mut parts = Vec::new();
323        if self.bold {
324            parts.push("1");
325        }
326        if self.faint {
327            parts.push("2");
328        }
329        if self.italic {
330            parts.push("3");
331        }
332        if self.underline {
333            parts.push("4");
334        }
335        if self.reverse {
336            parts.push("7");
337        }
338        if let Some(ref fg) = self.fg_color {
339            parts.push(fg.as_str());
340        }
341        if let Some(ref bg) = self.bg_color {
342            parts.push(bg.as_str());
343        }
344        let mut result = if parts.is_empty() {
345            String::new()
346        } else {
347            format!("\x1b[{}m", parts.join(";"))
348        };
349        if let Some(ref link) = self.hyperlink {
350            result.push_str(&format!(
351                "\x1b]8;{};{}{}",
352                link.params, link.url, link.terminator
353            ));
354        }
355        result
356    }
357
358    /// Return the escape sequences needed to close active codes at a line end.
359    ///
360    /// Unlike a full SGR reset, this only closes attributes that would bleed
361    /// into padding or subsequent lines (underline and hyperlinks). The caller
362    /// is responsible for emitting `\x1b[0m` when a full SGR reset is needed.
363    pub fn line_end_reset(&self) -> String {
364        let mut result = String::new();
365        if self.underline {
366            result.push_str("\x1b[24m");
367        }
368        if self.reverse {
369            result.push_str("\x1b[27m");
370        }
371        if let Some(ref link) = self.hyperlink {
372            result.push_str(&format!("\x1b]8;;{}", link.terminator));
373        }
374        result
375    }
376
377    /// Returns `true` if any SGR or OSC 8 code is currently active.
378    pub fn has_active_codes(&self) -> bool {
379        self.bold ||
380            self.faint ||
381            self.italic ||
382            self.underline ||
383            self.reverse ||
384            self.fg_color.is_some() ||
385            self.bg_color.is_some() ||
386            self.hyperlink.is_some()
387    }
388}
389
390/// Wrap text into lines that fit within `width` columns, preserving ANSI codes.
391///
392/// ANSI SGR sequences (`\x1b[…m`) and OSC 8 hyperlink sequences (`\x1b]8;…`)
393/// are parsed and carried across line boundaries so that styles remain
394/// continuous. Newlines in the input produce new lines in the output.
395///
396/// # Example
397///
398/// ```
399/// use photon_ui::utils::wrap_text_with_ansi;
400///
401/// let lines = wrap_text_with_ansi("hello world", 6);
402/// assert_eq!(lines, vec!["hello ", "world"]);
403/// ```
404pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
405    let w = width as usize;
406    let mut lines: Vec<String> = Vec::new();
407    let mut current = String::new();
408    let mut current_width = 0;
409    let mut tracker = AnsiCodeTracker::new();
410
411    let mut chars = text.chars().peekable();
412    while let Some(ch) = chars.next() {
413        if ch == '\x1b' {
414            match chars.peek() {
415                | Some(&'[') => {
416                    chars.next();
417                    let mut seq = String::from("\x1b[");
418                    while let Some(&c) = chars.peek() {
419                        seq.push(c);
420                        chars.next();
421                        if c.is_alphabetic() {
422                            break;
423                        }
424                    }
425                    tracker.process(&seq);
426                    current.push_str(&seq);
427                    continue;
428                },
429                | Some(&']') => {
430                    chars.next();
431                    let mut seq = String::from("\x1b]");
432                    while let Some(&c) = chars.peek() {
433                        seq.push(c);
434                        chars.next();
435                        if c == '\x07' {
436                            break;
437                        }
438                        if c == '\x1b' &&
439                            let Some(&'\\') = chars.peek()
440                        {
441                            seq.push('\\');
442                            chars.next();
443                            break;
444                        }
445                    }
446                    tracker.process(&seq);
447                    current.push_str(&seq);
448                    continue;
449                },
450                | _ => {},
451            }
452        }
453
454        if ch == '\n' {
455            if tracker.bold ||
456                tracker.italic ||
457                tracker.underline ||
458                tracker.fg_color.is_some() ||
459                tracker.bg_color.is_some()
460            {
461                current.push_str("\x1b[0m");
462            }
463            let reset = tracker.line_end_reset();
464            if !reset.is_empty() {
465                current.push_str(&reset);
466            }
467            lines.push(current);
468            current = tracker.current_codes();
469            current_width = 0;
470            continue;
471        }
472
473        let cw = ch.width().unwrap_or(0);
474        if current_width + cw > w && !current.is_empty() {
475            if tracker.bold ||
476                tracker.italic ||
477                tracker.underline ||
478                tracker.fg_color.is_some() ||
479                tracker.bg_color.is_some()
480            {
481                current.push_str("\x1b[0m");
482            }
483            let reset = tracker.line_end_reset();
484            if !reset.is_empty() {
485                current.push_str(&reset);
486            }
487            lines.push(current);
488            current = tracker.current_codes();
489            current_width = 0;
490        }
491        current.push(ch);
492        current_width += cw;
493    }
494
495    if !current.is_empty() {
496        lines.push(current);
497    }
498    lines
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn tracker_tracks_hyperlink() {
507        let mut tracker = AnsiCodeTracker::new();
508        tracker.process("\x1b]8;;https://example.com\x1b\\");
509        assert!(tracker.hyperlink.is_some());
510        assert_eq!(
511            tracker.hyperlink.as_ref().unwrap().url,
512            "https://example.com"
513        );
514        assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
515    }
516
517    #[test]
518    fn tracker_hyperlink_bel_terminator() {
519        let mut tracker = AnsiCodeTracker::new();
520        tracker.process("\x1b]8;;https://example.com\x07");
521        assert!(tracker.hyperlink.is_some());
522        assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
523    }
524
525    #[test]
526    fn tracker_hyperlink_close() {
527        let mut tracker = AnsiCodeTracker::new();
528        tracker.process("\x1b]8;;https://example.com\x1b\\");
529        assert!(tracker.hyperlink.is_some());
530        tracker.process("\x1b]8;;\x1b\\");
531        assert!(tracker.hyperlink.is_none());
532    }
533
534    #[test]
535    fn current_codes_includes_hyperlink() {
536        let mut tracker = AnsiCodeTracker::new();
537        tracker.process("\x1b]8;;https://example.com\x1b\\");
538        let codes = tracker.current_codes();
539        assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
540    }
541
542    #[test]
543    fn line_end_reset_closes_hyperlink() {
544        let mut tracker = AnsiCodeTracker::new();
545        tracker.process("\x1b]8;;https://example.com\x1b\\");
546        let reset = tracker.line_end_reset();
547        assert!(reset.contains("\x1b]8;;\x1b\\"));
548    }
549
550    #[test]
551    fn wrap_preserves_hyperlink_across_lines() {
552        let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
553        let lines = wrap_text_with_ansi(text, 6);
554        assert_eq!(lines.len(), 2);
555        // First line should close hyperlink at end
556        assert!(lines[0].contains("\x1b]8;;\x1b\\"));
557        // Second line should reopen hyperlink
558        assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
559    }
560
561    #[test]
562    fn has_active_codes_with_hyperlink() {
563        let mut tracker = AnsiCodeTracker::new();
564        assert!(!tracker.has_active_codes());
565        tracker.process("\x1b]8;;https://example.com\x1b\\");
566        assert!(tracker.has_active_codes());
567    }
568
569    #[test]
570    fn line_end_reset_with_underline() {
571        let mut tracker = AnsiCodeTracker::new();
572        tracker.process("\x1b[4m");
573        let reset = tracker.line_end_reset();
574        assert!(reset.contains("\x1b[24m"));
575    }
576
577    #[test]
578    fn wrap_hyperlink_bel_terminator() {
579        let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
580        let lines = wrap_text_with_ansi(text, 6);
581        assert_eq!(lines.len(), 2);
582        assert!(lines[0].contains("\x1b]8;;\x07"));
583        assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
584    }
585
586    #[test]
587    fn wrap_newline_with_active_sgr() {
588        let text = "\x1b[31mhello\nworld\x1b[0m";
589        let lines = wrap_text_with_ansi(text, 20);
590        assert_eq!(lines.len(), 2);
591        // First line should have SGR reset and hyperlink reset at end
592        assert!(lines[0].contains("\x1b[0m"));
593        // Second line should reopen the SGR code
594        assert!(lines[1].starts_with("\x1b[31m"));
595    }
596
597    #[test]
598    fn tracker_invalid_osc_ignored() {
599        let mut tracker = AnsiCodeTracker::new();
600        tracker.process("\x1b]8;;url");
601        assert!(tracker.hyperlink.is_none());
602    }
603
604    #[test]
605    fn tracker_invalid_osc_no_prefix() {
606        let mut tracker = AnsiCodeTracker::new();
607        tracker.process("\x1b]9;;url\x1b\\");
608        assert!(tracker.hyperlink.is_none());
609    }
610
611    #[test]
612    fn has_active_codes_with_sgr() {
613        let mut tracker = AnsiCodeTracker::new();
614        tracker.process("\x1b[1m");
615        assert!(tracker.has_active_codes());
616    }
617
618    #[test]
619    fn truncate_jk_text_demo() {
620        let text = "  j/k = navigate list   Tab = switch focus   i = insert mode   Esc = normal mode   q = quit";
621        let truncated = truncate_to_width(text, 80, "…");
622        let vw = visible_width(&truncated);
623        eprintln!("original vw: {}", visible_width(text));
624        eprintln!("truncated: {:?}", truncated);
625        eprintln!("truncated vw: {}", vw);
626        assert!(vw <= 80, "truncated width {} exceeds 80", vw);
627        assert!(truncated.ends_with("…"));
628    }
629
630    #[test]
631    fn truncate_to_width_preserves_ansi_prefix() {
632        let s = "\x1b[44mhello\x1b[0m";
633        let truncated = truncate_to_width(s, 3, "…");
634        // Should preserve the ANSI prefix, truncate visible text, add ellipsis,
635        // and append a reset so attributes don't bleed.
636        assert!(truncated.starts_with("\x1b[44m"));
637        assert!(truncated.contains("…"));
638        assert!(truncated.ends_with("\x1b[0m"));
639        assert_eq!(visible_width(&truncated), 3);
640    }
641
642    #[test]
643    fn truncate_to_width_preserves_ansi_infix() {
644        let s = "hi\x1b[31mred\x1b[0mlo";
645        let truncated = truncate_to_width(s, 4, "…");
646        assert_eq!(visible_width(&truncated), 4);
647        // The ANSI sequence should be fully preserved, not split mid-sequence.
648        assert!(truncated.contains("\x1b[31m"));
649        assert!(truncated.contains("\x1b[0m"));
650    }
651
652    #[test]
653    fn truncate_to_width_no_truncation_when_fits() {
654        let s = "\x1b[44mhi\x1b[0m";
655        let truncated = truncate_to_width(s, 5, "…");
656        // visible width is 2, which fits in 5, so return as-is
657        assert_eq!(truncated, s);
658    }
659
660    #[test]
661    fn byte_index_at_visual_pos_plain() {
662        assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
663        assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
664        assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
665        assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
666    }
667
668    #[test]
669    fn byte_index_at_visual_pos_with_ansi_prefix() {
670        let s = "\x1b[31mhello\x1b[0m";
671        // "\x1b[31m" is 5 bytes, visible width 0
672        assert_eq!(byte_index_at_visual_pos(s, 0), 5);
673        assert_eq!(byte_index_at_visual_pos(s, 3), 8);
674        assert_eq!(byte_index_at_visual_pos(s, 5), 10);
675        // Past end → byte index after last visible char (including trailing ANSI)
676        assert_eq!(byte_index_at_visual_pos(s, 10), 14);
677    }
678
679    #[test]
680    fn byte_index_at_visual_pos_with_ansi_infix() {
681        let s = "hi\x1b[31mred\x1b[0mlo";
682        // visible: h i r e d l o = 7
683        assert_eq!(byte_index_at_visual_pos(s, 0), 0);
684        assert_eq!(byte_index_at_visual_pos(s, 2), 2);
685        // Position 3 is 'e' which starts at byte 8 (after "hi\x1b[31mr")
686        assert_eq!(byte_index_at_visual_pos(s, 3), 8);
687        // Past end
688        assert_eq!(byte_index_at_visual_pos(s, 7), 16);
689    }
690
691    #[test]
692    fn byte_index_at_visual_pos_with_hyperlink() {
693        let s = "\x1b]8;;https://example.com\x07hello";
694        // OSC hyperlink is 25 bytes, visible width 0
695        assert_eq!(byte_index_at_visual_pos(s, 0), 25);
696        assert_eq!(byte_index_at_visual_pos(s, 3), 28);
697    }
698}