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