lipgloss 0.1.1

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
435
436
437
438
439
440
use lipgloss::style::Style;

fn show_escapes(s: &str) -> String {
    s.replace("\x1b", "<ESC>")
}

/// Helper function to detect if we're in a no-color environment (like CI)
fn is_no_color_environment() -> bool {
    use lipgloss::renderer::{default_renderer, ColorProfileKind};
    default_renderer().color_profile() == ColorProfileKind::NoColor
}

#[test]
fn border_color_precedence_per_side_over_combined() {
    use lipgloss::color::Color;

    if is_no_color_environment() {
        // Skip color-dependent tests in CI/no-color environments
        return;
    }

    let base = Style::default()
        .border(lipgloss::normal_border())
        .border_top(true)
        .border_right(true)
        .border_bottom(true)
        .border_left(true);

    let combined = base.clone().border_foreground(Color::from("#112233"));
    let combined_out = combined.render("X");

    // Per-side override on top should take precedence over combined
    let override_top = combined
        .clone()
        .border_top_foreground(Color::from("#ff0000"));
    let override_out = override_top.render("X");

    // Expect SGR in both
    assert!(combined_out.contains("\x1b["));
    assert!(override_out.contains("\x1b["));

    // Compare lines: top line should differ; middle and bottom should be equal
    let c_lines: Vec<&str> = combined_out.split('\n').collect();
    let o_lines: Vec<&str> = override_out.split('\n').collect();
    assert_eq!(c_lines.len(), 3);
    assert_eq!(o_lines.len(), 3);
    assert_ne!(
        c_lines[0],
        o_lines[0],
        "top edge should differ due to per-side override\nC:{}\nO:{}",
        show_escapes(c_lines[0]),
        show_escapes(o_lines[0])
    );
    assert_eq!(
        c_lines[1], o_lines[1],
        "sides should remain same without overrides"
    );
    assert_eq!(
        c_lines[2], o_lines[2],
        "bottom should remain same without overrides"
    );
}

#[test]
fn border_color_inherit_behavior_and_precedence() {
    if is_no_color_environment() {
        // Skip color-dependent tests in CI/no-color environments
        return;
    }
    use lipgloss::color::Color;

    // Parent with combined border foreground
    let parent = Style::default()
        .border(lipgloss::normal_border())
        .border_top(true)
        .border_right(true)
        .border_bottom(true)
        .border_left(true)
        .border_foreground(Color::from("#ff0000"));

    // Child inherits and should have colored borders
    let child_inherited = Style::default().inherit(parent.clone());
    let child_out = child_inherited.render("hi");
    assert!(
        child_out.contains("\x1b["),
        "inherited border color should apply: {}",
        show_escapes(&child_out)
    );

    // Per-side override should survive inherit ordering: before vs after
    let s_before = Style::default()
        .border(lipgloss::normal_border())
        .border_top(true)
        .border_right(true)
        .border_bottom(true)
        .border_left(true)
        .border_left_foreground(Color::from("#00ff00"))
        .inherit(parent.clone());

    let s_after = Style::default()
        .inherit(parent)
        .border_left_foreground(Color::from("#00ff00"));

    let out_before = s_before.render("hi");
    let out_after = s_after.render("hi");

    // Both orders should yield the same result if per-side overrides take precedence over combined
    assert_eq!(
        out_before, out_after,
        "per-side override should be stable across inherit ordering"
    );

    // And they should differ from plain inherited (left border not green vs green). We can at least assert difference.
    assert_ne!(
        out_before, child_out,
        "override should change output compared to plain inherited"
    );
}

#[test]
fn border_render_snapshot_simple() {
    let s = Style::default()
        .border(lipgloss::normal_border())
        .border_top(true)
        .border_right(true)
        .border_bottom(true)
        .border_left(true);

    let got = s.render("Hi");
    let want = "┌──┐\n│Hi│\n└──┘".to_string();
    assert_eq!(
        got,
        want,
        "border rendering should match\nGOT:\n{}\nWANT:\n{}",
        show_escapes(&got),
        show_escapes(&want)
    );
}

#[test]
fn border_render_with_padding() {
    // Padding expands inner width; border should match outer width accordingly
    let s = Style::default()
        .padding_left(1)
        .padding_right(1)
        .border(lipgloss::normal_border())
        .border_top(true)
        .border_right(true)
        .border_bottom(true)
        .border_left(true);

    let got = s.render("Hi");
    // Inner becomes " Hi ", length 4; top/bottom edge length should be 4
    let want = "┌────┐\n│ Hi │\n└────┘".to_string();
    assert_eq!(
        got,
        want,
        "border+padding rendering should match\nGOT:\n{}\nWANT:\n{}",
        show_escapes(&got),
        show_escapes(&want)
    );
}

