Skip to main content

photon_ui/
utils.rs

1use unicode_width::UnicodeWidthChar;
2
3use crate::theme::{
4    Palette,
5    Style,
6    stylize,
7};
8
9/// Compute the visible display width of a string.
10///
11/// ANSI escape sequences (CSI `\x1b[…` and OSC `\x1b]…`) do not contribute to
12/// the width. Full-width characters (e.g. CJK) count as 2 columns.
13pub fn visible_width(s: &str) -> usize {
14    let mut width = 0;
15    let mut chars = s.chars().peekable();
16    while let Some(ch) = chars.next() {
17        if ch == '\x1b' {
18            match chars.peek() {
19                | Some(&'[') => {
20                    chars.next();
21                    while let Some(&c) = chars.peek() {
22                        chars.next();
23                        if c.is_alphabetic() {
24                            break;
25                        }
26                    }
27                    continue;
28                },
29                | Some(&']') => {
30                    chars.next();
31                    while let Some(&c) = chars.peek() {
32                        chars.next();
33                        if c == '\x07' {
34                            break;
35                        }
36                        if c == '\x1b' &&
37                            let Some(&'\\') = chars.peek()
38                        {
39                            chars.next();
40                            break;
41                        }
42                    }
43                    continue;
44                },
45                | _ => {},
46            }
47        }
48        width += ch.width().unwrap_or(0);
49    }
50    width
51}
52
53/// Return the byte index in `s` that corresponds to visual position
54/// `target_pos`.
55///
56/// ANSI escape sequences are skipped (they contribute 0 width). If `target_pos`
57/// is beyond the visible width of `s`, the byte index after the last visible
58/// character is returned.
59pub fn byte_index_at_visual_pos(s: &str, target_pos: usize) -> usize {
60    let mut width = 0;
61    let mut byte_idx = 0;
62    let mut chars = s.chars().peekable();
63
64    while let Some(&ch) = chars.peek() {
65        let ch_len = ch.len_utf8();
66        if ch == '\x1b' {
67            chars.next();
68            byte_idx += ch_len;
69            match chars.peek() {
70                | Some(&'[') => {
71                    chars.next();
72                    byte_idx += '['.len_utf8();
73                    while let Some(&c) = chars.peek() {
74                        chars.next();
75                        byte_idx += c.len_utf8();
76                        if c.is_alphabetic() {
77                            break;
78                        }
79                    }
80                },
81                | Some(&']') => {
82                    chars.next();
83                    byte_idx += ']'.len_utf8();
84                    while let Some(&c) = chars.peek() {
85                        chars.next();
86                        byte_idx += c.len_utf8();
87                        if c == '\x07' {
88                            break;
89                        }
90                        if c == '\x1b' &&
91                            let Some(&'\\') = chars.peek()
92                        {
93                            chars.next();
94                            byte_idx += '\\'.len_utf8();
95                            break;
96                        }
97                    }
98                },
99                | _ => {},
100            }
101            continue;
102        }
103        if width >= target_pos {
104            return byte_idx;
105        }
106        chars.next();
107        width += ch.width().unwrap_or(0);
108        byte_idx += ch_len;
109        if width >= target_pos {
110            return byte_idx;
111        }
112    }
113    byte_idx
114}
115
116/// Return the ANSI reset sequence, with an explicit intensity reset prefix
117/// when bold or faint was active.
118///
119/// Some macOS terminals (Ghostty, Terminal.app) do not reliably drop the bold
120/// attribute on `\x1b[0m` alone. Emitting `\x1b[22m` first turns off bold and
121/// faint explicitly before the full reset.
122fn sgr_reset(bold_or_faint: bool) -> &'static str {
123    if bold_or_faint {
124        "\x1b[22m\x1b[0m"
125    } else {
126        "\x1b[0m"
127    }
128}
129
130/// Truncate a string so its visible width does not exceed `max_width`.
131///
132/// If truncation is necessary, `ellipsis` is appended at the end. The result
133/// always satisfies `visible_width(result) <= max_width`.
134///
135/// # Example
136///
137/// ```
138/// use photon_ui::utils::truncate_to_width;
139///
140/// assert_eq!(truncate_to_width("hello world", 8, "…"), "hello w…");
141/// assert_eq!(truncate_to_width("hello", 10, "…"), "hello");
142/// ```
143pub fn truncate_to_width(s: &str, max_width: u16, ellipsis: &str) -> String {
144    let max = max_width as usize;
145    let ellip_width = visible_width(ellipsis);
146    let total = visible_width(s);
147    if total <= max {
148        return s.to_string();
149    }
150    let target = max.saturating_sub(ellip_width);
151    let mut result = String::new();
152    let mut w = 0;
153    let mut chars = s.chars().peekable();
154    let mut tracker = AnsiCodeTracker::new();
155    while let Some(ch) = chars.next() {
156        // Skip ANSI escape sequences (CSI and OSC) — they contribute 0 width.
157        if ch == '\x1b' {
158            match chars.peek() {
159                | Some(&'[') => {
160                    result.push(ch);
161                    chars.next(); // consume '['
162                    result.push('[');
163                    let mut seq = String::from("\x1b[");
164                    while let Some(&c) = chars.peek() {
165                        chars.next();
166                        result.push(c);
167                        seq.push(c);
168                        if c.is_alphabetic() {
169                            break;
170                        }
171                    }
172                    tracker.process(&seq);
173                    continue;
174                },
175                | Some(&']') => {
176                    result.push(ch);
177                    chars.next(); // consume ']'
178                    result.push(']');
179                    while let Some(&c) = chars.peek() {
180                        chars.next();
181                        result.push(c);
182                        if c == '\x07' {
183                            break;
184                        }
185                        if c == '\x1b' &&
186                            let Some(&'\\') = chars.peek()
187                        {
188                            chars.next();
189                            result.push('\\');
190                            break;
191                        }
192                    }
193                    continue;
194                },
195                | _ => {},
196            }
197        }
198        let cw = ch.width().unwrap_or(0);
199        if w + cw > target {
200            break;
201        }
202        result.push(ch);
203        w += cw;
204    }
205    result.push_str(ellipsis);
206    // If the original string contained ANSI codes, append a reset so that
207    // truncated strings don't leave active attributes (e.g. background colours)
208    // dangling. When bold or faint is active, emit an explicit intensity reset
209    // first to work around terminals that don't clear bold on `\x1b[0m` alone.
210    if s.contains('\x1b') {
211        result.push_str(sgr_reset(tracker.bold || tracker.faint));
212    }
213    result
214}
215
216/// An active OSC 8 hyperlink tracked by [`AnsiCodeTracker`].
217#[derive(Debug, Clone, PartialEq)]
218pub struct ActiveHyperlink {
219    /// Hyperlink parameters (e.g. `id` or empty string).
220    pub params: String,
221    /// The target URL.
222    pub url: String,
223    /// The original terminator sequence (`\x1b\\` or `\x07`).
224    pub terminator: String,
225}
226
227/// Tracks active ANSI SGR and OSC 8 state across line breaks.
228///
229/// When wrapping styled text, styles must be closed at the end of each
230/// physical line and reopened at the start of the next. This struct records
231/// which attributes are currently active and can emit the corresponding
232/// escape sequences.
233///
234/// # Example
235///
236/// ```
237/// use photon_ui::utils::AnsiCodeTracker;
238///
239/// let mut tracker = AnsiCodeTracker::new();
240/// tracker.process("\x1b[1m"); // bold on
241/// tracker.process("\x1b[31m"); // red fg
242/// assert_eq!(tracker.current_codes(), "\x1b[1;31m");
243/// ```
244#[derive(Debug, Default, Clone, PartialEq)]
245pub struct AnsiCodeTracker {
246    /// Bold (SGR 1) is active.
247    pub bold: bool,
248    /// Italic (SGR 3) is active.
249    pub italic: bool,
250    /// Underline (SGR 4) is active.
251    pub underline: bool,
252    /// Faint / dim (SGR 2) is active.
253    pub faint: bool,
254    /// Reverse video (SGR 7) is active.
255    pub reverse: bool,
256    /// Active foreground color SGR parameter, e.g. `"31"` or `"38;5;240"`.
257    pub fg_color: Option<String>,
258    /// Active background color SGR parameter, e.g. `"41"` or `"48;5;240"`.
259    pub bg_color: Option<String>,
260    /// Active OSC 8 hyperlink, if any.
261    pub hyperlink: Option<ActiveHyperlink>,
262}
263
264impl AnsiCodeTracker {
265    /// Create a tracker with no active codes.
266    pub fn new() -> Self {
267        Self::default()
268    }
269
270    /// Parse an OSC 8 hyperlink sequence.
271    ///
272    /// Returns `Some(Some(link))` on open, `Some(None)` on close, and
273    /// `None` if the sequence is not a valid OSC 8 hyperlink.
274    fn parse_osc8(seq: &str) -> Option<Option<ActiveHyperlink>> {
275        let body = match seq.strip_prefix("\x1b]") {
276            | Some(b) => b,
277            | None => return None,
278        };
279        let (body, terminator) = if let Some(body) = body.strip_suffix("\x1b\\") {
280            (body, "\x1b\\".to_string())
281        } else if let Some(body) = body.strip_suffix('\x07') {
282            (body, "\x07".to_string())
283        } else {
284            return None;
285        };
286        let rest = match body.strip_prefix("8;") {
287            | Some(r) => r,
288            | None => return None,
289        };
290        let sep = match rest.find(';') {
291            | Some(s) => s,
292            | None => return None,
293        };
294        let params = rest[..sep].to_string();
295        let url = rest[sep + 1..].to_string();
296        if url.is_empty() {
297            Some(None)
298        } else {
299            Some(Some(ActiveHyperlink {
300                params,
301                url,
302                terminator,
303            }))
304        }
305    }
306
307    /// Process an ANSI escape sequence, updating internal state.
308    ///
309    /// Supports:
310    /// - OSC 8 hyperlink open / close (`\x1b]8;;URL\x1b\\`, `\x1b]8;;\x1b\\`)
311    /// - SGR codes (`\x1b[…m`) for bold, italic, underline, and colors,
312    ///   including 256-color (`38;5;N` / `48;5;N`) and 24-bit truecolor
313    ///   (`38;2;R;G;B` / `48;2;R;G;B`) forms.
314    pub fn process(&mut self, seq: &str) {
315        if let Some(parsed) = Self::parse_osc8(seq) {
316            self.hyperlink = parsed;
317            return;
318        }
319
320        let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
321        let body = body.strip_suffix('m').unwrap_or(body);
322        let codes: Vec<&str> = body.split(';').collect();
323        let mut i = 0;
324        while i < codes.len() {
325            let code = codes[i];
326            match code {
327                | "0" => *self = Self::default(),
328                | "1" => self.bold = true,
329                | "2" => self.faint = true,
330                | "3" => self.italic = true,
331                | "4" => self.underline = true,
332                | "7" => self.reverse = true,
333                | "22" => {
334                    self.bold = false;
335                    self.faint = false;
336                },
337                | "23" => self.italic = false,
338                | "24" => self.underline = false,
339                | "27" => self.reverse = false,
340                | "39" => self.fg_color = None,
341                | "49" => self.bg_color = None,
342                | "38" => {
343                    self.fg_color = Self::parse_extended_color(&codes, &mut i, "38");
344                    continue;
345                },
346                | "48" => {
347                    self.bg_color = Self::parse_extended_color(&codes, &mut i, "48");
348                    continue;
349                },
350                | c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
351                | c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
352                | _ => {},
353            }
354            i += 1;
355        }
356    }
357
358    /// Parse an extended color specification that follows `38` or `48`.
359    ///
360    /// The `prefix` is `"38"` for foreground or `"48"` for background. The
361    /// returned string includes the prefix so it can be emitted directly as an
362    /// SGR parameter (e.g. `"38;2;250;82;15"`). `i` is advanced past the
363    /// consumed codes; incomplete specifications return `None`.
364    fn parse_extended_color(codes: &[&str], i: &mut usize, prefix: &str) -> Option<String> {
365        *i += 1;
366        if *i >= codes.len() {
367            return None;
368        }
369        match codes[*i] {
370            | "5" => {
371                *i += 1;
372                if *i >= codes.len() {
373                    return None;
374                }
375                let idx = codes[*i];
376                *i += 1;
377                Some(format!("{};5;{}", prefix, idx))
378            },
379            | "2" => {
380                *i += 1;
381                if *i + 2 >= codes.len() {
382                    return None;
383                }
384                let r = codes[*i];
385                let g = codes[*i + 1];
386                let b = codes[*i + 2];
387                *i += 3;
388                Some(format!("{};2;{};{};{}", prefix, r, g, b))
389            },
390            | _ => None,
391        }
392    }
393
394    /// Return the escape sequences needed to restore all active codes.
395    ///
396    /// This is used to reopen styles at the beginning of a continuation line.
397    pub fn current_codes(&self) -> String {
398        let mut parts = Vec::new();
399        if self.bold {
400            parts.push("1");
401        }
402        if self.faint {
403            parts.push("2");
404        }
405        if self.italic {
406            parts.push("3");
407        }
408        if self.underline {
409            parts.push("4");
410        }
411        if self.reverse {
412            parts.push("7");
413        }
414        if let Some(ref fg) = self.fg_color {
415            parts.push(fg.as_str());
416        }
417        if let Some(ref bg) = self.bg_color {
418            parts.push(bg.as_str());
419        }
420        let mut result = if parts.is_empty() {
421            String::new()
422        } else {
423            format!("\x1b[{}m", parts.join(";"))
424        };
425        if let Some(ref link) = self.hyperlink {
426            result.push_str(&format!(
427                "\x1b]8;{};{}{}",
428                link.params, link.url, link.terminator
429            ));
430        }
431        result
432    }
433
434    /// Return the escape sequences needed to close active codes at a line end.
435    ///
436    /// Unlike a full SGR reset, this only closes attributes that would bleed
437    /// into padding or subsequent lines (underline and hyperlinks). The caller
438    /// is responsible for emitting `\x1b[0m` when a full SGR reset is needed.
439    pub fn line_end_reset(&self) -> String {
440        let mut result = String::new();
441        if self.underline {
442            result.push_str("\x1b[24m");
443        }
444        if self.reverse {
445            result.push_str("\x1b[27m");
446        }
447        if let Some(ref link) = self.hyperlink {
448            result.push_str(&format!("\x1b]8;;{}", link.terminator));
449        }
450        result
451    }
452
453    /// Returns `true` if any SGR or OSC 8 code is currently active.
454    pub fn has_active_codes(&self) -> bool {
455        self.bold ||
456            self.faint ||
457            self.italic ||
458            self.underline ||
459            self.reverse ||
460            self.fg_color.is_some() ||
461            self.bg_color.is_some() ||
462            self.hyperlink.is_some()
463    }
464}
465
466/// Wrap text into lines that fit within `width` columns, preserving ANSI codes.
467///
468/// ANSI SGR sequences (`\x1b[…m`) and OSC 8 hyperlink sequences (`\x1b]8;…`)
469/// are parsed and carried across line boundaries so that styles remain
470/// continuous. Newlines in the input produce new lines in the output.
471///
472/// # Example
473///
474/// ```
475/// use photon_ui::utils::wrap_text_with_ansi;
476///
477/// let lines = wrap_text_with_ansi("hello world", 6);
478/// assert_eq!(lines, vec!["hello ", "world"]);
479/// ```
480pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
481    let w = width as usize;
482    let mut lines: Vec<String> = Vec::new();
483    let mut current = String::new();
484    let mut current_width = 0;
485    let mut tracker = AnsiCodeTracker::new();
486
487    let mut chars = text.chars().peekable();
488    while let Some(ch) = chars.next() {
489        if ch == '\x1b' {
490            match chars.peek() {
491                | Some(&'[') => {
492                    chars.next();
493                    let mut seq = String::from("\x1b[");
494                    while let Some(&c) = chars.peek() {
495                        seq.push(c);
496                        chars.next();
497                        if c.is_alphabetic() {
498                            break;
499                        }
500                    }
501                    tracker.process(&seq);
502                    current.push_str(&seq);
503                    continue;
504                },
505                | Some(&']') => {
506                    chars.next();
507                    let mut seq = String::from("\x1b]");
508                    while let Some(&c) = chars.peek() {
509                        seq.push(c);
510                        chars.next();
511                        if c == '\x07' {
512                            break;
513                        }
514                        if c == '\x1b' &&
515                            let Some(&'\\') = chars.peek()
516                        {
517                            seq.push('\\');
518                            chars.next();
519                            break;
520                        }
521                    }
522                    tracker.process(&seq);
523                    current.push_str(&seq);
524                    continue;
525                },
526                | _ => {},
527            }
528        }
529
530        if ch == '\n' {
531            if tracker.bold ||
532                tracker.faint ||
533                tracker.italic ||
534                tracker.underline ||
535                tracker.fg_color.is_some() ||
536                tracker.bg_color.is_some()
537            {
538                current.push_str(sgr_reset(tracker.bold || tracker.faint));
539            }
540            let reset = tracker.line_end_reset();
541            if !reset.is_empty() {
542                current.push_str(&reset);
543            }
544            lines.push(current);
545            current = tracker.current_codes();
546            current_width = 0;
547            continue;
548        }
549
550        let cw = ch.width().unwrap_or(0);
551        if current_width + cw > w && !current.is_empty() {
552            if tracker.bold ||
553                tracker.faint ||
554                tracker.italic ||
555                tracker.underline ||
556                tracker.fg_color.is_some() ||
557                tracker.bg_color.is_some()
558            {
559                current.push_str(sgr_reset(tracker.bold || tracker.faint));
560            }
561            let reset = tracker.line_end_reset();
562            if !reset.is_empty() {
563                current.push_str(&reset);
564            }
565            lines.push(current);
566            current = tracker.current_codes();
567            current_width = 0;
568        }
569        current.push(ch);
570        current_width += cw;
571    }
572
573    if !current.is_empty() {
574        lines.push(current);
575    }
576    lines
577}
578
579/// The full-block character used to draw the editable cursor.
580pub const EDIT_CURSOR: char = '█';
581
582/// Right-pad `s` with spaces so its visible width equals `width`.
583///
584/// ANSI escape sequences are ignored when measuring width.
585pub fn pad_to_width(s: &str, width: u16) -> String {
586    let w = width as usize;
587    let vw = visible_width(s);
588    match vw.cmp(&w) {
589        | std::cmp::Ordering::Less => format!("{}{}", s, " ".repeat(w - vw)),
590        | _ => s.to_string(),
591    }
592}
593
594/// Render a single line of text with a full-block cursor at `cursor_col`.
595///
596/// The grapheme at the visual column `cursor_col` is replaced by `EDIT_CURSOR`.
597/// If `cursor_col` is at or past the end of the line, the cursor is appended.
598/// The result is padded to `width` and styled: text with `edit_style`, cursor
599/// with `cursor_style`.
600pub fn render_line_with_cursor(
601    line: &str,
602    cursor_col: usize,
603    width: u16,
604    edit_style: &Style,
605    cursor_style: &Style,
606) -> String {
607    let before_idx = byte_index_at_visual_pos(line, cursor_col);
608    let before = &line[..before_idx];
609
610    let after_idx = if before_idx >= line.len() {
611        before_idx
612    } else {
613        let mut chars = line[before_idx..].chars();
614        match chars.next() {
615            | Some(ch) => before_idx + ch.len_utf8(),
616            | None => before_idx,
617        }
618    };
619    let after = &line[after_idx..];
620
621    let inner = format!("{}{}{}", before, EDIT_CURSOR, after);
622    let inner_width = visible_width(&inner);
623    let w = width as usize;
624    let pad = w.saturating_sub(inner_width);
625    let padded = format!("{}{}", inner, " ".repeat(pad));
626
627    let cursor_byte_len = EDIT_CURSOR.len_utf8();
628    let before_part = &padded[..before_idx];
629    let cursor_end = before_idx + cursor_byte_len;
630    let cursor_part = &padded[before_idx..cursor_end];
631    let rest_part = &padded[cursor_end..];
632
633    format!(
634        "{}{}{}",
635        stylize(before_part, edit_style),
636        stylize(cursor_part, cursor_style),
637        stylize(rest_part, edit_style)
638    )
639}
640
641/// Render a single-line editable field with a visible block cursor.
642///
643/// `prefix` is rendered with the muted text colour, the editable `buffer` on a
644/// surface background, and the cursor in the configured cursor colour. The
645/// result spans `width` columns and the cursor is always visible, even when
646/// `buffer` is empty.
647pub fn render_editable_line(prefix: &str, buffer: &str, width: u16, theme: &dyn Palette) -> String {
648    let prefix_style = Style::new().fg(theme.text_muted());
649    let edit_style = Style::new().fg(theme.text()).bg(theme.surface());
650    let cursor_style = Style::new().fg(theme.cursor()).bg(theme.surface());
651
652    let prefix_styled = stylize(prefix, &prefix_style);
653    let prefix_width = visible_width(prefix);
654    let available = (width as usize).saturating_sub(prefix_width);
655    if available == 0 {
656        return prefix_styled;
657    }
658
659    let buffer_width = visible_width(buffer);
660    let (buffer_display, pad) = if buffer_width < available {
661        (buffer.to_string(), available - buffer_width - 1)
662    } else {
663        let truncated = truncate_to_width(buffer, (available - 1) as u16, "");
664        let truncated_width = visible_width(&truncated);
665        (truncated, available - truncated_width - 1)
666    };
667
668    let content = format!("{}{}{}", buffer_display, EDIT_CURSOR, " ".repeat(pad));
669    let buffer_end = buffer_display.len();
670    let cursor_end = buffer_end + EDIT_CURSOR.len_utf8();
671
672    let buffer_part = &content[..buffer_end];
673    let cursor_part = &content[buffer_end..cursor_end];
674    let pad_part = &content[cursor_end..];
675
676    format!(
677        "{}{}{}{}",
678        prefix_styled,
679        stylize(buffer_part, &edit_style),
680        stylize(cursor_part, &cursor_style),
681        stylize(pad_part, &edit_style)
682    )
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn tracker_tracks_hyperlink() {
691        let mut tracker = AnsiCodeTracker::new();
692        tracker.process("\x1b]8;;https://example.com\x1b\\");
693        assert!(tracker.hyperlink.is_some());
694        assert_eq!(
695            tracker.hyperlink.as_ref().unwrap().url,
696            "https://example.com"
697        );
698        assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
699    }
700
701    #[test]
702    fn tracker_hyperlink_bel_terminator() {
703        let mut tracker = AnsiCodeTracker::new();
704        tracker.process("\x1b]8;;https://example.com\x07");
705        assert!(tracker.hyperlink.is_some());
706        assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
707    }
708
709    #[test]
710    fn tracker_hyperlink_close() {
711        let mut tracker = AnsiCodeTracker::new();
712        tracker.process("\x1b]8;;https://example.com\x1b\\");
713        assert!(tracker.hyperlink.is_some());
714        tracker.process("\x1b]8;;\x1b\\");
715        assert!(tracker.hyperlink.is_none());
716    }
717
718    #[test]
719    fn current_codes_includes_hyperlink() {
720        let mut tracker = AnsiCodeTracker::new();
721        tracker.process("\x1b]8;;https://example.com\x1b\\");
722        let codes = tracker.current_codes();
723        assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
724    }
725
726    #[test]
727    fn line_end_reset_closes_hyperlink() {
728        let mut tracker = AnsiCodeTracker::new();
729        tracker.process("\x1b]8;;https://example.com\x1b\\");
730        let reset = tracker.line_end_reset();
731        assert!(reset.contains("\x1b]8;;\x1b\\"));
732    }
733
734    #[test]
735    fn wrap_preserves_hyperlink_across_lines() {
736        let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
737        let lines = wrap_text_with_ansi(text, 6);
738        assert_eq!(lines.len(), 2);
739        // First line should close hyperlink at end
740        assert!(lines[0].contains("\x1b]8;;\x1b\\"));
741        // Second line should reopen hyperlink
742        assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
743    }
744
745    #[test]
746    fn has_active_codes_with_hyperlink() {
747        let mut tracker = AnsiCodeTracker::new();
748        assert!(!tracker.has_active_codes());
749        tracker.process("\x1b]8;;https://example.com\x1b\\");
750        assert!(tracker.has_active_codes());
751    }
752
753    #[test]
754    fn line_end_reset_with_underline() {
755        let mut tracker = AnsiCodeTracker::new();
756        tracker.process("\x1b[4m");
757        let reset = tracker.line_end_reset();
758        assert!(reset.contains("\x1b[24m"));
759    }
760
761    #[test]
762    fn wrap_hyperlink_bel_terminator() {
763        let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
764        let lines = wrap_text_with_ansi(text, 6);
765        assert_eq!(lines.len(), 2);
766        assert!(lines[0].contains("\x1b]8;;\x07"));
767        assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
768    }
769
770    #[test]
771    fn wrap_newline_with_active_sgr() {
772        let text = "\x1b[31mhello\nworld\x1b[0m";
773        let lines = wrap_text_with_ansi(text, 20);
774        assert_eq!(lines.len(), 2);
775        // First line should have SGR reset and hyperlink reset at end
776        assert!(lines[0].contains("\x1b[0m"));
777        // Second line should reopen the SGR code
778        assert!(lines[1].starts_with("\x1b[31m"));
779    }
780
781    #[test]
782    fn tracker_invalid_osc_ignored() {
783        let mut tracker = AnsiCodeTracker::new();
784        tracker.process("\x1b]8;;url");
785        assert!(tracker.hyperlink.is_none());
786    }
787
788    #[test]
789    fn tracker_invalid_osc_no_prefix() {
790        let mut tracker = AnsiCodeTracker::new();
791        tracker.process("\x1b]9;;url\x1b\\");
792        assert!(tracker.hyperlink.is_none());
793    }
794
795    #[test]
796    fn has_active_codes_with_sgr() {
797        let mut tracker = AnsiCodeTracker::new();
798        tracker.process("\x1b[1m");
799        assert!(tracker.has_active_codes());
800    }
801
802    /// Regression: 24-bit truecolor foreground must be preserved in full.
803    #[test]
804    fn tracker_preserves_truecolor_foreground() {
805        let mut tracker = AnsiCodeTracker::new();
806        tracker.process("\x1b[38;2;250;82;15m");
807        assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
808        assert_eq!(tracker.current_codes(), "\x1b[38;2;250;82;15m");
809    }
810
811    /// Regression: 24-bit truecolor background must be preserved in full.
812    #[test]
813    fn tracker_preserves_truecolor_background() {
814        let mut tracker = AnsiCodeTracker::new();
815        tracker.process("\x1b[48;2;42;42;42m");
816        assert_eq!(tracker.bg_color, Some("48;2;42;42;42".to_string()));
817        assert_eq!(tracker.current_codes(), "\x1b[48;2;42;42;42m");
818    }
819
820    /// Regression: 256-color foreground must be preserved.
821    #[test]
822    fn tracker_preserves_256_foreground() {
823        let mut tracker = AnsiCodeTracker::new();
824        tracker.process("\x1b[38;5;196m");
825        assert_eq!(tracker.fg_color, Some("38;5;196".to_string()));
826    }
827
828    /// Regression: mixed truecolor and attribute codes must all be tracked.
829    #[test]
830    fn tracker_mixed_truecolor_and_attributes() {
831        let mut tracker = AnsiCodeTracker::new();
832        tracker.process("\x1b[1;38;2;250;82;15;48;2;0;0;0m");
833        assert!(tracker.bold);
834        assert_eq!(tracker.fg_color, Some("38;2;250;82;15".to_string()));
835        assert_eq!(tracker.bg_color, Some("48;2;0;0;0".to_string()));
836        assert_eq!(tracker.current_codes(), "\x1b[1;38;2;250;82;15;48;2;0;0;0m");
837    }
838
839    /// Regression: default foreground/background codes must still clear state.
840    #[test]
841    fn tracker_default_colors_clear_state() {
842        let mut tracker = AnsiCodeTracker::new();
843        tracker.process("\x1b[38;2;250;82;15;48;2;0;0;0m");
844        tracker.process("\x1b[39;49m");
845        assert!(tracker.fg_color.is_none());
846        assert!(tracker.bg_color.is_none());
847    }
848
849    #[test]
850    fn truncate_jk_text_demo() {
851        let text = "  j/k = navigate list   Tab = switch focus   i = insert mode   Esc = normal mode   q = quit";
852        let truncated = truncate_to_width(text, 80, "…");
853        let vw = visible_width(&truncated);
854        eprintln!("original vw: {}", visible_width(text));
855        eprintln!("truncated: {:?}", truncated);
856        eprintln!("truncated vw: {}", vw);
857        assert!(vw <= 80, "truncated width {} exceeds 80", vw);
858        assert!(truncated.ends_with("…"));
859    }
860
861    #[test]
862    fn truncate_to_width_preserves_ansi_prefix() {
863        let s = "\x1b[44mhello\x1b[0m";
864        let truncated = truncate_to_width(s, 3, "…");
865        // Should preserve the ANSI prefix, truncate visible text, add ellipsis,
866        // and append a reset so attributes don't bleed.
867        assert!(truncated.starts_with("\x1b[44m"));
868        assert!(truncated.contains("…"));
869        assert!(truncated.ends_with("\x1b[0m"));
870        assert_eq!(visible_width(&truncated), 3);
871    }
872
873    #[test]
874    fn truncate_to_width_preserves_ansi_infix() {
875        let s = "hi\x1b[31mred\x1b[0mlo";
876        let truncated = truncate_to_width(s, 4, "…");
877        assert_eq!(visible_width(&truncated), 4);
878        // The ANSI sequence should be fully preserved, not split mid-sequence.
879        assert!(truncated.contains("\x1b[31m"));
880        assert!(truncated.contains("\x1b[0m"));
881    }
882
883    #[test]
884    fn truncate_to_width_no_truncation_when_fits() {
885        let s = "\x1b[44mhi\x1b[0m";
886        let truncated = truncate_to_width(s, 5, "…");
887        // visible width is 2, which fits in 5, so return as-is
888        assert_eq!(truncated, s);
889    }
890
891    #[test]
892    fn byte_index_at_visual_pos_plain() {
893        assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
894        assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
895        assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
896        assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
897    }
898
899    #[test]
900    fn byte_index_at_visual_pos_with_ansi_prefix() {
901        let s = "\x1b[31mhello\x1b[0m";
902        // "\x1b[31m" is 5 bytes, visible width 0
903        assert_eq!(byte_index_at_visual_pos(s, 0), 5);
904        assert_eq!(byte_index_at_visual_pos(s, 3), 8);
905        assert_eq!(byte_index_at_visual_pos(s, 5), 10);
906        // Past end → byte index after last visible char (including trailing ANSI)
907        assert_eq!(byte_index_at_visual_pos(s, 10), 14);
908    }
909
910    #[test]
911    fn byte_index_at_visual_pos_with_ansi_infix() {
912        let s = "hi\x1b[31mred\x1b[0mlo";
913        // visible: h i r e d l o = 7
914        assert_eq!(byte_index_at_visual_pos(s, 0), 0);
915        assert_eq!(byte_index_at_visual_pos(s, 2), 2);
916        // Position 3 is 'e' which starts at byte 8 (after "hi\x1b[31mr")
917        assert_eq!(byte_index_at_visual_pos(s, 3), 8);
918        // Past end
919        assert_eq!(byte_index_at_visual_pos(s, 7), 16);
920    }
921
922    #[test]
923    fn byte_index_at_visual_pos_with_hyperlink() {
924        let s = "\x1b]8;;https://example.com\x07hello";
925        // OSC hyperlink is 25 bytes, visible width 0
926        assert_eq!(byte_index_at_visual_pos(s, 0), 25);
927        assert_eq!(byte_index_at_visual_pos(s, 3), 28);
928    }
929
930    #[test]
931    fn pad_to_width_adds_spaces() {
932        assert_eq!(pad_to_width("hi", 5), "hi   ");
933    }
934
935    #[test]
936    fn pad_to_width_ignores_ansi() {
937        let s = "\x1b[31mhi\x1b[0m";
938        assert_eq!(pad_to_width(s, 5), "\x1b[31mhi\x1b[0m   ");
939    }
940
941    #[test]
942    fn render_line_with_cursor_inserts_block() {
943        use crate::theme::{
944            Style,
945            Theme,
946        };
947        Theme::with(Theme::Light, || {
948            let theme = Theme::palette();
949            let edit = Style::new().fg(theme.text()).bg(theme.surface());
950            let cursor = Style::new().fg(theme.cursor()).bg(theme.surface());
951            let line = render_line_with_cursor("hello", 2, 8, &edit, &cursor);
952            assert!(line.contains(EDIT_CURSOR));
953            assert!(line.contains("he"));
954            assert!(line.contains("lo"));
955            assert!(line.contains("\x1b[48;"));
956            assert!(line.contains("\x1b[38;"));
957            assert_eq!(visible_width(&line), 8);
958        });
959    }
960
961    #[test]
962    fn render_line_with_cursor_at_end() {
963        use crate::theme::{
964            Style,
965            Theme,
966        };
967        Theme::with(Theme::Light, || {
968            let theme = Theme::palette();
969            let edit = Style::new().fg(theme.text()).bg(theme.surface());
970            let cursor = Style::new().fg(theme.cursor()).bg(theme.surface());
971            let line = render_line_with_cursor("hi", 5, 6, &edit, &cursor);
972            assert!(line.contains("hi"));
973            assert!(line.contains(EDIT_CURSOR));
974            assert_eq!(visible_width(&line), 6);
975        });
976    }
977
978    #[test]
979    fn render_editable_line_shows_prefix_buffer_and_cursor() {
980        use crate::theme::Theme;
981        Theme::with(Theme::Light, || {
982            let theme = Theme::palette();
983            let line = render_editable_line("/", "abc", 10, &*theme);
984            assert!(line.contains('/'));
985            assert!(line.contains("abc"));
986            assert!(line.contains(EDIT_CURSOR));
987            assert!(line.contains("\x1b[48;"));
988            assert_eq!(visible_width(&line), 10);
989        });
990    }
991
992    #[test]
993    fn render_editable_line_cursor_visible_when_empty() {
994        use crate::theme::Theme;
995        Theme::with(Theme::Light, || {
996            let theme = Theme::palette();
997            let line = render_editable_line("/", "", 10, &*theme);
998            assert!(line.contains('/'));
999            assert!(line.contains(EDIT_CURSOR));
1000            assert_eq!(visible_width(&line), 10);
1001        });
1002    }
1003
1004    #[test]
1005    fn render_editable_line_respects_custom_cursor_colour() {
1006        use std::sync::Arc;
1007
1008        use crate::theme::{
1009            Color,
1010            Palette,
1011            Theme,
1012        };
1013
1014        struct CyanCursor;
1015        impl Palette for CyanCursor {
1016            fn background(&self) -> Color {
1017                Color::SUNBEAM_BLACK
1018            }
1019
1020            fn surface(&self) -> Color {
1021                Color::CARD_DARK
1022            }
1023
1024            fn field(&self) -> Color {
1025                Color::CARD_DARK
1026            }
1027
1028            fn text(&self) -> Color {
1029                Color::WHITE
1030            }
1031
1032            fn text_muted(&self) -> Color {
1033                Color(0xbb, 0xbb, 0xbb)
1034            }
1035
1036            fn text_on_accent(&self) -> Color {
1037                Color::WHITE
1038            }
1039
1040            fn accent(&self) -> Color {
1041                Color::SUNBEAM_ORANGE
1042            }
1043
1044            fn accent_hover(&self) -> Color {
1045                Color::SUNBEAM_FLAME
1046            }
1047
1048            fn border(&self) -> Color {
1049                Color(0x55, 0x55, 0x55)
1050            }
1051
1052            fn border_muted(&self) -> Color {
1053                Color(0x44, 0x44, 0x44)
1054            }
1055
1056            fn focus(&self) -> Color {
1057                Color::BEAM_ORANGE
1058            }
1059
1060            fn success(&self) -> Color {
1061                Color(0x22, 0x99, 0x55)
1062            }
1063
1064            fn warning(&self) -> Color {
1065                Color::SUNSHINE_900
1066            }
1067
1068            fn error(&self) -> Color {
1069                Color(0xdd, 0x33, 0x33)
1070            }
1071
1072            fn info(&self) -> Color {
1073                Color(0x33, 0x77, 0xcc)
1074            }
1075
1076            fn cursor(&self) -> Color {
1077                Color(0x00, 0xff, 0xff)
1078            }
1079        }
1080
1081        Theme::set_palette(Arc::new(CyanCursor));
1082        let theme = Theme::palette();
1083        let line = render_editable_line("/", "x", 10, &*theme);
1084        Theme::clear_palette();
1085        assert!(line.contains(EDIT_CURSOR));
1086        // Cyan truecolor foreground
1087        assert!(line.contains("\x1b[38;2;0;255;255m"));
1088    }
1089}