1use crate::cli::{Cli, ColorChoice};
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};
14use retch_sysinfo::network::NetworkInterface;
15
16fn should_show_logo(
30 config_show_logo: Option<bool>,
31 no_logo: bool,
32 ascii_logo: bool,
33 stdout_is_tty: bool,
34) -> bool {
35 if no_logo {
36 return false; }
38 if ascii_logo {
39 return true; }
41 config_show_logo.unwrap_or(true) && stdout_is_tty }
43
44fn should_use_color(
53 choice: Option<ColorChoice>,
54 no_color: Option<&std::ffi::OsStr>,
55 stdout_is_tty: bool,
56) -> bool {
57 match choice {
58 Some(ColorChoice::Always) => true,
59 Some(ColorChoice::Never) => false,
60 Some(ColorChoice::Auto) | None => {
61 stdout_is_tty && no_color.map(|v| v.is_empty()).unwrap_or(true)
62 }
63 }
64}
65
66fn strip_sgr(s: &str) -> String {
79 let bytes = s.as_bytes();
80 let mut out = String::with_capacity(s.len());
81 let mut copied_to = 0;
82 let mut i = 0;
83 while i + 1 < bytes.len() {
84 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
85 let mut j = i + 2;
86 while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b';') {
87 j += 1;
88 }
89 if j < bytes.len() && bytes[j] == b'm' {
90 out.push_str(&s[copied_to..i]);
92 copied_to = j + 1;
93 i = j + 1;
94 continue;
95 }
96 }
97 i += 1;
98 }
99 out.push_str(&s[copied_to..]);
100 out
101}
102
103struct LayoutPlan {
112 side_by_side: bool,
113 text_column_width: usize,
114 logo_column: usize,
115}
116
117fn plan_layout(
145 info_widths: &[usize],
146 logo_height: usize,
147 logo_width: usize,
148 term_width: usize,
149 show_logo: bool,
150) -> LayoutPlan {
151 let beside_count = info_widths.len().min(logo_height);
152 let max_beside_width = info_widths[..beside_count]
153 .iter()
154 .copied()
155 .max()
156 .unwrap_or(0);
157 let text_column_width = if term_width >= 95 {
158 (term_width.saturating_sub(logo_width + 4))
159 .min(std::cmp::max(max_beside_width + 4, 45))
160 .clamp(45, 65)
161 } else {
162 std::cmp::max(max_beside_width + 4, 45)
163 };
164 let side_by_side =
165 show_logo && term_width >= 95 && term_width >= text_column_width + logo_width;
166 let logo_column = term_width.saturating_sub(logo_width).max(text_column_width);
169 LayoutPlan {
170 side_by_side,
171 text_column_width,
172 logo_column,
173 }
174}
175
176pub fn visible_len(s: &str) -> usize {
198 use unicode_width::UnicodeWidthStr;
199
200 let mut visible = String::with_capacity(s.len());
201 let mut in_esc = false;
202 for c in s.chars() {
203 if c == '\x1b' {
204 in_esc = true;
205 } else if in_esc {
206 if c.is_ascii_alphabetic() {
207 in_esc = false;
208 }
209 } else {
210 visible.push(c);
211 }
212 }
213 visible.width()
214}
215
216pub fn wrap_info_line(line: &str, max_width: usize) -> Vec<String> {
222 let vis_len = visible_len(line);
223 if vis_len <= max_width || max_width < 20 {
224 return vec![line.to_string()];
225 }
226
227 let prefix_len = if let Some(idx) = line.find(':') {
228 let prefix_sub = &line[..=idx];
229 let extra_space = if line[idx + 1..].starts_with(' ') {
230 1
231 } else {
232 0
233 };
234 visible_len(prefix_sub) + extra_space
235 } else {
236 4
237 };
238
239 let indent = " ".repeat(prefix_len.min(max_width / 2));
240
241 if line.contains(", ") {
243 let parts: Vec<&str> = line.split(", ").collect();
244 let mut lines = Vec::new();
245 let mut current = String::new();
246
247 for (i, part) in parts.iter().enumerate() {
248 let item = if i == 0 {
249 part.to_string()
250 } else {
251 format!(", {}", part)
252 };
253 let item_vis = visible_len(&item);
254
255 if current.is_empty() || visible_len(¤t) + item_vis <= max_width {
256 current.push_str(&item);
257 } else {
258 lines.push(format!("{current},"));
263 current = format!("{}{}", indent, part);
264 }
265 }
266 if !current.is_empty() {
267 lines.push(current);
268 }
269 if lines.iter().all(|l| visible_len(l) <= max_width + 10) {
270 return carry_sgr_across_lines(lines);
271 }
272 }
273
274 let raw_words: Vec<&str> = line.split_whitespace().collect();
276 let mut words: Vec<String> = Vec::new();
277 let mut idx = 0;
278 while idx < raw_words.len() {
279 if raw_words[idx] == "RX:"
280 && idx + 3 < raw_words.len()
281 && raw_words.iter().skip(idx).any(|&w| w == "TX:")
282 {
283 let rx_tx = format!(
284 "{} {} {} {} {} {}",
285 raw_words[idx],
286 raw_words[idx + 1],
287 raw_words[idx + 2],
288 raw_words[idx + 3],
289 raw_words.get(idx + 4).copied().unwrap_or(""),
290 raw_words.get(idx + 5).copied().unwrap_or("")
291 );
292 words.push(rx_tx.trim().to_string());
293 idx += if idx + 5 < raw_words.len() { 6 } else { 4 };
294 continue;
295 }
296 words.push(raw_words[idx].to_string());
297 idx += 1;
298 }
299
300 let mut lines = Vec::new();
301 let mut current = String::new();
302
303 for word in words {
304 let word_vis = visible_len(&word);
305 if current.is_empty() {
306 current.push_str(&word);
307 } else if visible_len(¤t) + 1 + word_vis <= max_width {
308 current.push(' ');
309 current.push_str(&word);
310 } else {
311 lines.push(current);
312 current = format!("{}{}", indent, word);
313 }
314 }
315 if !current.is_empty() {
316 lines.push(current);
317 }
318
319 if lines.is_empty() {
320 vec![line.to_string()]
321 } else {
322 carry_sgr_across_lines(lines)
325 }
326}
327
328fn active_sgr_after(s: &str, entry: Option<String>) -> Option<String> {
335 let mut active = entry;
336 let bytes = s.as_bytes();
337 let mut i = 0;
338 while i < bytes.len() {
339 if bytes[i] != 0x1b {
340 i += 1;
341 continue;
342 }
343 let start = i;
344 i += 1;
345 while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
346 i += 1;
347 }
348 if i < bytes.len() {
349 let seq = &s[start..=i];
350 if seq.ends_with('m') {
351 active = if seq == "\x1b[0m" || seq == "\x1b[39m" {
352 None
353 } else {
354 Some(seq.to_string())
355 };
356 }
357 i += 1;
358 }
359 }
360 active
361}
362
363fn carry_sgr_across_lines(lines: Vec<String>) -> Vec<String> {
376 let mut active: Option<String> = None;
377 let mut out = Vec::with_capacity(lines.len());
378 for line in lines {
379 let reopened = match &active {
380 Some(sgr) => format!("{sgr}{line}"),
381 None => line.clone(),
382 };
383 let end_state = active_sgr_after(&line, active.clone());
384 active = end_state.clone();
385 out.push(match end_state {
386 Some(_) => format!("{reopened}\x1b[39m"),
388 None => reopened,
389 });
390 }
391 out
392}
393
394fn split_wifi_line(wifi: &str) -> (&str, Option<&str>) {
403 match wifi.split_once(" - ") {
404 Some((hardware, connection)) => (hardware, Some(connection)),
405 None => (wifi, None),
406 }
407}
408
409fn compose_side_by_side_row(info_line: &str, logo_line: &str, logo_column: usize) -> String {
424 let vis_len = visible_len(info_line);
425 if logo_line.is_empty() || vis_len >= logo_column {
426 return format!("{info_line}{logo_line}");
427 }
428 format!(
429 "{info_line}{}{logo_line}",
430 " ".repeat(logo_column - vis_len)
431 )
432}
433
434fn graphical_side_by_side_prelude(logo_column: usize, logo_rows: usize) -> String {
449 let mut prelude = String::new();
450 if logo_rows > 0 {
451 prelude.push_str(&"\n".repeat(logo_rows));
452 prelude.push_str(&format!("\x1b[{}A", logo_rows));
453 }
454 prelude.push_str(&format!("\x1b[{}C\x1b7", logo_column));
455 prelude
456}
457
458fn render_graphical_side_by_side(
474 logo_column: usize,
475 info_lines: &[String],
476 logo_rows: usize,
477 draw: impl FnOnce(),
478) {
479 use std::io::Write;
480 print!("{}", graphical_side_by_side_prelude(logo_column, logo_rows));
483 draw(); print!("\x1b8\r");
485 for line in info_lines {
486 println!("{}", line);
487 }
488 for _ in info_lines.len()..logo_rows {
491 println!();
492 }
493 let _ = std::io::stdout().flush();
494}
495
496fn partition_net_lines<'a>(
511 nets: &'a [NetworkInterface],
512 active: Option<&str>,
513) -> (Vec<&'a NetworkInterface>, Vec<&'a NetworkInterface>) {
514 nets.iter().partition(|n| active == Some(n.name.as_str()))
515}
516
517fn choose_net_line<'a>(
526 nets: &'a [NetworkInterface],
527 active: Option<&str>,
528) -> Option<&'a NetworkInterface> {
529 nets.iter()
530 .find(|n| active == Some(n.name.as_str()))
531 .or_else(|| nets.iter().find(|n| n.is_up))
532}
533
534pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
535 let _config = config;
536 let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
537 let mut theme = match theme_name {
538 Some(name) => Theme::from_name(name),
539 None => Theme::detect_system_theme(), };
541
542 if let Some(custom) = &_config.custom_theme {
544 theme = Theme::with_custom_overrides(theme, custom);
545 }
546
547 let term_size = terminal_size::terminal_size();
549 let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
550 w as usize
551 } else {
552 80
553 };
554 let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
557 let use_color = should_use_color(
558 cli.color,
559 std::env::var_os("NO_COLOR").as_deref(),
560 stdout_is_tty,
561 );
562
563 let show_logo = should_show_logo(
564 _config.show_logo,
565 cli.no_logo,
566 cli.ascii_logo,
567 stdout_is_tty,
568 );
569
570 let allowed_fields: Option<Vec<String>> = if cli.full {
575 Some(fields::fields_for(Mode::Full))
576 } else if cli.long {
577 Some(fields::fields_for(Mode::Long))
578 } else if cli.short {
579 Some(fields::fields_for(Mode::Short))
580 } else if let Some(fields) = &_config.fields {
581 Some(fields.iter().map(|s| s.to_lowercase()).collect())
582 } else {
583 Some(fields::fields_for(Mode::Standard))
584 };
585
586 let should_show = |label: &str| -> bool {
587 match &allowed_fields {
588 Some(fields) => {
589 let norm_label = label.to_lowercase().replace(['-', '_'], " ");
590 let norm_label_no_spaces = norm_label.replace(' ', "");
591 fields.iter().any(|f| {
592 let norm_f = f.to_lowercase().replace(['-', '_'], " ");
593 norm_f == norm_label
594 || norm_f.replace(' ', "") == norm_label_no_spaces
595 || (norm_label == "dns server" && norm_f == "dns")
597 || (norm_label == "memory usage" && norm_f == "memory")
599 || (norm_label == "wi fi link" && norm_f == "wifi")
601 })
602 }
603 None => true,
604 }
605 };
606
607 let label_width = 10;
609 let mut info_lines = Vec::new();
610 let mut print_line = |label: &str, value: &str| {
611 if should_show(label) {
612 info_lines.push(format!(
613 "{:>width$}{} {}",
614 theme.color_label(label),
615 theme.color_separator(":"),
616 theme.color_value(value),
617 width = label_width
618 ));
619 }
620 };
621
622 if let Some(host) = &info.hostname {
626 print_line("Host", host);
627 }
628 print_line("OS", &info.os);
629 if let Some(kernel) = &info.kernel {
630 print_line("Kernel", kernel);
631 }
632 if let Some(domain) = &info.domain {
633 print_line("Domain", domain);
634 }
635 if should_show("domain-search") {
636 for entry in &info.domain_search {
637 print_line("Domain Search", entry);
638 }
639 }
640 if let Some(chassis) = &info.chassis {
641 print_line("Chassis", chassis);
642 }
643 if let Some(init) = &info.init_system {
644 print_line("Init", init);
645 }
646 if let Some(locale) = &info.locale {
647 print_line("Locale", locale);
648 }
649 print_line("Arch", &info.arch);
650 if info.users > 0 {
654 print_line("Users", &info.users.to_string());
655 }
656 if let Some(pkgs) = info.packages {
657 if pkgs > 0 {
658 print_line("Packages", &pkgs.to_string());
659 }
660 }
661 if let Some(user) = &info.current_user {
662 print_line("User", user);
663 }
664 let uptime_str = format_uptime(&info.uptime);
666 let boot_display = format!("{} since {}", uptime_str, info.boot_time);
667 print_line("Uptime", &boot_display);
668
669 print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
671 if let Some(freq) = &info.cpu_freq {
672 print_line("CPU Freq", freq);
673 }
674 if let Some(cache) = &info.cpu_cache {
675 print_line("CPU Cache", cache);
676 }
677 if let Some(usage) = &info.cpu_usage {
678 print_line("CPU Usage", usage);
679 }
680 if let Some(motherboard) = &info.motherboard {
681 print_line("Motherboard", motherboard);
682 }
683 if let Some(bios) = &info.bios {
684 print_line("BIOS", bios);
685 }
686 if let Some(bootmgr) = &info.bootmgr {
687 print_line("Bootmgr", bootmgr);
688 }
689 if let Some(tpm) = &info.tpm {
690 print_line("TPM", tpm);
691 }
692 if should_show("GPU") {
693 for gpu in &info.gpu {
694 print_line("GPU", gpu);
695 }
696 }
697 if should_show("Display") {
698 for display in &info.displays {
699 print_line("Display", display);
700 }
701 }
702 if let Some(vulkan) = &info.vulkan {
703 print_line("Vulkan", vulkan);
704 }
705 if let Some(opengl) = &info.opengl {
706 print_line("OpenGL", opengl);
707 }
708 if let Some(opencl) = &info.opencl {
709 print_line("OpenCL", opencl);
710 }
711 if let Some(brightness) = &info.brightness {
712 print_line("Brightness", brightness);
713 }
714 if let Some(audio) = &info.audio {
715 print_line("Audio", audio);
716 }
717 if should_show("Camera") {
718 for cam in &info.camera {
719 print_line("Camera", cam);
720 }
721 }
722 if should_show("Gamepad") {
723 for gp in &info.gamepad {
724 print_line("Gamepad", gp);
725 }
726 }
727 if should_show("Keyboard") {
728 for kb in &info.keyboard {
729 print_line("Keyboard", kb);
730 }
731 }
732 if should_show("Mouse") {
733 for m in &info.mouse {
734 print_line("Mouse", m);
735 }
736 }
737 if let Some(wifi) = &info.wifi {
738 let (hardware, connection) = split_wifi_line(wifi);
741 print_line("Wi-Fi", hardware);
742 if let Some(conn) = connection {
743 print_line("Wi-Fi Link", conn);
744 }
745 }
746 if let Some(bt) = &info.bluetooth {
747 print_line("Bluetooth", bt);
748 }
749 if let Some(bat) = &info.battery {
750 print_line("Battery", bat);
751 }
752 if let Some(power) = &info.power_adapter {
753 print_line("Power Adapter", power);
754 }
755 print_line("Memory Usage", &info.memory);
756 if let Some(phys_mem) = &info.physical_memory {
757 print_line("Phys Mem", phys_mem);
758 }
759 print_line("Swap", &info.swap);
760 print_line("Procs", &info.processes.to_string());
761 if let Some(load) = &info.load_avg {
762 print_line("Load", load);
763 }
764 if should_show("Disk") {
765 for disk in &info.disks {
766 print_line("Disk", disk);
767 }
768 }
769 if should_show("Phys Disk") {
770 for disk in &info.physical_disks {
771 print_line("Phys Disk", disk);
772 }
773 }
774 if should_show("Disk IO") {
775 for io in &info.disk_io {
776 print_line("Disk IO", io);
777 }
778 }
779 if should_show("Btrfs") {
780 for vol in &info.btrfs {
781 print_line("Btrfs", vol);
782 }
783 }
784 if should_show("Zpool") {
785 for pool in &info.zpool {
786 print_line("Zpool", pool);
787 }
788 }
789 if should_show("Temp") {
790 if cli.full {
791 for temp in &info.temps {
792 print_line("Temp", temp);
793 }
794 } else {
795 for temp in consolidate_temps(&info.temps) {
796 print_line("Temp", &temp);
797 }
798 }
799 }
800
801 if should_show("Net") {
803 let active = info.active_interface.as_deref();
804 if cli.long || cli.full {
805 let (active_nets, others) = partition_net_lines(&info.networks, active);
806 for net in active_nets {
807 print_line("Net", &colorize_nested(&net.line, ACTIVE_IFACE_PREFIX));
811 }
812 for net in others {
813 print_line("Net", &net.line);
814 }
815 } else if let Some(net) = choose_net_line(&info.networks, active) {
816 print_line("Net", &net.line);
817 }
818 }
819 if should_show("Net IO") {
820 for io in &info.net_io {
821 print_line("Net IO", io);
822 }
823 }
824 if let Some(ip) = &info.public_ip {
825 print_line("Public IP", ip);
826 }
827 if !info.dns.is_empty() {
828 print_line("DNS Server", &info.dns.join(", "));
829 }
830
831 if let Some(shell) = &info.shell {
833 print_line("Shell", shell);
834 }
835 if let Some(editor) = &info.editor {
836 print_line("Editor", editor);
837 }
838 if let Some(term) = &info.terminal {
839 print_line("Terminal", term);
840 }
841 if let Some(ts) = &info.terminal_size {
842 print_line("Terminal Size", ts);
843 }
844 if let Some(de) = &info.desktop {
845 print_line("Desktop", de);
846 }
847 if let Some(wm) = &info.wm {
848 let duplicate = info
849 .desktop
850 .as_deref()
851 .map(|de| de.to_lowercase() == wm.to_lowercase())
852 .unwrap_or(false);
853 if !duplicate {
854 print_line("WM", wm);
855 }
856 }
857 if let Some(wm_theme) = &info.wm_theme {
858 print_line("WM Theme", wm_theme);
859 }
860 if let Some(wallpaper) = &info.wallpaper {
861 print_line("Wallpaper", wallpaper);
862 }
863 if let Some(lm) = &info.login_manager {
864 print_line("Login Manager", lm);
865 }
866 if let Some(player) = &info.player {
867 print_line("Player", player);
868 }
869 if let Some(media) = &info.media {
870 print_line("Media", media);
871 }
872 if let Some(ui_theme) = &info.ui_theme {
873 print_line("Theme", ui_theme);
874 }
875 if let Some(icons) = &info.icons {
876 print_line("Icons", icons);
877 }
878 if let Some(cursor) = &info.cursor {
879 print_line("Cursor", cursor);
880 }
881 if let Some(font) = &info.font {
882 print_line("Font", font);
883 }
884 if let Some(term_font) = &info.terminal_font {
885 print_line("Terminal Font", term_font);
886 }
887 if let Some(term_theme) = &info.terminal_theme {
888 print_line("Terminal Theme", term_theme);
889 }
890 if let Some(weather) = &info.weather {
891 print_line("Weather", weather);
892 }
893
894 enum ActiveLogo {
896 Lines(Vec<String>),
897 Kitty(Vec<u8>, usize, usize), Iterm2(Vec<u8>, usize, usize),
899 Sixel(Vec<u8>, usize, usize),
900 None,
901 }
902
903 let mut active_logo = ActiveLogo::None;
904
905 if show_logo {
906 let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
907 let user_logo = if let Some(config_dir) = dirs::config_dir() {
908 let p = config_dir.join("retch").join("logo.png");
909 if p.exists() {
910 Some(p)
911 } else {
912 None
913 }
914 } else {
915 None
916 };
917
918 if cli.ascii_logo {
919 active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
920 } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
921 let mut resolved = false;
922 if use_color && logo::chafa_available() {
925 if let Some(path) = &user_logo {
926 if let Some(lines) = logo::get_chafa_logo_lines(path) {
927 active_logo = ActiveLogo::Lines(lines);
928 resolved = true;
929 }
930 } else if let Some(distro) = &distro_hint {
931 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
932 let temp_path = std::env::temp_dir()
933 .join(format!("retch_logo_{}.png", std::process::id()));
934 if std::fs::write(&temp_path, bytes).is_ok() {
935 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
936 active_logo = ActiveLogo::Lines(lines);
937 resolved = true;
938 }
939 let _ = std::fs::remove_file(&temp_path);
940 }
941 }
942 }
943 }
944 if !resolved {
945 active_logo =
946 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
947 }
948 } else {
949 let mut resolved = false;
950
951 #[cfg(feature = "graphics")]
953 if !resolved && logo::supports_kitty() {
954 if let Some(path) = &user_logo {
955 if let Ok(bytes) = std::fs::read(path) {
956 let (cols, rows) = graphical_logo_cells(&bytes);
957 active_logo = ActiveLogo::Kitty(bytes, cols, rows);
958 resolved = true;
959 }
960 } else if let Some(distro) = &distro_hint {
961 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
962 let (cols, rows) = graphical_logo_cells(bytes);
963 active_logo = ActiveLogo::Kitty(bytes.to_vec(), cols, rows);
964 resolved = true;
965 }
966 }
967 }
968
969 #[cfg(feature = "graphics")]
971 if !resolved && logo::supports_iterm2() {
972 if let Some(path) = &user_logo {
973 if let Ok(bytes) = std::fs::read(path) {
974 let (cols, rows) = graphical_logo_cells(&bytes);
975 active_logo = ActiveLogo::Iterm2(bytes, cols, rows);
976 resolved = true;
977 }
978 } else if let Some(distro) = &distro_hint {
979 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
980 let (cols, rows) = graphical_logo_cells(bytes);
981 active_logo = ActiveLogo::Iterm2(bytes.to_vec(), cols, rows);
982 resolved = true;
983 }
984 }
985 }
986
987 #[cfg(feature = "graphics")]
989 if !resolved && logo::supports_sixel() {
990 if let Some(path) = &user_logo {
991 if let Ok(bytes) = std::fs::read(path) {
992 let (cols, rows) = graphical_logo_cells(&bytes);
993 active_logo = ActiveLogo::Sixel(bytes, cols, rows);
994 resolved = true;
995 }
996 } else if let Some(distro) = &distro_hint {
997 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
998 let (cols, rows) = graphical_logo_cells(bytes);
999 active_logo = ActiveLogo::Sixel(bytes.to_vec(), cols, rows);
1000 resolved = true;
1001 }
1002 }
1003 }
1004
1005 if !resolved && use_color && logo::chafa_available() {
1008 if let Some(path) = &user_logo {
1009 if let Some(lines) = logo::get_chafa_logo_lines(path) {
1010 active_logo = ActiveLogo::Lines(lines);
1011 resolved = true;
1012 }
1013 } else if let Some(distro) = &distro_hint {
1014 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
1015 let temp_path = std::env::temp_dir()
1017 .join(format!("retch_logo_{}.png", std::process::id()));
1018 if std::fs::write(&temp_path, bytes).is_ok() {
1019 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
1020 active_logo = ActiveLogo::Lines(lines);
1021 resolved = true;
1022 }
1023 let _ = std::fs::remove_file(&temp_path);
1024 }
1025 }
1026 }
1027 }
1028
1029 if !resolved {
1031 active_logo =
1032 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
1033 }
1034 }
1035 }
1036
1037 if !use_color {
1040 info_lines = info_lines.iter().map(|line| strip_sgr(line)).collect();
1041 if let ActiveLogo::Lines(logo_lines) = &mut active_logo {
1042 *logo_lines = logo_lines.iter().map(|line| strip_sgr(line)).collect();
1043 }
1044 }
1045
1046 let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
1054
1055 let (logo_height, max_logo_width) = match &active_logo {
1059 ActiveLogo::Lines(logo_lines) => (
1060 logo_lines.len(),
1061 logo_lines
1062 .iter()
1063 .map(|line| visible_len(line))
1064 .max()
1065 .unwrap_or(0),
1066 ),
1067 ActiveLogo::Kitty(_, cols, rows)
1068 | ActiveLogo::Iterm2(_, cols, rows)
1069 | ActiveLogo::Sixel(_, cols, rows) => (*rows, *cols),
1070 ActiveLogo::None => (0, 0),
1071 };
1072
1073 let LayoutPlan {
1076 side_by_side,
1077 text_column_width,
1078 logo_column,
1079 } = plan_layout(
1080 &info_widths,
1081 logo_height,
1082 max_logo_width,
1083 term_width,
1084 show_logo,
1085 );
1086
1087 println!(); let formatted_info_lines: Vec<String> = if side_by_side && text_column_width > 15 {
1090 let mut result = Vec::new();
1091 for (i, line) in info_lines.iter().enumerate() {
1092 let max_w = if i < logo_height {
1099 logo_column.saturating_sub(2)
1100 } else {
1101 term_width.saturating_sub(2)
1102 };
1103 result.extend(wrap_info_line(line, max_w));
1104 }
1105 result
1106 } else {
1107 info_lines.clone()
1108 };
1109
1110 if side_by_side {
1111 match active_logo {
1112 ActiveLogo::Lines(logo_lines) => {
1113 let max_lines = std::cmp::max(formatted_info_lines.len(), logo_lines.len());
1114 for i in 0..max_lines {
1115 let info_line = formatted_info_lines.get(i).cloned().unwrap_or_default();
1116 let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
1117 println!(
1118 "{}",
1119 compose_side_by_side_row(&info_line, &logo_line, logo_column)
1120 );
1121 }
1122 }
1123 ActiveLogo::Kitty(bytes, _, logo_rows) => {
1124 render_graphical_side_by_side(
1125 logo_column,
1126 &formatted_info_lines,
1127 logo_rows,
1128 || logo::print_graphical_logo(&bytes),
1129 );
1130 }
1131 ActiveLogo::Iterm2(bytes, _, logo_rows) => {
1132 render_graphical_side_by_side(
1133 logo_column,
1134 &formatted_info_lines,
1135 logo_rows,
1136 || logo::print_iterm2_logo(&bytes),
1137 );
1138 }
1139 ActiveLogo::Sixel(bytes, _, logo_rows) => {
1140 render_graphical_side_by_side(
1141 logo_column,
1142 &formatted_info_lines,
1143 logo_rows,
1144 || logo::print_sixel_logo(&bytes),
1145 );
1146 }
1147 ActiveLogo::None => {
1148 for line in &formatted_info_lines {
1149 println!("{}", line);
1150 }
1151 }
1152 }
1153 } else {
1154 match active_logo {
1156 ActiveLogo::Lines(logo_lines) => {
1157 for line in logo_lines {
1158 println!("{}", line);
1159 }
1160 println!();
1161 }
1162 ActiveLogo::Kitty(bytes, _, _) => {
1163 logo::print_graphical_logo(&bytes);
1164 println!();
1165 }
1166 ActiveLogo::Iterm2(bytes, _, _) => {
1167 logo::print_iterm2_logo(&bytes);
1168 println!();
1169 }
1170 ActiveLogo::Sixel(bytes, _, _) => {
1171 logo::print_sixel_logo(&bytes);
1172 println!();
1173 }
1174 ActiveLogo::None => {}
1175 }
1176 for line in &info_lines {
1177 println!("{}", line);
1178 }
1179 }
1180
1181 Ok(())
1182}
1183
1184fn consolidate_temps(temps: &[String]) -> Vec<String> {
1190 fn categorize(label: &str) -> &'static str {
1191 let l = label.to_lowercase();
1192 if l.contains("cpu")
1193 || l.contains("core")
1194 || l.contains("k10temp")
1195 || l.contains("k8temp")
1196 || l.contains("coretemp")
1197 || l.contains("tctl")
1198 || l.contains("tdie")
1199 || l.contains("tccd")
1200 || l.contains("package")
1201 {
1202 "CPU"
1203 } else if l.contains("gpu")
1204 || l.contains("nouveau")
1205 || l.contains("radeon")
1206 || l.contains("amdgpu")
1207 {
1208 "GPU"
1209 } else if l.contains("nvme") || l.contains("nand") {
1210 "NVMe"
1211 } else if l.contains("ath")
1212 || l.contains("wifi")
1213 || l.contains("wireless")
1214 || l.contains("wlan")
1215 || l.contains("iwl")
1216 {
1217 "WiFi"
1218 } else if l.contains("bat") {
1219 "Battery"
1220 } else {
1221 "System"
1222 }
1223 }
1224
1225 let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
1226 for s in temps {
1227 if let Some((label_part, val_part)) = s.rsplit_once(':') {
1229 let val_str = val_part.trim().trim_end_matches("°C");
1230 if let Ok(val) = val_str.parse::<f32>() {
1231 let cat = categorize(label_part.trim());
1232 let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
1233 if val > *entry {
1234 *entry = val;
1235 }
1236 }
1237 }
1238 }
1239
1240 const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
1241 ORDER
1242 .iter()
1243 .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
1244 .collect()
1245}
1246
1247fn format_uptime(uptime: &str) -> String {
1251 let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
1253
1254 let years = seconds / (365 * 24 * 3600);
1255 let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
1256 let hours = (seconds % (24 * 3600)) / 3600;
1257 let minutes = (seconds % 3600) / 60;
1258 let secs = seconds % 60;
1259
1260 let mut parts = Vec::new();
1261 if years > 0 {
1262 parts.push(format!("{}y", years));
1263 }
1264 if days > 0 {
1265 parts.push(format!("{}d", days));
1266 }
1267 if hours > 0 {
1268 parts.push(format!("{}h", hours));
1269 }
1270 if minutes > 0 {
1271 parts.push(format!("{}m", minutes));
1272 }
1273 if secs > 0 || parts.is_empty() {
1274 parts.push(format!("{}s", secs));
1275 }
1276
1277 parts.join(" ")
1278}
1279
1280#[cfg(feature = "graphics")]
1289fn graphical_logo_cells(bytes: &[u8]) -> (usize, usize) {
1290 let (img_w, img_h) = image::load_from_memory(bytes)
1291 .map(|img| (img.width(), img.height()))
1292 .unwrap_or((0, 0));
1293 let fit = logo::logo_cells_for(img_w, img_h);
1294 (fit.cols, fit.rows)
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300
1301 fn net(name: &str, is_up: bool) -> NetworkInterface {
1304 let status = if is_up {
1309 "\x1b[32mUp\x1b[39m"
1310 } else {
1311 "\x1b[31mDown\x1b[39m"
1312 };
1313 NetworkInterface {
1314 name: name.to_string(),
1315 is_up,
1316 line: format!("{name} (10.0.0.1) [{status}] RX: 1.0 MB TX: 1.0 MB"),
1317 }
1318 }
1319
1320 #[test]
1321 fn test_active_interface_is_matched_by_exact_name_not_substring() {
1322 let nets = vec![
1325 net("Wi-Fi-Native WiFi Filter Driver-0000", true),
1326 net("Wi-Fi", true),
1327 ];
1328 let (active, others) = partition_net_lines(&nets, Some("Wi-Fi"));
1329 assert_eq!(active.len(), 1);
1330 assert_eq!(active[0].name, "Wi-Fi");
1331 assert_eq!(others.len(), 1);
1332 assert_eq!(others[0].name, "Wi-Fi-Native WiFi Filter Driver-0000");
1333 }
1334
1335 #[test]
1336 fn test_active_interface_does_not_match_a_vlan_or_veth_sibling() {
1337 let nets = vec![
1340 net("eth0", true),
1341 net("eth0.100", true),
1342 net("veth0a1b2c3", true),
1343 ];
1344 let (active, others) = partition_net_lines(&nets, Some("eth0"));
1345 assert_eq!(active.len(), 1);
1346 assert_eq!(active[0].name, "eth0");
1347 assert_eq!(others.len(), 2);
1348 }
1349
1350 #[test]
1351 fn test_no_active_interface_means_no_line_is_highlighted() {
1352 let nets = vec![net("eth0", true), net("wlan0", true)];
1353 let (active, others) = partition_net_lines(&nets, None);
1354 assert!(active.is_empty());
1355 assert_eq!(others.len(), 2);
1356 }
1357
1358 #[test]
1359 fn test_standard_mode_prefers_the_active_interface() {
1360 let nets = vec![net("docker0", true), net("wlan0", true)];
1361 let chosen = choose_net_line(&nets, Some("wlan0")).expect("a line");
1362 assert_eq!(chosen.name, "wlan0");
1363 }
1364
1365 #[test]
1366 fn test_standard_mode_falls_back_to_the_first_up_interface() {
1367 let nets = vec![net("eth0", false), net("wlan0", true), net("eth1", true)];
1371 let chosen = choose_net_line(&nets, None).expect("a line, not None");
1372 assert_eq!(chosen.name, "wlan0");
1373
1374 let chosen = choose_net_line(&nets, Some("ppp0")).expect("a line, not None");
1376 assert_eq!(chosen.name, "wlan0");
1377 }
1378
1379 #[test]
1380 fn test_standard_mode_reports_nothing_when_every_interface_is_down() {
1381 let nets = vec![net("eth0", false), net("eth1", false)];
1384 assert!(choose_net_line(&nets, None).is_none());
1385 }
1386
1387 #[test]
1390 fn test_show_logo_auto_requires_tty() {
1391 assert!(should_show_logo(None, false, false, true));
1393 assert!(!should_show_logo(None, false, false, false));
1394 }
1395
1396 #[test]
1397 fn test_show_logo_ascii_forces_without_tty() {
1398 assert!(should_show_logo(None, false, true, false));
1400 assert!(should_show_logo(None, false, true, true));
1401 }
1402
1403 #[test]
1404 fn test_show_logo_no_logo_always_wins() {
1405 assert!(!should_show_logo(None, true, true, true));
1407 assert!(!should_show_logo(None, true, false, true));
1408 }
1409
1410 #[test]
1411 fn test_show_logo_config_disable() {
1412 assert!(!should_show_logo(Some(false), false, false, true));
1414 assert!(should_show_logo(Some(false), false, true, false));
1416 }
1417
1418 #[test]
1421 fn test_visible_len_strips_every_escape_form_retch_emits() {
1422 assert_eq!(visible_len("plain"), 5);
1426 assert_eq!(visible_len("\x1b[38;2;1;2;3mabc\x1b[39m"), 3);
1427 assert_eq!(visible_len("\x1b[?25labc"), 3);
1428 assert_eq!(visible_len("\x1b(Babc"), 3);
1429 assert_eq!(visible_len("\x1b[0m \x1b[38;2;0;0;0m\u{2582}"), 2);
1430 }
1431
1432 #[test]
1433 fn test_visible_len_counts_columns_not_characters() {
1434 assert_eq!(visible_len("宇多田ヒカル"), 12); assert_eq!(visible_len("아이유"), 6); assert_eq!(visible_len("Media: 宇多田ヒカル - 花束を君に"), 32);
1440 assert_eq!(visible_len("Media: 아이유 - 밤편지"), 22);
1441
1442 assert_eq!(visible_len("cafe\u{301}"), 4);
1444 assert_eq!(visible_len("café"), 4);
1446
1447 assert_eq!(
1450 visible_len("\x1b[38;2;1;2;3m宇多田\x1b[39m"),
1451 visible_len("宇多田")
1452 );
1453 }
1454
1455 #[test]
1456 fn test_visible_len_ascii_art_and_chafa_symbols_are_one_column_each() {
1457 for line in logo::get_ascii_logo(Some("fedora")) {
1461 let stripped: String = strip_for_test(&line);
1462 assert_eq!(
1463 visible_len(&line),
1464 stripped.chars().count(),
1465 "fedora ASCII logo line is not one column per character: {stripped:?}"
1466 );
1467 }
1468 for sym in [
1470 '\u{2580}', '\u{2584}', '\u{2588}', '\u{258c}', '\u{2596}', '\u{2582}',
1471 ] {
1472 assert_eq!(visible_len(&sym.to_string()), 1, "{sym:?} is not 1 column");
1473 }
1474 }
1475
1476 fn strip_for_test(s: &str) -> String {
1479 let mut out = String::new();
1480 let mut in_esc = false;
1481 for c in s.chars() {
1482 if c == '\x1b' {
1483 in_esc = true;
1484 } else if in_esc {
1485 if c.is_ascii_alphabetic() {
1486 in_esc = false;
1487 }
1488 } else {
1489 out.push(c);
1490 }
1491 }
1492 out
1493 }
1494
1495 const CYAN: &str = "\x1b[38;2;0;255;255m";
1499 const RESET: &str = "\x1b[39m";
1500
1501 #[test]
1502 fn test_wrap_keeps_the_comma_it_split_on() {
1503 let out = wrap_info_line(
1507 "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)",
1508 40,
1509 );
1510 assert!(out.len() > 1, "expected a wrap, got {out:?}");
1511 assert!(
1512 out[0].ends_with(','),
1513 "separator lost at the break: {:?}",
1514 out[0]
1515 );
1516 let rejoined: String = out
1518 .iter()
1519 .map(|l| l.trim_start().to_string())
1520 .collect::<Vec<_>>()
1521 .join(" ");
1522 assert_eq!(
1523 rejoined,
1524 "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_wrap_reopens_the_colour_on_every_continuation_line() {
1530 let line =
1534 format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1535 let out = wrap_info_line(&line, 40);
1536 assert!(out.len() > 1, "expected a wrap, got {out:?}");
1537 for (i, l) in out.iter().enumerate().skip(1) {
1538 assert!(
1539 l.contains(CYAN),
1540 "continuation line {i} has no colour: {l:?}"
1541 );
1542 }
1543 for l in &out {
1545 if l.contains(CYAN) {
1546 assert!(l.ends_with(RESET), "colour left open on {l:?}");
1547 }
1548 }
1549 }
1550
1551 #[test]
1552 fn test_wrap_colour_carry_does_not_change_visible_width() {
1553 let plain = "BIOS: American Megatrends International, LLC. HN7306EAC.310";
1556 let coloured =
1557 format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1558 let a = wrap_info_line(plain, 40);
1559 let b = wrap_info_line(&coloured, 40);
1560 assert_eq!(a.len(), b.len());
1561 for (x, y) in a.iter().zip(b.iter()) {
1562 assert_eq!(visible_len(x), visible_len(y), "{x:?} vs {y:?}");
1563 }
1564 }
1565
1566 #[test]
1567 fn test_wrap_uncoloured_line_is_untouched_by_the_carry() {
1568 let out = wrap_info_line("Disk: aaaa, bbbb, cccc, dddd, eeee, ffff, gggg, hhhh", 24);
1569 assert!(out.len() > 1);
1570 assert!(
1571 out.iter().all(|l| !l.contains('\x1b')),
1572 "carry injected escapes into an uncoloured line: {out:?}"
1573 );
1574 }
1575
1576 #[test]
1577 fn test_active_sgr_after_tracks_open_and_reset() {
1578 assert_eq!(active_sgr_after("plain", None), None);
1579 assert_eq!(active_sgr_after(CYAN, None), Some(CYAN.to_string()));
1580 assert_eq!(active_sgr_after(&format!("{CYAN}x{RESET}"), None), None);
1581 assert_eq!(active_sgr_after("\x1b[0m", Some(CYAN.into())), None);
1582 assert_eq!(
1584 active_sgr_after("more text", Some(CYAN.into())),
1585 Some(CYAN.to_string())
1586 );
1587 assert_eq!(
1589 active_sgr_after("\x1b[?25l", Some(CYAN.into())),
1590 Some(CYAN.to_string())
1591 );
1592 }
1593
1594 #[test]
1595 fn test_active_sgr_after_takes_the_last_colour_when_nested() {
1596 let green = "\x1b[32m";
1599 let s = format!("{CYAN}[{green}Up{RESET}] RX: 1 MB");
1600 assert_eq!(active_sgr_after(&s, None), None); let s2 = format!("{CYAN}[{green}Up{RESET}]{CYAN} RX: 1 MB");
1602 assert_eq!(active_sgr_after(&s2, None), Some(CYAN.to_string()));
1603 }
1604
1605 #[test]
1608 fn test_row_places_the_logo_at_the_logo_column() {
1609 let row = compose_side_by_side_row("OS: Fedora", "###", 20);
1610 assert_eq!(row, format!("OS: Fedora{}###", " ".repeat(10)));
1611 assert_eq!(visible_len(&row), 23);
1612 }
1613
1614 #[test]
1615 fn test_row_aligns_wide_characters_by_column_not_character_count() {
1616 let latin = compose_side_by_side_row("Locale: en_US.UTF-8", "###", 40);
1621 let cjk = compose_side_by_side_row("Locale: ja_JP.宇多田ヒカル", "###", 40);
1622 assert_eq!(visible_len(&latin), 43);
1623 assert_eq!(
1624 visible_len(&cjk),
1625 43,
1626 "a wide-character info line must not shift the logo column"
1627 );
1628 assert!(latin.ends_with(" ###") && cjk.ends_with(" ###"));
1630 }
1631
1632 #[test]
1633 fn test_row_without_a_logo_gets_no_trailing_padding() {
1634 assert_eq!(compose_side_by_side_row("Net: eth0", "", 40), "Net: eth0");
1636 }
1637
1638 #[test]
1639 fn test_row_with_overlong_info_does_not_underflow() {
1640 let row = compose_side_by_side_row("x".repeat(50).as_str(), "###", 40);
1642 assert_eq!(row, format!("{}###", "x".repeat(50)));
1643 }
1644
1645 #[test]
1646 fn test_row_ignores_ansi_colour_when_measuring() {
1647 let plain = compose_side_by_side_row("abc", "###", 10);
1648 let coloured = compose_side_by_side_row("\x1b[31mabc\x1b[39m", "###", 10);
1649 assert_eq!(visible_len(&plain), visible_len(&coloured));
1650 }
1651
1652 fn realistic_full_widths() -> Vec<usize> {
1657 let mut w = vec![40; 20]; w[13] = 54; w.extend([158, 91, 79, 60, 45, 62]); w
1661 }
1662
1663 #[test]
1664 fn test_layout_long_line_below_logo_stays_side_by_side() {
1665 let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
1667 assert!(p.side_by_side);
1668 assert_eq!(p.text_column_width, 58); }
1671
1672 #[test]
1673 fn test_layout_old_behavior_would_have_stacked() {
1674 let widths = realistic_full_widths();
1677 let old_text_col = std::cmp::max(widths.iter().copied().max().unwrap() + 4, 45);
1678 assert!(120 < old_text_col + 40); assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); }
1681
1682 #[test]
1683 fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() {
1684 let mut w = vec![40; 20];
1687 w[5] = 158;
1688 let p = plan_layout(&w, 20, 40, 120, true);
1689 assert!(p.side_by_side);
1690 assert_eq!(p.text_column_width, 65);
1691 }
1692
1693 #[test]
1694 fn test_layout_narrow_terminal_stacks() {
1695 assert!(!plan_layout(&[40; 30], 20, 40, 94, true).side_by_side); assert!(!plan_layout(&[40; 30], 20, 40, 80, true).side_by_side);
1697 }
1698
1699 #[test]
1700 fn test_layout_show_logo_false_stacks() {
1701 assert!(!plan_layout(&[40; 30], 20, 40, 200, false).side_by_side);
1702 }
1703
1704 #[test]
1705 fn test_layout_column_floor_and_graphical_width() {
1706 let p = plan_layout(&[10; 25], 20, 40, 100, true);
1708 assert!(p.side_by_side);
1709 assert_eq!(p.text_column_width, 45); }
1711
1712 #[test]
1713 fn test_layout_widened_logo_box_still_fits_at_the_side_by_side_threshold() {
1714 let p = plan_layout(&[10; 25], 10, logo::LOGO_MAX_COLS, 95, true);
1718 assert!(
1719 p.side_by_side,
1720 "a full-width logo must still sit beside the text at 95 columns"
1721 );
1722 assert!(p.text_column_width + logo::LOGO_MAX_COLS <= 95);
1723
1724 let wide = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, 169, true);
1726 assert!(wide.side_by_side);
1727 assert_eq!(wide.text_column_width, 65);
1728 }
1729
1730 #[test]
1731 fn test_layout_logo_taller_than_text() {
1732 let p = plan_layout(&[50, 30, 54], 20, 40, 120, true);
1734 assert!(p.side_by_side);
1735 assert_eq!(p.text_column_width, 58); }
1737
1738 #[test]
1739 fn test_layout_logo_is_flush_with_the_right_margin() {
1740 let p = plan_layout(&realistic_full_widths(), 20, 49, 138, true);
1745 assert!(p.side_by_side);
1746 assert_eq!(p.text_column_width, 58); assert_eq!(p.logo_column, 138 - 49); assert!(
1749 p.logo_column > p.text_column_width,
1750 "the pre-fix behaviour was logo_column == text_column_width"
1751 );
1752 }
1753
1754 #[test]
1755 fn test_layout_right_anchor_never_overlaps_the_text_column() {
1756 for term_width in 95..200 {
1759 let p = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, term_width, true);
1760 if p.side_by_side {
1761 assert!(
1762 p.logo_column >= p.text_column_width,
1763 "logo_column {} < text_column_width {} at {} cols",
1764 p.logo_column,
1765 p.text_column_width,
1766 term_width
1767 );
1768 assert_eq!(p.logo_column + logo::LOGO_MAX_COLS, term_width);
1769 }
1770 }
1771 }
1772
1773 #[test]
1774 fn test_layout_logo_column_does_not_underflow_on_an_oversized_logo() {
1775 let p = plan_layout(&[40; 10], 10, 200, 100, true);
1777 assert!(!p.side_by_side);
1778 assert_eq!(p.logo_column, p.text_column_width);
1779 }
1780
1781 #[test]
1784 fn test_prelude_reserves_rows_before_saving_cursor() {
1785 let p = graphical_side_by_side_prelude(52, 3);
1789 assert_eq!(p, "\n\n\n\x1b[3A\x1b[52C\x1b7");
1790 }
1791
1792 #[test]
1793 fn test_prelude_v068_shape_only_differs_by_reservation() {
1794 let p = graphical_side_by_side_prelude(45, 20);
1797 assert_eq!(
1798 p.replace(&format!("{}\x1b[20A", "\n".repeat(20)), ""),
1799 "\x1b[45C\x1b7"
1800 );
1801 }
1802
1803 #[test]
1804 fn test_prelude_zero_rows_skips_reservation_and_cursor_up() {
1805 let p = graphical_side_by_side_prelude(45, 0);
1808 assert_eq!(p, "\x1b[45C\x1b7");
1809 }
1810
1811 #[test]
1814 fn test_split_wifi_hardware_and_connection() {
1815 let s = "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0] - myssid (5.0 GHz ch36 [↓866 ↑866])";
1817 let (hw, conn) = split_wifi_line(s);
1818 assert_eq!(
1819 hw,
1820 "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0]"
1821 );
1822 assert_eq!(conn, Some("myssid (5.0 GHz ch36 [↓866 ↑866])"));
1823 }
1824
1825 #[test]
1826 fn test_split_wifi_splits_on_first_separator() {
1827 let (hw, conn) = split_wifi_line("Card X [wlan0] - Guest - 5G (5 GHz)");
1830 assert_eq!(hw, "Card X [wlan0]");
1831 assert_eq!(conn, Some("Guest - 5G (5 GHz)"));
1832 }
1833
1834 #[test]
1835 fn test_split_wifi_connection_only_fallback() {
1836 let (hw, conn) = split_wifi_line("myssid (300 Mbps)");
1838 assert_eq!(hw, "myssid (300 Mbps)");
1839 assert_eq!(conn, None);
1840 }
1841
1842 #[test]
1843 fn test_consolidate_temps_basic() {
1844 let raw = vec![
1845 "k10temp Tctl: 83°C".to_string(),
1846 "amdgpu edge: 65°C".to_string(),
1847 "nvme Composite: 62°C".to_string(),
1848 "ath11k_hwmon temp1: 58°C".to_string(),
1849 "acpitz temp1: 77°C".to_string(),
1850 ];
1851 let result = consolidate_temps(&raw);
1852 assert_eq!(
1853 result,
1854 vec![
1855 "CPU: 83°C",
1856 "GPU: 65°C",
1857 "NVMe: 62°C",
1858 "WiFi: 58°C",
1859 "System: 77°C"
1860 ]
1861 );
1862 }
1863
1864 #[test]
1865 fn test_consolidate_temps_highest_wins() {
1866 let raw = vec![
1867 "thinkpad CPU: 83°C".to_string(),
1868 "k10temp Tctl: 79°C".to_string(),
1869 "nvme Composite: 62°C".to_string(),
1870 "nvme Sensor 1: 59°C".to_string(),
1871 "nvme Sensor 2: 56°C".to_string(),
1872 ];
1873 let result = consolidate_temps(&raw);
1874 assert!(result.contains(&"CPU: 83°C".to_string()));
1875 assert!(result.contains(&"NVMe: 62°C".to_string()));
1876 assert!(!result
1877 .iter()
1878 .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
1879 }
1880
1881 #[test]
1882 fn test_consolidate_temps_order() {
1883 let raw = vec![
1884 "acpitz: 60°C".to_string(),
1885 "nvme: 55°C".to_string(),
1886 "amdgpu edge: 65°C".to_string(),
1887 "k10temp Tctl: 80°C".to_string(),
1888 ];
1889 let result = consolidate_temps(&raw);
1890 let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
1891 let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
1892 let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
1893 let sys_pos = result.iter().position(|s| s.starts_with("System"));
1894 assert!(cpu_pos < gpu_pos);
1895 assert!(gpu_pos < nvme_pos);
1896 assert!(nvme_pos < sys_pos);
1897 }
1898
1899 #[test]
1900 fn test_consolidate_temps_empty() {
1901 assert!(consolidate_temps(&[]).is_empty());
1902 }
1903
1904 #[test]
1905 fn test_format_uptime() {
1906 assert_eq!(format_uptime("60s"), "1m");
1907 assert_eq!(format_uptime("3600s"), "1h");
1908 assert_eq!(format_uptime("3661s"), "1h 1m 1s");
1909 assert_eq!(format_uptime("86400s"), "1d");
1910 assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
1911 assert_eq!(format_uptime("31536000s"), "1y");
1912 assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
1913 assert_eq!(format_uptime("0s"), "0s");
1914 }
1915
1916 #[test]
1917 fn test_wrap_info_line_short_line_unchanged() {
1918 let line = "Audio: Windows Audio (USB Audio Device)";
1919 let wrapped = wrap_info_line(line, 50);
1920 assert_eq!(wrapped, vec![line.to_string()]);
1921 }
1922
1923 #[test]
1924 fn test_wrap_info_line_wraps_and_indents() {
1925 let line = "Audio: Windows Audio (USB Audio Device, AMD High Definition Audio Device, AMD SoundWire Device)";
1926 let wrapped = wrap_info_line(line, 45);
1927 assert!(wrapped.len() > 1);
1928 assert!(wrapped[0].starts_with("Audio: Windows Audio"));
1929 assert!(wrapped[1].starts_with(" "));
1930 }
1931
1932 #[test]
1935 fn test_should_use_color_explicit_choice_beats_everything() {
1936 let set = Some(std::ffi::OsStr::new("1"));
1937 for tty in [true, false] {
1938 for env in [None, set] {
1939 assert!(should_use_color(Some(ColorChoice::Always), env, tty));
1940 assert!(!should_use_color(Some(ColorChoice::Never), env, tty));
1941 }
1942 }
1943 }
1944
1945 #[test]
1946 fn test_should_use_color_auto_needs_tty_and_no_no_color() {
1947 for choice in [None, Some(ColorChoice::Auto)] {
1948 assert!(should_use_color(choice, None, true));
1949 assert!(!should_use_color(choice, None, false), "piped output");
1950 assert!(
1951 !should_use_color(choice, Some(std::ffi::OsStr::new("1")), true),
1952 "NO_COLOR set"
1953 );
1954 assert!(
1957 should_use_color(choice, Some(std::ffi::OsStr::new("")), true),
1958 "NO_COLOR empty"
1959 );
1960 }
1961 }
1962
1963 #[test]
1964 fn test_strip_sgr_removes_every_colour_form() {
1965 let theme = Theme::neutral();
1966 let net = colorize_nested(
1969 &format!("eth0 [{}] RX: 1 GB", "\x1b[32mUp\x1b[39m"),
1970 ACTIVE_IFACE_PREFIX,
1971 );
1972 let row = format!(
1973 "{}{} {}",
1974 theme.color_label("Net"),
1975 theme.color_separator(":"),
1976 theme.color_value(&net)
1977 );
1978 assert_eq!(strip_sgr(&row), "Net: eth0 [Up] RX: 1 GB");
1979 assert_eq!(strip_sgr("\x1b[38;5;252m/\\\x1b[0m\x1b[m"), "/\\");
1981 }
1982
1983 #[test]
1984 fn test_strip_sgr_keeps_everything_that_is_not_sgr() {
1985 for s in ["\x1b[5Cx", "\x1b[?25lx", "x\x1b", "\x1b[", "plain 宇多田"] {
1988 assert_eq!(strip_sgr(s), s);
1989 }
1990 }
1991
1992 #[test]
1993 fn test_strip_sgr_preserves_visible_width() {
1994 let theme = Theme::neutral();
1996 let row = format!(
1997 "{:>10}{} {}",
1998 theme.color_label("Media"),
1999 theme.color_separator(":"),
2000 theme.color_value("宇多田ヒカル - 花束を君に")
2001 );
2002 assert_eq!(visible_len(&strip_sgr(&row)), visible_len(&row));
2003 }
2004}