Skip to main content

a3s_tui/
style.rs

1//! Color, style, and text formatting utilities.
2
3use unicode_segmentation::UnicodeSegmentation;
4use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Color {
8    Black,
9    Red,
10    Green,
11    Yellow,
12    Blue,
13    Magenta,
14    Cyan,
15    White,
16    BrightBlack,
17    BrightRed,
18    BrightGreen,
19    BrightYellow,
20    BrightBlue,
21    BrightMagenta,
22    BrightCyan,
23    BrightWhite,
24    Ansi256(u8),
25    Rgb(u8, u8, u8),
26}
27
28impl Color {
29    pub fn fg_ansi(&self) -> String {
30        self.fg_code()
31    }
32
33    pub fn bg_ansi(&self) -> String {
34        self.bg_code()
35    }
36
37    fn fg_code(&self) -> String {
38        match self {
39            Color::Black => "30".into(),
40            Color::Red => "31".into(),
41            Color::Green => "32".into(),
42            Color::Yellow => "33".into(),
43            Color::Blue => "34".into(),
44            Color::Magenta => "35".into(),
45            Color::Cyan => "36".into(),
46            Color::White => "37".into(),
47            Color::BrightBlack => "90".into(),
48            Color::BrightRed => "91".into(),
49            Color::BrightGreen => "92".into(),
50            Color::BrightYellow => "93".into(),
51            Color::BrightBlue => "94".into(),
52            Color::BrightMagenta => "95".into(),
53            Color::BrightCyan => "96".into(),
54            Color::BrightWhite => "97".into(),
55            Color::Ansi256(n) => format!("38;5;{}", n),
56            Color::Rgb(r, g, b) => format!("38;2;{};{};{}", r, g, b),
57        }
58    }
59
60    fn bg_code(&self) -> String {
61        match self {
62            Color::Black => "40".into(),
63            Color::Red => "41".into(),
64            Color::Green => "42".into(),
65            Color::Yellow => "43".into(),
66            Color::Blue => "44".into(),
67            Color::Magenta => "45".into(),
68            Color::Cyan => "46".into(),
69            Color::White => "47".into(),
70            Color::BrightBlack => "100".into(),
71            Color::BrightRed => "101".into(),
72            Color::BrightGreen => "102".into(),
73            Color::BrightYellow => "103".into(),
74            Color::BrightBlue => "104".into(),
75            Color::BrightMagenta => "105".into(),
76            Color::BrightCyan => "106".into(),
77            Color::BrightWhite => "107".into(),
78            Color::Ansi256(n) => format!("48;5;{}", n),
79            Color::Rgb(r, g, b) => format!("48;2;{};{};{}", r, g, b),
80        }
81    }
82
83    /// Parse a hex color string (e.g., "#ff0000" or "ff0000").
84    pub fn from_hex(hex: &str) -> Option<Self> {
85        let hex = hex.trim_start_matches('#');
86        if hex.len() != 6 {
87            return None;
88        }
89        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
90        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
91        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
92        Some(Color::Rgb(r, g, b))
93    }
94
95    /// Lighten an RGB color by a factor (0.0 to 1.0).
96    pub fn lighten(&self, amount: f64) -> Self {
97        match self {
98            Color::Rgb(r, g, b) => {
99                let factor = normalized_color_amount(amount);
100                Color::Rgb(
101                    (*r as f64 + (255.0 - *r as f64) * factor) as u8,
102                    (*g as f64 + (255.0 - *g as f64) * factor) as u8,
103                    (*b as f64 + (255.0 - *b as f64) * factor) as u8,
104                )
105            }
106            other => *other,
107        }
108    }
109
110    /// Darken an RGB color by a factor (0.0 to 1.0).
111    pub fn darken(&self, amount: f64) -> Self {
112        match self {
113            Color::Rgb(r, g, b) => {
114                let factor = 1.0 - normalized_color_amount(amount);
115                Color::Rgb(
116                    (*r as f64 * factor) as u8,
117                    (*g as f64 * factor) as u8,
118                    (*b as f64 * factor) as u8,
119                )
120            }
121            other => *other,
122        }
123    }
124
125    /// Create a grayscale color (0 = black, 255 = white).
126    pub fn gray(level: u8) -> Self {
127        Color::Rgb(level, level, level)
128    }
129
130    /// Create an RGB color.
131    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
132        Color::Rgb(r, g, b)
133    }
134
135    /// Create a color from ANSI 256 palette index.
136    pub fn ansi(n: u8) -> Self {
137        Color::Ansi256(n)
138    }
139}
140
141fn normalized_color_amount(amount: f64) -> f64 {
142    if amount.is_finite() {
143        amount.clamp(0.0, 1.0)
144    } else {
145        0.0
146    }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum Border {
151    None,
152    Single,
153    Double,
154    Rounded,
155    Thick,
156}
157
158impl Border {
159    fn chars(&self) -> Option<BorderChars> {
160        match self {
161            Border::None => None,
162            Border::Single => Some(BorderChars {
163                tl: '┌',
164                tr: '┐',
165                bl: '└',
166                br: '┘',
167                h: '─',
168                v: '│',
169            }),
170            Border::Double => Some(BorderChars {
171                tl: '╔',
172                tr: '╗',
173                bl: '╚',
174                br: '╝',
175                h: '═',
176                v: '║',
177            }),
178            Border::Rounded => Some(BorderChars {
179                tl: '╭',
180                tr: '╮',
181                bl: '╰',
182                br: '╯',
183                h: '─',
184                v: '│',
185            }),
186            Border::Thick => Some(BorderChars {
187                tl: '┏',
188                tr: '┓',
189                bl: '┗',
190                br: '┛',
191                h: '━',
192                v: '┃',
193            }),
194        }
195    }
196}
197
198struct BorderChars {
199    tl: char,
200    tr: char,
201    bl: char,
202    br: char,
203    h: char,
204    v: char,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum Align {
209    Left,
210    Center,
211    Right,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct Style {
216    fg: Option<Color>,
217    bg: Option<Color>,
218    bold: bool,
219    italic: bool,
220    underline: bool,
221    strikethrough: bool,
222    dim: bool,
223    reverse: bool,
224    padding: [u16; 4],
225    margin: [u16; 4],
226    border: Border,
227    border_fg: Option<Color>,
228    width: Option<u16>,
229    height: Option<u16>,
230    align: Align,
231}
232
233// PLACEHOLDER_STYLE_IMPL
234
235impl Default for Style {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241impl Style {
242    pub fn new() -> Self {
243        Self {
244            fg: None,
245            bg: None,
246            bold: false,
247            italic: false,
248            underline: false,
249            strikethrough: false,
250            dim: false,
251            reverse: false,
252            padding: [0; 4],
253            margin: [0; 4],
254            border: Border::None,
255            border_fg: None,
256            width: None,
257            height: None,
258            align: Align::Left,
259        }
260    }
261
262    pub fn fg(mut self, color: Color) -> Self {
263        self.fg = Some(color);
264        self
265    }
266    pub fn bg(mut self, color: Color) -> Self {
267        self.bg = Some(color);
268        self
269    }
270    pub fn bold(mut self) -> Self {
271        self.bold = true;
272        self
273    }
274    pub fn italic(mut self) -> Self {
275        self.italic = true;
276        self
277    }
278    pub fn underline(mut self) -> Self {
279        self.underline = true;
280        self
281    }
282    pub fn strikethrough(mut self) -> Self {
283        self.strikethrough = true;
284        self
285    }
286    pub fn dim(mut self) -> Self {
287        self.dim = true;
288        self
289    }
290    pub fn reverse(mut self) -> Self {
291        self.reverse = true;
292        self
293    }
294
295    pub fn foreground(&self) -> Option<Color> {
296        self.fg
297    }
298
299    pub fn background(&self) -> Option<Color> {
300        self.bg
301    }
302
303    pub fn is_bold(&self) -> bool {
304        self.bold
305    }
306
307    pub fn is_italic(&self) -> bool {
308        self.italic
309    }
310
311    pub fn is_underline(&self) -> bool {
312        self.underline
313    }
314
315    pub fn is_strikethrough(&self) -> bool {
316        self.strikethrough
317    }
318
319    pub fn is_dim(&self) -> bool {
320        self.dim
321    }
322
323    pub fn is_reverse(&self) -> bool {
324        self.reverse
325    }
326
327    pub fn padding_top(mut self, n: u16) -> Self {
328        self.padding[0] = n;
329        self
330    }
331    pub fn padding_right(mut self, n: u16) -> Self {
332        self.padding[1] = n;
333        self
334    }
335    pub fn padding_bottom(mut self, n: u16) -> Self {
336        self.padding[2] = n;
337        self
338    }
339    pub fn padding_left(mut self, n: u16) -> Self {
340        self.padding[3] = n;
341        self
342    }
343
344    pub fn padding(mut self, vertical: u16, horizontal: u16) -> Self {
345        self.padding = [vertical, horizontal, vertical, horizontal];
346        self
347    }
348
349    pub fn margin_top(mut self, n: u16) -> Self {
350        self.margin[0] = n;
351        self
352    }
353    pub fn margin_right(mut self, n: u16) -> Self {
354        self.margin[1] = n;
355        self
356    }
357    pub fn margin_bottom(mut self, n: u16) -> Self {
358        self.margin[2] = n;
359        self
360    }
361    pub fn margin_left(mut self, n: u16) -> Self {
362        self.margin[3] = n;
363        self
364    }
365
366    pub fn margin(mut self, vertical: u16, horizontal: u16) -> Self {
367        self.margin = [vertical, horizontal, vertical, horizontal];
368        self
369    }
370
371    pub fn border(mut self, border: Border) -> Self {
372        self.border = border;
373        self
374    }
375    pub fn border_fg(mut self, color: Color) -> Self {
376        self.border_fg = Some(color);
377        self
378    }
379
380    pub fn width(mut self, w: u16) -> Self {
381        self.width = Some(w);
382        self
383    }
384    pub fn height(mut self, h: u16) -> Self {
385        self.height = Some(h);
386        self
387    }
388    pub fn align(mut self, a: Align) -> Self {
389        self.align = a;
390        self
391    }
392
393    pub fn render(&self, content: &str) -> String {
394        let lines = split_lines_preserving_trailing_blank(content);
395        let content_height = self.content_height(lines.len());
396        let mut content_lines = lines
397            .iter()
398            .copied()
399            .take(content_height)
400            .collect::<Vec<_>>();
401        while content_lines.len() < content_height {
402            content_lines.push("");
403        }
404
405        let content_width = self.content_width(&content_lines);
406        let pad_left = self.padding[3] as usize;
407        let pad_right = self.padding[1] as usize;
408        let inner_width = content_width + pad_left + pad_right;
409
410        let border_chars = self.border.chars();
411
412        let mut result = Vec::new();
413
414        // Top margin
415        for _ in 0..self.margin[0] {
416            result.push(String::new());
417        }
418
419        let margin_left = " ".repeat(self.margin[3] as usize);
420        let margin_right = " ".repeat(self.margin[1] as usize);
421
422        // Top border
423        if let Some(ref bc) = border_chars {
424            let border_line = format!(
425                "{}{}{}{}{}",
426                margin_left,
427                bc.tl,
428                str::repeat(&bc.h.to_string(), inner_width),
429                bc.tr,
430                margin_right
431            );
432            result.push(self.apply_border_style(&border_line));
433        }
434
435        // Top padding
436        for _ in 0..self.padding[0] {
437            result.push(self.apply_padding_line_style(
438                border_chars.as_ref(),
439                &margin_left,
440                inner_width,
441                &margin_right,
442            ));
443        }
444
445        // Content lines
446        for line in &content_lines {
447            let visible_width = visible_len(line);
448            let aligned = self.align_text(line, visible_width, content_width);
449            let padded = format!(
450                "{}{}{}",
451                " ".repeat(pad_left),
452                aligned,
453                " ".repeat(pad_right)
454            );
455
456            let styled_line = match &border_chars {
457                Some(bc) => self.apply_bordered_content_style(
458                    &margin_left,
459                    bc.v,
460                    &padded,
461                    bc.v,
462                    &margin_right,
463                ),
464                None => {
465                    let full_line = format!("{}{}{}", margin_left, padded, margin_right);
466                    self.apply_text_style(&full_line)
467                }
468            };
469
470            result.push(styled_line);
471        }
472
473        // Bottom padding
474        for _ in 0..self.padding[2] {
475            result.push(self.apply_padding_line_style(
476                border_chars.as_ref(),
477                &margin_left,
478                inner_width,
479                &margin_right,
480            ));
481        }
482
483        // Bottom border
484        if let Some(ref bc) = border_chars {
485            let border_line = format!(
486                "{}{}{}{}{}",
487                margin_left,
488                bc.bl,
489                str::repeat(&bc.h.to_string(), inner_width),
490                bc.br,
491                margin_right
492            );
493            result.push(self.apply_border_style(&border_line));
494        }
495
496        // Bottom margin
497        for _ in 0..self.margin[2] {
498            result.push(String::new());
499        }
500
501        if let Some(height) = self.height {
502            let height = height as usize;
503            result.truncate(height);
504            while result.len() < height {
505                result.push(String::new());
506            }
507        }
508
509        result.join("\n")
510    }
511
512    fn content_width(&self, lines: &[&str]) -> usize {
513        if let Some(w) = self.width {
514            let border_cost = if self.border != Border::None { 2 } else { 0 };
515            let pad_cost = self.padding[1] as usize + self.padding[3] as usize;
516            let margin_cost = self.margin[1] as usize + self.margin[3] as usize;
517            (w as usize).saturating_sub(border_cost + pad_cost + margin_cost)
518        } else {
519            lines.iter().map(|l| visible_len(l)).max().unwrap_or(0)
520        }
521    }
522
523    fn content_height(&self, line_count: usize) -> usize {
524        if let Some(h) = self.height {
525            let border_cost = if self.border != Border::None { 2 } else { 0 };
526            let pad_cost = self.padding[0] as usize + self.padding[2] as usize;
527            let margin_cost = self.margin[0] as usize + self.margin[2] as usize;
528            (h as usize).saturating_sub(border_cost + pad_cost + margin_cost)
529        } else {
530            line_count
531        }
532    }
533
534    fn align_text(&self, text: &str, text_width: usize, target_width: usize) -> String {
535        if text_width >= target_width {
536            return truncate_visible(text, target_width);
537        }
538        match self.align {
539            Align::Left => pad_visible(text, target_width),
540            Align::Right => right_visible(text, target_width),
541            Align::Center => center_visible(text, target_width),
542        }
543    }
544
545    fn apply_bordered_content_style(
546        &self,
547        margin_left: &str,
548        left_border: char,
549        content: &str,
550        right_border: char,
551        margin_right: &str,
552    ) -> String {
553        let left_border = left_border.to_string();
554        let right_border = right_border.to_string();
555
556        format!(
557            "{}{}{}{}{}",
558            self.apply_text_style_nonempty(margin_left),
559            self.apply_border_style(&left_border),
560            self.apply_text_style(content),
561            self.apply_border_style(&right_border),
562            self.apply_text_style_nonempty(margin_right)
563        )
564    }
565
566    fn apply_padding_line_style(
567        &self,
568        border_chars: Option<&BorderChars>,
569        margin_left: &str,
570        inner_width: usize,
571        margin_right: &str,
572    ) -> String {
573        let content = " ".repeat(inner_width);
574        match border_chars {
575            Some(bc) => {
576                self.apply_bordered_content_style(margin_left, bc.v, &content, bc.v, margin_right)
577            }
578            None => {
579                let line = format!("{margin_left}{content}{margin_right}");
580                self.apply_text_style(&line)
581            }
582        }
583    }
584
585    fn apply_text_style_nonempty(&self, text: &str) -> String {
586        if text.is_empty() {
587            String::new()
588        } else {
589            self.apply_text_style(text)
590        }
591    }
592
593    fn apply_text_style(&self, text: &str) -> String {
594        if text.is_empty() {
595            return String::new();
596        }
597
598        let mut codes = Vec::new();
599        if self.bold {
600            codes.push("1".to_string());
601        }
602        if self.dim {
603            codes.push("2".to_string());
604        }
605        if self.italic {
606            codes.push("3".to_string());
607        }
608        if self.underline {
609            codes.push("4".to_string());
610        }
611        if self.reverse {
612            codes.push("7".to_string());
613        }
614        if self.strikethrough {
615            codes.push("9".to_string());
616        }
617        if let Some(ref c) = self.fg {
618            codes.push(c.fg_code());
619        }
620        if let Some(ref c) = self.bg {
621            codes.push(c.bg_code());
622        }
623
624        if codes.is_empty() {
625            text.to_string()
626        } else {
627            format!("\x1b[{}m{}\x1b[0m", codes.join(";"), text)
628        }
629    }
630
631    fn apply_border_style(&self, text: &str) -> String {
632        let mut codes = Vec::new();
633        if let Some(c) = self.border_fg.or(self.fg) {
634            codes.push(c.fg_code());
635        }
636        if let Some(c) = self.bg {
637            codes.push(c.bg_code());
638        }
639
640        if codes.is_empty() {
641            text.to_string()
642        } else {
643            format!("\x1b[{}m{}\x1b[0m", codes.join(";"), text)
644        }
645    }
646}
647
648/// Measure visible width of a string, ignoring ANSI escape sequences.
649pub fn visible_len(s: &str) -> usize {
650    let stripped = strip_ansi(s);
651    UnicodeWidthStr::width(stripped.as_str())
652}
653
654/// Truncate a string to a visible width, preserving ANSI escape sequences.
655pub fn truncate_visible(s: &str, width: usize) -> String {
656    if width == 0 {
657        return String::new();
658    }
659    if visible_len(s) <= width {
660        return s.to_string();
661    }
662    if width == 1 {
663        return "…".to_string();
664    }
665
666    let target = width - 1;
667    let mut out = String::new();
668    let mut used = 0;
669    let mut saw_sgr = false;
670    let mut hyperlink_open = false;
671    let mut index = 0usize;
672
673    while index < s.len() {
674        if let Some(end) = ansi_escape_sequence_end(s, index) {
675            let sequence = &s[index..end];
676            out.push_str(sequence);
677            if sequence.starts_with("\x1b[") {
678                saw_sgr = true;
679            }
680            if let Some(target) = osc8_link_target(sequence) {
681                hyperlink_open = !target.is_empty();
682            }
683            index = end;
684            continue;
685        }
686
687        let Some((end, cw)) = next_display_cell_boundary(s, index) else {
688            break;
689        };
690        if used + cw > target {
691            break;
692        }
693        out.push_str(&s[index..end]);
694        used += cw;
695        index = end;
696    }
697
698    out.push('…');
699    if saw_sgr {
700        out.push_str("\x1b[0m");
701    }
702    if hyperlink_open {
703        out.push_str("\x1b]8;;\x1b\\");
704    }
705    out
706}
707
708/// Pad a string with spaces to `width` display columns, preserving ANSI codes.
709///
710/// If `s` is already at least `width` columns wide, it is returned unchanged.
711pub fn pad_visible(s: &str, width: usize) -> String {
712    let len = visible_len(s);
713    if len >= width {
714        s.to_string()
715    } else {
716        format!("{s}{}", " ".repeat(width - len))
717    }
718}
719
720/// Repeat a character until the output fills `width` display columns.
721///
722/// If the character is zero-width, or if a wide character would straddle the
723/// final column, spaces are used for the remaining columns.
724pub fn repeat_visible_char(ch: char, width: usize) -> String {
725    let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
726    if width == 0 {
727        return String::new();
728    }
729    if char_width == 0 {
730        return " ".repeat(width);
731    }
732
733    let count = width / char_width;
734    let remainder = width % char_width;
735    format!("{}{}", ch.to_string().repeat(count), " ".repeat(remainder))
736}
737
738/// Repeat a string pattern until the output fills `width` display columns.
739///
740/// ANSI escape sequences are stripped from the pattern before repeating. If the
741/// pattern is zero-width, or if a wide glyph would straddle the final column,
742/// spaces are used for the remaining columns.
743pub fn repeat_visible(pattern: &str, width: usize) -> String {
744    if width == 0 {
745        return String::new();
746    }
747
748    let pattern = strip_ansi(pattern);
749    if UnicodeWidthStr::width(pattern.as_str()) == 0 {
750        return " ".repeat(width);
751    }
752
753    let mut out = String::new();
754    let mut used = 0usize;
755    while used < width {
756        let pass_start = used;
757        let mut index = 0usize;
758        while used < width {
759            let Some((end, cell_width)) = next_display_cell_boundary(&pattern, index) else {
760                break;
761            };
762            let cell = &pattern[index..end];
763            index = end;
764
765            if cell_width == 0 {
766                if !out.is_empty() {
767                    out.push_str(cell);
768                }
769                continue;
770            }
771            if used + cell_width > width {
772                out.push_str(&" ".repeat(width - used));
773                return out;
774            }
775
776            out.push_str(cell);
777            used += cell_width;
778        }
779
780        if used == pass_start {
781            out.push_str(&" ".repeat(width - used));
782            return out;
783        }
784    }
785
786    out
787}
788
789/// Truncate a string to `width` display columns, then pad it to exactly `width`.
790pub fn fit_visible(s: &str, width: usize) -> String {
791    pad_visible(&truncate_visible(s, width), width)
792}
793
794/// Right-align a string in `width` display columns, truncating first if needed.
795pub fn right_visible(s: &str, width: usize) -> String {
796    let truncated = truncate_visible(s, width);
797    let len = visible_len(&truncated);
798    if len >= width {
799        truncated
800    } else {
801        format!("{}{truncated}", " ".repeat(width - len))
802    }
803}
804
805/// Center a string in `width` display columns, truncating first if needed.
806pub fn center_visible(s: &str, width: usize) -> String {
807    let truncated = truncate_visible(s, width);
808    let len = visible_len(&truncated);
809    if len >= width {
810        return truncated;
811    }
812    let left = (width - len) / 2;
813    let right = width - len - left;
814    format!("{}{}{}", " ".repeat(left), truncated, " ".repeat(right))
815}
816
817/// Return the byte boundary and width of the next ANSI sequence or grapheme.
818pub(crate) fn next_display_cell_boundary(value: &str, start: usize) -> Option<(usize, usize)> {
819    if start >= value.len() {
820        return None;
821    }
822    if let Some(end) = ansi_escape_sequence_end(value, start) {
823        return Some((end, 0));
824    }
825
826    let grapheme = value[start..].graphemes(true).next()?;
827    Some((start + grapheme.len(), UnicodeWidthStr::width(grapheme)))
828}
829
830pub(crate) fn display_cell_char_span(chars: &[char], cursor: usize) -> (usize, usize) {
831    if cursor >= chars.len() {
832        return (cursor, cursor);
833    }
834
835    let mut start = cursor;
836    while start > 0 && UnicodeWidthChar::width(chars[start]).unwrap_or(0) == 0 {
837        start -= 1;
838    }
839
840    let mut end = start + 1;
841    while end < chars.len() && UnicodeWidthChar::width(chars[end]).unwrap_or(0) == 0 {
842        end += 1;
843    }
844    (start, end)
845}
846
847pub(crate) fn previous_display_cell_char_span(chars: &[char], cursor: usize) -> (usize, usize) {
848    if cursor == 0 || chars.is_empty() {
849        return (0, 0);
850    }
851
852    let start = cursor.saturating_sub(1).min(chars.len() - 1);
853    display_cell_char_span(chars, start)
854}
855
856pub(crate) fn ansi_escape_sequence_end(value: &str, start: usize) -> Option<usize> {
857    let bytes = value.as_bytes();
858    if bytes.get(start) != Some(&0x1b) {
859        return None;
860    }
861    match bytes.get(start + 1).copied() {
862        Some(b'[') => bytes[start + 2..]
863            .iter()
864            .position(|byte| (0x40..=0x7e).contains(byte))
865            .map(|offset| start + 3 + offset),
866        Some(b']') => {
867            let mut index = start + 2;
868            while index < bytes.len() {
869                if bytes[index] == 0x07 {
870                    return Some(index + 1);
871                }
872                if bytes[index] == 0x1b && bytes.get(index + 1) == Some(&b'\\') {
873                    return Some(index + 2);
874                }
875                index += 1;
876            }
877            None
878        }
879        Some(_) => Some((start + 2).min(bytes.len())),
880        None => None,
881    }
882}
883
884pub(crate) fn osc8_link_target(sequence: &str) -> Option<&str> {
885    let body = sequence.strip_prefix("\x1b]8;")?;
886    let body = body
887        .strip_suffix("\x1b\\")
888        .or_else(|| body.strip_suffix('\x07'))?;
889    let (_, target) = body.split_once(';')?;
890    Some(target)
891}
892
893/// Return the substring spanning display columns `[from, to)`.
894///
895/// ANSI escape sequences are stripped before slicing. Wide glyphs count by
896/// terminal display columns: a glyph that starts before `from` is dropped, while
897/// one that starts before `to` is kept even if it extends past `to`.
898pub fn slice_visible_cols(s: &str, from: usize, to: usize) -> String {
899    if from >= to {
900        return String::new();
901    }
902
903    let plain = strip_ansi(s);
904    let mut col = 0usize;
905    let mut out = String::new();
906    let mut index = 0usize;
907
908    while let Some((end, width)) = next_display_cell_boundary(&plain, index) {
909        let cell = &plain[index..end];
910        index = end;
911        if width == 0 {
912            if col >= to {
913                break;
914            }
915            if col >= from {
916                out.push_str(cell);
917            }
918            continue;
919        }
920
921        if col >= to {
922            break;
923        }
924
925        if col >= from {
926            out.push_str(cell);
927        }
928        col = col.saturating_add(width);
929    }
930
931    out
932}
933
934/// Wrap plain text on whitespace using display-column width.
935///
936/// This helper is intended for unstyled text. It preserves blank input lines;
937/// use [`wrap_words_compact`] when transient panels should collapse empty rows.
938pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
939    wrap_words_inner(text, width, false)
940}
941
942/// Split text rows while retaining a final empty row from a trailing newline.
943pub(crate) fn split_lines_preserving_trailing_blank(text: &str) -> Vec<&str> {
944    text.split('\n').collect()
945}
946
947/// Split non-empty text rows while retaining a final empty row from a trailing newline.
948pub(crate) fn split_nonempty_lines_preserving_trailing_blank(text: &str) -> Vec<&str> {
949    if text.is_empty() {
950        Vec::new()
951    } else {
952        split_lines_preserving_trailing_blank(text)
953    }
954}
955
956/// Wrap plain text on whitespace and drop blank input lines.
957pub fn wrap_words_compact(text: &str, width: usize) -> Vec<String> {
958    wrap_words_inner(text, width, true)
959}
960
961fn wrap_words_inner(text: &str, width: usize, compact: bool) -> Vec<String> {
962    if width == 0 {
963        return vec![text.to_string()];
964    }
965
966    let mut out = Vec::new();
967    for para in split_lines_preserving_trailing_blank(text) {
968        if para.trim().is_empty() {
969            if !compact {
970                out.push(String::new());
971            }
972            continue;
973        }
974
975        let mut line = String::new();
976        for word in para.split_whitespace() {
977            if line.is_empty() {
978                line.push_str(word);
979            } else if visible_len(&line) + 1 + visible_len(word) <= width {
980                line.push(' ');
981                line.push_str(word);
982            } else {
983                out.push(std::mem::take(&mut line));
984                line.push_str(word);
985            }
986
987            while visible_len(&line) > width {
988                let (head, rest) = split_visible_prefix(&line, width);
989                if head.is_empty() {
990                    break;
991                }
992                out.push(head);
993                line = rest;
994            }
995        }
996
997        if !line.is_empty() {
998            out.push(line);
999        }
1000    }
1001
1002    if out.is_empty() {
1003        out.push(String::new());
1004    }
1005    out
1006}
1007
1008fn split_visible_prefix(value: &str, width: usize) -> (String, String) {
1009    let mut head = String::new();
1010    let mut used = 0usize;
1011    let mut consumed_bytes = 0usize;
1012    let mut index = 0usize;
1013
1014    while let Some((end, ch_width)) = next_display_cell_boundary(value, index) {
1015        let cell = &value[index..end];
1016        if ch_width == 0 {
1017            head.push_str(cell);
1018            consumed_bytes = end;
1019            index = end;
1020            continue;
1021        }
1022
1023        if ch_width > width {
1024            if head.is_empty() {
1025                head = truncate_visible(cell, width);
1026                consumed_bytes = end;
1027            }
1028            break;
1029        }
1030        if used > 0 && used + ch_width > width {
1031            break;
1032        }
1033
1034        head.push_str(cell);
1035        consumed_bytes = end;
1036        index = end;
1037        used += ch_width;
1038        if used >= width {
1039            break;
1040        }
1041    }
1042
1043    (head, value[consumed_bytes..].to_string())
1044}
1045
1046/// Strip ANSI escape sequences from a string.
1047pub fn strip_ansi(s: &str) -> String {
1048    let mut out = String::with_capacity(s.len());
1049    let mut index = 0usize;
1050    while index < s.len() {
1051        if let Some(end) = ansi_escape_sequence_end(s, index) {
1052            index = end;
1053            continue;
1054        }
1055        let ch = s[index..].chars().next().unwrap_or_default();
1056        out.push(ch);
1057        index += ch.len_utf8();
1058    }
1059    out
1060}
1061
1062use std::str;
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067
1068    #[test]
1069    fn color_from_hex_valid() {
1070        assert_eq!(Color::from_hex("#ff0000"), Some(Color::Rgb(255, 0, 0)));
1071        assert_eq!(Color::from_hex("00ff00"), Some(Color::Rgb(0, 255, 0)));
1072        assert_eq!(Color::from_hex("#1e1e2e"), Some(Color::Rgb(30, 30, 46)));
1073    }
1074
1075    #[test]
1076    fn color_from_hex_invalid() {
1077        assert_eq!(Color::from_hex(""), None);
1078        assert_eq!(Color::from_hex("#fff"), None);
1079        assert_eq!(Color::from_hex("zzzzzz"), None);
1080    }
1081
1082    #[test]
1083    fn color_lighten() {
1084        let c = Color::Rgb(100, 100, 100);
1085        let lighter = c.lighten(0.5);
1086        match lighter {
1087            Color::Rgb(r, g, b) => {
1088                assert!(r > 100);
1089                assert!(g > 100);
1090                assert!(b > 100);
1091            }
1092            _ => panic!("expected Rgb"),
1093        }
1094    }
1095
1096    #[test]
1097    fn color_darken() {
1098        let c = Color::Rgb(200, 200, 200);
1099        let darker = c.darken(0.5);
1100        match darker {
1101            Color::Rgb(r, g, b) => {
1102                assert!(r < 200);
1103                assert!(g < 200);
1104                assert!(b < 200);
1105            }
1106            _ => panic!("expected Rgb"),
1107        }
1108    }
1109
1110    #[test]
1111    fn color_lighten_non_rgb_is_noop() {
1112        let c = Color::Red;
1113        assert_eq!(c.lighten(0.5), Color::Red);
1114    }
1115
1116    #[test]
1117    fn color_lighten_and_darken_ignore_non_finite_amounts() {
1118        let c = Color::Rgb(120, 80, 40);
1119
1120        assert_eq!(c.lighten(f64::NAN), c);
1121        assert_eq!(c.lighten(f64::INFINITY), c);
1122        assert_eq!(c.lighten(f64::NEG_INFINITY), c);
1123        assert_eq!(c.darken(f64::NAN), c);
1124        assert_eq!(c.darken(f64::INFINITY), c);
1125        assert_eq!(c.darken(f64::NEG_INFINITY), c);
1126    }
1127
1128    #[test]
1129    fn visible_len_plain() {
1130        assert_eq!(visible_len("hello"), 5);
1131        assert_eq!(visible_len(""), 0);
1132    }
1133
1134    #[test]
1135    fn visible_len_with_ansi() {
1136        assert_eq!(visible_len("\x1b[31mhello\x1b[0m"), 5);
1137        assert_eq!(visible_len("\x1b[1;32mtest\x1b[0m"), 4);
1138        assert_eq!(
1139            visible_len("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"),
1140            4
1141        );
1142    }
1143
1144    #[test]
1145    fn strip_ansi_removes_codes() {
1146        assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
1147        assert_eq!(
1148            strip_ansi("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"),
1149            "link"
1150        );
1151        assert_eq!(strip_ansi("plain"), "plain");
1152    }
1153
1154    #[test]
1155    fn color_gray() {
1156        assert_eq!(Color::gray(128), Color::Rgb(128, 128, 128));
1157        assert_eq!(Color::gray(0), Color::Rgb(0, 0, 0));
1158        assert_eq!(Color::gray(255), Color::Rgb(255, 255, 255));
1159    }
1160
1161    #[test]
1162    fn color_rgb_constructor() {
1163        assert_eq!(Color::rgb(10, 20, 30), Color::Rgb(10, 20, 30));
1164    }
1165
1166    #[test]
1167    fn color_ansi_constructor() {
1168        assert_eq!(Color::ansi(196), Color::Ansi256(196));
1169    }
1170
1171    #[test]
1172    fn pads_visible_width_with_ansi() {
1173        let styled = Style::new().fg(Color::Cyan).render("ok");
1174        let padded = pad_visible(&styled, 6);
1175
1176        assert_eq!(visible_len(&padded), 6);
1177        assert!(padded.starts_with("\x1b[36m"));
1178    }
1179
1180    #[test]
1181    fn repeats_visible_char_to_display_width() {
1182        assert_eq!(repeat_visible_char('·', 4), "····");
1183        assert_eq!(repeat_visible_char('界', 3), "界 ");
1184        assert_eq!(repeat_visible_char('\u{301}', 3), "   ");
1185        assert_eq!(visible_len(&repeat_visible_char('界', 5)), 5);
1186    }
1187
1188    #[test]
1189    fn repeats_visible_pattern_to_display_width() {
1190        assert_eq!(repeat_visible("ab", 5), "ababa");
1191        assert_eq!(repeat_visible("界", 5), "界界 ");
1192        assert_eq!(repeat_visible("\u{301}", 3), "   ");
1193        assert_eq!(repeat_visible("\x1b[31m-\x1b[0m", 3), "---");
1194        assert_eq!(visible_len(&repeat_visible("界a", 6)), 6);
1195    }
1196
1197    #[test]
1198    fn fits_visible_width_by_truncating_then_padding() {
1199        let out = fit_visible("一二三四五", 6);
1200
1201        assert!(visible_len(&out) <= 6, "{out:?}");
1202        assert!(out.ends_with('…') || out.ends_with(' '));
1203    }
1204
1205    #[test]
1206    fn truncate_visible_preserves_ansi_when_truncating() {
1207        let styled = Style::new().fg(Color::Red).render("abcdef");
1208        let out = truncate_visible(&styled, 4);
1209
1210        assert_eq!(visible_len(&out), 4);
1211        assert_eq!(strip_ansi(&out), "abc…");
1212        assert!(out.starts_with("\x1b[31m"));
1213        assert!(out.ends_with("\x1b[0m"));
1214    }
1215
1216    #[test]
1217    fn truncate_visible_closes_osc8_hyperlinks() {
1218        let linked = "\x1b]8;;https://example.com\x1b\\abcdef\x1b]8;;\x1b\\";
1219        let out = truncate_visible(linked, 4);
1220
1221        assert_eq!(visible_len(&out), 4);
1222        assert_eq!(strip_ansi(&out), "abc…");
1223        assert!(out.ends_with("\x1b]8;;\x1b\\"), "{out:?}");
1224    }
1225
1226    #[test]
1227    fn truncate_visible_skips_ansi_reset_between_segments() {
1228        let styled_prefix = Style::new().fg(Color::Green).render("ok");
1229        let out = truncate_visible(&format!("{styled_prefix}abcdef"), 5);
1230
1231        assert_eq!(visible_len(&out), 5);
1232        assert_eq!(strip_ansi(&out), "okab…");
1233        assert!(out.contains("\x1b[0mab"));
1234    }
1235
1236    #[test]
1237    fn truncate_visible_keeps_zero_width_marks_with_base_glyph() {
1238        let out = truncate_visible("e\u{301}xyz", 2);
1239
1240        assert_eq!(out, "e\u{301}…");
1241        assert_eq!(visible_len(&out), 2);
1242    }
1243
1244    #[test]
1245    fn truncate_visible_keeps_emoji_presentation_sequences_within_width() {
1246        let out = truncate_visible("✏️abc", 3);
1247
1248        assert_eq!(out, "✏️…");
1249        assert_eq!(visible_len(&out), 3);
1250    }
1251
1252    #[test]
1253    fn truncate_visible_does_not_split_emoji_zwj_graphemes() {
1254        let out = truncate_visible("👩‍💻abc", 3);
1255
1256        assert_eq!(out, "👩‍💻…");
1257        assert_eq!(visible_len(&out), 3);
1258    }
1259
1260    #[test]
1261    fn render_border_with_padding() {
1262        let out = Style::new()
1263            .border(Border::Rounded)
1264            .padding(1, 1)
1265            .render("x");
1266
1267        assert_eq!(
1268            out.lines().collect::<Vec<_>>(),
1269            vec!["╭───╮", "│   │", "│ x │", "│   │", "╰───╯"]
1270        );
1271    }
1272
1273    #[test]
1274    fn render_border_preserves_trailing_blank_content_row() {
1275        let out = Style::new().border(Border::Rounded).render("x\n");
1276
1277        assert_eq!(
1278            out.lines().collect::<Vec<_>>(),
1279            vec!["╭─╮", "│x│", "│ │", "╰─╯"]
1280        );
1281    }
1282
1283    #[test]
1284    fn render_bordered_content_uses_border_style_for_vertical_edges() {
1285        let out = Style::new()
1286            .fg(Color::Red)
1287            .border(Border::Rounded)
1288            .border_fg(Color::Cyan)
1289            .render("x");
1290        let lines = out.lines().collect::<Vec<_>>();
1291
1292        assert_eq!(lines[0], "\x1b[36m╭─╮\x1b[0m");
1293        assert_eq!(lines[1], "\x1b[36m│\x1b[0m\x1b[31mx\x1b[0m\x1b[36m│\x1b[0m");
1294        assert_eq!(lines[2], "\x1b[36m╰─╯\x1b[0m");
1295        assert_eq!(strip_ansi(lines[1]), "│x│");
1296        assert_eq!(visible_len(lines[1]), 3);
1297    }
1298
1299    #[test]
1300    fn render_bordered_cells_inherit_background_style() {
1301        let out = Style::new()
1302            .fg(Color::Red)
1303            .bg(Color::Blue)
1304            .border(Border::Rounded)
1305            .border_fg(Color::Cyan)
1306            .render("x");
1307        let lines = out.lines().collect::<Vec<_>>();
1308
1309        assert_eq!(lines[0], "\x1b[36;44m╭─╮\x1b[0m");
1310        assert_eq!(
1311            lines[1],
1312            "\x1b[36;44m│\x1b[0m\x1b[31;44mx\x1b[0m\x1b[36;44m│\x1b[0m"
1313        );
1314        assert_eq!(lines[2], "\x1b[36;44m╰─╯\x1b[0m");
1315        assert!(lines.iter().all(|line| visible_len(line) == 3));
1316    }
1317
1318    #[test]
1319    fn render_bordered_padding_uses_text_style_inside_vertical_edges() {
1320        let out = Style::new()
1321            .bg(Color::Blue)
1322            .border(Border::Rounded)
1323            .border_fg(Color::Cyan)
1324            .padding(1, 1)
1325            .render("x");
1326        let lines = out.lines().collect::<Vec<_>>();
1327
1328        assert_eq!(lines[0], "\x1b[36;44m╭───╮\x1b[0m");
1329        assert_eq!(
1330            lines[1],
1331            "\x1b[36;44m│\x1b[0m\x1b[44m   \x1b[0m\x1b[36;44m│\x1b[0m"
1332        );
1333        assert_eq!(strip_ansi(lines[1]), "│   │");
1334        assert_eq!(visible_len(lines[1]), 5);
1335    }
1336
1337    #[test]
1338    fn render_unbordered_padding_uses_text_style() {
1339        let out = Style::new()
1340            .bg(Color::Blue)
1341            .border_fg(Color::Cyan)
1342            .padding(1, 0)
1343            .render("x");
1344        let lines = out.lines().collect::<Vec<_>>();
1345
1346        assert_eq!(
1347            lines,
1348            vec!["\x1b[44m \x1b[0m", "\x1b[44mx\x1b[0m", "\x1b[44m \x1b[0m"]
1349        );
1350        assert!(lines.iter().all(|line| visible_len(line) == 1));
1351    }
1352
1353    #[test]
1354    fn render_empty_styled_text_stays_empty() {
1355        let out = Style::new().fg(Color::Red).bold().render("");
1356
1357        assert_eq!(out, "");
1358    }
1359
1360    #[test]
1361    fn centers_visible_width_with_ansi_and_cjk() {
1362        let styled = Style::new().fg(Color::Green).render("中");
1363        let centered = center_visible(&styled, 6);
1364
1365        assert_eq!(visible_len(&centered), 6);
1366        assert!(strip_ansi(&centered).starts_with("  中"));
1367    }
1368
1369    #[test]
1370    fn right_aligns_visible_width_with_ansi_and_cjk() {
1371        let styled = Style::new().fg(Color::Green).render("中");
1372        let right = right_visible(&styled, 6);
1373
1374        assert_eq!(visible_len(&right), 6);
1375        assert!(strip_ansi(&right).starts_with("    中"));
1376    }
1377
1378    #[test]
1379    fn render_aligns_text_by_visible_width() {
1380        let styled = Style::new().fg(Color::Red).render("中");
1381        let right = Style::new().width(6).align(Align::Right).render(&styled);
1382        let center = Style::new().width(7).align(Align::Center).render("中");
1383
1384        assert_eq!(visible_len(&right), 6);
1385        assert_eq!(strip_ansi(&right), "    中");
1386        assert!(right.ends_with("\x1b[0m"));
1387        assert_eq!(visible_len(&center), 7);
1388        assert_eq!(center, "  中   ");
1389    }
1390
1391    #[test]
1392    fn render_width_truncates_long_content_by_visible_width() {
1393        let out = Style::new().width(4).render("abcdef");
1394        let cjk = Style::new().width(5).render("中文测试");
1395
1396        assert_eq!(visible_len(&out), 4);
1397        assert_eq!(out, "abc…");
1398        assert_eq!(visible_len(&cjk), 5);
1399        assert_eq!(cjk, "中文…");
1400    }
1401
1402    #[test]
1403    fn render_bordered_width_truncates_inner_content() {
1404        let out = Style::new()
1405            .width(6)
1406            .border(Border::Rounded)
1407            .render("abcdef");
1408        let lines = out.lines().collect::<Vec<_>>();
1409
1410        assert_eq!(lines, vec!["╭────╮", "│abc…│", "╰────╯"]);
1411        assert!(lines.iter().all(|line| visible_len(line) == 6));
1412    }
1413
1414    #[test]
1415    fn render_width_includes_right_margin() {
1416        let out = Style::new()
1417            .width(8)
1418            .margin_left(1)
1419            .margin_right(2)
1420            .render("abcd");
1421
1422        assert_eq!(visible_len(&out), 8);
1423        assert_eq!(out, " abcd   ");
1424    }
1425
1426    #[test]
1427    fn render_bordered_width_includes_right_margin() {
1428        let out = Style::new()
1429            .width(8)
1430            .margin_left(1)
1431            .margin_right(1)
1432            .border(Border::Rounded)
1433            .render("abcdef");
1434        let lines = out.lines().collect::<Vec<_>>();
1435
1436        assert_eq!(lines, vec![" ╭────╮ ", " │abc…│ ", " ╰────╯ "]);
1437        assert!(lines.iter().all(|line| visible_len(line) == 8));
1438    }
1439
1440    #[test]
1441    fn render_height_truncates_and_pads_content_rows() {
1442        let truncated = Style::new().width(4).height(2).render("one\ntwo\nthree");
1443        let padded = Style::new().width(4).height(3).render("one");
1444
1445        assert_eq!(truncated.lines().collect::<Vec<_>>(), vec!["one ", "two "]);
1446        assert_eq!(
1447            padded.lines().collect::<Vec<_>>(),
1448            vec!["one ", "    ", "    "]
1449        );
1450    }
1451
1452    #[test]
1453    fn render_bordered_height_limits_inner_content() {
1454        let out = Style::new()
1455            .width(6)
1456            .height(3)
1457            .border(Border::Rounded)
1458            .render("one\ntwo");
1459        let lines = out.lines().collect::<Vec<_>>();
1460
1461        assert_eq!(lines, vec!["╭────╮", "│one │", "╰────╯"]);
1462        assert_eq!(lines.len(), 3);
1463    }
1464
1465    #[test]
1466    fn render_height_includes_vertical_spacing() {
1467        let out = Style::new()
1468            .width(6)
1469            .height(6)
1470            .margin_top(1)
1471            .margin_bottom(1)
1472            .padding(1, 1)
1473            .render("x\ny");
1474        let lines = out.split('\n').collect::<Vec<_>>();
1475
1476        assert_eq!(lines, vec!["", "      ", " x    ", " y    ", "      ", ""]);
1477        assert_eq!(lines.len(), 6);
1478    }
1479
1480    #[test]
1481    fn slices_visible_columns_for_ascii_cjk_and_ansi() {
1482        assert_eq!(slice_visible_cols("hello", 1, 4), "ell");
1483        assert_eq!(slice_visible_cols("hello", 0, 100), "hello");
1484        assert_eq!(slice_visible_cols("你好", 0, 2), "你");
1485        assert_eq!(slice_visible_cols("你好", 2, 4), "好");
1486
1487        let styled = Style::new().fg(Color::Red).render("hello");
1488        assert_eq!(slice_visible_cols(&styled, 1, 4), "ell");
1489    }
1490
1491    #[test]
1492    fn slices_visible_columns_drop_glyph_straddling_start() {
1493        assert_eq!(slice_visible_cols("你好", 1, 4), "好");
1494        assert_eq!(slice_visible_cols("你好", 0, 3), "你好");
1495        assert_eq!(slice_visible_cols("hello", 3, 3), "");
1496    }
1497
1498    #[test]
1499    fn slices_visible_columns_keep_zero_width_marks_with_base_glyph() {
1500        assert_eq!(slice_visible_cols("e\u{301}x", 0, 1), "e\u{301}");
1501        assert_eq!(slice_visible_cols("e\u{301}x", 1, 2), "x");
1502    }
1503
1504    #[test]
1505    fn display_cell_char_span_keeps_zero_width_marks_with_base_glyph() {
1506        let chars = "e\u{301}x".chars().collect::<Vec<_>>();
1507
1508        assert_eq!(display_cell_char_span(&chars, 0), (0, 2));
1509        assert_eq!(display_cell_char_span(&chars, 1), (0, 2));
1510        assert_eq!(display_cell_char_span(&chars, 2), (2, 3));
1511        assert_eq!(display_cell_char_span(&chars, 3), (3, 3));
1512    }
1513
1514    #[test]
1515    fn previous_display_cell_char_span_keeps_zero_width_marks_with_base_glyph() {
1516        let chars = "e\u{301}x".chars().collect::<Vec<_>>();
1517
1518        assert_eq!(previous_display_cell_char_span(&chars, 0), (0, 0));
1519        assert_eq!(previous_display_cell_char_span(&chars, 2), (0, 2));
1520        assert_eq!(previous_display_cell_char_span(&chars, 3), (2, 3));
1521    }
1522
1523    #[test]
1524    fn wraps_words_on_display_columns() {
1525        let lines = wrap_words("the quick brown fox jumps", 9);
1526
1527        assert!(lines.iter().all(|line| visible_len(line) <= 9));
1528        assert_eq!(
1529            lines.join(" ").split_whitespace().collect::<Vec<_>>(),
1530            vec!["the", "quick", "brown", "fox", "jumps"]
1531        );
1532    }
1533
1534    #[test]
1535    fn wrap_words_preserves_blank_lines() {
1536        let lines = wrap_words("alpha\n\nbeta", 40);
1537
1538        assert_eq!(lines, vec!["alpha", "", "beta"]);
1539    }
1540
1541    #[test]
1542    fn wrap_words_preserves_trailing_blank_lines() {
1543        let lines = wrap_words("alpha\n", 40);
1544
1545        assert_eq!(lines, vec!["alpha", ""]);
1546    }
1547
1548    #[test]
1549    fn split_lines_preserves_trailing_blank_line() {
1550        assert_eq!(
1551            split_lines_preserving_trailing_blank("alpha\n"),
1552            vec!["alpha", ""]
1553        );
1554        assert_eq!(split_lines_preserving_trailing_blank(""), vec![""]);
1555    }
1556
1557    #[test]
1558    fn split_nonempty_lines_preserves_trailing_blank_line() {
1559        assert_eq!(
1560            split_nonempty_lines_preserving_trailing_blank(""),
1561            Vec::<&str>::new()
1562        );
1563        assert_eq!(
1564            split_nonempty_lines_preserving_trailing_blank("alpha\n"),
1565            vec!["alpha", ""]
1566        );
1567    }
1568
1569    #[test]
1570    fn compact_wrap_words_drops_blank_lines() {
1571        let lines = wrap_words_compact("alpha\n\nbeta", 40);
1572
1573        assert_eq!(lines, vec!["alpha", "beta"]);
1574    }
1575
1576    #[test]
1577    fn wrap_words_hard_breaks_wide_tokens_by_columns() {
1578        let lines = wrap_words_compact("中文测试内容", 8);
1579
1580        assert!(lines.iter().all(|line| visible_len(line) <= 8));
1581        assert_eq!(lines.concat(), "中文测试内容");
1582    }
1583
1584    #[test]
1585    fn wrap_words_hard_break_keeps_zero_width_marks_with_base_glyph() {
1586        let lines = wrap_words_compact("e\u{301}e\u{301}e", 1);
1587
1588        assert!(lines.iter().all(|line| visible_len(line) <= 1));
1589        assert_eq!(lines, vec!["e\u{301}", "e\u{301}", "e"]);
1590    }
1591
1592    #[test]
1593    fn wrap_words_hard_break_packs_zero_width_marks_by_display_width() {
1594        let lines = wrap_words_compact("e\u{301}e\u{301}e", 2);
1595
1596        assert!(lines.iter().all(|line| visible_len(line) <= 2));
1597        assert_eq!(lines, vec!["e\u{301}e\u{301}", "e"]);
1598    }
1599
1600    #[test]
1601    fn wrap_words_clips_wide_glyphs_at_single_column_width() {
1602        let lines = wrap_words_compact("中文", 1);
1603
1604        assert!(lines.iter().all(|line| visible_len(line) <= 1));
1605    }
1606}