lipgloss 0.0.6

Style definitions for nice terminal layouts. The core of the lipgloss-rs library.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
//! Rendering utility methods for Style
//!
//! This module provides internal utility methods for the [`Style`] struct that handle
//! various text processing and rendering operations. These utilities are designed to
//! work with terminal output and handle ANSI escape sequences, Unicode characters,
//! and text layout operations.
//!
//! ## Key Features
//!
//! - **Tab Conversion**: Convert tabs to spaces based on configurable tab width
//! - **Color Parsing**: Parse hexadecimal color strings with support for RGB and RGBA formats
//! - **Text Truncation**: Truncate text by visible width or height while preserving ANSI sequences
//! - **Text Wrapping**: Hard wrap text with ANSI sequence awareness
//! - **Tokenization**: Split text on configurable breakpoint characters
//!
//! ## ANSI Sequence Handling
//!
//! Most utilities in this module are ANSI-aware, meaning they properly handle ANSI
//! escape sequences (like color codes) when calculating text width, wrapping, or
//! truncating text. This ensures that styling information is preserved during
//! text processing operations.
//!
//! ## Unicode Support
//!
//! Text width calculations use the `unicode-width` crate to properly handle
//! wide characters (like CJK characters) and zero-width characters (like
//! combining marks and emoji modifiers).

use crate::security::{safe_repeat, MAX_ANSI_SEQ_LEN};
use crate::style::{properties::*, Style};

#[allow(dead_code)]
impl Style {
    /// Convert tabs to spaces if tab width is set
    ///
    /// This method converts all tab characters (`\t`) in the input string to spaces
    /// based on the style's configured tab width. If no tab width is set or the
    /// tab width is zero or negative, the string is returned unchanged.
    ///
    /// # Arguments
    ///
    /// * `s` - The input string that may contain tab characters
    ///
    /// # Returns
    ///
    /// A new string with tab characters replaced by the appropriate number of spaces,
    /// or the original string if tab width is not configured
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use lipgloss::Style;
    ///
    /// let style = Style::new().tab_width(4);
    /// let result = style.maybe_convert_tabs("hello\tworld");
    /// assert_eq!(result, "hello    world"); // Tab replaced with 4 spaces
    ///
    /// // Without tab width set, tabs are preserved
    /// let style = Style::new();
    /// let result = style.maybe_convert_tabs("hello\tworld");
    /// assert_eq!(result, "hello\tworld"); // Tab unchanged
    /// ```
    pub fn maybe_convert_tabs(&self, s: &str) -> String {
        if !self.is_set(TAB_WIDTH_KEY) || self.tab_width <= 0 {
            return s.to_string();
        }

        let tab_spaces = safe_repeat(' ', self.tab_width as usize);
        s.replace('\t', &tab_spaces)
    }

    /// Truncate text to visible width while preserving ANSI escape sequences
    ///
    /// Truncates a single line of text to fit within the specified maximum visible width.
    /// This function is ANSI-aware, meaning it preserves all ANSI escape sequences
    /// (such as color codes) while only counting the visible characters toward the width limit.
    /// Unicode characters are properly handled using their display width.
    ///
    /// # Arguments
    ///
    /// * `s` - The input string to truncate
    /// * `maxw` - Maximum visible width in characters
    ///
    /// # Returns
    ///
    /// A truncated string that fits within `maxw` visible characters, with all
    /// ANSI escape sequences preserved
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use lipgloss::Style;
    ///
    /// // Basic truncation
    /// let result = Style::truncate_visible_line("Hello World", 5);
    /// assert_eq!(result, "Hello");
    ///
    /// // ANSI sequences are preserved and don't count toward width
    /// let colored = "\x1b[31mHello\x1b[0m World";
    /// let result = Style::truncate_visible_line(colored, 8);
    /// assert_eq!(result, "\x1b[31mHello\x1b[0m Wo");
    ///
    /// // Wide characters (like CJK) are handled correctly
    /// let result = Style::truncate_visible_line("你好世界", 4); // Each character is width 2
    /// assert_eq!(result, "你好");
    ///
    /// // Zero width returns empty string
    /// let result = Style::truncate_visible_line("Hello", 0);
    /// assert_eq!(result, "");
    /// ```
    pub fn truncate_visible_line(s: &str, maxw: usize) -> String {
        if maxw == 0 {
            return String::new();
        }

        let mut result = String::new();
        let mut width = 0;
        let mut chars = s.chars().peekable();

        while let Some(ch) = chars.next() {
            if ch == '\x1b' {
                // Preserve ANSI escape sequence
                result.push(ch);
                // Read until we find a terminating byte or hit safety cap
                let mut scanned = 0usize;
                for esc_ch in chars.by_ref() {
                    result.push(esc_ch);
                    scanned += 1;
                    // Break on SGR 'm', or on any CSI final byte (@ through ~) excluding '[',
                    // or if we exceed our maximum safe sequence length
                    if esc_ch == 'm'
                        || (esc_ch != '[' && ('@'..='~').contains(&esc_ch))
                        || scanned >= MAX_ANSI_SEQ_LEN
                    {
                        break;
                    }
                }
                continue;
            }

            let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
            if width + char_width > maxw {
                break;
            }

            result.push(ch);
            width += char_width;
        }

        result
    }

