Skip to main content

lipgloss/
lib.rs

1#![forbid(unsafe_code)]
2// Allow these clippy lints for API ergonomics and terminal UI code
3#![allow(clippy::must_use_candidate)]
4#![allow(clippy::doc_markdown)]
5#![allow(clippy::missing_panics_doc)]
6#![allow(clippy::use_self)]
7#![allow(clippy::return_self_not_must_use)]
8#![allow(clippy::missing_const_for_fn)]
9#![allow(clippy::cast_lossless)]
10#![allow(clippy::cast_possible_truncation)]
11#![allow(clippy::cast_sign_loss)]
12#![allow(clippy::cast_precision_loss)]
13#![allow(clippy::struct_field_names)]
14#![allow(clippy::struct_excessive_bools)]
15#![allow(clippy::enum_glob_use)]
16#![allow(clippy::match_like_matches_macro)]
17#![allow(clippy::redundant_closure)]
18#![allow(clippy::redundant_closure_for_method_calls)]
19#![allow(clippy::similar_names)]
20#![allow(clippy::too_many_lines)]
21#![allow(clippy::items_after_statements)]
22#![allow(clippy::module_name_repetitions)]
23#![allow(clippy::single_match_else)]
24#![allow(clippy::needless_pass_by_value)]
25#![allow(clippy::new_without_default)]
26#![allow(clippy::collapsible_if)]
27#![allow(clippy::missing_fields_in_debug)]
28#![allow(clippy::option_if_let_else)]
29#![allow(clippy::uninlined_format_args)]
30#![allow(clippy::manual_repeat_n)]
31#![allow(clippy::if_not_else)]
32#![allow(clippy::map_unwrap_or)]
33#![allow(clippy::same_item_push)]
34#![allow(clippy::bool_to_int_with_if)]
35#![allow(clippy::if_same_then_else)]
36#![allow(clippy::branches_sharing_code)]
37#![allow(clippy::items_after_test_module)]
38
39//! # Lipgloss
40//!
41//! A powerful terminal styling library for creating beautiful CLI applications.
42//!
43//! Lipgloss provides a declarative, CSS-like approach to terminal styling with support for:
44//! - **Colors**: ANSI, 256-color, true color, and adaptive colors
45//! - **Text formatting**: Bold, italic, underline, strikethrough, and more
46//! - **Layout**: Padding, margins, borders, and alignment
47//! - **Word wrapping** and **text truncation**
48//!
49//! ## Role in `charmed_rust`
50//!
51//! Lipgloss is the styling foundation for the entire stack:
52//! - **bubbletea** renders views using lipgloss styles.
53//! - **bubbles** components expose styling hooks via lipgloss.
54//! - **glamour** uses lipgloss for Markdown theming.
55//! - **charmed_log** formats log output with lipgloss styles.
56//! - **demo_showcase** centralizes themes and visual identity with lipgloss.
57//!
58//! ## Quick Start
59//!
60//! ```rust
61//! use lipgloss::{Style, Border, Position};
62//!
63//! // Create a styled box
64//! let style = Style::new()
65//!     .bold()
66//!     .foreground("#ff00ff")
67//!     .background("#1a1a1a")
68//!     .padding((1, 2))
69//!     .border(Border::rounded())
70//!     .align(Position::Center);
71//!
72//! println!("{}", style.render("Hello, Lipgloss!"));
73//! ```
74//!
75//! ## Style Builder
76//!
77//! Styles are built using a fluent API where each method returns a new style:
78//!
79//! ```rust
80//! use lipgloss::Style;
81//!
82//! let base = Style::new().bold();
83//! let red = base.clone().foreground("#ff0000");
84//! let blue = base.clone().foreground("#0000ff");
85//! ```
86//!
87//! ## Colors
88//!
89//! Multiple color formats are supported:
90//!
91//! ```rust
92//! use lipgloss::{Style, AdaptiveColor, Color};
93//!
94//! // Hex colors
95//! let style = Style::new().foreground("#ff00ff");
96//!
97//! // ANSI 256 colors
98//! let style = Style::new().foreground("196");
99//!
100//! // Adaptive colors (light/dark themes)
101//! let adaptive = AdaptiveColor {
102//!     light: Color::from("#000000"),
103//!     dark: Color::from("#ffffff"),
104//! };
105//! let style = Style::new().foreground_color(adaptive);
106//! ```
107//!
108//! ## Borders
109//!
110//! Several preset borders are available:
111//!
112//! ```rust
113//! use lipgloss::{Style, Border};
114//!
115//! let style = Style::new()
116//!     .border(Border::rounded())
117//!     .padding(1);
118//!
119//! // Available borders:
120//! // Border::normal()    ┌───┐
121//! // Border::rounded()   ╭───╮
122//! // Border::thick()     ┏━━━┓
123//! // Border::double()    ╔═══╗
124//! // Border::hidden()    (spaces)
125//! // Border::ascii()     +---+
126//! ```
127//!
128//! ## Layout
129//!
130//! CSS-like padding and margin with shorthand notation:
131//!
132//! ```rust
133//! use lipgloss::Style;
134//!
135//! // All sides
136//! let style = Style::new().padding(2);
137//!
138//! // Vertical, horizontal
139//! let style = Style::new().padding((1, 2));
140//!
141//! // Top, horizontal, bottom
142//! let style = Style::new().padding((1, 2, 3));
143//!
144//! // Top, right, bottom, left (clockwise)
145//! let style = Style::new().padding((1, 2, 3, 4));
146//! ```
147
148pub mod backend;
149pub mod border;
150pub mod color;
151pub mod position;
152pub mod renderer;
153pub mod style;
154pub mod theme;
155
156#[cfg(feature = "wasm")]
157pub mod wasm;
158
159// Re-exports
160pub use backend::{
161    AnsiBackend, DefaultBackend, HtmlBackend, OutputBackend, PlainBackend, default_backend,
162};
163pub use border::{Border, BorderEdges};
164pub use color::{
165    AdaptiveColor, AnsiColor, Color, ColorProfile, CompleteAdaptiveColor, CompleteColor, NoColor,
166    RgbColor, TerminalColor,
167};
168pub use position::{Position, Sides};
169pub use renderer::{Renderer, color_profile, default_renderer, has_dark_background};
170pub use style::{Style, truncate_line_ansi};
171#[cfg(feature = "tokio")]
172pub use theme::AsyncThemeContext;
173pub use theme::{
174    CachedThemedStyle, CatppuccinFlavor, ColorSlot, ColorTransform, ListenerId, Theme,
175    ThemeChangeListener, ThemeColors, ThemeContext, ThemePreset, ThemeRole, ThemedColor,
176    ThemedStyle, global_theme, set_global_preset, set_global_theme,
177};
178
179// WASM bindings (only available with the "wasm" feature)
180#[cfg(feature = "wasm")]
181pub use wasm::{
182    JsColor, JsStyle, join_horizontal as wasm_join_horizontal, join_vertical as wasm_join_vertical,
183    new_style as wasm_new_style, place as wasm_place,
184};
185
186/// Prelude module for convenient imports.
187pub mod prelude {
188    pub use crate::backend::{
189        AnsiBackend, DefaultBackend, HtmlBackend, OutputBackend, PlainBackend,
190    };
191    pub use crate::border::Border;
192    pub use crate::color::{AdaptiveColor, Color, ColorProfile, NoColor};
193    pub use crate::position::{Position, Sides};
194    pub use crate::renderer::Renderer;
195    pub use crate::style::Style;
196    #[cfg(feature = "tokio")]
197    pub use crate::theme::AsyncThemeContext;
198    pub use crate::theme::{
199        CachedThemedStyle, CatppuccinFlavor, ColorSlot, ColorTransform, ListenerId, Theme,
200        ThemeChangeListener, ThemeColors, ThemeContext, ThemePreset, ThemeRole, ThemedColor,
201        ThemedStyle, global_theme, set_global_preset, set_global_theme,
202    };
203    #[cfg(feature = "wasm")]
204    pub use crate::wasm::{JsColor, JsStyle};
205}
206
207// Convenience constructors
208
209/// Create a new empty style.
210///
211/// This is equivalent to `Style::new()`.
212pub fn new_style() -> Style {
213    Style::new()
214}
215
216// Join utilities
217
218/// Horizontally joins multi-line strings along a vertical axis.
219///
220/// The `pos` parameter controls vertical alignment of blocks:
221/// - `Position::Top` (0.0): Align to top
222/// - `Position::Center` (0.5): Center vertically
223/// - `Position::Bottom` (1.0): Align to bottom
224///
225/// # Example
226///
227/// ```rust
228/// use lipgloss::{join_horizontal, Position};
229///
230/// let left = "Line 1\nLine 2\nLine 3";
231/// let right = "A\nB";
232/// let combined = join_horizontal(Position::Top, &[left, right]);
233/// ```
234pub fn join_horizontal(pos: Position, strs: &[&str]) -> String {
235    if strs.is_empty() {
236        return String::new();
237    }
238    if strs.len() == 1 {
239        return strs[0].to_string();
240    }
241
242    // Split each string into lines and calculate dimensions
243    let blocks: Vec<Vec<&str>> = strs.iter().map(|s| s.lines().collect()).collect();
244    let widths: Vec<usize> = blocks
245        .iter()
246        .map(|lines| lines.iter().map(|l| visible_width(l)).max().unwrap_or(0))
247        .collect();
248    let max_height = blocks.iter().map(|lines| lines.len()).max().unwrap_or(0);
249
250    // Pre-compute alignment factor once
251    let factor = pos.factor();
252
253    // Pre-compute vertical offsets for each block (avoid per-row calculation)
254    // Use round() to match Go's lipgloss behavior for center alignment (bd-3vqi)
255    let offsets: Vec<usize> = blocks
256        .iter()
257        .map(|block| {
258            let extra = max_height.saturating_sub(block.len());
259            (extra as f64 * factor).round() as usize
260        })
261        .collect();
262
263    // Estimate total capacity: sum of widths * max_height + newlines
264    let total_width: usize = widths.iter().sum();
265    let estimated_capacity = max_height * (total_width + 1);
266    let mut result = String::with_capacity(estimated_capacity);
267
268    // Build result directly without intermediate Vec<String>
269    for row in 0..max_height {
270        if row > 0 {
271            result.push('\n');
272        }
273
274        for (block_idx, block) in blocks.iter().enumerate() {
275            let block_height = block.len();
276            let width = widths[block_idx];
277            let top_offset = offsets[block_idx];
278
279            // Determine which line from this block to use
280            let content = row
281                .checked_sub(top_offset)
282                .filter(|&br| br < block_height)
283                .map_or("", |br| block[br]);
284
285            // Pad to block width (avoid " ".repeat() allocation)
286            let content_width = visible_width(content);
287            let padding = width.saturating_sub(content_width);
288            result.push_str(content);
289            for _ in 0..padding {
290                result.push(' ');
291            }
292        }
293    }
294
295    result
296}
297
298/// Vertically joins multi-line strings along a horizontal axis.
299///
300/// The `pos` parameter controls horizontal alignment:
301/// - `Position::Left` (0.0): Align to left
302/// - `Position::Center` (0.5): Center horizontally
303/// - `Position::Right` (1.0): Align to right
304///
305/// # Example
306///
307/// ```rust
308/// use lipgloss::{join_vertical, Position};
309///
310/// let top = "Short";
311/// let bottom = "A longer line";
312/// let combined = join_vertical(Position::Center, &[top, bottom]);
313/// ```
314pub fn join_vertical(pos: Position, strs: &[&str]) -> String {
315    if strs.is_empty() {
316        return String::new();
317    }
318    if strs.len() == 1 {
319        return strs[0].to_string();
320    }
321
322    // Find the maximum width across all lines
323    let max_width = strs
324        .iter()
325        .flat_map(|s| s.lines())
326        .map(|l| visible_width(l))
327        .max()
328        .unwrap_or(0);
329
330    // Pre-compute alignment factor once
331    let factor = pos.factor();
332    let is_right_aligned = factor >= 1.0;
333
334    // Count total lines for capacity estimation (newlines + 1 per string, avoiding double iteration)
335    let line_count: usize = strs
336        .iter()
337        .map(|s| s.bytes().filter(|&b| b == b'\n').count() + 1)
338        .sum();
339    let estimated_capacity = line_count * (max_width + 1);
340    let mut result = String::with_capacity(estimated_capacity);
341
342    // Pad each line to max width based on position - single pass, no Vec<String>
343    let mut first = true;
344    for s in strs {
345        for line in s.lines() {
346            if !first {
347                result.push('\n');
348            }
349            first = false;
350
351            let line_width = visible_width(line);
352            let extra = max_width.saturating_sub(line_width);
353            // Use round() to match Go's lipgloss behavior for center alignment (bd-3vqi)
354            let left_pad = (extra as f64 * factor).round() as usize;
355            let right_pad = extra.saturating_sub(left_pad);
356
357            // Add left padding (avoid " ".repeat() allocation)
358            for _ in 0..left_pad {
359                result.push(' ');
360            }
361            result.push_str(line);
362
363            // Add right padding only if not right-aligned
364            if !is_right_aligned {
365                for _ in 0..right_pad {
366                    result.push(' ');
367                }
368            }
369        }
370    }
371
372    result
373}
374
375/// Calculate the visible width of a string, excluding ANSI escape sequences.
376///
377/// This is the canonical implementation used throughout lipgloss for measuring
378/// the display width of styled text. It properly handles:
379///
380/// - **SGR sequences** (e.g., `\x1b[31m` for red text)
381/// - **CSI sequences** (e.g., `\x1b[2J` for clear screen, `\x1b[10;20H` for cursor positioning)
382/// - **OSC sequences** (e.g., `\x1b]0;title\x07` for window titles)
383/// - **Simple escapes** (e.g., `\x1b7` for save cursor, `\x1b>` for keypad mode)
384/// - **Unicode width** (correctly handles wide characters like CJK and emoji)
385///
386/// # Examples
387///
388/// ```
389/// use lipgloss::visible_width;
390///
391/// // Plain ASCII text
392/// assert_eq!(visible_width("hello"), 5);
393///
394/// // Text with ANSI color codes (SGR)
395/// assert_eq!(visible_width("\x1b[31mred\x1b[0m"), 3);
396///
397/// // Text with cursor movement (CSI)
398/// assert_eq!(visible_width("\x1b[2Jcleared"), 7);
399///
400/// // Unicode wide characters (CJK)
401/// assert_eq!(visible_width("日本語"), 6);  // Each character is width 2
402///
403/// // Mixed content
404/// assert_eq!(visible_width("\x1b[1;32mHello 世界\x1b[0m"), 10);
405/// ```
406///
407/// # Performance
408///
409/// Includes a fast path for ASCII-only strings without escape sequences,
410/// which is the common case for most terminal text.
411#[inline]
412pub fn visible_width(s: &str) -> usize {
413    // Fast path: ASCII-only content without escapes (common case)
414    if s.is_ascii() && !s.contains('\x1b') {
415        return s.len();
416    }
417
418    fn grapheme_cluster_width(grapheme: &str) -> usize {
419        use unicode_width::UnicodeWidthChar;
420
421        // Keycap sequences (e.g., "1️⃣") are rendered as a single cell in Go's
422        // width calculations.
423        let chars: Vec<char> = grapheme.chars().collect();
424        if chars.len() == 2 || chars.len() == 3 {
425            if chars.last() == Some(&'\u{20e3}') {
426                let first_ok = matches!(chars[0], '0'..='9' | '#' | '*');
427                let mid_ok = chars.len() == 2 || chars.get(1) == Some(&'\u{fe0f}');
428                if first_ok && mid_ok {
429                    return 1;
430                }
431            }
432        }
433
434        // Flags are pairs of regional indicator symbols and occupy two cells.
435        if chars.len() == 2
436            && chars
437                .iter()
438                .all(|&c| ('\u{1f1e6}'..='\u{1f1ff}').contains(&c))
439        {
440            return 2;
441        }
442
443        let mut w = grapheme
444            .chars()
445            .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
446            .max()
447            .unwrap_or(0);
448
449        // Emoji variation selector 16 requests emoji presentation. Go's runewidth
450        // treats many VS16 sequences as double-width; match that behavior.
451        if grapheme.contains('\u{fe0f}') {
452            w = w.max(2);
453        }
454
455        w
456    }
457
458    fn text_width(text: &str) -> usize {
459        use unicode_segmentation::UnicodeSegmentation;
460
461        UnicodeSegmentation::graphemes(text, true)
462            .map(grapheme_cluster_width)
463            .sum()
464    }
465
466    // Full state machine for proper ANSI handling.
467    // Width is computed over grapheme clusters (not scalar values) to match
468    // Go's behavior for ZWJ emoji sequences, emoji modifiers, etc.
469    let mut width = 0;
470
471    #[derive(Clone, Copy)]
472    enum State {
473        Normal,
474        Esc,
475        Csi,
476        /// String-type sequence (OSC, DCS, SOS, PM, APC) — runs until BEL or ST.
477        Str,
478        /// Seen ESC while inside a string sequence — expecting `\` to complete ST.
479        StrEsc,
480    }
481
482    let mut state = State::Normal;
483    let mut segment_start = 0usize;
484
485    for (idx, c) in s.char_indices() {
486        match state {
487            State::Normal => {
488                if c == '\x1b' {
489                    width += text_width(&s[segment_start..idx]);
490                    state = State::Esc;
491                    segment_start = idx + c.len_utf8();
492                } else {
493                    // Defer counting until we have a full text segment.
494                }
495            }
496            State::Esc => {
497                match c {
498                    '[' => state = State::Csi,
499                    // OSC (]), DCS (P), SOS (X), PM (^), APC (_) are all
500                    // string-type sequences terminated by BEL or ST (ESC \).
501                    ']' | 'P' | 'X' | '^' | '_' => state = State::Str,
502                    _ => {
503                        // Simple escapes: single char after ESC (e.g., \x1b7 save cursor)
504                        state = State::Normal;
505                        segment_start = idx + c.len_utf8();
506                    }
507                }
508            }
509            State::Csi => {
510                // CSI sequence ends with final byte 0x40-0x7E (@ to ~)
511                if ('@'..='~').contains(&c) {
512                    state = State::Normal;
513                    segment_start = idx + c.len_utf8();
514                }
515            }
516            State::Str => {
517                // String sequence ends with BEL (\x07) or ST (ESC \)
518                if c == '\x07' {
519                    state = State::Normal;
520                    segment_start = idx + c.len_utf8();
521                } else if c == '\x1b' {
522                    state = State::StrEsc;
523                }
524                // All other characters are part of the payload, ignored for width
525            }
526            State::StrEsc => {
527                // We saw ESC while inside a string sequence.
528                if c == '\\' {
529                    // Valid ST terminator (ESC \) — sequence is properly closed.
530                    state = State::Normal;
531                    segment_start = idx + c.len_utf8();
532                } else if c == '[' {
533                    // Malformed sequence followed by a new CSI.
534                    state = State::Csi;
535                } else if c == ']' || c == 'P' || c == 'X' || c == '^' || c == '_' {
536                    // Malformed sequence followed by another string-type sequence.
537                    state = State::Str;
538                } else {
539                    // Unknown escape; recover to Normal.
540                    state = State::Normal;
541                    segment_start = idx + c.len_utf8();
542                }
543            }
544        }
545    }
546
547    if matches!(state, State::Normal) && segment_start <= s.len() {
548        width += text_width(&s[segment_start..]);
549    }
550
551    width
552}
553
554/// Get the width of the widest line in a string.
555pub fn width(s: &str) -> usize {
556    s.lines().map(|l| visible_width(l)).max().unwrap_or(0)
557}
558
559/// Get the number of lines in a string.
560pub fn height(s: &str) -> usize {
561    s.lines().count().max(1)
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_join_vertical_left_alignment() {
570        let result = join_vertical(Position::Left, &["Short", "LongerText"]);
571        println!("Result bytes: {:?}", result.as_bytes());
572        println!("Result repr: {:?}", result);
573        // Expected: "Short     \nLongerText" (Short with 5 trailing spaces)
574        assert_eq!(result, "Short     \nLongerText");
575    }
576
577    #[test]
578    fn test_join_vertical_center_alignment() {
579        let result = join_vertical(Position::Center, &["Short", "LongerText"]);
580        println!("Result bytes: {:?}", result.as_bytes());
581        println!("Result repr: {:?}", result);
582        // Go rounds for center alignment: round(5 * 0.5) = 3 left, 2 right (bd-3vqi)
583        let expected = "   Short  \nLongerText";
584        assert_eq!(result, expected);
585    }
586
587    // =========================================================================
588    // visible_width tests - comprehensive coverage of ANSI escape handling
589    // =========================================================================
590
591    #[test]
592    fn test_visible_width_plain_ascii() {
593        assert_eq!(visible_width("hello"), 5);
594        assert_eq!(visible_width(""), 0);
595        assert_eq!(visible_width(" "), 1);
596        assert_eq!(visible_width("hello world"), 11);
597    }
598
599    #[test]
600    fn test_visible_width_sgr_sequences() {
601        // Basic SGR: ESC[Nm where N is parameter
602        assert_eq!(visible_width("\x1b[31mred\x1b[0m"), 3);
603        assert_eq!(visible_width("\x1b[1mbold\x1b[0m"), 4);
604        assert_eq!(visible_width("\x1b[1;32mbold green\x1b[0m"), 10);
605
606        // Multiple SGR codes
607        assert_eq!(visible_width("\x1b[1m\x1b[31m\x1b[4mhello\x1b[0m"), 5);
608
609        // SGR with no visible content
610        assert_eq!(visible_width("\x1b[31m\x1b[0m"), 0);
611    }
612
613    #[test]
614    fn test_visible_width_csi_sequences() {
615        // Cursor movement: ESC[H (cursor home), ESC[2J (clear screen)
616        assert_eq!(visible_width("\x1b[Hstart"), 5);
617        assert_eq!(visible_width("\x1b[2Jcleared"), 7);
618
619        // Cursor positioning: ESC[10;20H
620        assert_eq!(visible_width("\x1b[10;20Htext"), 4);
621
622        // Erase in line: ESC[K
623        assert_eq!(visible_width("text\x1b[Kmore"), 8);
624
625        // Scroll: ESC[5S (scroll up 5)
626        assert_eq!(visible_width("\x1b[5Sscrolled"), 8);
627    }
628
629    #[test]
630    fn test_visible_width_osc_sequences() {
631        // Window title (terminated with BEL \x07)
632        assert_eq!(visible_width("\x1b]0;My Title\x07text"), 4);
633
634        // Window title (terminated with ST: ESC \)
635        assert_eq!(visible_width("\x1b]0;Title\x1b\\visible"), 7);
636
637        // OSC with no visible content
638        assert_eq!(visible_width("\x1b]0;title\x07"), 0);
639    }
640
641    #[test]
642    fn test_visible_width_osc_st_termination() {
643        // Valid ST terminator (ESC \) after OSC
644        assert_eq!(visible_width("\x1b]0;title\x1b\\text"), 4);
645
646        // BEL terminator
647        assert_eq!(visible_width("\x1b]0;title\x07text"), 4);
648
649        // OSC 8 hyperlink (BEL terminated)
650        assert_eq!(
651            visible_width("\x1b]8;;https://example.com\x07link\x1b]8;;\x07"),
652            4
653        );
654
655        // OSC 8 hyperlink (ST terminated)
656        assert_eq!(
657            visible_width("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"),
658            4
659        );
660
661        // Malformed: OSC followed immediately by CSI (no proper terminator)
662        assert_eq!(visible_width("\x1b]0;title\x1b[31mred\x1b[0m"), 3);
663
664        // Malformed: OSC followed by another OSC
665        assert_eq!(visible_width("\x1b]0;first\x1b]0;second\x07text"), 4);
666
667        // Truncated OSC at end of string (no terminator)
668        assert_eq!(visible_width("\x1b]0;title"), 0);
669
670        // Empty OSC with BEL
671        assert_eq!(visible_width("\x1b]\x07text"), 4);
672
673        // Empty OSC with ST
674        assert_eq!(visible_width("\x1b]\x1b\\text"), 4);
675
676        // OSC with ESC at end of string (incomplete ST)
677        assert_eq!(visible_width("\x1b]0;title\x1b"), 0);
678
679        // OSC then ESC followed by 'X' (SOS introducer) — enters a new
680        // string-type sequence per ECMA-48, so "visible" is payload, not visible.
681        assert_eq!(visible_width("\x1b]0;title\x1bXvisible"), 0);
682
683        // OSC then ESC followed by a non-sequence char — simple escape, back to Normal
684        assert_eq!(visible_width("\x1b]0;title\x1b7visible"), 7);
685    }
686
687    #[test]
688    fn test_visible_width_simple_escapes() {
689        // Save cursor: ESC 7
690        assert_eq!(visible_width("\x1b7text"), 4);
691
692        // Restore cursor: ESC 8
693        assert_eq!(visible_width("\x1b8text"), 4);
694
695        // Keypad mode: ESC > and ESC =
696        assert_eq!(visible_width("\x1b>text\x1b="), 4);
697    }
698
699    #[test]
700    fn test_visible_width_unicode() {
701        // CJK characters (width 2 each)
702        assert_eq!(visible_width("日本語"), 6);
703        assert_eq!(visible_width("中文"), 4);
704        assert_eq!(visible_width("한글"), 4);
705
706        // Emoji (typically width 2)
707        assert_eq!(visible_width("🦀"), 2);
708        assert_eq!(visible_width("🎉"), 2);
709        assert_eq!(visible_width("👋"), 2);
710    }
711
712    #[test]
713    fn test_visible_width_mixed_content() {
714        // ASCII + CJK
715        assert_eq!(visible_width("Hi日本"), 6); // 2 + 4
716
717        // ASCII + emoji
718        assert_eq!(visible_width("Hi 🦀!"), 6); // 2 + 1 + 2 + 1
719
720        // ANSI + Unicode
721        assert_eq!(visible_width("\x1b[31m日本\x1b[0m"), 4);
722        assert_eq!(visible_width("\x1b[1m🦀\x1b[0m"), 2);
723
724        // Complex mixed
725        assert_eq!(visible_width("\x1b[1;32mHello 世界\x1b[0m"), 10);
726    }
727
728    #[test]
729    fn test_visible_width_combining_chars() {
730        // e + combining acute accent = é (width 1)
731        let combining = "e\u{0301}";
732        assert_eq!(visible_width(combining), 1);
733
734        // Precomposed é (width 1)
735        let precomposed = "\u{00e9}";
736        assert_eq!(visible_width(precomposed), 1);
737    }
738
739    #[test]
740    fn test_visible_width_edge_cases() {
741        // Unterminated escape (escape at end)
742        assert_eq!(visible_width("text\x1b"), 4);
743
744        // Unterminated CSI
745        assert_eq!(visible_width("text\x1b[31"), 4);
746
747        // Double escape: second ESC acts as simple escape, then [31m is literal
748        // \x1b\x1b -> first ESC starts escape, second ESC is simple escape (back to normal)
749        // "[31m" is now literal text (width 4), then "red" (width 3) = 7
750        assert_eq!(visible_width("\x1b\x1b[31mred"), 7);
751
752        // Escape character itself has no width
753        assert_eq!(visible_width("\x1b"), 0);
754
755        // Empty CSI has no width (no final byte, but ESC[ consumed)
756        assert_eq!(visible_width("\x1b["), 0);
757    }
758
759    #[test]
760    fn test_visible_width_fast_path() {
761        // Pure ASCII without escapes uses fast path
762        let ascii = "The quick brown fox jumps over the lazy dog";
763        assert_eq!(visible_width(ascii), 43);
764
765        // Long ASCII string
766        let long_ascii = "x".repeat(1000);
767        assert_eq!(visible_width(&long_ascii), 1000);
768    }
769}
770
771/// Place a string at a position within a given width and height.
772///
773/// # Example
774///
775/// ```rust
776/// use lipgloss::{place, Position};
777///
778/// let text = "Hello";
779/// let placed = place(20, 5, Position::Center, Position::Center, text);
780/// ```
781pub fn place(width: usize, height: usize, h_pos: Position, v_pos: Position, s: &str) -> String {
782    let content_width = self::width(s);
783    let content_height = self::height(s);
784
785    // Horizontal padding - use floor() to match Go lipgloss Place() behavior
786    let h_extra = width.saturating_sub(content_width);
787    let left_pad = (h_extra as f64 * h_pos.factor()).floor() as usize;
788    let _right_pad = h_extra.saturating_sub(left_pad);
789
790    // Vertical padding - use floor() to match Go lipgloss Place() behavior
791    let v_extra = height.saturating_sub(content_height);
792    let top_pad = (v_extra as f64 * v_pos.factor()).floor() as usize;
793    let bottom_pad = v_extra.saturating_sub(top_pad);
794
795    // Pre-compute alignment factor once for content lines
796    let h_factor = h_pos.factor();
797
798    // Pre-allocate blank line once for reuse (avoids allocation per blank line)
799    let blank_line = " ".repeat(width);
800
801    // Pre-allocate result with estimated capacity: height lines * (width + newline)
802    let estimated_capacity = height * (width + 1);
803    let mut result = String::with_capacity(estimated_capacity);
804
805    // Top padding - reuse blank_line
806    for i in 0..top_pad {
807        if i > 0 {
808            result.push('\n');
809        }
810        result.push_str(&blank_line);
811    }
812
813    // Content with horizontal padding - single-pass, avoid format!
814    for (i, line) in s.lines().enumerate() {
815        if top_pad > 0 || i > 0 {
816            result.push('\n');
817        }
818
819        let line_width = visible_width(line);
820        let line_extra = width.saturating_sub(line_width);
821        // Use floor() to match Go lipgloss Place() behavior
822        let line_left = (line_extra as f64 * h_factor).floor() as usize;
823        let line_right = line_extra.saturating_sub(line_left);
824
825        // Use slices of blank_line for padding (no allocation)
826        result.push_str(&blank_line[..line_left]);
827        result.push_str(line);
828        result.push_str(&blank_line[..line_right]);
829    }
830
831    // Bottom padding - reuse blank_line
832    for _ in 0..bottom_pad {
833        result.push('\n');
834        result.push_str(&blank_line);
835    }
836
837    result
838}
839
840// =============================================================================
841// StyleRanges and Range
842// =============================================================================
843
844/// Range specifies a section of text with a start index, end index, and the Style to apply.
845///
846/// Used with [`style_ranges`] to apply different styles to different parts of a string.
847///
848/// # Example
849///
850/// ```rust
851/// use lipgloss::{Range, Style, style_ranges};
852///
853/// let style = Style::new().bold();
854/// let range = Range {
855///     start: 0,
856///     end: 5,
857///     style,
858/// };
859/// ```
860#[derive(Debug, Clone)]
861pub struct Range {
862    /// The starting index (inclusive, in bytes).
863    pub start: usize,
864    /// The ending index (exclusive, in bytes).
865    pub end: usize,
866    /// The Style to apply to this range.
867    pub style: Style,
868}
869
870impl Range {
871    /// Creates a new Range.
872    pub fn new(start: usize, end: usize, style: Style) -> Self {
873        Self { start, end, style }
874    }
875}
876
877/// Creates a new Range that can be used with [`style_ranges`].
878///
879/// # Arguments
880///
881/// * `start` - The starting index of the range (inclusive, in bytes)
882/// * `end` - The ending index of the range (exclusive, in bytes)
883/// * `style` - The Style to apply to this range
884///
885/// # Example
886///
887/// ```rust
888/// use lipgloss::{new_range, Style, style_ranges};
889///
890/// let styled = style_ranges(
891///     "Hello, World!",
892///     &[
893///         new_range(0, 5, Style::new().bold()),
894///         new_range(7, 12, Style::new().italic()),
895///     ],
896/// );
897/// ```
898pub fn new_range(start: usize, end: usize, style: Style) -> Range {
899    Range::new(start, end, style)
900}
901
902/// Applies styles to ranges in a string. Existing ANSI styles will be taken into account.
903/// Ranges should not overlap.
904///
905/// # Arguments
906///
907/// * `s` - The input string to style
908/// * `ranges` - A slice of Range objects specifying which parts of the string to style
909///
910/// # Returns
911///
912/// The styled string with each range having its specified style applied.
913///
914/// # Example
915///
916/// ```rust
917/// use lipgloss::{style_ranges, new_range, Style};
918///
919/// let styled = style_ranges(
920///     "Hello, World!",
921///     &[
922///         new_range(0, 5, Style::new().bold()),
923///         new_range(7, 12, Style::new().italic()),
924///     ],
925/// );
926/// ```
927pub fn style_ranges(s: &str, ranges: &[Range]) -> String {
928    if ranges.is_empty() {
929        return s.to_string();
930    }
931
932    // Sort ranges by start position
933    let mut sorted_ranges: Vec<_> = ranges.iter().collect();
934    sorted_ranges.sort_by_key(|r| r.start);
935
936    let bytes = s.as_bytes();
937    let mut result = String::new();
938    let mut current_pos = 0;
939
940    for range in sorted_ranges {
941        let start = range.start.min(bytes.len());
942        let end = range.end.min(bytes.len());
943
944        if start > current_pos {
945            // Add unstyled text between ranges
946            if let Ok(text) = std::str::from_utf8(&bytes[current_pos..start]) {
947                result.push_str(text);
948            }
949        }
950
951        if end > start {
952            // Apply style to this range
953            if let Ok(text) = std::str::from_utf8(&bytes[start..end]) {
954                result.push_str(&range.style.render(text));
955            }
956        }
957
958        current_pos = end.max(current_pos);
959    }
960
961    // Add remaining text after last range
962    if current_pos < bytes.len() {
963        if let Ok(text) = std::str::from_utf8(&bytes[current_pos..]) {
964            result.push_str(text);
965        }
966    }
967
968    result
969}
970
971/// Applies styles to runes at the given indices in the string.
972///
973/// You must provide styling options for both matched and unmatched runes.
974/// Indices out of bounds will be ignored.
975///
976/// # Arguments
977///
978/// * `s` - The input string to style
979/// * `indices` - Array of character indices indicating which runes to style
980/// * `matched` - The Style to apply to runes at the specified indices
981/// * `unmatched` - The Style to apply to all other runes
982///
983/// # Example
984///
985/// ```rust
986/// use lipgloss::{style_runes, Style};
987///
988/// let styled = style_runes(
989///     "Hello",
990///     &[0, 1, 2],
991///     Style::new().bold(),
992///     Style::new().faint(),
993/// );
994/// ```
995pub fn style_runes(s: &str, indices: &[usize], matched: Style, unmatched: Style) -> String {
996    use std::collections::HashSet;
997    let indices_set: HashSet<_> = indices.iter().copied().collect();
998
999    let mut result = String::new();
1000
1001    for (i, c) in s.chars().enumerate() {
1002        let char_str = c.to_string();
1003        if indices_set.contains(&i) {
1004            result.push_str(&matched.render(&char_str));
1005        } else {
1006            result.push_str(&unmatched.render(&char_str));
1007        }
1008    }
1009
1010    result
1011}
1012
1013#[cfg(test)]
1014mod escape_sequence_tests {
1015    use super::*;
1016
1017    #[test]
1018    fn test_width_with_terminal_escape_sequences() {
1019        // CSI DEC private modes (like hide cursor)
1020        assert_eq!(
1021            visible_width("\x1b[?25l"),
1022            0,
1023            "Hide cursor CSI should have 0 width"
1024        );
1025        assert_eq!(
1026            visible_width("\x1b[?1000h"),
1027            0,
1028            "Mouse mode CSI should have 0 width"
1029        );
1030
1031        // MoveTo and Clear
1032        assert_eq!(
1033            visible_width("\x1b[1;1H"),
1034            0,
1035            "MoveTo CSI should have 0 width"
1036        );
1037        assert_eq!(visible_width("\x1b[2J"), 0, "Clear CSI should have 0 width");
1038
1039        // OSC title
1040        assert_eq!(
1041            visible_width("\x1b]0;Title\x07"),
1042            0,
1043            "OSC title should have 0 width"
1044        );
1045        assert_eq!(
1046            visible_width("\x1b]0;Title\x1b\\"),
1047            0,
1048            "OSC title with ST should have 0 width"
1049        );
1050
1051        // Combined sequence like in PTY output
1052        let setup = "\x1b[?25l\x1b[?1000h\x1b[1;1H\x1b[2JLoading...";
1053        assert_eq!(
1054            visible_width(setup),
1055            10,
1056            "Setup + Loading... should be 10 chars"
1057        );
1058
1059        // With OSC title
1060        let with_title = "\x1b[?25l\x1b[1;1H\x1b[2JLoading...\x1b]0;Charmed\x07More";
1061        assert_eq!(
1062            visible_width(with_title),
1063            14,
1064            "With OSC should count Loading + More = 14"
1065        );
1066
1067        println!("All escape sequence width tests passed!");
1068    }
1069}