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 wants_ascii_logo(cli_ascii_logo: bool, config_ascii_only: Option<bool>) -> bool {
49 cli_ascii_logo || config_ascii_only.unwrap_or(false)
50}
51
52fn should_use_color(
61 choice: Option<ColorChoice>,
62 no_color: Option<&std::ffi::OsStr>,
63 stdout_is_tty: bool,
64) -> bool {
65 match choice {
66 Some(ColorChoice::Always) => true,
67 Some(ColorChoice::Never) => false,
68 Some(ColorChoice::Auto) | None => {
69 stdout_is_tty && no_color.map(|v| v.is_empty()).unwrap_or(true)
70 }
71 }
72}
73
74fn strip_sgr(s: &str) -> String {
87 let bytes = s.as_bytes();
88 let mut out = String::with_capacity(s.len());
89 let mut copied_to = 0;
90 let mut i = 0;
91 while i + 1 < bytes.len() {
92 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
93 let mut j = i + 2;
94 while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b';') {
95 j += 1;
96 }
97 if j < bytes.len() && bytes[j] == b'm' {
98 out.push_str(&s[copied_to..i]);
100 copied_to = j + 1;
101 i = j + 1;
102 continue;
103 }
104 }
105 i += 1;
106 }
107 out.push_str(&s[copied_to..]);
108 out
109}
110
111struct LayoutPlan {
120 side_by_side: bool,
121 text_column_width: usize,
122 logo_column: usize,
123}
124
125fn plan_layout(
153 info_widths: &[usize],
154 logo_height: usize,
155 logo_width: usize,
156 term_width: usize,
157 show_logo: bool,
158) -> LayoutPlan {
159 let beside_count = info_widths.len().min(logo_height);
160 let max_beside_width = info_widths[..beside_count]
161 .iter()
162 .copied()
163 .max()
164 .unwrap_or(0);
165 let text_column_width = if term_width >= 95 {
166 (term_width.saturating_sub(logo_width + 4))
167 .min(std::cmp::max(max_beside_width + 4, 45))
168 .clamp(45, 65)
169 } else {
170 std::cmp::max(max_beside_width + 4, 45)
171 };
172 let side_by_side =
173 show_logo && term_width >= 95 && term_width >= text_column_width + logo_width;
174 let logo_column = term_width.saturating_sub(logo_width).max(text_column_width);
177 LayoutPlan {
178 side_by_side,
179 text_column_width,
180 logo_column,
181 }
182}
183
184pub fn visible_len(s: &str) -> usize {
206 use unicode_width::UnicodeWidthStr;
207
208 let mut visible = String::with_capacity(s.len());
209 let mut in_esc = false;
210 for c in s.chars() {
211 if c == '\x1b' {
212 in_esc = true;
213 } else if in_esc {
214 if c.is_ascii_alphabetic() {
215 in_esc = false;
216 }
217 } else {
218 visible.push(c);
219 }
220 }
221 visible.width()
222}
223
224pub fn wrap_info_line(line: &str, max_width: usize) -> Vec<String> {
230 let vis_len = visible_len(line);
231 if vis_len <= max_width || max_width < 20 {
232 return vec![line.to_string()];
233 }
234
235 let prefix_len = if let Some(idx) = line.find(':') {
236 let prefix_sub = &line[..=idx];
237 let extra_space = if line[idx + 1..].starts_with(' ') {
238 1
239 } else {
240 0
241 };
242 visible_len(prefix_sub) + extra_space
243 } else {
244 4
245 };
246
247 let indent = " ".repeat(prefix_len.min(max_width / 2));
248
249 if line.contains(", ") {
251 let parts: Vec<&str> = line.split(", ").collect();
252 let mut lines = Vec::new();
253 let mut current = String::new();
254
255 for (i, part) in parts.iter().enumerate() {
256 let item = if i == 0 {
257 part.to_string()
258 } else {
259 format!(", {}", part)
260 };
261 let item_vis = visible_len(&item);
262
263 if current.is_empty() || visible_len(¤t) + item_vis <= max_width {
264 current.push_str(&item);
265 } else {
266 lines.push(format!("{current},"));
271 current = format!("{}{}", indent, part);
272 }
273 }
274 if !current.is_empty() {
275 lines.push(current);
276 }
277 if lines.iter().all(|l| visible_len(l) <= max_width + 10) {
278 return carry_sgr_across_lines(lines);
279 }
280 }
281
282 let raw_words: Vec<&str> = line.split_whitespace().collect();
284 let mut words: Vec<String> = Vec::new();
285 let mut idx = 0;
286 while idx < raw_words.len() {
287 if raw_words[idx] == "RX:"
288 && idx + 3 < raw_words.len()
289 && raw_words.iter().skip(idx).any(|&w| w == "TX:")
290 {
291 let rx_tx = format!(
292 "{} {} {} {} {} {}",
293 raw_words[idx],
294 raw_words[idx + 1],
295 raw_words[idx + 2],
296 raw_words[idx + 3],
297 raw_words.get(idx + 4).copied().unwrap_or(""),
298 raw_words.get(idx + 5).copied().unwrap_or("")
299 );
300 words.push(rx_tx.trim().to_string());
301 idx += if idx + 5 < raw_words.len() { 6 } else { 4 };
302 continue;
303 }
304 words.push(raw_words[idx].to_string());
305 idx += 1;
306 }
307
308 let mut lines = Vec::new();
309 let mut current = String::new();
310
311 for word in words {
312 let word_vis = visible_len(&word);
313 if current.is_empty() {
314 current.push_str(&word);
315 } else if visible_len(¤t) + 1 + word_vis <= max_width {
316 current.push(' ');
317 current.push_str(&word);
318 } else {
319 lines.push(current);
320 current = format!("{}{}", indent, word);
321 }
322 }
323 if !current.is_empty() {
324 lines.push(current);
325 }
326
327 if lines.is_empty() {
328 vec![line.to_string()]
329 } else {
330 carry_sgr_across_lines(lines)
333 }
334}
335
336fn active_sgr_after(s: &str, entry: Option<String>) -> Option<String> {
343 let mut active = entry;
344 let bytes = s.as_bytes();
345 let mut i = 0;
346 while i < bytes.len() {
347 if bytes[i] != 0x1b {
348 i += 1;
349 continue;
350 }
351 let start = i;
352 i += 1;
353 while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
354 i += 1;
355 }
356 if i < bytes.len() {
357 let seq = &s[start..=i];
358 if seq.ends_with('m') {
359 active = if seq == "\x1b[0m" || seq == "\x1b[39m" {
360 None
361 } else {
362 Some(seq.to_string())
363 };
364 }
365 i += 1;
366 }
367 }
368 active
369}
370
371fn carry_sgr_across_lines(lines: Vec<String>) -> Vec<String> {
384 let mut active: Option<String> = None;
385 let mut out = Vec::with_capacity(lines.len());
386 for line in lines {
387 let reopened = match &active {
388 Some(sgr) => format!("{sgr}{line}"),
389 None => line.clone(),
390 };
391 let end_state = active_sgr_after(&line, active.clone());
392 active = end_state.clone();
393 out.push(match end_state {
394 Some(_) => format!("{reopened}\x1b[39m"),
396 None => reopened,
397 });
398 }
399 out
400}
401
402fn split_wifi_line(wifi: &str) -> (&str, Option<&str>) {
411 match wifi.split_once(" - ") {
412 Some((hardware, connection)) => (hardware, Some(connection)),
413 None => (wifi, None),
414 }
415}
416
417fn compose_side_by_side_row(info_line: &str, logo_line: &str, logo_column: usize) -> String {
432 let vis_len = visible_len(info_line);
433 if logo_line.is_empty() || vis_len >= logo_column {
434 return format!("{info_line}{logo_line}");
435 }
436 format!(
437 "{info_line}{}{logo_line}",
438 " ".repeat(logo_column - vis_len)
439 )
440}
441
442fn graphical_side_by_side_prelude(logo_column: usize, logo_rows: usize) -> String {
457 let mut prelude = String::new();
458 if logo_rows > 0 {
459 prelude.push_str(&"\n".repeat(logo_rows));
460 prelude.push_str(&format!("\x1b[{}A", logo_rows));
461 }
462 prelude.push_str(&format!("\x1b[{}C\x1b7", logo_column));
463 prelude
464}
465
466fn render_graphical_side_by_side(
482 logo_column: usize,
483 info_lines: &[String],
484 logo_rows: usize,
485 draw: impl FnOnce(),
486) {
487 use std::io::Write;
488 print!("{}", graphical_side_by_side_prelude(logo_column, logo_rows));
491 draw(); print!("\x1b8\r");
493 for line in info_lines {
494 println!("{}", line);
495 }
496 for _ in info_lines.len()..logo_rows {
499 println!();
500 }
501 let _ = std::io::stdout().flush();
502}
503
504fn partition_net_lines<'a>(
519 nets: &'a [NetworkInterface],
520 active: Option<&str>,
521) -> (Vec<&'a NetworkInterface>, Vec<&'a NetworkInterface>) {
522 nets.iter().partition(|n| active == Some(n.name.as_str()))
523}
524
525fn choose_net_line<'a>(
534 nets: &'a [NetworkInterface],
535 active: Option<&str>,
536) -> Option<&'a NetworkInterface> {
537 nets.iter()
538 .find(|n| active == Some(n.name.as_str()))
539 .or_else(|| nets.iter().find(|n| n.is_up))
540}
541
542pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
543 let _config = config;
544 let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
545 let mut theme = match theme_name {
546 Some(name) => Theme::from_name(name),
547 None => Theme::detect_system_theme(), };
549
550 if let Some(custom) = &_config.custom_theme {
552 theme = Theme::with_custom_overrides(theme, custom);
553 }
554
555 let term_size = terminal_size::terminal_size();
557 let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
558 w as usize
559 } else {
560 80
561 };
562 let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
565 let use_color = should_use_color(
566 cli.color,
567 std::env::var_os("NO_COLOR").as_deref(),
568 stdout_is_tty,
569 );
570
571 let show_logo = should_show_logo(
572 _config.show_logo,
573 cli.no_logo,
574 cli.ascii_logo,
575 stdout_is_tty,
576 );
577
578 let allowed_fields: Option<Vec<String>> = if cli.full {
583 Some(fields::fields_for(Mode::Full))
584 } else if cli.long {
585 Some(fields::fields_for(Mode::Long))
586 } else if cli.short {
587 Some(fields::fields_for(Mode::Short))
588 } else if let Some(fields) = &_config.fields {
589 Some(fields.iter().map(|s| s.to_lowercase()).collect())
590 } else {
591 Some(fields::fields_for(Mode::Standard))
592 };
593
594 let should_show = |label: &str| -> bool {
595 match &allowed_fields {
596 Some(fields) => {
597 let norm_label = label.to_lowercase().replace(['-', '_'], " ");
598 let norm_label_no_spaces = norm_label.replace(' ', "");
599 fields.iter().any(|f| {
600 let norm_f = f.to_lowercase().replace(['-', '_'], " ");
601 norm_f == norm_label
602 || norm_f.replace(' ', "") == norm_label_no_spaces
603 || (norm_label == "dns server" && norm_f == "dns")
605 || (norm_label == "memory usage" && norm_f == "memory")
607 || (norm_label == "wi fi link" && norm_f == "wifi")
609 })
610 }
611 None => true,
612 }
613 };
614
615 let label_width = 10;
617 let mut info_lines = Vec::new();
618 let mut print_line = |label: &str, value: &str| {
619 if should_show(label) {
620 info_lines.push(format!(
621 "{:>width$}{} {}",
622 theme.color_label(label),
623 theme.color_separator(":"),
624 theme.color_value(value),
625 width = label_width
626 ));
627 }
628 };
629
630 if let Some(host) = &info.hostname {
634 print_line("Host", host);
635 }
636 print_line("OS", &info.os);
637 if let Some(kernel) = &info.kernel {
638 print_line("Kernel", kernel);
639 }
640 if let Some(domain) = &info.domain {
641 print_line("Domain", domain);
642 }
643 if should_show("domain-search") {
644 for entry in &info.domain_search {
645 print_line("Domain Search", entry);
646 }
647 }
648 if let Some(chassis) = &info.chassis {
649 print_line("Chassis", chassis);
650 }
651 if let Some(init) = &info.init_system {
652 print_line("Init", init);
653 }
654 if let Some(locale) = &info.locale {
655 print_line("Locale", locale);
656 }
657 print_line("Arch", &info.arch);
658 if info.users > 0 {
662 print_line("Users", &info.users.to_string());
663 }
664 if let Some(pkgs) = info.packages {
665 if pkgs > 0 {
666 print_line("Packages", &pkgs.to_string());
667 }
668 }
669 if let Some(user) = &info.current_user {
670 print_line("User", user);
671 }
672 let uptime_str = format_uptime(&info.uptime);
674 let boot_display = format!("{} since {}", uptime_str, info.boot_time);
675 print_line("Uptime", &boot_display);
676
677 print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
679 if let Some(freq) = &info.cpu_freq {
680 print_line("CPU Freq", freq);
681 }
682 if let Some(cache) = &info.cpu_cache {
683 print_line("CPU Cache", cache);
684 }
685 if let Some(usage) = &info.cpu_usage {
686 print_line("CPU Usage", usage);
687 }
688 if let Some(motherboard) = &info.motherboard {
689 print_line("Motherboard", motherboard);
690 }
691 if let Some(bios) = &info.bios {
692 print_line("BIOS", bios);
693 }
694 if let Some(bootmgr) = &info.bootmgr {
695 print_line("Bootmgr", bootmgr);
696 }
697 if let Some(tpm) = &info.tpm {
698 print_line("TPM", tpm);
699 }
700 if should_show("GPU") {
701 for gpu in &info.gpu {
702 print_line("GPU", gpu);
703 }
704 }
705 if should_show("Display") {
706 for display in &info.displays {
707 print_line("Display", display);
708 }
709 }
710 if let Some(vulkan) = &info.vulkan {
711 print_line("Vulkan", vulkan);
712 }
713 if let Some(opengl) = &info.opengl {
714 print_line("OpenGL", opengl);
715 }
716 if let Some(opencl) = &info.opencl {
717 print_line("OpenCL", opencl);
718 }
719 if let Some(brightness) = &info.brightness {
720 print_line("Brightness", brightness);
721 }
722 if let Some(audio) = &info.audio {
723 print_line("Audio", audio);
724 }
725 if should_show("Camera") {
726 for cam in &info.camera {
727 print_line("Camera", cam);
728 }
729 }
730 if should_show("Gamepad") {
731 for gp in &info.gamepad {
732 print_line("Gamepad", gp);
733 }
734 }
735 if should_show("Keyboard") {
736 for kb in &info.keyboard {
737 print_line("Keyboard", kb);
738 }
739 }
740 if should_show("Mouse") {
741 for m in &info.mouse {
742 print_line("Mouse", m);
743 }
744 }
745 if let Some(wifi) = &info.wifi {
746 let (hardware, connection) = split_wifi_line(wifi);
749 print_line("Wi-Fi", hardware);
750 if let Some(conn) = connection {
751 print_line("Wi-Fi Link", conn);
752 }
753 }
754 if let Some(bt) = &info.bluetooth {
755 print_line("Bluetooth", bt);
756 }
757 if let Some(bat) = &info.battery {
758 print_line("Battery", bat);
759 }
760 if let Some(power) = &info.power_adapter {
761 print_line("Power Adapter", power);
762 }
763 print_line("Memory Usage", &info.memory);
764 if let Some(phys_mem) = &info.physical_memory {
765 print_line("Phys Mem", phys_mem);
766 }
767 print_line("Swap", &info.swap);
768 print_line("Procs", &info.processes.to_string());
769 if let Some(load) = &info.load_avg {
770 print_line("Load", load);
771 }
772 if should_show("Disk") {
773 for disk in &info.disks {
774 print_line("Disk", disk);
775 }
776 }
777 if should_show("Phys Disk") {
778 for disk in &info.physical_disks {
779 print_line("Phys Disk", disk);
780 }
781 }
782 if should_show("Disk IO") {
783 for io in &info.disk_io {
784 print_line("Disk IO", io);
785 }
786 }
787 if should_show("Btrfs") {
788 for vol in &info.btrfs {
789 print_line("Btrfs", vol);
790 }
791 }
792 if should_show("Zpool") {
793 for pool in &info.zpool {
794 print_line("Zpool", pool);
795 }
796 }
797 if should_show("Temp") {
798 if cli.full {
799 for temp in &info.temps {
800 print_line("Temp", temp);
801 }
802 } else {
803 for temp in consolidate_temps(&info.temps) {
804 print_line("Temp", &temp);
805 }
806 }
807 }
808
809 if should_show("Net") {
811 let active = info.active_interface.as_deref();
812 if cli.long || cli.full {
813 let (active_nets, others) = partition_net_lines(&info.networks, active);
814 for net in active_nets {
815 print_line("Net", &colorize_nested(&net.line, ACTIVE_IFACE_PREFIX));
819 }
820 for net in others {
821 print_line("Net", &net.line);
822 }
823 } else if let Some(net) = choose_net_line(&info.networks, active) {
824 print_line("Net", &net.line);
825 }
826 }
827 if should_show("Net IO") {
828 for io in &info.net_io {
829 print_line("Net IO", io);
830 }
831 }
832 if let Some(ip) = &info.public_ip {
833 print_line("Public IP", ip);
834 }
835 if !info.dns.is_empty() {
836 print_line("DNS Server", &info.dns.join(", "));
837 }
838
839 if let Some(shell) = &info.shell {
841 print_line("Shell", shell);
842 }
843 if let Some(editor) = &info.editor {
844 print_line("Editor", editor);
845 }
846 if let Some(term) = &info.terminal {
847 print_line("Terminal", term);
848 }
849 if let Some(ts) = &info.terminal_size {
850 print_line("Terminal Size", ts);
851 }
852 if let Some(de) = &info.desktop {
853 print_line("Desktop", de);
854 }
855 if let Some(wm) = &info.wm {
856 let duplicate = info
857 .desktop
858 .as_deref()
859 .map(|de| de.to_lowercase() == wm.to_lowercase())
860 .unwrap_or(false);
861 if !duplicate {
862 print_line("WM", wm);
863 }
864 }
865 if let Some(wm_theme) = &info.wm_theme {
866 print_line("WM Theme", wm_theme);
867 }
868 if let Some(wallpaper) = &info.wallpaper {
869 print_line("Wallpaper", wallpaper);
870 }
871 if let Some(lm) = &info.login_manager {
872 print_line("Login Manager", lm);
873 }
874 if let Some(player) = &info.player {
875 print_line("Player", player);
876 }
877 if let Some(media) = &info.media {
878 print_line("Media", media);
879 }
880 if let Some(ui_theme) = &info.ui_theme {
881 print_line("Theme", ui_theme);
882 }
883 if let Some(icons) = &info.icons {
884 print_line("Icons", icons);
885 }
886 if let Some(cursor) = &info.cursor {
887 print_line("Cursor", cursor);
888 }
889 if let Some(font) = &info.font {
890 print_line("Font", font);
891 }
892 if let Some(term_font) = &info.terminal_font {
893 print_line("Terminal Font", term_font);
894 }
895 if let Some(term_theme) = &info.terminal_theme {
896 print_line("Terminal Theme", term_theme);
897 }
898 if let Some(weather) = &info.weather {
899 print_line("Weather", weather);
900 }
901
902 #[cfg_attr(not(feature = "graphics"), allow(dead_code))]
906 enum ActiveLogo {
907 Lines(Vec<String>),
908 Kitty(Vec<u8>, usize, usize), Iterm2(Vec<u8>, usize, usize),
910 Sixel(Vec<u8>, usize, usize),
911 None,
912 }
913
914 let mut active_logo = ActiveLogo::None;
915
916 if show_logo {
917 let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
918 let user_logo = if let Some(config_dir) = dirs::config_dir() {
919 let p = config_dir.join("retch").join("logo.png");
920 if p.exists() {
921 Some(p)
922 } else {
923 None
924 }
925 } else {
926 None
927 };
928
929 if wants_ascii_logo(cli.ascii_logo, _config.ascii_only) {
930 active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
931 } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
932 let mut resolved = false;
933 if use_color && logo::chafa_available() {
936 if let Some(path) = &user_logo {
937 if let Some(lines) = logo::get_chafa_logo_lines(path) {
938 active_logo = ActiveLogo::Lines(lines);
939 resolved = true;
940 }
941 } else if let Some(distro) = &distro_hint {
942 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
943 let temp_path = std::env::temp_dir()
944 .join(format!("retch_logo_{}.png", std::process::id()));
945 if std::fs::write(&temp_path, bytes).is_ok() {
946 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
947 active_logo = ActiveLogo::Lines(lines);
948 resolved = true;
949 }
950 let _ = std::fs::remove_file(&temp_path);
951 }
952 }
953 }
954 }
955 if !resolved {
956 active_logo =
957 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
958 }
959 } else {
960 let mut resolved = false;
961
962 #[cfg(feature = "graphics")]
964 if !resolved && logo::supports_kitty() {
965 if let Some(path) = &user_logo {
966 if let Ok(bytes) = std::fs::read(path) {
967 let (cols, rows) = graphical_logo_cells(&bytes);
968 active_logo = ActiveLogo::Kitty(bytes, cols, rows);
969 resolved = true;
970 }
971 } else if let Some(distro) = &distro_hint {
972 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
973 let (cols, rows) = graphical_logo_cells(bytes);
974 active_logo = ActiveLogo::Kitty(bytes.to_vec(), cols, rows);
975 resolved = true;
976 }
977 }
978 }
979
980 #[cfg(feature = "graphics")]
982 if !resolved && logo::supports_iterm2() {
983 if let Some(path) = &user_logo {
984 if let Ok(bytes) = std::fs::read(path) {
985 let (cols, rows) = graphical_logo_cells(&bytes);
986 active_logo = ActiveLogo::Iterm2(bytes, cols, rows);
987 resolved = true;
988 }
989 } else if let Some(distro) = &distro_hint {
990 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
991 let (cols, rows) = graphical_logo_cells(bytes);
992 active_logo = ActiveLogo::Iterm2(bytes.to_vec(), cols, rows);
993 resolved = true;
994 }
995 }
996 }
997
998 #[cfg(feature = "graphics")]
1000 if !resolved && logo::supports_sixel() {
1001 if let Some(path) = &user_logo {
1002 if let Ok(bytes) = std::fs::read(path) {
1003 let (cols, rows) = graphical_logo_cells(&bytes);
1004 active_logo = ActiveLogo::Sixel(bytes, cols, rows);
1005 resolved = true;
1006 }
1007 } else if let Some(distro) = &distro_hint {
1008 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
1009 let (cols, rows) = graphical_logo_cells(bytes);
1010 active_logo = ActiveLogo::Sixel(bytes.to_vec(), cols, rows);
1011 resolved = true;
1012 }
1013 }
1014 }
1015
1016 if !resolved && use_color && logo::chafa_available() {
1019 if let Some(path) = &user_logo {
1020 if let Some(lines) = logo::get_chafa_logo_lines(path) {
1021 active_logo = ActiveLogo::Lines(lines);
1022 resolved = true;
1023 }
1024 } else if let Some(distro) = &distro_hint {
1025 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
1026 let temp_path = std::env::temp_dir()
1028 .join(format!("retch_logo_{}.png", std::process::id()));
1029 if std::fs::write(&temp_path, bytes).is_ok() {
1030 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
1031 active_logo = ActiveLogo::Lines(lines);
1032 resolved = true;
1033 }
1034 let _ = std::fs::remove_file(&temp_path);
1035 }
1036 }
1037 }
1038 }
1039
1040 if !resolved {
1042 active_logo =
1043 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
1044 }
1045 }
1046 }
1047
1048 if !use_color {
1051 info_lines = info_lines.iter().map(|line| strip_sgr(line)).collect();
1052 if let ActiveLogo::Lines(logo_lines) = &mut active_logo {
1053 *logo_lines = logo_lines.iter().map(|line| strip_sgr(line)).collect();
1054 }
1055 }
1056
1057 let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
1065
1066 let (logo_height, max_logo_width) = match &active_logo {
1070 ActiveLogo::Lines(logo_lines) => (
1071 logo_lines.len(),
1072 logo_lines
1073 .iter()
1074 .map(|line| visible_len(line))
1075 .max()
1076 .unwrap_or(0),
1077 ),
1078 ActiveLogo::Kitty(_, cols, rows)
1079 | ActiveLogo::Iterm2(_, cols, rows)
1080 | ActiveLogo::Sixel(_, cols, rows) => (*rows, *cols),
1081 ActiveLogo::None => (0, 0),
1082 };
1083
1084 let LayoutPlan {
1087 side_by_side,
1088 text_column_width,
1089 logo_column,
1090 } = plan_layout(
1091 &info_widths,
1092 logo_height,
1093 max_logo_width,
1094 term_width,
1095 show_logo,
1096 );
1097
1098 println!(); let formatted_info_lines: Vec<String> = if side_by_side && text_column_width > 15 {
1101 let mut result = Vec::new();
1102 for (i, line) in info_lines.iter().enumerate() {
1103 let max_w = if i < logo_height {
1110 logo_column.saturating_sub(2)
1111 } else {
1112 term_width.saturating_sub(2)
1113 };
1114 result.extend(wrap_info_line(line, max_w));
1115 }
1116 result
1117 } else {
1118 info_lines.clone()
1119 };
1120
1121 if side_by_side {
1122 match active_logo {
1123 ActiveLogo::Lines(logo_lines) => {
1124 let max_lines = std::cmp::max(formatted_info_lines.len(), logo_lines.len());
1125 for i in 0..max_lines {
1126 let info_line = formatted_info_lines.get(i).cloned().unwrap_or_default();
1127 let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
1128 println!(
1129 "{}",
1130 compose_side_by_side_row(&info_line, &logo_line, logo_column)
1131 );
1132 }
1133 }
1134 ActiveLogo::Kitty(bytes, _, logo_rows) => {
1135 render_graphical_side_by_side(
1136 logo_column,
1137 &formatted_info_lines,
1138 logo_rows,
1139 || logo::print_graphical_logo(&bytes),
1140 );
1141 }
1142 ActiveLogo::Iterm2(bytes, _, logo_rows) => {
1143 render_graphical_side_by_side(
1144 logo_column,
1145 &formatted_info_lines,
1146 logo_rows,
1147 || logo::print_iterm2_logo(&bytes),
1148 );
1149 }
1150 ActiveLogo::Sixel(bytes, _, logo_rows) => {
1151 render_graphical_side_by_side(
1152 logo_column,
1153 &formatted_info_lines,
1154 logo_rows,
1155 || logo::print_sixel_logo(&bytes),
1156 );
1157 }
1158 ActiveLogo::None => {
1159 for line in &formatted_info_lines {
1160 println!("{}", line);
1161 }
1162 }
1163 }
1164 } else {
1165 match active_logo {
1167 ActiveLogo::Lines(logo_lines) => {
1168 for line in logo_lines {
1169 println!("{}", line);
1170 }
1171 println!();
1172 }
1173 ActiveLogo::Kitty(bytes, _, _) => {
1174 logo::print_graphical_logo(&bytes);
1175 println!();
1176 }
1177 ActiveLogo::Iterm2(bytes, _, _) => {
1178 logo::print_iterm2_logo(&bytes);
1179 println!();
1180 }
1181 ActiveLogo::Sixel(bytes, _, _) => {
1182 logo::print_sixel_logo(&bytes);
1183 println!();
1184 }
1185 ActiveLogo::None => {}
1186 }
1187 for line in &info_lines {
1188 println!("{}", line);
1189 }
1190 }
1191
1192 Ok(())
1193}
1194
1195fn consolidate_temps(temps: &[String]) -> Vec<String> {
1201 fn categorize(label: &str) -> &'static str {
1202 let l = label.to_lowercase();
1203 if l.contains("cpu")
1204 || l.contains("core")
1205 || l.contains("k10temp")
1206 || l.contains("k8temp")
1207 || l.contains("coretemp")
1208 || l.contains("tctl")
1209 || l.contains("tdie")
1210 || l.contains("tccd")
1211 || l.contains("package")
1212 {
1213 "CPU"
1214 } else if l.contains("gpu")
1215 || l.contains("nouveau")
1216 || l.contains("radeon")
1217 || l.contains("amdgpu")
1218 {
1219 "GPU"
1220 } else if l.contains("nvme") || l.contains("nand") {
1221 "NVMe"
1222 } else if l.contains("ath")
1223 || l.contains("wifi")
1224 || l.contains("wireless")
1225 || l.contains("wlan")
1226 || l.contains("iwl")
1227 {
1228 "WiFi"
1229 } else if l.contains("bat") {
1230 "Battery"
1231 } else {
1232 "System"
1233 }
1234 }
1235
1236 let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
1237 for s in temps {
1238 if let Some((label_part, val_part)) = s.rsplit_once(':') {
1240 let val_str = val_part.trim().trim_end_matches("°C");
1241 if let Ok(val) = val_str.parse::<f32>() {
1242 let cat = categorize(label_part.trim());
1243 let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
1244 if val > *entry {
1245 *entry = val;
1246 }
1247 }
1248 }
1249 }
1250
1251 const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
1252 ORDER
1253 .iter()
1254 .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
1255 .collect()
1256}
1257
1258fn format_uptime(uptime: &str) -> String {
1262 let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
1264
1265 let years = seconds / (365 * 24 * 3600);
1266 let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
1267 let hours = (seconds % (24 * 3600)) / 3600;
1268 let minutes = (seconds % 3600) / 60;
1269 let secs = seconds % 60;
1270
1271 let mut parts = Vec::new();
1272 if years > 0 {
1273 parts.push(format!("{}y", years));
1274 }
1275 if days > 0 {
1276 parts.push(format!("{}d", days));
1277 }
1278 if hours > 0 {
1279 parts.push(format!("{}h", hours));
1280 }
1281 if minutes > 0 {
1282 parts.push(format!("{}m", minutes));
1283 }
1284 if secs > 0 || parts.is_empty() {
1285 parts.push(format!("{}s", secs));
1286 }
1287
1288 parts.join(" ")
1289}
1290
1291#[cfg(feature = "graphics")]
1300fn graphical_logo_cells(bytes: &[u8]) -> (usize, usize) {
1301 let (img_w, img_h) = image::load_from_memory(bytes)
1302 .map(|img| (img.width(), img.height()))
1303 .unwrap_or((0, 0));
1304 let fit = logo::logo_cells_for(img_w, img_h);
1305 (fit.cols, fit.rows)
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311
1312 fn net(name: &str, is_up: bool) -> NetworkInterface {
1315 let status = if is_up {
1320 "\x1b[32mUp\x1b[39m"
1321 } else {
1322 "\x1b[31mDown\x1b[39m"
1323 };
1324 NetworkInterface {
1325 name: name.to_string(),
1326 is_up,
1327 line: format!("{name} (10.0.0.1) [{status}] RX: 1.0 MB TX: 1.0 MB"),
1328 }
1329 }
1330
1331 #[test]
1332 fn test_active_interface_is_matched_by_exact_name_not_substring() {
1333 let nets = vec![
1336 net("Wi-Fi-Native WiFi Filter Driver-0000", true),
1337 net("Wi-Fi", true),
1338 ];
1339 let (active, others) = partition_net_lines(&nets, Some("Wi-Fi"));
1340 assert_eq!(active.len(), 1);
1341 assert_eq!(active[0].name, "Wi-Fi");
1342 assert_eq!(others.len(), 1);
1343 assert_eq!(others[0].name, "Wi-Fi-Native WiFi Filter Driver-0000");
1344 }
1345
1346 #[test]
1347 fn test_active_interface_does_not_match_a_vlan_or_veth_sibling() {
1348 let nets = vec![
1351 net("eth0", true),
1352 net("eth0.100", true),
1353 net("veth0a1b2c3", true),
1354 ];
1355 let (active, others) = partition_net_lines(&nets, Some("eth0"));
1356 assert_eq!(active.len(), 1);
1357 assert_eq!(active[0].name, "eth0");
1358 assert_eq!(others.len(), 2);
1359 }
1360
1361 #[test]
1362 fn test_no_active_interface_means_no_line_is_highlighted() {
1363 let nets = vec![net("eth0", true), net("wlan0", true)];
1364 let (active, others) = partition_net_lines(&nets, None);
1365 assert!(active.is_empty());
1366 assert_eq!(others.len(), 2);
1367 }
1368
1369 #[test]
1370 fn test_standard_mode_prefers_the_active_interface() {
1371 let nets = vec![net("docker0", true), net("wlan0", true)];
1372 let chosen = choose_net_line(&nets, Some("wlan0")).expect("a line");
1373 assert_eq!(chosen.name, "wlan0");
1374 }
1375
1376 #[test]
1377 fn test_standard_mode_falls_back_to_the_first_up_interface() {
1378 let nets = vec![net("eth0", false), net("wlan0", true), net("eth1", true)];
1382 let chosen = choose_net_line(&nets, None).expect("a line, not None");
1383 assert_eq!(chosen.name, "wlan0");
1384
1385 let chosen = choose_net_line(&nets, Some("ppp0")).expect("a line, not None");
1387 assert_eq!(chosen.name, "wlan0");
1388 }
1389
1390 #[test]
1391 fn test_standard_mode_reports_nothing_when_every_interface_is_down() {
1392 let nets = vec![net("eth0", false), net("eth1", false)];
1395 assert!(choose_net_line(&nets, None).is_none());
1396 }
1397
1398 #[test]
1401 fn test_ascii_only_config_selects_the_ascii_logo() {
1402 assert!(wants_ascii_logo(false, Some(true)));
1404 assert!(wants_ascii_logo(true, None));
1405 assert!(
1406 wants_ascii_logo(true, Some(false)),
1407 "the flag wins over a config false"
1408 );
1409 assert!(!wants_ascii_logo(false, Some(false)));
1410 assert!(!wants_ascii_logo(false, None));
1411 }
1412
1413 #[test]
1416 fn test_show_logo_auto_requires_tty() {
1417 assert!(should_show_logo(None, false, false, true));
1419 assert!(!should_show_logo(None, false, false, false));
1420 }
1421
1422 #[test]
1423 fn test_show_logo_ascii_forces_without_tty() {
1424 assert!(should_show_logo(None, false, true, false));
1426 assert!(should_show_logo(None, false, true, true));
1427 }
1428
1429 #[test]
1430 fn test_show_logo_no_logo_always_wins() {
1431 assert!(!should_show_logo(None, true, true, true));
1433 assert!(!should_show_logo(None, true, false, true));
1434 }
1435
1436 #[test]
1437 fn test_show_logo_config_disable() {
1438 assert!(!should_show_logo(Some(false), false, false, true));
1440 assert!(should_show_logo(Some(false), false, true, false));
1442 }
1443
1444 #[test]
1447 fn test_visible_len_strips_every_escape_form_retch_emits() {
1448 assert_eq!(visible_len("plain"), 5);
1452 assert_eq!(visible_len("\x1b[38;2;1;2;3mabc\x1b[39m"), 3);
1453 assert_eq!(visible_len("\x1b[?25labc"), 3);
1454 assert_eq!(visible_len("\x1b(Babc"), 3);
1455 assert_eq!(visible_len("\x1b[0m \x1b[38;2;0;0;0m\u{2582}"), 2);
1456 }
1457
1458 #[test]
1459 fn test_visible_len_counts_columns_not_characters() {
1460 assert_eq!(visible_len("宇多田ヒカル"), 12); assert_eq!(visible_len("아이유"), 6); assert_eq!(visible_len("Media: 宇多田ヒカル - 花束を君に"), 32);
1466 assert_eq!(visible_len("Media: 아이유 - 밤편지"), 22);
1467
1468 assert_eq!(visible_len("cafe\u{301}"), 4);
1470 assert_eq!(visible_len("café"), 4);
1472
1473 assert_eq!(
1476 visible_len("\x1b[38;2;1;2;3m宇多田\x1b[39m"),
1477 visible_len("宇多田")
1478 );
1479 }
1480
1481 #[test]
1482 fn test_visible_len_ascii_art_and_chafa_symbols_are_one_column_each() {
1483 for line in logo::get_ascii_logo(Some("fedora")) {
1487 let stripped: String = strip_for_test(&line);
1488 assert_eq!(
1489 visible_len(&line),
1490 stripped.chars().count(),
1491 "fedora ASCII logo line is not one column per character: {stripped:?}"
1492 );
1493 }
1494 for sym in [
1496 '\u{2580}', '\u{2584}', '\u{2588}', '\u{258c}', '\u{2596}', '\u{2582}',
1497 ] {
1498 assert_eq!(visible_len(&sym.to_string()), 1, "{sym:?} is not 1 column");
1499 }
1500 }
1501
1502 fn strip_for_test(s: &str) -> String {
1505 let mut out = String::new();
1506 let mut in_esc = false;
1507 for c in s.chars() {
1508 if c == '\x1b' {
1509 in_esc = true;
1510 } else if in_esc {
1511 if c.is_ascii_alphabetic() {
1512 in_esc = false;
1513 }
1514 } else {
1515 out.push(c);
1516 }
1517 }
1518 out
1519 }
1520
1521 const CYAN: &str = "\x1b[38;2;0;255;255m";
1525 const RESET: &str = "\x1b[39m";
1526
1527 #[test]
1528 fn test_wrap_keeps_the_comma_it_split_on() {
1529 let out = wrap_info_line(
1533 "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)",
1534 40,
1535 );
1536 assert!(out.len() > 1, "expected a wrap, got {out:?}");
1537 assert!(
1538 out[0].ends_with(','),
1539 "separator lost at the break: {:?}",
1540 out[0]
1541 );
1542 let rejoined: String = out
1544 .iter()
1545 .map(|l| l.trim_start().to_string())
1546 .collect::<Vec<_>>()
1547 .join(" ");
1548 assert_eq!(
1549 rejoined,
1550 "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)"
1551 );
1552 }
1553
1554 #[test]
1555 fn test_wrap_reopens_the_colour_on_every_continuation_line() {
1556 let line =
1560 format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1561 let out = wrap_info_line(&line, 40);
1562 assert!(out.len() > 1, "expected a wrap, got {out:?}");
1563 for (i, l) in out.iter().enumerate().skip(1) {
1564 assert!(
1565 l.contains(CYAN),
1566 "continuation line {i} has no colour: {l:?}"
1567 );
1568 }
1569 for l in &out {
1571 if l.contains(CYAN) {
1572 assert!(l.ends_with(RESET), "colour left open on {l:?}");
1573 }
1574 }
1575 }
1576
1577 #[test]
1578 fn test_wrap_colour_carry_does_not_change_visible_width() {
1579 let plain = "BIOS: American Megatrends International, LLC. HN7306EAC.310";
1582 let coloured =
1583 format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1584 let a = wrap_info_line(plain, 40);
1585 let b = wrap_info_line(&coloured, 40);
1586 assert_eq!(a.len(), b.len());
1587 for (x, y) in a.iter().zip(b.iter()) {
1588 assert_eq!(visible_len(x), visible_len(y), "{x:?} vs {y:?}");
1589 }
1590 }
1591
1592 #[test]
1593 fn test_wrap_uncoloured_line_is_untouched_by_the_carry() {
1594 let out = wrap_info_line("Disk: aaaa, bbbb, cccc, dddd, eeee, ffff, gggg, hhhh", 24);
1595 assert!(out.len() > 1);
1596 assert!(
1597 out.iter().all(|l| !l.contains('\x1b')),
1598 "carry injected escapes into an uncoloured line: {out:?}"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_active_sgr_after_tracks_open_and_reset() {
1604 assert_eq!(active_sgr_after("plain", None), None);
1605 assert_eq!(active_sgr_after(CYAN, None), Some(CYAN.to_string()));
1606 assert_eq!(active_sgr_after(&format!("{CYAN}x{RESET}"), None), None);
1607 assert_eq!(active_sgr_after("\x1b[0m", Some(CYAN.into())), None);
1608 assert_eq!(
1610 active_sgr_after("more text", Some(CYAN.into())),
1611 Some(CYAN.to_string())
1612 );
1613 assert_eq!(
1615 active_sgr_after("\x1b[?25l", Some(CYAN.into())),
1616 Some(CYAN.to_string())
1617 );
1618 }
1619
1620 #[test]
1621 fn test_active_sgr_after_takes_the_last_colour_when_nested() {
1622 let green = "\x1b[32m";
1625 let s = format!("{CYAN}[{green}Up{RESET}] RX: 1 MB");
1626 assert_eq!(active_sgr_after(&s, None), None); let s2 = format!("{CYAN}[{green}Up{RESET}]{CYAN} RX: 1 MB");
1628 assert_eq!(active_sgr_after(&s2, None), Some(CYAN.to_string()));
1629 }
1630
1631 #[test]
1634 fn test_row_places_the_logo_at_the_logo_column() {
1635 let row = compose_side_by_side_row("OS: Fedora", "###", 20);
1636 assert_eq!(row, format!("OS: Fedora{}###", " ".repeat(10)));
1637 assert_eq!(visible_len(&row), 23);
1638 }
1639
1640 #[test]
1641 fn test_row_aligns_wide_characters_by_column_not_character_count() {
1642 let latin = compose_side_by_side_row("Locale: en_US.UTF-8", "###", 40);
1647 let cjk = compose_side_by_side_row("Locale: ja_JP.宇多田ヒカル", "###", 40);
1648 assert_eq!(visible_len(&latin), 43);
1649 assert_eq!(
1650 visible_len(&cjk),
1651 43,
1652 "a wide-character info line must not shift the logo column"
1653 );
1654 assert!(latin.ends_with(" ###") && cjk.ends_with(" ###"));
1656 }
1657
1658 #[test]
1659 fn test_row_without_a_logo_gets_no_trailing_padding() {
1660 assert_eq!(compose_side_by_side_row("Net: eth0", "", 40), "Net: eth0");
1662 }
1663
1664 #[test]
1665 fn test_row_with_overlong_info_does_not_underflow() {
1666 let row = compose_side_by_side_row("x".repeat(50).as_str(), "###", 40);
1668 assert_eq!(row, format!("{}###", "x".repeat(50)));
1669 }
1670
1671 #[test]
1672 fn test_row_ignores_ansi_colour_when_measuring() {
1673 let plain = compose_side_by_side_row("abc", "###", 10);
1674 let coloured = compose_side_by_side_row("\x1b[31mabc\x1b[39m", "###", 10);
1675 assert_eq!(visible_len(&plain), visible_len(&coloured));
1676 }
1677
1678 fn realistic_full_widths() -> Vec<usize> {
1683 let mut w = vec![40; 20]; w[13] = 54; w.extend([158, 91, 79, 60, 45, 62]); w
1687 }
1688
1689 #[test]
1690 fn test_layout_long_line_below_logo_stays_side_by_side() {
1691 let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
1693 assert!(p.side_by_side);
1694 assert_eq!(p.text_column_width, 58); }
1697
1698 #[test]
1699 fn test_layout_old_behavior_would_have_stacked() {
1700 let widths = realistic_full_widths();
1703 let old_text_col = std::cmp::max(widths.iter().copied().max().unwrap() + 4, 45);
1704 assert!(120 < old_text_col + 40); assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); }
1707
1708 #[test]
1709 fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() {
1710 let mut w = vec![40; 20];
1713 w[5] = 158;
1714 let p = plan_layout(&w, 20, 40, 120, true);
1715 assert!(p.side_by_side);
1716 assert_eq!(p.text_column_width, 65);
1717 }
1718
1719 #[test]
1720 fn test_layout_narrow_terminal_stacks() {
1721 assert!(!plan_layout(&[40; 30], 20, 40, 94, true).side_by_side); assert!(!plan_layout(&[40; 30], 20, 40, 80, true).side_by_side);
1723 }
1724
1725 #[test]
1726 fn test_layout_show_logo_false_stacks() {
1727 assert!(!plan_layout(&[40; 30], 20, 40, 200, false).side_by_side);
1728 }
1729
1730 #[test]
1731 fn test_layout_column_floor_and_graphical_width() {
1732 let p = plan_layout(&[10; 25], 20, 40, 100, true);
1734 assert!(p.side_by_side);
1735 assert_eq!(p.text_column_width, 45); }
1737
1738 #[test]
1739 fn test_layout_widened_logo_box_still_fits_at_the_side_by_side_threshold() {
1740 let p = plan_layout(&[10; 25], 10, logo::LOGO_MAX_COLS, 95, true);
1744 assert!(
1745 p.side_by_side,
1746 "a full-width logo must still sit beside the text at 95 columns"
1747 );
1748 assert!(p.text_column_width + logo::LOGO_MAX_COLS <= 95);
1749
1750 let wide = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, 169, true);
1752 assert!(wide.side_by_side);
1753 assert_eq!(wide.text_column_width, 65);
1754 }
1755
1756 #[test]
1757 fn test_layout_logo_taller_than_text() {
1758 let p = plan_layout(&[50, 30, 54], 20, 40, 120, true);
1760 assert!(p.side_by_side);
1761 assert_eq!(p.text_column_width, 58); }
1763
1764 #[test]
1765 fn test_layout_logo_is_flush_with_the_right_margin() {
1766 let p = plan_layout(&realistic_full_widths(), 20, 49, 138, true);
1771 assert!(p.side_by_side);
1772 assert_eq!(p.text_column_width, 58); assert_eq!(p.logo_column, 138 - 49); assert!(
1775 p.logo_column > p.text_column_width,
1776 "the pre-fix behaviour was logo_column == text_column_width"
1777 );
1778 }
1779
1780 #[test]
1781 fn test_layout_right_anchor_never_overlaps_the_text_column() {
1782 for term_width in 95..200 {
1785 let p = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, term_width, true);
1786 if p.side_by_side {
1787 assert!(
1788 p.logo_column >= p.text_column_width,
1789 "logo_column {} < text_column_width {} at {} cols",
1790 p.logo_column,
1791 p.text_column_width,
1792 term_width
1793 );
1794 assert_eq!(p.logo_column + logo::LOGO_MAX_COLS, term_width);
1795 }
1796 }
1797 }
1798
1799 #[test]
1800 fn test_layout_logo_column_does_not_underflow_on_an_oversized_logo() {
1801 let p = plan_layout(&[40; 10], 10, 200, 100, true);
1803 assert!(!p.side_by_side);
1804 assert_eq!(p.logo_column, p.text_column_width);
1805 }
1806
1807 #[test]
1810 fn test_prelude_reserves_rows_before_saving_cursor() {
1811 let p = graphical_side_by_side_prelude(52, 3);
1815 assert_eq!(p, "\n\n\n\x1b[3A\x1b[52C\x1b7");
1816 }
1817
1818 #[test]
1819 fn test_prelude_v068_shape_only_differs_by_reservation() {
1820 let p = graphical_side_by_side_prelude(45, 20);
1823 assert_eq!(
1824 p.replace(&format!("{}\x1b[20A", "\n".repeat(20)), ""),
1825 "\x1b[45C\x1b7"
1826 );
1827 }
1828
1829 #[test]
1830 fn test_prelude_zero_rows_skips_reservation_and_cursor_up() {
1831 let p = graphical_side_by_side_prelude(45, 0);
1834 assert_eq!(p, "\x1b[45C\x1b7");
1835 }
1836
1837 #[test]
1840 fn test_split_wifi_hardware_and_connection() {
1841 let s = "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0] - myssid (5.0 GHz ch36 [↓866 ↑866])";
1843 let (hw, conn) = split_wifi_line(s);
1844 assert_eq!(
1845 hw,
1846 "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0]"
1847 );
1848 assert_eq!(conn, Some("myssid (5.0 GHz ch36 [↓866 ↑866])"));
1849 }
1850
1851 #[test]
1852 fn test_split_wifi_splits_on_first_separator() {
1853 let (hw, conn) = split_wifi_line("Card X [wlan0] - Guest - 5G (5 GHz)");
1856 assert_eq!(hw, "Card X [wlan0]");
1857 assert_eq!(conn, Some("Guest - 5G (5 GHz)"));
1858 }
1859
1860 #[test]
1861 fn test_split_wifi_connection_only_fallback() {
1862 let (hw, conn) = split_wifi_line("myssid (300 Mbps)");
1864 assert_eq!(hw, "myssid (300 Mbps)");
1865 assert_eq!(conn, None);
1866 }
1867
1868 #[test]
1869 fn test_consolidate_temps_basic() {
1870 let raw = vec![
1871 "k10temp Tctl: 83°C".to_string(),
1872 "amdgpu edge: 65°C".to_string(),
1873 "nvme Composite: 62°C".to_string(),
1874 "ath11k_hwmon temp1: 58°C".to_string(),
1875 "acpitz temp1: 77°C".to_string(),
1876 ];
1877 let result = consolidate_temps(&raw);
1878 assert_eq!(
1879 result,
1880 vec![
1881 "CPU: 83°C",
1882 "GPU: 65°C",
1883 "NVMe: 62°C",
1884 "WiFi: 58°C",
1885 "System: 77°C"
1886 ]
1887 );
1888 }
1889
1890 #[test]
1891 fn test_consolidate_temps_highest_wins() {
1892 let raw = vec![
1893 "thinkpad CPU: 83°C".to_string(),
1894 "k10temp Tctl: 79°C".to_string(),
1895 "nvme Composite: 62°C".to_string(),
1896 "nvme Sensor 1: 59°C".to_string(),
1897 "nvme Sensor 2: 56°C".to_string(),
1898 ];
1899 let result = consolidate_temps(&raw);
1900 assert!(result.contains(&"CPU: 83°C".to_string()));
1901 assert!(result.contains(&"NVMe: 62°C".to_string()));
1902 assert!(!result
1903 .iter()
1904 .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
1905 }
1906
1907 #[test]
1908 fn test_consolidate_temps_order() {
1909 let raw = vec![
1910 "acpitz: 60°C".to_string(),
1911 "nvme: 55°C".to_string(),
1912 "amdgpu edge: 65°C".to_string(),
1913 "k10temp Tctl: 80°C".to_string(),
1914 ];
1915 let result = consolidate_temps(&raw);
1916 let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
1917 let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
1918 let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
1919 let sys_pos = result.iter().position(|s| s.starts_with("System"));
1920 assert!(cpu_pos < gpu_pos);
1921 assert!(gpu_pos < nvme_pos);
1922 assert!(nvme_pos < sys_pos);
1923 }
1924
1925 #[test]
1926 fn test_consolidate_temps_empty() {
1927 assert!(consolidate_temps(&[]).is_empty());
1928 }
1929
1930 #[test]
1931 fn test_format_uptime() {
1932 assert_eq!(format_uptime("60s"), "1m");
1933 assert_eq!(format_uptime("3600s"), "1h");
1934 assert_eq!(format_uptime("3661s"), "1h 1m 1s");
1935 assert_eq!(format_uptime("86400s"), "1d");
1936 assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
1937 assert_eq!(format_uptime("31536000s"), "1y");
1938 assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
1939 assert_eq!(format_uptime("0s"), "0s");
1940 }
1941
1942 #[test]
1943 fn test_wrap_info_line_short_line_unchanged() {
1944 let line = "Audio: Windows Audio (USB Audio Device)";
1945 let wrapped = wrap_info_line(line, 50);
1946 assert_eq!(wrapped, vec![line.to_string()]);
1947 }
1948
1949 #[test]
1950 fn test_wrap_info_line_wraps_and_indents() {
1951 let line = "Audio: Windows Audio (USB Audio Device, AMD High Definition Audio Device, AMD SoundWire Device)";
1952 let wrapped = wrap_info_line(line, 45);
1953 assert!(wrapped.len() > 1);
1954 assert!(wrapped[0].starts_with("Audio: Windows Audio"));
1955 assert!(wrapped[1].starts_with(" "));
1956 }
1957
1958 #[test]
1961 fn test_should_use_color_explicit_choice_beats_everything() {
1962 let set = Some(std::ffi::OsStr::new("1"));
1963 for tty in [true, false] {
1964 for env in [None, set] {
1965 assert!(should_use_color(Some(ColorChoice::Always), env, tty));
1966 assert!(!should_use_color(Some(ColorChoice::Never), env, tty));
1967 }
1968 }
1969 }
1970
1971 #[test]
1972 fn test_should_use_color_auto_needs_tty_and_no_no_color() {
1973 for choice in [None, Some(ColorChoice::Auto)] {
1974 assert!(should_use_color(choice, None, true));
1975 assert!(!should_use_color(choice, None, false), "piped output");
1976 assert!(
1977 !should_use_color(choice, Some(std::ffi::OsStr::new("1")), true),
1978 "NO_COLOR set"
1979 );
1980 assert!(
1983 should_use_color(choice, Some(std::ffi::OsStr::new("")), true),
1984 "NO_COLOR empty"
1985 );
1986 }
1987 }
1988
1989 #[test]
1990 fn test_strip_sgr_removes_every_colour_form() {
1991 let theme = Theme::neutral();
1992 let net = colorize_nested(
1995 &format!("eth0 [{}] RX: 1 GB", "\x1b[32mUp\x1b[39m"),
1996 ACTIVE_IFACE_PREFIX,
1997 );
1998 let row = format!(
1999 "{}{} {}",
2000 theme.color_label("Net"),
2001 theme.color_separator(":"),
2002 theme.color_value(&net)
2003 );
2004 assert_eq!(strip_sgr(&row), "Net: eth0 [Up] RX: 1 GB");
2005 assert_eq!(strip_sgr("\x1b[38;5;252m/\\\x1b[0m\x1b[m"), "/\\");
2007 }
2008
2009 #[test]
2010 fn test_strip_sgr_keeps_everything_that_is_not_sgr() {
2011 for s in ["\x1b[5Cx", "\x1b[?25lx", "x\x1b", "\x1b[", "plain 宇多田"] {
2014 assert_eq!(strip_sgr(s), s);
2015 }
2016 }
2017
2018 #[test]
2019 fn test_strip_sgr_preserves_visible_width() {
2020 let theme = Theme::neutral();
2022 let row = format!(
2023 "{:>10}{} {}",
2024 theme.color_label("Media"),
2025 theme.color_separator(":"),
2026 theme.color_value("宇多田ヒカル - 花束を君に")
2027 );
2028 assert_eq!(visible_len(&strip_sgr(&row)), visible_len(&row));
2029 }
2030}