#[test]
fn renderer_assignment_color_profile_effects() {
    if is_no_color_environment() {
        // Skip color-dependent tests in CI/no-color environments
        return;
    }
    use lipgloss::color::Color;
    use lipgloss::renderer::{ColorProfileKind, Renderer};

    // With colors: expect ANSI sequences when profile != NoColor
    let base = Style::default().foreground(Color::from("#ff0000"));

    // TrueColor renderer
    let mut r_true = Renderer::new();
    r_true.set_color_profile(ColorProfileKind::TrueColor);
    let out_true = base.clone().renderer(r_true).render("hello");
    assert!(
        out_true.contains("\x1b["),
        "TrueColor should include SGR escapes: {}",
        show_escapes(&out_true)
    );
    assert!(out_true.ends_with("\x1b[0m"), "Should reset at end");

    // ANSI renderer
    let mut r_ansi = Renderer::new();
    r_ansi.set_color_profile(ColorProfileKind::ANSI);
    let out_ansi = base.clone().renderer(r_ansi).render("hello");
    assert!(
        out_ansi.contains("\x1b["),
        "ANSI should include SGR escapes: {}",
        show_escapes(&out_ansi)
    );

    // NoColor renderer
    let mut r_none = Renderer::new();
    r_none.set_color_profile(ColorProfileKind::NoColor);
    let out_none = base.renderer(r_none).render("hello");
    assert_eq!(
        out_none,
        "hello",
        "NoColor should not include escapes, got: {}",
        show_escapes(&out_none)
    );
}

#[test]
fn adaptive_color_changes_with_background() {
    if is_no_color_environment() {
        // Skip color-dependent tests in CI/no-color environments
        return;
    }
    use lipgloss::color::AdaptiveColor;
    use lipgloss::renderer::Renderer;

    let adaptive = AdaptiveColor {
        Light: "#0000FF",
        Dark: "#FF0000",
    };
    let base = Style::default().foreground(adaptive);

    // Dark background -> use Dark color
    let mut r_dark = Renderer::new();
    r_dark.set_has_dark_background(true);
    let s_dark = base.clone().renderer(r_dark);
    let out_dark = s_dark.render("x");

    // Light background -> use Light color
    let mut r_light = Renderer::new();
    r_light.set_has_dark_background(false);
    let s_light = base.renderer(r_light);
    let out_light = s_light.render("x");

    // Ensure coloring applies under both backgrounds
    assert!(out_dark.contains("\x1b["));
    assert!(out_light.contains("\x1b["));
}

#[test]
fn complete_color_changes_with_profile() {
    if is_no_color_environment() {
        // Skip color-dependent tests in CI/no-color environments
        return;
    }
    use lipgloss::color::CompleteColor;
    use lipgloss::renderer::{ColorProfileKind, Renderer};

    let complete = CompleteColor {
        TrueColor: "#FF0000".to_string(),
        ANSI256: "231".to_string(),
        ANSI: "12".to_string(),
    };
    let base = Style::default().foreground(complete);

    let mut r_true = Renderer::new();
    r_true.set_color_profile(ColorProfileKind::TrueColor);
    let out_true = base.clone().renderer(r_true).render("z");

    let mut r_256 = Renderer::new();
    r_256.set_color_profile(ColorProfileKind::ANSI256);
    let out_256 = base.clone().renderer(r_256).render("z");

    let mut r_ansi = Renderer::new();
    r_ansi.set_color_profile(ColorProfileKind::ANSI);
    let out_ansi = base.renderer(r_ansi).render("z");

    // All colored, but should not be identical across profiles
    assert!(out_true.contains("\x1b["));
    assert!(out_256.contains("\x1b["));
    assert!(out_ansi.contains("\x1b["));
    assert!(
        out_true != out_256 || out_true != out_ansi,
        "CompleteColor outputs should vary by profile"
    );
}

#[test]
fn underline_and_strikethrough_with_spaces_render() {
    // Basic sanity: enabling underline/strikethrough should wrap with SGR and reset
    // When spaces toggles are on, styling should apply across spaces as well.
    let s = Style::default()
        .underline(true)
        .underline_spaces(true)
        .strikethrough(true)
        .strikethrough_spaces(true);

    let out = s.render(" a ");
    assert!(
        out.starts_with("\x1b["),
        "should start with SGR: {}",
        show_escapes(&out)
    );
    // We don't require a plain " a " substring because escapes may wrap the entire string.
    assert!(out.ends_with("\x1b[0m"), "should reset at end");
}

