1use crate::cli::Cli;
9use crate::config::Config;
10use crate::fetch::SystemInfo;
11use crate::fields::{self, Mode};
12use crate::logo;
13use crate::theme::{colorize_nested, Theme, ACTIVE_IFACE_PREFIX};
14
15fn should_show_logo(
29 config_show_logo: Option<bool>,
30 no_logo: bool,
31 ascii_logo: bool,
32 stdout_is_tty: bool,
33) -> bool {
34 if no_logo {
35 return false; }
37 if ascii_logo {
38 return true; }
40 config_show_logo.unwrap_or(true) && stdout_is_tty }
42
43struct LayoutPlan {
46 side_by_side: bool,
47 text_column_width: usize,
48}
49
50fn plan_layout(
67 info_widths: &[usize],
68 logo_height: usize,
69 logo_width: usize,
70 term_width: usize,
71 show_logo: bool,
72) -> LayoutPlan {
73 let beside_count = info_widths.len().min(logo_height);
74 let max_beside_width = info_widths[..beside_count]
75 .iter()
76 .copied()
77 .max()
78 .unwrap_or(0);
79 let text_column_width = if term_width >= 95 {
80 (term_width.saturating_sub(logo_width + 4))
81 .min(std::cmp::max(max_beside_width + 4, 45))
82 .clamp(45, 65)
83 } else {
84 std::cmp::max(max_beside_width + 4, 45)
85 };
86 let side_by_side =
87 show_logo && term_width >= 95 && term_width >= text_column_width + logo_width;
88 LayoutPlan {
89 side_by_side,
90 text_column_width,
91 }
92}
93
94pub fn visible_len(s: &str) -> usize {
96 let mut count = 0;
97 let mut in_esc = false;
98 for c in s.chars() {
99 if c == '\x1b' {
100 in_esc = true;
101 } else if in_esc {
102 if c.is_ascii_alphabetic() {
103 in_esc = false;
104 }
105 } else {
106 count += 1;
107 }
108 }
109 count
110}
111
112pub fn wrap_info_line(line: &str, max_width: usize) -> Vec<String> {
118 let vis_len = visible_len(line);
119 if vis_len <= max_width || max_width < 20 {
120 return vec![line.to_string()];
121 }
122
123 let prefix_len = if let Some(idx) = line.find(':') {
124 let prefix_sub = &line[..=idx];
125 let extra_space = if line[idx + 1..].starts_with(' ') {
126 1
127 } else {
128 0
129 };
130 visible_len(prefix_sub) + extra_space
131 } else {
132 4
133 };
134
135 let indent = " ".repeat(prefix_len.min(max_width / 2));
136
137 if line.contains(", ") {
139 let parts: Vec<&str> = line.split(", ").collect();
140 let mut lines = Vec::new();
141 let mut current = String::new();
142
143 for (i, part) in parts.iter().enumerate() {
144 let item = if i == 0 {
145 part.to_string()
146 } else {
147 format!(", {}", part)
148 };
149 let item_vis = visible_len(&item);
150
151 if current.is_empty() || visible_len(¤t) + item_vis <= max_width {
152 current.push_str(&item);
153 } else {
154 lines.push(current);
155 current = format!("{}{}", indent, part);
156 }
157 }
158 if !current.is_empty() {
159 lines.push(current);
160 }
161 if lines.iter().all(|l| visible_len(l) <= max_width + 10) {
162 return lines;
163 }
164 }
165
166 let raw_words: Vec<&str> = line.split_whitespace().collect();
168 let mut words: Vec<String> = Vec::new();
169 let mut idx = 0;
170 while idx < raw_words.len() {
171 if raw_words[idx] == "RX:"
172 && idx + 3 < raw_words.len()
173 && raw_words.iter().skip(idx).any(|&w| w == "TX:")
174 {
175 let rx_tx = format!(
176 "{} {} {} {} {} {}",
177 raw_words[idx],
178 raw_words[idx + 1],
179 raw_words[idx + 2],
180 raw_words[idx + 3],
181 raw_words.get(idx + 4).copied().unwrap_or(""),
182 raw_words.get(idx + 5).copied().unwrap_or("")
183 );
184 words.push(rx_tx.trim().to_string());
185 idx += if idx + 5 < raw_words.len() { 6 } else { 4 };
186 continue;
187 }
188 words.push(raw_words[idx].to_string());
189 idx += 1;
190 }
191
192 let mut lines = Vec::new();
193 let mut current = String::new();
194
195 for word in words {
196 let word_vis = visible_len(&word);
197 if current.is_empty() {
198 current.push_str(&word);
199 } else if visible_len(¤t) + 1 + word_vis <= max_width {
200 current.push(' ');
201 current.push_str(&word);
202 } else {
203 lines.push(current);
204 current = format!("{}{}", indent, word);
205 }
206 }
207 if !current.is_empty() {
208 lines.push(current);
209 }
210
211 if lines.is_empty() {
212 vec![line.to_string()]
213 } else {
214 lines
215 }
216}
217
218fn split_wifi_line(wifi: &str) -> (&str, Option<&str>) {
227 match wifi.split_once(" - ") {
228 Some((hardware, connection)) => (hardware, Some(connection)),
229 None => (wifi, None),
230 }
231}
232
233fn graphical_side_by_side_prelude(text_column_width: usize, logo_rows: usize) -> String {
248 let mut prelude = String::new();
249 if logo_rows > 0 {
250 prelude.push_str(&"\n".repeat(logo_rows));
251 prelude.push_str(&format!("\x1b[{}A", logo_rows));
252 }
253 prelude.push_str(&format!("\x1b[{}C\x1b7", text_column_width));
254 prelude
255}
256
257fn render_graphical_side_by_side(
273 text_column_width: usize,
274 info_lines: &[String],
275 logo_rows: usize,
276 draw: impl FnOnce(),
277) {
278 use std::io::Write;
279 print!(
282 "{}",
283 graphical_side_by_side_prelude(text_column_width, logo_rows)
284 );
285 draw(); print!("\x1b8\r");
287 for line in info_lines {
288 println!("{}", line);
289 }
290 for _ in info_lines.len()..logo_rows {
293 println!();
294 }
295 let _ = std::io::stdout().flush();
296}
297
298pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
304 let _config = config;
305 let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
306 let mut theme = match theme_name {
307 Some(name) => Theme::from_name(name),
308 None => Theme::detect_system_theme(), };
310
311 if let Some(custom) = &_config.custom_theme {
313 theme = Theme::with_custom_overrides(theme, custom);
314 }
315
316 let term_size = terminal_size::terminal_size();
318 let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
319 w as usize
320 } else {
321 80
322 };
323 let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
326
327 let show_logo = should_show_logo(
328 _config.show_logo,
329 cli.no_logo,
330 cli.ascii_logo,
331 stdout_is_tty,
332 );
333
334 let allowed_fields: Option<Vec<String>> = if cli.full {
339 Some(fields::fields_for(Mode::Full))
340 } else if cli.long {
341 Some(fields::fields_for(Mode::Long))
342 } else if cli.short {
343 Some(fields::fields_for(Mode::Short))
344 } else if let Some(fields) = &_config.fields {
345 Some(fields.iter().map(|s| s.to_lowercase()).collect())
346 } else {
347 Some(fields::fields_for(Mode::Standard))
348 };
349
350 let should_show = |label: &str| -> bool {
351 match &allowed_fields {
352 Some(fields) => {
353 let norm_label = label.to_lowercase().replace(['-', '_'], " ");
354 let norm_label_no_spaces = norm_label.replace(' ', "");
355 fields.iter().any(|f| {
356 let norm_f = f.to_lowercase().replace(['-', '_'], " ");
357 norm_f == norm_label
358 || norm_f.replace(' ', "") == norm_label_no_spaces
359 || (norm_label == "dns server" && norm_f == "dns")
361 || (norm_label == "memory usage" && norm_f == "memory")
363 || (norm_label == "wi fi link" && norm_f == "wifi")
365 })
366 }
367 None => true,
368 }
369 };
370
371 let label_width = 10;
373 let mut info_lines = Vec::new();
374 let mut print_line = |label: &str, value: &str| {
375 if should_show(label) {
376 info_lines.push(format!(
377 "{:>width$}{} {}",
378 theme.color_label(label),
379 theme.color_separator(":"),
380 theme.color_value(value),
381 width = label_width
382 ));
383 }
384 };
385
386 print_line("OS", &info.os);
388 if let Some(kernel) = &info.kernel {
389 print_line("Kernel", kernel);
390 }
391 if let Some(host) = &info.hostname {
392 print_line("Host", host);
393 }
394 if let Some(domain) = &info.domain {
395 print_line("Domain", domain);
396 }
397 if should_show("domain-search") {
398 for entry in &info.domain_search {
399 print_line("Domain Search", entry);
400 }
401 }
402 if let Some(chassis) = &info.chassis {
403 print_line("Chassis", chassis);
404 }
405 if let Some(init) = &info.init_system {
406 print_line("Init", init);
407 }
408 if let Some(locale) = &info.locale {
409 print_line("Locale", locale);
410 }
411 print_line("Arch", &info.arch);
412 if info.users > 0 {
416 print_line("Users", &info.users.to_string());
417 }
418 if let Some(pkgs) = info.packages {
419 if pkgs > 0 {
420 print_line("Packages", &pkgs.to_string());
421 }
422 }
423 if let Some(user) = &info.current_user {
424 print_line("User", user);
425 }
426 let uptime_str = format_uptime(&info.uptime);
428 let boot_display = format!("{} since {}", uptime_str, info.boot_time);
429 print_line("Uptime", &boot_display);
430
431 print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
433 if let Some(freq) = &info.cpu_freq {
434 print_line("CPU Freq", freq);
435 }
436 if let Some(cache) = &info.cpu_cache {
437 print_line("CPU Cache", cache);
438 }
439 if let Some(usage) = &info.cpu_usage {
440 print_line("CPU Usage", usage);
441 }
442 if let Some(motherboard) = &info.motherboard {
443 print_line("Motherboard", motherboard);
444 }
445 if let Some(bios) = &info.bios {
446 print_line("BIOS", bios);
447 }
448 if let Some(bootmgr) = &info.bootmgr {
449 print_line("Bootmgr", bootmgr);
450 }
451 if let Some(tpm) = &info.tpm {
452 print_line("TPM", tpm);
453 }
454 if should_show("GPU") {
455 for gpu in &info.gpu {
456 print_line("GPU", gpu);
457 }
458 }
459 if should_show("Display") {
460 for display in &info.displays {
461 print_line("Display", display);
462 }
463 }
464 if let Some(brightness) = &info.brightness {
465 print_line("Brightness", brightness);
466 }
467 if let Some(audio) = &info.audio {
468 print_line("Audio", audio);
469 }
470 if should_show("Camera") {
471 for cam in &info.camera {
472 print_line("Camera", cam);
473 }
474 }
475 if should_show("Gamepad") {
476 for gp in &info.gamepad {
477 print_line("Gamepad", gp);
478 }
479 }
480 if should_show("Keyboard") {
481 for kb in &info.keyboard {
482 print_line("Keyboard", kb);
483 }
484 }
485 if should_show("Mouse") {
486 for m in &info.mouse {
487 print_line("Mouse", m);
488 }
489 }
490 if let Some(wifi) = &info.wifi {
491 let (hardware, connection) = split_wifi_line(wifi);
494 print_line("Wi-Fi", hardware);
495 if let Some(conn) = connection {
496 print_line("Wi-Fi Link", conn);
497 }
498 }
499 if let Some(bt) = &info.bluetooth {
500 print_line("Bluetooth", bt);
501 }
502 if let Some(bat) = &info.battery {
503 print_line("Battery", bat);
504 }
505 if let Some(power) = &info.power_adapter {
506 print_line("Power Adapter", power);
507 }
508 print_line("Memory Usage", &info.memory);
509 if let Some(phys_mem) = &info.physical_memory {
510 print_line("Phys Mem", phys_mem);
511 }
512 print_line("Swap", &info.swap);
513 print_line("Procs", &info.processes.to_string());
514 if let Some(load) = &info.load_avg {
515 print_line("Load", load);
516 }
517 if should_show("Disk") {
518 for disk in &info.disks {
519 print_line("Disk", disk);
520 }
521 }
522 if should_show("Phys Disk") {
523 for disk in &info.physical_disks {
524 print_line("Phys Disk", disk);
525 }
526 }
527 if should_show("Btrfs") {
528 for vol in &info.btrfs {
529 print_line("Btrfs", vol);
530 }
531 }
532 if should_show("Zpool") {
533 for pool in &info.zpool {
534 print_line("Zpool", pool);
535 }
536 }
537 if should_show("Temp") {
538 if cli.full {
539 for temp in &info.temps {
540 print_line("Temp", temp);
541 }
542 } else {
543 for temp in consolidate_temps(&info.temps) {
544 print_line("Temp", &temp);
545 }
546 }
547 }
548
549 if should_show("Net") {
551 if cli.long || cli.full {
552 for net in &info.networks {
553 if let Some(ref active) = info.active_interface {
554 if net.contains(active) {
555 print_line("Net", &colorize_nested(net, ACTIVE_IFACE_PREFIX));
559 }
560 }
561 }
562 for net in &info.networks {
563 if let Some(ref active) = info.active_interface {
564 if net.contains(active) {
565 continue;
566 }
567 }
568 print_line("Net", net);
569 }
570 } else {
571 let mut printed = false;
572 if let Some(ref active) = info.active_interface {
573 for net in &info.networks {
574 if net.contains(active) {
575 print_line("Net", net);
576 printed = true;
577 break;
578 }
579 }
580 }
581 if !printed {
582 for net in &info.networks {
583 if net.contains("[Up]") {
584 print_line("Net", net);
585 break;
586 }
587 }
588 }
589 }
590 }
591 if let Some(ip) = &info.public_ip {
592 print_line("Public IP", ip);
593 }
594 if !info.dns.is_empty() {
595 print_line("DNS Server", &info.dns.join(", "));
596 }
597
598 if let Some(shell) = &info.shell {
600 print_line("Shell", shell);
601 }
602 if let Some(editor) = &info.editor {
603 print_line("Editor", editor);
604 }
605 if let Some(term) = &info.terminal {
606 print_line("Terminal", term);
607 }
608 if let Some(ts) = &info.terminal_size {
609 print_line("Terminal Size", ts);
610 }
611 if let Some(de) = &info.desktop {
612 print_line("Desktop", de);
613 }
614 if let Some(wm) = &info.wm {
615 let duplicate = info
616 .desktop
617 .as_deref()
618 .map(|de| de.to_lowercase() == wm.to_lowercase())
619 .unwrap_or(false);
620 if !duplicate {
621 print_line("WM", wm);
622 }
623 }
624 if let Some(lm) = &info.login_manager {
625 print_line("Login Manager", lm);
626 }
627 if let Some(player) = &info.player {
628 print_line("Player", player);
629 }
630 if let Some(media) = &info.media {
631 print_line("Media", media);
632 }
633 if let Some(ui_theme) = &info.ui_theme {
634 print_line("Theme", ui_theme);
635 }
636 if let Some(icons) = &info.icons {
637 print_line("Icons", icons);
638 }
639 if let Some(cursor) = &info.cursor {
640 print_line("Cursor", cursor);
641 }
642 if let Some(font) = &info.font {
643 print_line("Font", font);
644 }
645 if let Some(term_font) = &info.terminal_font {
646 print_line("Terminal Font", term_font);
647 }
648 if let Some(weather) = &info.weather {
649 print_line("Weather", weather);
650 }
651
652 enum ActiveLogo {
654 Lines(Vec<String>),
655 Kitty(Vec<u8>, usize, usize), Iterm2(Vec<u8>, usize, usize),
657 Sixel(Vec<u8>, usize, usize),
658 None,
659 }
660
661 let mut active_logo = ActiveLogo::None;
662
663 if show_logo {
664 let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
665 let user_logo = if let Some(config_dir) = dirs::config_dir() {
666 let p = config_dir.join("retch").join("logo.png");
667 if p.exists() {
668 Some(p)
669 } else {
670 None
671 }
672 } else {
673 None
674 };
675
676 if cli.ascii_logo {
677 active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
678 } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
679 let mut resolved = false;
680 if logo::chafa_available() {
681 if let Some(path) = &user_logo {
682 if let Some(lines) = logo::get_chafa_logo_lines(path) {
683 active_logo = ActiveLogo::Lines(lines);
684 resolved = true;
685 }
686 } else if let Some(distro) = &distro_hint {
687 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
688 let temp_path = std::env::temp_dir()
689 .join(format!("retch_logo_{}.png", std::process::id()));
690 if std::fs::write(&temp_path, bytes).is_ok() {
691 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
692 active_logo = ActiveLogo::Lines(lines);
693 resolved = true;
694 }
695 let _ = std::fs::remove_file(&temp_path);
696 }
697 }
698 }
699 }
700 if !resolved {
701 active_logo =
702 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
703 }
704 } else {
705 let mut resolved = false;
706
707 #[cfg(feature = "graphics")]
709 if !resolved && logo::supports_kitty() {
710 if let Some(path) = &user_logo {
711 if let Ok(bytes) = std::fs::read(path) {
712 let (cols, rows) = graphical_logo_cells(&bytes);
713 active_logo = ActiveLogo::Kitty(bytes, cols, rows);
714 resolved = true;
715 }
716 } else if let Some(distro) = &distro_hint {
717 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
718 let (cols, rows) = graphical_logo_cells(bytes);
719 active_logo = ActiveLogo::Kitty(bytes.to_vec(), cols, rows);
720 resolved = true;
721 }
722 }
723 }
724
725 #[cfg(feature = "graphics")]
727 if !resolved && logo::supports_iterm2() {
728 if let Some(path) = &user_logo {
729 if let Ok(bytes) = std::fs::read(path) {
730 let (cols, rows) = graphical_logo_cells(&bytes);
731 active_logo = ActiveLogo::Iterm2(bytes, cols, rows);
732 resolved = true;
733 }
734 } else if let Some(distro) = &distro_hint {
735 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
736 let (cols, rows) = graphical_logo_cells(bytes);
737 active_logo = ActiveLogo::Iterm2(bytes.to_vec(), cols, rows);
738 resolved = true;
739 }
740 }
741 }
742
743 #[cfg(feature = "graphics")]
745 if !resolved && logo::supports_sixel() {
746 if let Some(path) = &user_logo {
747 if let Ok(bytes) = std::fs::read(path) {
748 let (cols, rows) = graphical_logo_cells(&bytes);
749 active_logo = ActiveLogo::Sixel(bytes, cols, rows);
750 resolved = true;
751 }
752 } else if let Some(distro) = &distro_hint {
753 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
754 let (cols, rows) = graphical_logo_cells(bytes);
755 active_logo = ActiveLogo::Sixel(bytes.to_vec(), cols, rows);
756 resolved = true;
757 }
758 }
759 }
760
761 if !resolved && logo::chafa_available() {
763 if let Some(path) = &user_logo {
764 if let Some(lines) = logo::get_chafa_logo_lines(path) {
765 active_logo = ActiveLogo::Lines(lines);
766 resolved = true;
767 }
768 } else if let Some(distro) = &distro_hint {
769 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
770 let temp_path = std::env::temp_dir()
772 .join(format!("retch_logo_{}.png", std::process::id()));
773 if std::fs::write(&temp_path, bytes).is_ok() {
774 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
775 active_logo = ActiveLogo::Lines(lines);
776 resolved = true;
777 }
778 let _ = std::fs::remove_file(&temp_path);
779 }
780 }
781 }
782 }
783
784 if !resolved {
786 active_logo =
787 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
788 }
789 }
790 }
791
792 let visible_len = |s: &str| -> usize {
794 let mut count = 0;
795 let mut in_esc = false;
796 for c in s.chars() {
797 if c == '\x1b' {
798 in_esc = true;
799 } else if in_esc {
800 if c.is_ascii_alphabetic() {
801 in_esc = false;
802 }
803 } else {
804 count += 1;
805 }
806 }
807 count
808 };
809
810 let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
811
812 let (logo_height, max_logo_width) = match &active_logo {
816 ActiveLogo::Lines(logo_lines) => (
817 logo_lines.len(),
818 logo_lines
819 .iter()
820 .map(|line| visible_len(line))
821 .max()
822 .unwrap_or(0),
823 ),
824 ActiveLogo::Kitty(_, cols, rows)
825 | ActiveLogo::Iterm2(_, cols, rows)
826 | ActiveLogo::Sixel(_, cols, rows) => (*rows, *cols),
827 ActiveLogo::None => (0, 0),
828 };
829
830 let LayoutPlan {
833 side_by_side,
834 text_column_width,
835 } = plan_layout(
836 &info_widths,
837 logo_height,
838 max_logo_width,
839 term_width,
840 show_logo,
841 );
842
843 println!(); let formatted_info_lines: Vec<String> = if side_by_side && text_column_width > 15 {
846 let mut result = Vec::new();
847 for (i, line) in info_lines.iter().enumerate() {
848 let max_w = if i < logo_height {
849 text_column_width.saturating_sub(2)
850 } else {
851 term_width.saturating_sub(2)
852 };
853 result.extend(wrap_info_line(line, max_w));
854 }
855 result
856 } else {
857 info_lines.clone()
858 };
859
860 if side_by_side {
861 match active_logo {
862 ActiveLogo::Lines(logo_lines) => {
863 let max_lines = std::cmp::max(formatted_info_lines.len(), logo_lines.len());
864 for i in 0..max_lines {
865 let info_line = formatted_info_lines.get(i).cloned().unwrap_or_default();
866 let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
867 let vis_len = visible_len(&info_line);
868 let padding = if vis_len < text_column_width {
869 " ".repeat(text_column_width - vis_len)
870 } else {
871 String::new()
872 };
873 println!("{}{}{}", info_line, padding, logo_line);
874 }
875 }
876 ActiveLogo::Kitty(bytes, _, logo_rows) => {
877 render_graphical_side_by_side(
878 text_column_width,
879 &formatted_info_lines,
880 logo_rows,
881 || logo::print_graphical_logo(&bytes),
882 );
883 }
884 ActiveLogo::Iterm2(bytes, _, logo_rows) => {
885 render_graphical_side_by_side(
886 text_column_width,
887 &formatted_info_lines,
888 logo_rows,
889 || logo::print_iterm2_logo(&bytes),
890 );
891 }
892 ActiveLogo::Sixel(bytes, _, logo_rows) => {
893 render_graphical_side_by_side(
894 text_column_width,
895 &formatted_info_lines,
896 logo_rows,
897 || logo::print_sixel_logo(&bytes),
898 );
899 }
900 ActiveLogo::None => {
901 for line in &formatted_info_lines {
902 println!("{}", line);
903 }
904 }
905 }
906 } else {
907 match active_logo {
909 ActiveLogo::Lines(logo_lines) => {
910 for line in logo_lines {
911 println!("{}", line);
912 }
913 println!();
914 }
915 ActiveLogo::Kitty(bytes, _, _) => {
916 logo::print_graphical_logo(&bytes);
917 println!();
918 }
919 ActiveLogo::Iterm2(bytes, _, _) => {
920 logo::print_iterm2_logo(&bytes);
921 println!();
922 }
923 ActiveLogo::Sixel(bytes, _, _) => {
924 logo::print_sixel_logo(&bytes);
925 println!();
926 }
927 ActiveLogo::None => {}
928 }
929 for line in &info_lines {
930 println!("{}", line);
931 }
932 }
933
934 Ok(())
935}
936
937fn consolidate_temps(temps: &[String]) -> Vec<String> {
943 fn categorize(label: &str) -> &'static str {
944 let l = label.to_lowercase();
945 if l.contains("cpu")
946 || l.contains("core")
947 || l.contains("k10temp")
948 || l.contains("k8temp")
949 || l.contains("coretemp")
950 || l.contains("tctl")
951 || l.contains("tdie")
952 || l.contains("tccd")
953 || l.contains("package")
954 {
955 "CPU"
956 } else if l.contains("gpu")
957 || l.contains("nouveau")
958 || l.contains("radeon")
959 || l.contains("amdgpu")
960 {
961 "GPU"
962 } else if l.contains("nvme") || l.contains("nand") {
963 "NVMe"
964 } else if l.contains("ath")
965 || l.contains("wifi")
966 || l.contains("wireless")
967 || l.contains("wlan")
968 || l.contains("iwl")
969 {
970 "WiFi"
971 } else if l.contains("bat") {
972 "Battery"
973 } else {
974 "System"
975 }
976 }
977
978 let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
979 for s in temps {
980 if let Some((label_part, val_part)) = s.rsplit_once(':') {
982 let val_str = val_part.trim().trim_end_matches("°C");
983 if let Ok(val) = val_str.parse::<f32>() {
984 let cat = categorize(label_part.trim());
985 let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
986 if val > *entry {
987 *entry = val;
988 }
989 }
990 }
991 }
992
993 const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
994 ORDER
995 .iter()
996 .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
997 .collect()
998}
999
1000fn format_uptime(uptime: &str) -> String {
1004 let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
1006
1007 let years = seconds / (365 * 24 * 3600);
1008 let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
1009 let hours = (seconds % (24 * 3600)) / 3600;
1010 let minutes = (seconds % 3600) / 60;
1011 let secs = seconds % 60;
1012
1013 let mut parts = Vec::new();
1014 if years > 0 {
1015 parts.push(format!("{}y", years));
1016 }
1017 if days > 0 {
1018 parts.push(format!("{}d", days));
1019 }
1020 if hours > 0 {
1021 parts.push(format!("{}h", hours));
1022 }
1023 if minutes > 0 {
1024 parts.push(format!("{}m", minutes));
1025 }
1026 if secs > 0 || parts.is_empty() {
1027 parts.push(format!("{}s", secs));
1028 }
1029
1030 parts.join(" ")
1031}
1032
1033#[cfg(feature = "graphics")]
1042fn graphical_logo_cells(bytes: &[u8]) -> (usize, usize) {
1043 let (img_w, img_h) = image::load_from_memory(bytes)
1044 .map(|img| (img.width(), img.height()))
1045 .unwrap_or((0, 0));
1046 let fit = logo::logo_cells_for(img_w, img_h);
1047 (fit.cols, fit.rows)
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use super::*;
1053
1054 #[test]
1057 fn test_show_logo_auto_requires_tty() {
1058 assert!(should_show_logo(None, false, false, true));
1060 assert!(!should_show_logo(None, false, false, false));
1061 }
1062
1063 #[test]
1064 fn test_show_logo_ascii_forces_without_tty() {
1065 assert!(should_show_logo(None, false, true, false));
1067 assert!(should_show_logo(None, false, true, true));
1068 }
1069
1070 #[test]
1071 fn test_show_logo_no_logo_always_wins() {
1072 assert!(!should_show_logo(None, true, true, true));
1074 assert!(!should_show_logo(None, true, false, true));
1075 }
1076
1077 #[test]
1078 fn test_show_logo_config_disable() {
1079 assert!(!should_show_logo(Some(false), false, false, true));
1081 assert!(should_show_logo(Some(false), false, true, false));
1083 }
1084
1085 fn realistic_full_widths() -> Vec<usize> {
1090 let mut w = vec![40; 20]; w[13] = 54; w.extend([158, 91, 79, 60, 45, 62]); w
1094 }
1095
1096 #[test]
1097 fn test_layout_long_line_below_logo_stays_side_by_side() {
1098 let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
1100 assert!(p.side_by_side);
1101 assert_eq!(p.text_column_width, 58); }
1104
1105 #[test]
1106 fn test_layout_old_behavior_would_have_stacked() {
1107 let widths = realistic_full_widths();
1110 let old_text_col = std::cmp::max(widths.iter().copied().max().unwrap() + 4, 45);
1111 assert!(120 < old_text_col + 40); assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); }
1114
1115 #[test]
1116 fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() {
1117 let mut w = vec![40; 20];
1120 w[5] = 158;
1121 let p = plan_layout(&w, 20, 40, 120, true);
1122 assert!(p.side_by_side);
1123 assert_eq!(p.text_column_width, 65);
1124 }
1125
1126 #[test]
1127 fn test_layout_narrow_terminal_stacks() {
1128 assert!(!plan_layout(&[40; 30], 20, 40, 94, true).side_by_side); assert!(!plan_layout(&[40; 30], 20, 40, 80, true).side_by_side);
1130 }
1131
1132 #[test]
1133 fn test_layout_show_logo_false_stacks() {
1134 assert!(!plan_layout(&[40; 30], 20, 40, 200, false).side_by_side);
1135 }
1136
1137 #[test]
1138 fn test_layout_column_floor_and_graphical_width() {
1139 let p = plan_layout(&[10; 25], 20, 40, 100, true);
1141 assert!(p.side_by_side);
1142 assert_eq!(p.text_column_width, 45); }
1144
1145 #[test]
1146 fn test_layout_widened_logo_box_still_fits_at_the_side_by_side_threshold() {
1147 let p = plan_layout(&[10; 25], 10, logo::LOGO_MAX_COLS, 95, true);
1151 assert!(
1152 p.side_by_side,
1153 "a full-width logo must still sit beside the text at 95 columns"
1154 );
1155 assert!(p.text_column_width + logo::LOGO_MAX_COLS <= 95);
1156
1157 let wide = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, 169, true);
1159 assert!(wide.side_by_side);
1160 assert_eq!(wide.text_column_width, 65);
1161 }
1162
1163 #[test]
1164 fn test_layout_logo_taller_than_text() {
1165 let p = plan_layout(&[50, 30, 54], 20, 40, 120, true);
1167 assert!(p.side_by_side);
1168 assert_eq!(p.text_column_width, 58); }
1170
1171 #[test]
1174 fn test_prelude_reserves_rows_before_saving_cursor() {
1175 let p = graphical_side_by_side_prelude(52, 3);
1179 assert_eq!(p, "\n\n\n\x1b[3A\x1b[52C\x1b7");
1180 }
1181
1182 #[test]
1183 fn test_prelude_v068_shape_only_differs_by_reservation() {
1184 let p = graphical_side_by_side_prelude(45, 20);
1187 assert_eq!(
1188 p.replace(&format!("{}\x1b[20A", "\n".repeat(20)), ""),
1189 "\x1b[45C\x1b7"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_prelude_zero_rows_skips_reservation_and_cursor_up() {
1195 let p = graphical_side_by_side_prelude(45, 0);
1198 assert_eq!(p, "\x1b[45C\x1b7");
1199 }
1200
1201 #[test]
1204 fn test_split_wifi_hardware_and_connection() {
1205 let s = "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0] - myssid (5.0 GHz ch36 [↓866 ↑866])";
1207 let (hw, conn) = split_wifi_line(s);
1208 assert_eq!(
1209 hw,
1210 "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0]"
1211 );
1212 assert_eq!(conn, Some("myssid (5.0 GHz ch36 [↓866 ↑866])"));
1213 }
1214
1215 #[test]
1216 fn test_split_wifi_splits_on_first_separator() {
1217 let (hw, conn) = split_wifi_line("Card X [wlan0] - Guest - 5G (5 GHz)");
1220 assert_eq!(hw, "Card X [wlan0]");
1221 assert_eq!(conn, Some("Guest - 5G (5 GHz)"));
1222 }
1223
1224 #[test]
1225 fn test_split_wifi_connection_only_fallback() {
1226 let (hw, conn) = split_wifi_line("myssid (300 Mbps)");
1228 assert_eq!(hw, "myssid (300 Mbps)");
1229 assert_eq!(conn, None);
1230 }
1231
1232 #[test]
1233 fn test_consolidate_temps_basic() {
1234 let raw = vec![
1235 "k10temp Tctl: 83°C".to_string(),
1236 "amdgpu edge: 65°C".to_string(),
1237 "nvme Composite: 62°C".to_string(),
1238 "ath11k_hwmon temp1: 58°C".to_string(),
1239 "acpitz temp1: 77°C".to_string(),
1240 ];
1241 let result = consolidate_temps(&raw);
1242 assert_eq!(
1243 result,
1244 vec![
1245 "CPU: 83°C",
1246 "GPU: 65°C",
1247 "NVMe: 62°C",
1248 "WiFi: 58°C",
1249 "System: 77°C"
1250 ]
1251 );
1252 }
1253
1254 #[test]
1255 fn test_consolidate_temps_highest_wins() {
1256 let raw = vec![
1257 "thinkpad CPU: 83°C".to_string(),
1258 "k10temp Tctl: 79°C".to_string(),
1259 "nvme Composite: 62°C".to_string(),
1260 "nvme Sensor 1: 59°C".to_string(),
1261 "nvme Sensor 2: 56°C".to_string(),
1262 ];
1263 let result = consolidate_temps(&raw);
1264 assert!(result.contains(&"CPU: 83°C".to_string()));
1265 assert!(result.contains(&"NVMe: 62°C".to_string()));
1266 assert!(!result
1267 .iter()
1268 .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
1269 }
1270
1271 #[test]
1272 fn test_consolidate_temps_order() {
1273 let raw = vec![
1274 "acpitz: 60°C".to_string(),
1275 "nvme: 55°C".to_string(),
1276 "amdgpu edge: 65°C".to_string(),
1277 "k10temp Tctl: 80°C".to_string(),
1278 ];
1279 let result = consolidate_temps(&raw);
1280 let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
1281 let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
1282 let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
1283 let sys_pos = result.iter().position(|s| s.starts_with("System"));
1284 assert!(cpu_pos < gpu_pos);
1285 assert!(gpu_pos < nvme_pos);
1286 assert!(nvme_pos < sys_pos);
1287 }
1288
1289 #[test]
1290 fn test_consolidate_temps_empty() {
1291 assert!(consolidate_temps(&[]).is_empty());
1292 }
1293
1294 #[test]
1295 fn test_format_uptime() {
1296 assert_eq!(format_uptime("60s"), "1m");
1297 assert_eq!(format_uptime("3600s"), "1h");
1298 assert_eq!(format_uptime("3661s"), "1h 1m 1s");
1299 assert_eq!(format_uptime("86400s"), "1d");
1300 assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
1301 assert_eq!(format_uptime("31536000s"), "1y");
1302 assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
1303 assert_eq!(format_uptime("0s"), "0s");
1304 }
1305
1306 #[test]
1307 fn test_wrap_info_line_short_line_unchanged() {
1308 let line = "Audio: Windows Audio (USB Audio Device)";
1309 let wrapped = wrap_info_line(line, 50);
1310 assert_eq!(wrapped, vec![line.to_string()]);
1311 }
1312
1313 #[test]
1314 fn test_wrap_info_line_wraps_and_indents() {
1315 let line = "Audio: Windows Audio (USB Audio Device, AMD High Definition Audio Device, AMD SoundWire Device)";
1316 let wrapped = wrap_info_line(line, 45);
1317 assert!(wrapped.len() > 1);
1318 assert!(wrapped[0].starts_with("Audio: Windows Audio"));
1319 assert!(wrapped[1].starts_with(" "));
1320 }
1321}