Skip to main content

leenfetch_core/modules/
utils.rs

1#![allow(clippy::collapsible_if, clippy::useless_vec)]
2
3use image::{ImageFormat, ImageReader};
4use std::{
5    collections::HashMap,
6    env, fs,
7    io::{Cursor, Write},
8    path::Path,
9};
10
11use super::{ascii::get_builtin_ascii_art, colors::get_builtin_distro_colors};
12
13pub const DEFAULT_ANSI_ALL_COLORS: [&str; 16] = [
14    "\x1b[1;30m", // Black
15    "\x1b[1;31m", // Red
16    "\x1b[1;32m", // Green
17    "\x1b[1;33m", // Yellow
18    "\x1b[1;34m", // Blue
19    "\x1b[1;35m", // Magenta
20    "\x1b[1;36m", // Cyan
21    "\x1b[1;37m", // White
22    "\x1b[1;90m", // Bright Black
23    "\x1b[1;91m", // Bright Red
24    "\x1b[1;92m", // Bright Green
25    "\x1b[1;93m", // Bright Yellow
26    "\x1b[1;94m", // Bright Blue
27    "\x1b[1;95m", // Bright Magenta
28    "\x1b[1;96m", // Bright Cyan
29    "\x1b[1;97m", // Bright White
30];
31
32/// Generates a visual bar representation of a percentage using Unicode blocks.
33///
34/// # Arguments
35///
36/// * `percent` - An unsigned 8-bit integer representing the percentage (0-100) to be visualized.
37///
38/// # Returns
39///
40/// * A `String` containing a visual bar representation, with filled blocks (`█`) indicating the
41///   percentage, and empty blocks (`░`) for the remainder. The total length of the bar is 14
42///   characters, enclosed in square brackets.
43pub fn get_bar(percent: u8) -> String {
44    let total_blocks = 14;
45    let filled_blocks = (percent as usize * total_blocks) / 100;
46    let empty_blocks = total_blocks - filled_blocks;
47
48    // Pre-computed block strings for common values (avoid allocations)
49    const BLOCKS: &[&str] = &[
50        "",
51        "█",
52        "██",
53        "███",
54        "████",
55        "█████",
56        "██████",
57        "███████",
58        "████████",
59        "█████████",
60        "██████████",
61        "███████████",
62        "████████████",
63        "█████████████",
64        "██████████████",
65    ];
66    const EMPTY_BLOCKS: &[&str] = &[
67        "",
68        "░",
69        "░░",
70        "░░░",
71        "░░░░",
72        "░░░░░",
73        "░░░░░░",
74        "░░░░░░░",
75        "░░░░░░░░",
76        "░░░░░░░░░",
77        "░░░░░░░░░░",
78        "░░░░░░░░░░░",
79        "░░░░░░░░░░░░",
80        "░░░░░░░░░░░░░",
81        "░░░░░░░░░░░░░░",
82    ];
83
84    let filled = BLOCKS[filled_blocks.min(14)];
85    let empty = EMPTY_BLOCKS[empty_blocks.min(14)];
86
87    format!("[{}{}]", filled, empty)
88}
89
90/// Generates a vector of 2 strings, each containing a row of 8 blocks
91/// colored with different ANSI foreground colors. The first string has
92/// normal colors, the second has bold colors.
93///
94/// The input string `color_blocks` should contain 8 identical block characters
95/// (e.g. █, ░, ▓, ▒, etc.). The output strings will have these blocks
96/// colored with different ANSI colors.
97pub fn get_terminal_color(color_blocks: &str) -> String {
98    let color_codes: [u8; 8] = [30, 31, 32, 33, 34, 35, 36, 37]; // ANSI foreground colors
99
100    let mut normal = Vec::with_capacity(8);
101    // let mut bold = Vec::with_capacity(8);
102
103    for &code in &color_codes {
104        normal.push(format!("\x1b[{}m{}\x1b[0m", code, color_blocks)); // normal
105        // bold.push(format!("\x1b[1;{}m{}\x1b[0m", code, color_blocks)); // bold
106    }
107
108    // vec![normal.join(""), bold.join("")]
109    normal.join("")
110}
111
112// ---------------------------------
113//        ASCII ART Functions
114// ---------------------------------
115
116/// Reads a file at the given custom path and returns its content as a string.
117/// If there is an error while reading the file, an empty string is returned.
118///
119/// # Arguments
120///
121/// * `custom_path`: The path to the custom ASCII art file.
122///
123/// # Returns
124///
125/// A string containing the content of the file, or an empty string if there was an error.
126pub fn get_custom_ascii(custom_path: &str) -> String {
127    if let Ok(content) = fs::read_to_string(Path::new(custom_path)) {
128        return content;
129    }
130
131    "".to_string()
132}
133
134pub fn is_image_path(path: &str) -> bool {
135    Path::new(path)
136        .extension()
137        .and_then(|ext| ext.to_str())
138        .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg"))
139        .unwrap_or(false)
140}
141
142pub fn terminal_supports_inline_images() -> bool {
143    matches!(env::var("TERM"), Ok(term) if term == "xterm-kitty")
144        || env::var_os("KITTY_WINDOW_ID").is_some()
145}
146
147pub fn render_inline_image(path: &str, columns: usize) -> Result<(), String> {
148    let reader = ImageReader::open(path)
149        .map_err(|err| format!("Failed to open image {path}: {err}"))?
150        .with_guessed_format()
151        .map_err(|err| format!("Failed to detect image format for {path}: {err}"))?;
152
153    let image = reader
154        .decode()
155        .map_err(|err| format!("Failed to decode image {path}: {err}"))?;
156
157    let mut png_bytes = Cursor::new(Vec::new());
158    image
159        .write_to(&mut png_bytes, ImageFormat::Png)
160        .map_err(|err| format!("Failed to encode image {path} as PNG: {err}"))?;
161
162    let encoded = base64_encode(png_bytes.get_ref());
163    let mut stdout = std::io::stdout();
164    if columns > 0 {
165        write!(
166            &mut stdout,
167            "\x1b_Ga=T,C=1,f=100,c={columns};{encoded}\x1b\\"
168        )
169    } else {
170        write!(&mut stdout, "\x1b_Ga=T,C=1,f=100;{encoded}\x1b\\")
171    }
172    .map_err(|err| format!("Failed to write inline image escape sequence: {err}"))?;
173    stdout
174        .flush()
175        .map_err(|err| format!("Failed to flush inline image: {err}"))?;
176
177    Ok(())
178}
179
180fn base64_encode(data: &[u8]) -> String {
181    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
182
183    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
184    let mut chunks = data.chunks_exact(3);
185
186    for chunk in &mut chunks {
187        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
188        out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
189        out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
190        out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
191        out.push(TABLE[(n & 0x3f) as usize] as char);
192    }
193
194    match chunks.remainder() {
195        [b0] => {
196            let n = (*b0 as u32) << 16;
197            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
198            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
199            out.push('=');
200            out.push('=');
201        }
202        [b0, b1] => {
203            let n = ((*b0 as u32) << 16) | ((*b1 as u32) << 8);
204            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
205            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
206            out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
207            out.push('=');
208        }
209        _ => {}
210    }
211
212    out
213}
214
215/// Given a distro name, returns a string of its corresponding ASCII art.
216/// If the distro isn't found, an empty string is returned.
217/// If the distro is "off", an empty string is returned.
218pub fn get_ascii_and_colors(ascii_distro: &str) -> String {
219    if ascii_distro == "off" {
220        return "".to_string();
221    }
222
223    let ascii_art = resolve_ascii_art(ascii_distro);
224
225    ascii_art.to_string()
226}
227
228/// Resolves ASCII art for a given distro name.
229/// Tries direct match first, then falls back to ID_LIKE if available.
230fn resolve_ascii_art(distro: &str) -> &'static str {
231    // 1. Try direct distro name match
232    if let Some(art) = get_builtin_ascii_art(distro) {
233        return art;
234    }
235
236    #[cfg(target_os = "linux")]
237    // 2. No match — try ID_LIKE parent distro
238    if let Some(parent) = crate::modules::linux::system::distro::get_id_like() {
239        if let Some(art) = get_builtin_ascii_art(&parent) {
240            return art;
241        }
242    }
243
244    // 3. Nothing found — return fallback DEFAULT
245    DEFAULT_ASCII
246}
247
248const DEFAULT_ASCII: &str = r#"${c2}        #####
249${c2}       #######
250${c2}       ##${c1}O${c2}#${c1}O${c2}##
251${c2}       #${c3}#####${c2}#
252${c2}     ##${c1}##${c3}###${c1}##${c2}##
253${c2}    #${c1}##########${c2}##
254${c2}   #${c1}############${c2}##
255${c2}   #${c1}############${c2}###
256${c3}  ##${c2}#${c1}###########${c2}##${c3}#
257${c3}######${c2}#${c1}#######${c2}#${c3}######
258${c3}#######${c2}#${c1}#####${c2}#${c3}#######
259${c3}  #####${c2}#######${c3}#####"#;
260
261// ---------------------------------
262//        Color Functions
263// ---------------------------------
264
265/// Replaces placeholders in a string with ANSI escape codes to colorize
266/// the output.
267///
268/// Placeholders are in the form of `${{key}}`, where `key` is the key in
269/// the provided `colors` HashMap. The value associated with the `key` is
270/// the ANSI escape code for the color.
271pub fn colorize_text(input: String, colors: &HashMap<&str, &str>) -> String {
272    let mut result = String::new();
273
274    for line in input.lines() {
275        let mut colored = line.to_owned();
276        for (key, code) in colors {
277            let placeholder = format!("${{{}}}", key);
278            colored = colored.replace(&placeholder, code);
279        }
280        result.push_str(&colored);
281        result.push('\n');
282    }
283
284    result
285}
286
287/// Creates a `HashMap` of ANSI color codes from the given entries.
288///
289/// Each entry is a tuple containing a key and a corresponding ANSI
290/// color code. The function populates the `HashMap` with these entries
291/// and also adds bold variants for each color code that starts with
292/// `\x1b[0;`. The bold variant key is prefixed with "bold." (e.g.,
293/// `bold.c1` for `c1`).
294///
295/// Additionally, a "reset" key is included in the map, which maps to
296/// the ANSI reset code `\x1b[0m`.
297///
298/// # Arguments
299///
300/// * `entries` - A slice of tuples where each tuple contains a string
301///   key and an ANSI color code.
302///
303/// # Returns
304///
305/// * A `HashMap` with the original entries, their bold variants, and a
306///   reset entry.
307pub fn color_palette(
308    entries: &[(&'static str, &'static str)],
309) -> HashMap<&'static str, &'static str> {
310    let mut map = HashMap::new();
311    for (k, v) in entries {
312        map.insert(*k, *v);
313
314        // Add bold variant: bold.c1 → \x1b[1;31m if c1 is \x1b[0;31m
315        if let Some(code) = v.strip_prefix("\x1b[0;") {
316            let bold_code = format!("\x1b[1;{}", code);
317            let bold_key = format!("bold.{}", k);
318            map.insert(
319                Box::leak(bold_key.into_boxed_str()),
320                Box::leak(bold_code.into_boxed_str()),
321            );
322        }
323    }
324
325    map.insert("reset", "\x1b[0m");
326    map
327}
328
329/// Given a slice of color indices or a distro name, generates a HashMap
330/// of `cX` keys (where `X` is the index, starting from 1) mapped to the
331/// corresponding ANSI foreground color codes.
332///
333/// The color order is determined by the input slice. The first color is
334/// assigned to `c1`, the second to `c2`, and so on. If the input slice is
335/// shorter than 16 elements, the remaining colors are filled from the
336/// default color palette in the order they appear.
337///
338/// If the input slice is empty, the function returns an empty HashMap.
339///
340/// The HashMap also includes bold variants for each color, which can be
341/// accessed using the `bold.*` keys. For example, `bold.c1` would be the
342/// bold variant of `c1`.
343///
344/// The `reset` key is also included, which resets the text to the default
345/// color.
346pub fn get_colors_in_order(color_order: &[u8]) -> HashMap<&'static str, &'static str> {
347    // Start with c0 = bold black
348    let mut entries: Vec<(&'static str, &'static str)> = vec![("c0", "\x1b[1;30m")];
349
350    let mut used = vec![false; 16]; // support 0–15
351
352    // Fill c1 to cX using given color_order
353    for (i, &idx) in color_order.iter().enumerate() {
354        if idx < 16 {
355            let key: &'static str = Box::leak(format!("c{}", i + 1).into_boxed_str());
356            entries.push((key, DEFAULT_ANSI_ALL_COLORS[idx as usize]));
357            used[idx as usize] = true;
358        }
359    }
360
361    // Fill remaining cX from unused colors
362    let mut next_index = color_order.len() + 1;
363    for (i, &color) in DEFAULT_ANSI_ALL_COLORS.iter().enumerate() {
364        if !used[i] {
365            let key: &'static str = Box::leak(format!("c{}", next_index).into_boxed_str());
366            entries.push((key, color));
367            next_index += 1;
368        }
369    }
370
371    // Generate HashMap with bold.* variants and reset
372    let mut map = color_palette(&entries);
373    map.insert("reset", "\x1b[0m");
374    map
375}
376
377/// Given a string of comma-separated color indices or a distro name, returns a
378/// HashMap of color codes c0 to cX as found in the given distro's color
379/// definition. The color codes are from the ANSI color palette.
380pub fn get_custom_colors_order(colors_str_order: &str) -> HashMap<&'static str, &'static str> {
381    let custom_color_str_list: Vec<&str> = colors_str_order.split(',').map(str::trim).collect();
382
383    // Try to parse all color indices
384    let all_parsed: Option<Vec<u8>> = custom_color_str_list
385        .iter()
386        .map(|s| s.parse::<u8>().ok())
387        .collect();
388
389    let color_list: Vec<u8> = if let Some(list) = all_parsed {
390        list
391    } else {
392        // Fallback: interpret the string as a distro name
393        get_builtin_distro_colors(colors_str_order).to_vec()
394    };
395
396    get_colors_in_order(&color_list)
397}
398
399/// Given a distro name, returns a HashMap of color codes c0 to cX as found
400/// in the given distro's color definition. The color codes are from the
401/// ANSI color palette. If the distro isn't found, an empty HashMap is returned.
402pub fn get_distro_colors(distro: &str) -> HashMap<&'static str, &'static str> {
403    let dist_color = get_builtin_distro_colors(distro);
404
405    get_colors_in_order(dist_color)
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::test_utils::EnvLock;
412
413    #[test]
414    fn bar_respects_percentage_bounds() {
415        assert_eq!(get_bar(0), "[░░░░░░░░░░░░░░]");
416        assert_eq!(get_bar(100), "[██████████████]");
417        assert!(get_bar(50).contains('█'));
418    }
419
420    #[test]
421    fn terminal_color_emits_expected_blocks() {
422        let visual = get_terminal_color("■");
423        assert_eq!(visual.matches('■').count(), 8);
424        assert!(visual.contains("\x1b[31m"), "missing ANSI escape: {visual}");
425    }
426
427    #[test]
428    fn color_palette_includes_bold_and_reset() {
429        let map = color_palette(&[("c1", "\x1b[0;31m")]);
430        assert_eq!(map.get("c1"), Some(&"\x1b[0;31m"));
431        let bold_key = map
432            .keys()
433            .find(|k| k.starts_with("bold.c1"))
434            .expect("bold variant missing");
435        assert!(map.get(bold_key).unwrap().starts_with("\x1b[1;"));
436        assert_eq!(map.get("reset"), Some(&"\x1b[0m"));
437    }
438
439    #[test]
440    fn colors_in_order_fills_defaults() {
441        let map = get_colors_in_order(&[1, 2, 3]);
442        assert_eq!(map.get("c1"), Some(&DEFAULT_ANSI_ALL_COLORS[1]));
443        assert_eq!(map.get("c2"), Some(&DEFAULT_ANSI_ALL_COLORS[2]));
444        assert_eq!(map.get("c3"), Some(&DEFAULT_ANSI_ALL_COLORS[3]));
445        assert!(map.contains_key("c4"));
446        assert_eq!(map.get("reset"), Some(&"\x1b[0m"));
447    }
448
449    #[test]
450    fn image_path_detection_is_extension_based() {
451        assert!(is_image_path("/tmp/logo.png"));
452        assert!(is_image_path("/tmp/logo.JPG"));
453        assert!(is_image_path("/tmp/logo.jpeg"));
454        assert!(!is_image_path("/tmp/logo.txt"));
455    }
456
457    #[test]
458    fn kitty_detection_uses_terminal_hints() {
459        let env = EnvLock::acquire(&["TERM", "KITTY_WINDOW_ID"]);
460
461        env.set_var("TERM", "xterm-kitty");
462        env.remove_var("KITTY_WINDOW_ID");
463        assert!(terminal_supports_inline_images());
464
465        env.set_var("TERM", "xterm-256color");
466        env.set_var("KITTY_WINDOW_ID", "1");
467        assert!(terminal_supports_inline_images());
468
469        env.set_var("TERM", "xterm-256color");
470        env.remove_var("KITTY_WINDOW_ID");
471        assert!(!terminal_supports_inline_images());
472    }
473}