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}