    /// Truncate text to maximum height
    ///
    /// Truncates multi-line text to fit within the style's configured maximum height.
    /// If the input text has fewer lines than the maximum height, it is returned unchanged.
    /// Otherwise, only the first `max_height` lines are kept.
    ///
    /// # Arguments
    ///
    /// * `s` - The input string which may contain multiple lines separated by '\n'
    ///
    /// # Returns
    ///
    /// A string containing at most `max_height` lines from the beginning of the input
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use lipgloss::Style;
    ///
    /// let style = Style::new().max_height(2);
    /// let text = "Line 1\nLine 2\nLine 3\nLine 4";
    /// let result = style.truncate_height(text);
    /// assert_eq!(result, "Line 1\nLine 2");
    ///
    /// // If text has fewer lines than max_height, it's unchanged
    /// let style = Style::new().max_height(5);
    /// let text = "Line 1\nLine 2";
    /// let result = style.truncate_height(text);
    /// assert_eq!(result, "Line 1\nLine 2");
    /// ```
    pub fn truncate_height(&self, s: &str) -> String {
        let lines: Vec<&str> = s.split('\n').collect();
        if lines.len() <= self.max_height as usize {
            return s.to_string();
        }

        lines[0..self.max_height as usize].join("\n")
    }

    /// Truncate each line to maximum width while preserving ANSI sequences
    ///
    /// Truncates each line of multi-line text to fit within the style's configured
    /// maximum width. This method processes each line independently using
    /// [`truncate_visible_line`], ensuring that ANSI escape sequences are preserved
    /// and Unicode characters are handled correctly.
    ///
    /// # Arguments
    ///
    /// * `s` - The input string which may contain multiple lines separated by '\n'
    ///
    /// # Returns
    ///
    /// A string where each line has been truncated to fit within `max_width` visible
    /// characters, with all ANSI sequences preserved
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use lipgloss::Style;
    ///
    /// let style = Style::new().max_width(5);
    /// let text = "Hello World\nThis is a long line\nShort";
    /// let result = style.truncate_width(text);
    /// assert_eq!(result, "Hello\nThis \nShort");
    ///
    /// // ANSI sequences are preserved on each line
    /// let style = Style::new().max_width(8);
    /// let colored = "\x1b[31mRed text here\x1b[0m\n\x1b[32mGreen text\x1b[0m";
    /// let result = style.truncate_width(colored);
    /// // Each line truncated while preserving color codes
    /// ```
    ///
    /// [`truncate_visible_line`]: Style::truncate_visible_line
    pub fn truncate_width(&self, s: &str) -> String {
        let lines: Vec<&str> = s.split('\n').collect();
        let truncated: Vec<String> = lines
            .iter()
            .map(|line| Self::truncate_visible_line(line, self.max_width as usize))
            .collect();
        truncated.join("\n")
    }

    pub fn word_wrap_ansi_aware(text: &str, width: usize) -> Vec<String> {
        if width == 0 {
            return vec![String::new()];
        }

        let mut lines = Vec::new();
        let tokens = Self::tokenize_with_breakpoints(text, &[' ']);
        if tokens.is_empty() {
            // If there are no tokens, return the original text as a single line
            // This handles empty or whitespace-only strings.
            return vec![text.to_string()];
        }

        let mut current_line = String::new();
        let mut current_width = 0;

        for token in tokens {
            let token_width = crate::width_visible(&token);

            if token_width > width {
                // Token is too long, must be hard-wrapped
                if !current_line.is_empty() {
                    lines.push(current_line);
                }
                let mut hard_wrapped = Self::hard_wrap_ansi_aware(&token, width);
                if !hard_wrapped.is_empty() {
                    current_line = hard_wrapped.pop().unwrap();
                    lines.extend(hard_wrapped);
                    current_width = crate::width_visible(&current_line);
                } else {
                    current_line = String::new();
                    current_width = 0;
                }
                continue;
            }

            if !current_line.is_empty() && current_width + token_width > width {
                lines.push(current_line);
                current_line = token;
                current_width = token_width;
            } else {
                current_line.push_str(&token);
                current_width += token_width;
            }
        }
        if !current_line.is_empty() {
            lines.push(current_line);
        }

        if lines.is_empty() {
            vec![String::new()]
        } else {
            lines
        }
    }

