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", "\x1b[1;31m", "\x1b[1;32m", "\x1b[1;33m", "\x1b[1;34m", "\x1b[1;35m", "\x1b[1;36m", "\x1b[1;37m", "\x1b[1;90m", "\x1b[1;91m", "\x1b[1;92m", "\x1b[1;93m", "\x1b[1;94m", "\x1b[1;95m", "\x1b[1;96m", "\x1b[1;97m", ];
31
32pub 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 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
90pub fn get_terminal_color(color_blocks: &str) -> String {
98 let color_codes: [u8; 8] = [30, 31, 32, 33, 34, 35, 36, 37]; let mut normal = Vec::with_capacity(8);
101 for &code in &color_codes {
104 normal.push(format!("\x1b[{}m{}\x1b[0m", code, color_blocks)); }
107
108 normal.join("")
110}
111
112pub 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
215pub 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
228fn resolve_ascii_art(distro: &str) -> &'static str {
231 if let Some(art) = get_builtin_ascii_art(distro) {
233 return art;
234 }
235
236 #[cfg(target_os = "linux")]
237 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 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
261pub 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
287pub 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 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
329pub fn get_colors_in_order(color_order: &[u8]) -> HashMap<&'static str, &'static str> {
347 let mut entries: Vec<(&'static str, &'static str)> = vec![("c0", "\x1b[1;30m")];
349
350 let mut used = vec![false; 16]; 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 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 let mut map = color_palette(&entries);
373 map.insert("reset", "\x1b[0m");
374 map
375}
376
377pub 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 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 get_builtin_distro_colors(colors_str_order).to_vec()
394 };
395
396 get_colors_in_order(&color_list)
397}
398
399pub 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}