Swadhin Biswas test(colors): add color parsing edge case tests · 3/5/2026
e679ba8
296 lines · 8.6 KB
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
//! Color system for FetchX — mirrors neofetch's color() / set_colors() logic.
//!
//! Colors 0-7 map to ANSI 30-37.
//! Color 8+ maps to 256-color: \e[38;5;Nm
//! "fg" resets to default foreground.

/// ANSI reset sequence.
pub const RESET: &str = "\x1b[0m";
/// ANSI bold sequence.
pub const BOLD: &str = "\x1b[1m";

/// Generate an ANSI foreground color escape sequence from a neofetch color number.
/// 0-7 → standard ANSI (30-37). 8-255 → 256-color mode.
pub fn color(n: u8) -> String {
    match n {
        0..=7 => format!("{}\x1b[3{}m", RESET, n),
        _ => format!("\x1b[38;5;{}m", n),
    }
}

/// A set of up to 6 colors for ASCII art (c1-c6) plus text colors.
#[derive(Clone, Debug)]
pub struct ColorScheme {
    /// ASCII art colors c1-c6
    pub c: [String; 6],
    /// Title color (username@hostname)
    pub title: String,
    /// @ separator color
    pub at: String,
    /// Underline color
    pub underline: String,
    /// Subtitle / label color
    pub subtitle: String,
    /// Colon separator color
    pub colon: String,
    /// Info value color
    pub info: String,
    /// Whether bold is enabled for ASCII art
    #[allow(dead_code)]
    pub ascii_bold: bool,
}

impl ColorScheme {
    /// Create a color scheme from a list of color numbers (like neofetch's set_colors).
    /// First color is primary, second is secondary, etc.
    pub fn from_colors(nums: &[u8], bold: bool) -> Self {
        let mut c = [
            String::new(),
            String::new(),
            String::new(),
            String::new(),
            String::new(),
            String::new(),
        ];
        let bold_str = if bold { BOLD } else { "" };
        for (i, &n) in nums.iter().take(6).enumerate() {
            c[i] = format!("{}{}", color(n), bold_str);
        }
        // Fill remaining with first color
        if !nums.is_empty() {
            let first = c[0].clone();
            for item in c.iter_mut() {
                if item.is_empty() {
                    item.clone_from(&first);
                }
            }
        }

        // Text colors derived from distro colors (neofetch "distro" mode)
        // Mirrors neofetch's set_text_colors(): title=color($1), subtitle=color($2),
        // at/underline/colon/info all reset. Special cases for colors 7 and 8.
        let c1 = nums.first().copied().unwrap_or(1);
        let c2 = nums.get(1).copied().unwrap_or(7);

        // If the first color is 8 or 7, title resets to default fg
        let title = if c1 == 8 || c1 == 7 {
            RESET.to_string()
        } else {
            color(c1)
        };

        // If the second color is 7 or 8, subtitle uses the first color instead
        let subtitle = if c2 == 7 || c2 == 8 {
            if c1 == 8 || c1 == 7 {
                RESET.to_string()
            } else {
                color(c1)
            }
        } else {
            color(c2)
        };

        Self {
            c,
            title,
            at: RESET.to_string(),
            underline: RESET.to_string(),
            subtitle,
            colon: RESET.to_string(),
            info: RESET.to_string(),
            ascii_bold: bold,
        }
    }

    /// Create a no-color scheme (for --stdout / --no-color).
    pub fn plain() -> Self {
        Self {
            c: Default::default(),
            title: String::new(),
            at: String::new(),
            underline: String::new(),
            subtitle: String::new(),
            colon: String::new(),
            info: String::new(),
            ascii_bold: false,
        }
    }
}

impl Default for ColorScheme {
    fn default() -> Self {
        Self::from_colors(&[6, 6, 7, 1], true)
    }
}

/// Generate color blocks matching neofetch's get_cols().
/// Colors 0-7 use standard ANSI (fg+bg), colors 8+ use 256-color mode.
/// Blocks are split into two rows: standard (0-7) and extended (8+).
pub fn color_blocks(start: u8, end: u8, width: usize) -> Vec<String> {
    let block = "\u{2588}".repeat(width); // █ repeated
    let mut row1 = String::new(); // standard colors 0-7
    let mut row2 = String::new(); // extended colors 8+

    for i in start..=end {
        match i {
            0..=7 => {
                // Standard ANSI: set both fg and bg to same color
                row1.push_str(&format!("\x1b[3{}m\x1b[4{}m{}", i, i, block));
            }
            _ => {
                // 256-color mode: set both fg and bg
                row2.push_str(&format!("\x1b[38;5;{}m\x1b[48;5;{}m{}", i, i, block));
            }
        }
    }

    let mut lines = Vec::new();
    if !row1.is_empty() {
        row1.push_str(RESET);
        lines.push(row1);
    }
    if !row2.is_empty() {
        row2.push_str(RESET);
        lines.push(row2);
    }
    lines
}