    ///
    /// # Notes
    ///
    /// This is a hard wrap function that breaks at character boundaries, not word
    /// boundaries. For word-aware wrapping, consider using a different approach.
    pub fn hard_wrap_ansi_aware(text: &str, width: usize) -> Vec<String> {
        if width == 0 {
            return vec![String::new()];
        }

        let mut lines = Vec::new();
        let mut current_line = String::new();
        let mut current_width = 0;
        let mut chars = text.chars().peekable();

        while let Some(ch) = chars.next() {
            if ch == '\x1b' {
                // Preserve ANSI escape sequence
                current_line.push(ch);
                // Read until we find a terminating byte or hit safety cap
                let mut scanned = 0usize;
                for esc_ch in chars.by_ref() {
                    current_line.push(esc_ch);
                    scanned += 1;
                    if esc_ch == 'm'
                        || (esc_ch != '[' && ('@'..='~').contains(&esc_ch))
                        || scanned >= MAX_ANSI_SEQ_LEN
                    {
                        break;
                    }
                }
                continue;
            }

            let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);

            if current_width + char_width > width && current_width > 0 {
                // Wrap to new line
                lines.push(current_line);
                current_line = String::new();
                current_width = 0;
            }

            current_line.push(ch);
            current_width += char_width;
        }

        if !current_line.is_empty() {
            lines.push(current_line);
        }

        if lines.is_empty() {
            vec![String::new()]
        } else {
            lines
        }
    }

    /// Tokenize text with configurable breakpoint characters while preserving ANSI sequences
    ///
    /// Splits text into tokens using the specified breakpoint characters. ANSI escape
    /// sequences are preserved within tokens and do not cause tokenization. Each
    /// breakpoint character becomes its own token, and non-empty sequences between
    /// breakpoints become separate tokens.
    ///
    /// # Arguments
    ///
    /// * `s` - The input string to tokenize
    /// * `break_chars` - A slice of characters that should trigger tokenization
    ///
    /// # Returns
    ///
    /// A vector of string tokens, where each token is either:
    /// - A sequence of non-breakpoint characters (may include ANSI sequences)
    /// - A single breakpoint character
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use lipgloss::Style;
    ///
    /// // Basic tokenization on spaces and commas
    /// let result = Style::tokenize_with_breakpoints("hello, world test", &[' ', ',']);
    /// assert_eq!(result, vec!["hello", ",", " ", "world", " ", "test"]);
    ///
    /// // ANSI sequences are preserved within tokens
    /// let colored = "\x1b[31mred\x1b[0m text";
    /// let result = Style::tokenize_with_breakpoints(colored, &[' ']);
    /// assert_eq!(result, vec!["\x1b[31mred\x1b[0m", " ", "text"]);
    ///
    /// // Multiple consecutive breakpoints create separate tokens
    /// let result = Style::tokenize_with_breakpoints("a,,b", &[',']);
    /// assert_eq!(result, vec!["a", ",", ",", "b"]);
    ///
    /// // Empty input returns empty vector
    /// let result = Style::tokenize_with_breakpoints("", &[' ']);
    /// assert_eq!(result, Vec::<String>::new());
    /// ```
    ///
    /// # Notes
    ///
    /// This function is useful for implementing word wrapping or other text layout
    /// algorithms that need to respect certain character boundaries while preserving
    /// text styling.
    pub fn tokenize_with_breakpoints(s: &str, break_chars: &[char]) -> Vec<String> {
        let mut tokens: Vec<String> = Vec::new();
        let mut current = String::new();
        let mut chars = s.chars().peekable();

        while let Some(ch) = chars.next() {
            if ch == '\x1b' {
                // Preserve ANSI escape sequence
                current.push(ch);
                // Read until we find a terminating byte or hit safety cap
                let mut scanned = 0usize;
                for esc_ch in chars.by_ref() {
                    current.push(esc_ch);
                    scanned += 1;
                    if esc_ch == 'm'
                        || (esc_ch != '[' && ('@'..='~').contains(&esc_ch))
                        || scanned >= MAX_ANSI_SEQ_LEN
                    {
                        break;
                    }
                }
            } else if break_chars.contains(&ch) {
                // Breakpoint character - end current token and create new token for break char
                if !current.is_empty() {
                    tokens.push(current);
                    current = String::new();
                }
                tokens.push(ch.to_string());
            } else {
                current.push(ch);
            }
        }

        if !current.is_empty() {
            tokens.push(current);
        }

        tokens
    }
}