#[test]
fn tab_width_conversion() {
    let s4 = Style::default().tab_width(4);
    let out4 = s4.render("a\tb");
    assert_eq!(out4, "a    b"); // current impl expands to full width regardless of column

    let s2 = Style::default().tab_width(2);
    let out2 = s2.render("a\tb");
    assert_eq!(out2, "a  b"); // expands to full width
}

#[test]
fn border_per_side_foreground_background_and_unsetters() {
    if is_no_color_environment() {
        // Skip color-dependent tests in CI/no-color environments
        return;
    }
    use lipgloss::color::Color;

    // Set per-side colors
    let s = Style::default()
        .border(lipgloss::normal_border())
        .border_top(true)
        .border_right(true)
        .border_bottom(true)
        .border_left(true)
        .border_top_foreground(Color::from("#ff0000"))
        .border_right_foreground(Color::from("#00ff00"))
        .border_bottom_foreground(Color::from("#0000ff"))
        .border_left_foreground(Color::from("#ff00ff"))
        .border_top_background(Color::from("#111111"))
        .border_right_background(Color::from("#222222"))
        .border_bottom_background(Color::from("#333333"))
        .border_left_background(Color::from("#444444"));

    let out_colored = s.render("hi");
    // Should contain SGR sequences for coloring borders
    assert!(
        out_colored.contains("\x1b["),
        "expected SGR in colored borders: {}",
        show_escapes(&out_colored)
    );

    // Unset all border foreground/background colors
    let s_unset = s
        .unset_border_top_foreground()
        .unset_border_right_foreground()
        .unset_border_bottom_foreground()
        .unset_border_left_foreground()
        .unset_border_top_background()
        .unset_border_right_background()
        .unset_border_bottom_background()
        .unset_border_left_background();

    let out_unset = s_unset.render("hi");
    // With colors unset, borders should not include SGR sequences (content may still include depending on style, but we didn't add any)
    assert!(
        out_unset.find('\u{1b}').is_none(),
        "expected no SGR after unsetting, got: {}",
        show_escapes(&out_unset)
    );
}

#[test]
fn border_combined_foreground_background_apply() {
    if is_no_color_environment() {
        // Skip color-dependent tests in CI/no-color environments
        return;
    }
    use lipgloss::color::Color;

    // Combined setters should color all sides
    let s = Style::default()
        .border(lipgloss::normal_border())
        .border_top(true)
        .border_right(true)
        .border_bottom(true)
        .border_left(true)
        .border_foreground(Color::from("#ff0000"))
        .border_background(Color::from("#000000"));

    let out = s.render("X");
    assert!(
        out.contains("\x1b["),
        "combined border fg/bg should produce SGR: {}",
        show_escapes(&out)
    );
}

#[test]
fn underline_strikethrough_spaces_off_rendering() {
    // When spaces toggles are off, ensure leading/trailing spaces remain plain
    let s = Style::default()
        .underline(true)
        .underline_spaces(false)
        .strikethrough(true)
        .strikethrough_spaces(false);

    let input = " a ";
    let out = s.render(input);

    // Output should start with a literal space, not an escape
    assert!(
        out.starts_with(' '),
        "should start with space, got: {}",
        show_escapes(&out)
    );
    // There should be SGR around the non-space character
    assert!(
        out.contains("\x1b["),
        "styled letter should include SGR: {}",
        show_escapes(&out)
    );
    // The leading and trailing spaces should remain present, and the non-space character is styled.
    // Reset should appear before the trailing space when spaces are not styled
    assert!(
        out.contains("\x1b[0m"),
        "should contain reset: {}",
        show_escapes(&out)
    );
    assert!(
        out.ends_with(' '),
        "should end with a plain space: {}",
        show_escapes(&out)
    );
}

#[test]
fn tab_width_then_wrapping_interaction() {
    // Tabs should expand before wrapping occurs. Width 4 should wrap after 4 cols per line.
    let s = Style::default().tab_width(4).width(4);

    let out = s.render("a\tbcd"); // expands to "a   bcd" (7 cols)
    let want = "a   \n bcd"; // current impl wraps with a leading space on the next line
    assert_eq!(
        out,
        want,
        "tab + wrap should split after expansion\nGOT:\n{}\nWANT:\n{}",
        show_escapes(&out),
        show_escapes(want)
    );
}