/// Generate a progress bar for memory/disk usage.
/// `percent` is 0-100. `width` is the total bar width in chars.
/// Returns something like: `[████████░░░░░░░░] 50%`
pub fn progress_bar(percent: f64, width: usize, bar_color: &str) -> String {
    let pct = percent.clamp(0.0, 100.0);
    let filled = ((pct / 100.0) * width as f64).round() as usize;
    let empty = width.saturating_sub(filled);

    let bar = format!(
        "{}{}{}{}{}",
        bar_color,
        "█".repeat(filled),
        RESET,
        "░".repeat(empty),
        RESET,
    );
    format!("[{}] {:.0}%", bar, pct)
}

/// Apply a gradient effect to text using 256-color (or RGB) ANSI codes.
/// Interpolates between `start_color` (r,g,b) and `end_color` (r,g,b) across the text.
#[allow(dead_code)]
pub fn gradient_text(text: &str, start: (u8, u8, u8), end: (u8, u8, u8)) -> String {
    let chars: Vec<char> = text.chars().collect();
    let len = chars.len();
    if len == 0 {
        return String::new();
    }

    let mut result = String::with_capacity(text.len() * 20);
    for (i, ch) in chars.iter().enumerate() {
        let t = if len > 1 {
            i as f64 / (len - 1) as f64
        } else {
            0.0
        };
        let r = (start.0 as f64 + (end.0 as f64 - start.0 as f64) * t) as u8;
        let g = (start.1 as f64 + (end.1 as f64 - start.1 as f64) * t) as u8;
        let b = (start.2 as f64 + (end.2 as f64 - start.2 as f64) * t) as u8;
        result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch));
    }
    result.push_str(RESET);
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_color_standard() {
        assert_eq!(color(0), "\x1b[0m\x1b[30m");
        assert_eq!(color(1), "\x1b[0m\x1b[31m");
        assert_eq!(color(7), "\x1b[0m\x1b[37m");
    }

    #[test]
    fn test_color_256() {
        assert_eq!(color(8), "\x1b[38;5;8m");
        assert_eq!(color(255), "\x1b[38;5;255m");
    }

    #[test]
    fn test_color_scheme_from_colors() {
        let scheme = ColorScheme::from_colors(&[6, 6, 7, 1], true);
        assert_eq!(scheme.c.len(), 6);
        // c1 should be cyan (6)
        assert!(scheme.c[0].contains("\x1b[36m"));
    }

    #[test]
    fn test_color_scheme_plain() {
        let scheme = ColorScheme::plain();
        assert!(scheme.title.is_empty());
        assert!(scheme.subtitle.is_empty());
    }

    #[test]
    fn test_color_blocks() {
        let blocks = color_blocks(0, 7, 3);
        assert!(!blocks.is_empty());
        assert!(blocks[0].contains("███"));
    }

    #[test]
    fn test_color_blocks_extended() {
        let blocks = color_blocks(0, 15, 3);
        assert!(blocks.len() >= 2); // standard + extended rows
    }

    #[test]
    fn test_progress_bar_zero() {
        let bar = progress_bar(0.0, 10, "\x1b[31m");
        assert!(bar.contains("0%"));
        assert!(bar.contains("░"));
    }

    #[test]
    fn test_progress_bar_full() {
        let bar = progress_bar(100.0, 10, "\x1b[32m");
        assert!(bar.contains("100%"));
        assert!(bar.contains("█"));
    }

    #[test]
    fn test_progress_bar_half() {
        let bar = progress_bar(50.0, 10, "\x1b[33m");
        assert!(bar.contains("50%"));
    }

    #[test]
    fn test_progress_bar_clamp() {
        let bar = progress_bar(150.0, 10, "");
        assert!(bar.contains("100%"));
        let bar2 = progress_bar(-50.0, 10, "");
        assert!(bar2.contains("0%"));
    }

    #[test]
    fn test_gradient_text() {
        let result = gradient_text("Hello", (255, 0, 0), (0, 0, 255));
        assert!(result.contains("\x1b[38;2;"));
        assert!(result.ends_with(RESET));
    }

    #[test]
    fn test_gradient_text_empty() {
        let result = gradient_text("", (255, 0, 0), (0, 0, 255));
        assert!(result.is_empty());
    }

    #[test]
    fn test_gradient_text_single_char() {
        let result = gradient_text("A", (255, 0, 0), (0, 0, 255));
        assert!(result.contains("A"));
    }
}