1use crate::cli::Cli;
9use crate::config::Config;
10use crate::fetch::SystemInfo;
11use crate::fields::{self, Mode};
12use crate::logo;
13use crate::theme::Theme;
14use owo_colors::OwoColorize;
15
16pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
22 let _config = config;
23 let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
24 let mut theme = match theme_name {
25 Some(name) => Theme::from_name(name),
26 None => Theme::detect_system_theme(), };
28
29 if let Some(custom) = &_config.custom_theme {
31 theme = Theme::with_custom_overrides(theme, custom);
32 }
33
34 let term_size = terminal_size::terminal_size();
36 let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
37 w as usize
38 } else {
39 80
40 };
41 let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
44
45 let show_logo = _config.show_logo.unwrap_or(true) && !cli.no_logo && stdout_is_tty;
46
47 let allowed_fields: Option<Vec<String>> = if cli.full {
52 Some(fields::fields_for(Mode::Full))
53 } else if cli.long {
54 Some(fields::fields_for(Mode::Long))
55 } else if cli.short {
56 Some(fields::fields_for(Mode::Short))
57 } else if let Some(fields) = &_config.fields {
58 Some(fields.iter().map(|s| s.to_lowercase()).collect())
59 } else {
60 Some(fields::fields_for(Mode::Standard))
61 };
62
63 let should_show = |label: &str| -> bool {
64 match &allowed_fields {
65 Some(fields) => {
66 let norm_label = label.to_lowercase().replace(['-', '_'], " ");
67 let norm_label_no_spaces = norm_label.replace(' ', "");
68 fields.iter().any(|f| {
69 let norm_f = f.to_lowercase().replace(['-', '_'], " ");
70 norm_f == norm_label
71 || norm_f.replace(' ', "") == norm_label_no_spaces
72 || (norm_label == "dns server" && norm_f == "dns")
74 || (norm_label == "memory usage" && norm_f == "memory")
76 })
77 }
78 None => true,
79 }
80 };
81
82 let label_width = 10;
84 let mut info_lines = Vec::new();
85 let mut print_line = |label: &str, value: &str| {
86 if should_show(label) {
87 info_lines.push(format!(
88 "{:>width$}{} {}",
89 theme.color_label(label),
90 theme.color_separator(":"),
91 theme.color_value(value),
92 width = label_width
93 ));
94 }
95 };
96
97 print_line("OS", &info.os);
99 if let Some(kernel) = &info.kernel {
100 print_line("Kernel", kernel);
101 }
102 if let Some(host) = &info.hostname {
103 print_line("Host", host);
104 }
105 if let Some(domain) = &info.domain {
106 print_line("Domain", domain);
107 }
108 if should_show("domain-search") {
109 for entry in &info.domain_search {
110 print_line("Domain Search", entry);
111 }
112 }
113 if let Some(chassis) = &info.chassis {
114 print_line("Chassis", chassis);
115 }
116 if let Some(init) = &info.init_system {
117 print_line("Init", init);
118 }
119 if let Some(locale) = &info.locale {
120 print_line("Locale", locale);
121 }
122 print_line("Arch", &info.arch);
123 print_line("Users", &info.users.to_string());
124 if let Some(pkgs) = info.packages {
125 if pkgs > 0 {
126 print_line("Packages", &pkgs.to_string());
127 }
128 }
129 if let Some(user) = &info.current_user {
130 print_line("User", user);
131 }
132 let uptime_str = format_uptime(&info.uptime);
134 let boot_display = format!("{} since {}", uptime_str, info.boot_time);
135 print_line("Uptime", &boot_display);
136
137 print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
139 if let Some(freq) = &info.cpu_freq {
140 print_line("CPU Freq", freq);
141 }
142 if let Some(cache) = &info.cpu_cache {
143 print_line("CPU Cache", cache);
144 }
145 if let Some(usage) = &info.cpu_usage {
146 print_line("CPU Usage", usage);
147 }
148 if let Some(motherboard) = &info.motherboard {
149 print_line("Motherboard", motherboard);
150 }
151 if let Some(bios) = &info.bios {
152 print_line("BIOS", bios);
153 }
154 if let Some(bootmgr) = &info.bootmgr {
155 print_line("Bootmgr", bootmgr);
156 }
157 if should_show("GPU") {
158 for gpu in &info.gpu {
159 print_line("GPU", gpu);
160 }
161 }
162 if should_show("Display") {
163 for display in &info.displays {
164 print_line("Display", display);
165 }
166 }
167 if let Some(audio) = &info.audio {
168 print_line("Audio", audio);
169 }
170 if should_show("Camera") {
171 for cam in &info.camera {
172 print_line("Camera", cam);
173 }
174 }
175 if should_show("Gamepad") {
176 for gp in &info.gamepad {
177 print_line("Gamepad", gp);
178 }
179 }
180 if let Some(wifi) = &info.wifi {
181 print_line("Wi-Fi", wifi);
182 }
183 if let Some(bt) = &info.bluetooth {
184 print_line("Bluetooth", bt);
185 }
186 if let Some(bat) = &info.battery {
187 print_line("Battery", bat);
188 }
189 print_line("Memory Usage", &info.memory);
190 if let Some(phys_mem) = &info.physical_memory {
191 print_line("Phys Mem", phys_mem);
192 }
193 print_line("Swap", &info.swap);
194 print_line("Procs", &info.processes.to_string());
195 if let Some(load) = &info.load_avg {
196 print_line("Load", load);
197 }
198 if should_show("Disk") {
199 for disk in &info.disks {
200 print_line("Disk", disk);
201 }
202 }
203 if should_show("Phys Disk") {
204 for disk in &info.physical_disks {
205 print_line("Phys Disk", disk);
206 }
207 }
208 if should_show("Btrfs") {
209 for vol in &info.btrfs {
210 print_line("Btrfs", vol);
211 }
212 }
213 if should_show("Zpool") {
214 for pool in &info.zpool {
215 print_line("Zpool", pool);
216 }
217 }
218 if should_show("Temp") {
219 if cli.full {
220 for temp in &info.temps {
221 print_line("Temp", temp);
222 }
223 } else {
224 for temp in consolidate_temps(&info.temps) {
225 print_line("Temp", &temp);
226 }
227 }
228 }
229
230 if should_show("Net") {
232 if cli.long || cli.full {
233 for net in &info.networks {
234 if let Some(ref active) = info.active_interface {
235 if net.contains(active) {
236 print_line("Net", &net.bright_blue().to_string());
237 }
238 }
239 }
240 for net in &info.networks {
241 if let Some(ref active) = info.active_interface {
242 if net.contains(active) {
243 continue;
244 }
245 }
246 print_line("Net", net);
247 }
248 } else {
249 let mut printed = false;
250 if let Some(ref active) = info.active_interface {
251 for net in &info.networks {
252 if net.contains(active) {
253 print_line("Net", net);
254 printed = true;
255 break;
256 }
257 }
258 }
259 if !printed {
260 for net in &info.networks {
261 if net.contains("[Up]") {
262 print_line("Net", net);
263 break;
264 }
265 }
266 }
267 }
268 }
269 if let Some(ip) = &info.public_ip {
270 print_line("Public IP", ip);
271 }
272 if !info.dns.is_empty() {
273 print_line("DNS Server", &info.dns.join(", "));
274 }
275
276 if let Some(shell) = &info.shell {
278 print_line("Shell", shell);
279 }
280 if let Some(editor) = &info.editor {
281 print_line("Editor", editor);
282 }
283 if let Some(term) = &info.terminal {
284 print_line("Terminal", term);
285 }
286 if let Some(ts) = &info.terminal_size {
287 print_line("Terminal Size", ts);
288 }
289 if let Some(de) = &info.desktop {
290 print_line("Desktop", de);
291 }
292 if let Some(wm) = &info.wm {
293 let duplicate = info
294 .desktop
295 .as_deref()
296 .map(|de| de.to_lowercase() == wm.to_lowercase())
297 .unwrap_or(false);
298 if !duplicate {
299 print_line("WM", wm);
300 }
301 }
302 if let Some(ui_theme) = &info.ui_theme {
303 print_line("Theme", ui_theme);
304 }
305 if let Some(icons) = &info.icons {
306 print_line("Icons", icons);
307 }
308 if let Some(cursor) = &info.cursor {
309 print_line("Cursor", cursor);
310 }
311 if let Some(font) = &info.font {
312 print_line("Font", font);
313 }
314 if let Some(term_font) = &info.terminal_font {
315 print_line("Terminal Font", term_font);
316 }
317 if let Some(weather) = &info.weather {
318 print_line("Weather", weather);
319 }
320
321 enum ActiveLogo {
323 Lines(Vec<String>),
324 Kitty(Vec<u8>, usize), Iterm2(Vec<u8>, usize),
326 Sixel(Vec<u8>, usize),
327 None,
328 }
329
330 let mut active_logo = ActiveLogo::None;
331
332 if show_logo {
333 let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
334 let user_logo = if let Some(config_dir) = dirs::config_dir() {
335 let p = config_dir.join("retch").join("logo.png");
336 if p.exists() {
337 Some(p)
338 } else {
339 None
340 }
341 } else {
342 None
343 };
344
345 if cli.ascii_logo {
346 active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
347 } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
348 let mut resolved = false;
349 if logo::chafa_available() {
350 if let Some(path) = &user_logo {
351 if let Some(lines) = logo::get_chafa_logo_lines(path) {
352 active_logo = ActiveLogo::Lines(lines);
353 resolved = true;
354 }
355 } else if let Some(distro) = &distro_hint {
356 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
357 let temp_path = std::env::temp_dir()
358 .join(format!("retch_logo_{}.png", std::process::id()));
359 if std::fs::write(&temp_path, bytes).is_ok() {
360 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
361 active_logo = ActiveLogo::Lines(lines);
362 resolved = true;
363 }
364 let _ = std::fs::remove_file(&temp_path);
365 }
366 }
367 }
368 }
369 if !resolved {
370 active_logo =
371 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
372 }
373 } else {
374 let mut resolved = false;
375
376 #[cfg(feature = "graphics")]
378 if !resolved && logo::supports_kitty() {
379 if let Some(path) = &user_logo {
380 if let Ok(bytes) = std::fs::read(path) {
381 let h = graphical_logo_height_lines(&bytes);
382 active_logo = ActiveLogo::Kitty(bytes, h);
383 resolved = true;
384 }
385 } else if let Some(distro) = &distro_hint {
386 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
387 let h = graphical_logo_height_lines(bytes);
388 active_logo = ActiveLogo::Kitty(bytes.to_vec(), h);
389 resolved = true;
390 }
391 }
392 }
393
394 #[cfg(feature = "graphics")]
396 if !resolved && logo::supports_iterm2() {
397 if let Some(path) = &user_logo {
398 if let Ok(bytes) = std::fs::read(path) {
399 let h = graphical_logo_height_lines(&bytes);
400 active_logo = ActiveLogo::Iterm2(bytes, h);
401 resolved = true;
402 }
403 } else if let Some(distro) = &distro_hint {
404 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
405 let h = graphical_logo_height_lines(bytes);
406 active_logo = ActiveLogo::Iterm2(bytes.to_vec(), h);
407 resolved = true;
408 }
409 }
410 }
411
412 #[cfg(feature = "graphics")]
414 if !resolved && logo::supports_sixel() {
415 if let Some(path) = &user_logo {
416 if let Ok(bytes) = std::fs::read(path) {
417 let h = graphical_logo_height_lines(&bytes);
418 active_logo = ActiveLogo::Sixel(bytes, h);
419 resolved = true;
420 }
421 } else if let Some(distro) = &distro_hint {
422 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
423 let h = graphical_logo_height_lines(bytes);
424 active_logo = ActiveLogo::Sixel(bytes.to_vec(), h);
425 resolved = true;
426 }
427 }
428 }
429
430 if !resolved && logo::chafa_available() {
432 if let Some(path) = &user_logo {
433 if let Some(lines) = logo::get_chafa_logo_lines(path) {
434 active_logo = ActiveLogo::Lines(lines);
435 resolved = true;
436 }
437 } else if let Some(distro) = &distro_hint {
438 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
439 let temp_path = std::env::temp_dir()
441 .join(format!("retch_logo_{}.png", std::process::id()));
442 if std::fs::write(&temp_path, bytes).is_ok() {
443 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
444 active_logo = ActiveLogo::Lines(lines);
445 resolved = true;
446 }
447 let _ = std::fs::remove_file(&temp_path);
448 }
449 }
450 }
451 }
452
453 if !resolved {
455 active_logo =
456 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
457 }
458 }
459 }
460
461 let visible_len = |s: &str| -> usize {
463 let mut count = 0;
464 let mut in_esc = false;
465 for c in s.chars() {
466 if c == '\x1b' {
467 in_esc = true;
468 } else if in_esc {
469 if c.is_ascii_alphabetic() {
470 in_esc = false;
471 }
472 } else {
473 count += 1;
474 }
475 }
476 count
477 };
478
479 let max_text_width = info_lines
480 .iter()
481 .map(|line| visible_len(line))
482 .max()
483 .unwrap_or(0);
484 let text_column_width = std::cmp::max(max_text_width + 4, 45);
485
486 let max_logo_width = match &active_logo {
487 ActiveLogo::Lines(logo_lines) => logo_lines
488 .iter()
489 .map(|line| visible_len(line))
490 .max()
491 .unwrap_or(0),
492 ActiveLogo::Kitty(_, _) | ActiveLogo::Iterm2(_, _) | ActiveLogo::Sixel(_, _) => 40,
493 ActiveLogo::None => 0,
494 };
495
496 let side_by_side =
497 show_logo && term_width >= 95 && term_width >= (text_column_width + max_logo_width);
498
499 println!(); if side_by_side {
502 match active_logo {
503 ActiveLogo::Lines(logo_lines) => {
504 let max_lines = std::cmp::max(info_lines.len(), logo_lines.len());
505 for i in 0..max_lines {
506 let info_line = info_lines.get(i).cloned().unwrap_or_default();
507 let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
508 let vis_len = visible_len(&info_line);
509 let padding = if vis_len < text_column_width {
510 " ".repeat(text_column_width - vis_len)
511 } else {
512 String::new()
513 };
514 println!("{}{}{}", info_line, padding, logo_line);
515 }
516 }
517 ActiveLogo::Kitty(bytes, height_lines) => {
518 for line in &info_lines {
519 println!("{}", line);
520 }
521 let num_lines = info_lines.len();
522 print!("\x1b7"); if num_lines > 0 {
524 print!("\x1b[{}A", num_lines); }
526 print!("\x1b[{}C", text_column_width); logo::print_graphical_logo(&bytes);
528 print!("\x1b8"); let overflow = height_lines.saturating_sub(num_lines);
531 if overflow > 0 {
532 print!("\x1b[{}B", overflow);
533 }
534 }
535 ActiveLogo::Iterm2(bytes, height_lines) => {
536 for line in &info_lines {
537 println!("{}", line);
538 }
539 let num_lines = info_lines.len();
540 print!("\x1b7"); if num_lines > 0 {
542 print!("\x1b[{}A", num_lines); }
544 print!("\x1b[{}C", text_column_width); logo::print_iterm2_logo(&bytes);
546 print!("\x1b8"); let overflow = height_lines.saturating_sub(num_lines);
548 if overflow > 0 {
549 print!("\x1b[{}B", overflow);
550 }
551 }
552 ActiveLogo::Sixel(bytes, height_lines) => {
553 for line in &info_lines {
554 println!("{}", line);
555 }
556 let num_lines = info_lines.len();
557 print!("\x1b7"); if num_lines > 0 {
559 print!("\x1b[{}A", num_lines); }
561 print!("\x1b[{}C", text_column_width); logo::print_sixel_logo(&bytes);
563 print!("\x1b8"); let overflow = height_lines.saturating_sub(num_lines);
565 if overflow > 0 {
566 print!("\x1b[{}B", overflow);
567 }
568 }
569 ActiveLogo::None => {
570 for line in &info_lines {
571 println!("{}", line);
572 }
573 }
574 }
575 } else {
576 match active_logo {
578 ActiveLogo::Lines(logo_lines) => {
579 for line in logo_lines {
580 println!("{}", line);
581 }
582 println!();
583 }
584 ActiveLogo::Kitty(bytes, _) => {
585 logo::print_graphical_logo(&bytes);
586 println!();
587 }
588 ActiveLogo::Iterm2(bytes, _) => {
589 logo::print_iterm2_logo(&bytes);
590 println!();
591 }
592 ActiveLogo::Sixel(bytes, _) => {
593 logo::print_sixel_logo(&bytes);
594 println!();
595 }
596 ActiveLogo::None => {}
597 }
598 for line in &info_lines {
599 println!("{}", line);
600 }
601 }
602
603 Ok(())
604}
605
606fn consolidate_temps(temps: &[String]) -> Vec<String> {
612 fn categorize(label: &str) -> &'static str {
613 let l = label.to_lowercase();
614 if l.contains("cpu")
615 || l.contains("core")
616 || l.contains("k10temp")
617 || l.contains("k8temp")
618 || l.contains("coretemp")
619 || l.contains("tctl")
620 || l.contains("tdie")
621 || l.contains("tccd")
622 || l.contains("package")
623 {
624 "CPU"
625 } else if l.contains("gpu")
626 || l.contains("nouveau")
627 || l.contains("radeon")
628 || l.contains("amdgpu")
629 {
630 "GPU"
631 } else if l.contains("nvme") || l.contains("nand") {
632 "NVMe"
633 } else if l.contains("ath")
634 || l.contains("wifi")
635 || l.contains("wireless")
636 || l.contains("wlan")
637 || l.contains("iwl")
638 {
639 "WiFi"
640 } else if l.contains("bat") {
641 "Battery"
642 } else {
643 "System"
644 }
645 }
646
647 let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
648 for s in temps {
649 if let Some((label_part, val_part)) = s.rsplit_once(':') {
651 let val_str = val_part.trim().trim_end_matches("°C");
652 if let Ok(val) = val_str.parse::<f32>() {
653 let cat = categorize(label_part.trim());
654 let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
655 if val > *entry {
656 *entry = val;
657 }
658 }
659 }
660 }
661
662 const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
663 ORDER
664 .iter()
665 .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
666 .collect()
667}
668
669fn format_uptime(uptime: &str) -> String {
673 let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
675
676 let years = seconds / (365 * 24 * 3600);
677 let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
678 let hours = (seconds % (24 * 3600)) / 3600;
679 let minutes = (seconds % 3600) / 60;
680 let secs = seconds % 60;
681
682 let mut parts = Vec::new();
683 if years > 0 {
684 parts.push(format!("{}y", years));
685 }
686 if days > 0 {
687 parts.push(format!("{}d", days));
688 }
689 if hours > 0 {
690 parts.push(format!("{}h", hours));
691 }
692 if minutes > 0 {
693 parts.push(format!("{}m", minutes));
694 }
695 if secs > 0 || parts.is_empty() {
696 parts.push(format!("{}s", secs));
697 }
698
699 parts.join(" ")
700}
701
702#[cfg(feature = "graphics")]
707fn graphical_logo_height_lines(bytes: &[u8]) -> usize {
708 let img_h = image::load_from_memory(bytes)
709 .map(|img| img.height() as usize)
710 .unwrap_or(384);
711 let cell_h = terminal_cell_height_px();
712 img_h.div_ceil(cell_h)
713}
714
715fn terminal_cell_height_px() -> usize {
717 #[cfg(unix)]
718 {
719 use std::mem::MaybeUninit;
720 let mut ws: libc::winsize = unsafe { MaybeUninit::zeroed().assume_init() };
721 let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
722 if ret == 0 && ws.ws_row > 0 && ws.ws_ypixel > 0 {
723 return ws.ws_ypixel as usize / ws.ws_row as usize;
724 }
725 }
726 20
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn test_consolidate_temps_basic() {
735 let raw = vec![
736 "k10temp Tctl: 83°C".to_string(),
737 "amdgpu edge: 65°C".to_string(),
738 "nvme Composite: 62°C".to_string(),
739 "ath11k_hwmon temp1: 58°C".to_string(),
740 "acpitz temp1: 77°C".to_string(),
741 ];
742 let result = consolidate_temps(&raw);
743 assert_eq!(
744 result,
745 vec![
746 "CPU: 83°C",
747 "GPU: 65°C",
748 "NVMe: 62°C",
749 "WiFi: 58°C",
750 "System: 77°C"
751 ]
752 );
753 }
754
755 #[test]
756 fn test_consolidate_temps_highest_wins() {
757 let raw = vec![
758 "thinkpad CPU: 83°C".to_string(),
759 "k10temp Tctl: 79°C".to_string(),
760 "nvme Composite: 62°C".to_string(),
761 "nvme Sensor 1: 59°C".to_string(),
762 "nvme Sensor 2: 56°C".to_string(),
763 ];
764 let result = consolidate_temps(&raw);
765 assert!(result.contains(&"CPU: 83°C".to_string()));
766 assert!(result.contains(&"NVMe: 62°C".to_string()));
767 assert!(!result
768 .iter()
769 .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
770 }
771
772 #[test]
773 fn test_consolidate_temps_order() {
774 let raw = vec![
775 "acpitz: 60°C".to_string(),
776 "nvme: 55°C".to_string(),
777 "amdgpu edge: 65°C".to_string(),
778 "k10temp Tctl: 80°C".to_string(),
779 ];
780 let result = consolidate_temps(&raw);
781 let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
782 let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
783 let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
784 let sys_pos = result.iter().position(|s| s.starts_with("System"));
785 assert!(cpu_pos < gpu_pos);
786 assert!(gpu_pos < nvme_pos);
787 assert!(nvme_pos < sys_pos);
788 }
789
790 #[test]
791 fn test_consolidate_temps_empty() {
792 assert!(consolidate_temps(&[]).is_empty());
793 }
794
795 #[test]
796 fn test_format_uptime() {
797 assert_eq!(format_uptime("60s"), "1m");
798 assert_eq!(format_uptime("3600s"), "1h");
799 assert_eq!(format_uptime("3661s"), "1h 1m 1s");
800 assert_eq!(format_uptime("86400s"), "1d");
801 assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
802 assert_eq!(format_uptime("31536000s"), "1y");
803 assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
804 assert_eq!(format_uptime("0s"), "0s");
805 }
806}