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