1use crate::cli::Cli;
9use crate::config::Config;
10use crate::fetch::SystemInfo;
11use crate::fields::{self, Mode};
12use crate::logo;
13use crate::theme::{colorize_nested, Theme, ACTIVE_IFACE_PREFIX};
14
15fn should_show_logo(
29 config_show_logo: Option<bool>,
30 no_logo: bool,
31 ascii_logo: bool,
32 stdout_is_tty: bool,
33) -> bool {
34 if no_logo {
35 return false; }
37 if ascii_logo {
38 return true; }
40 config_show_logo.unwrap_or(true) && stdout_is_tty }
42
43struct LayoutPlan {
52 side_by_side: bool,
53 text_column_width: usize,
54 logo_column: usize,
55}
56
57fn plan_layout(
85 info_widths: &[usize],
86 logo_height: usize,
87 logo_width: usize,
88 term_width: usize,
89 show_logo: bool,
90) -> LayoutPlan {
91 let beside_count = info_widths.len().min(logo_height);
92 let max_beside_width = info_widths[..beside_count]
93 .iter()
94 .copied()
95 .max()
96 .unwrap_or(0);
97 let text_column_width = if term_width >= 95 {
98 (term_width.saturating_sub(logo_width + 4))
99 .min(std::cmp::max(max_beside_width + 4, 45))
100 .clamp(45, 65)
101 } else {
102 std::cmp::max(max_beside_width + 4, 45)
103 };
104 let side_by_side =
105 show_logo && term_width >= 95 && term_width >= text_column_width + logo_width;
106 let logo_column = term_width.saturating_sub(logo_width).max(text_column_width);
109 LayoutPlan {
110 side_by_side,
111 text_column_width,
112 logo_column,
113 }
114}
115
116pub fn visible_len(s: &str) -> usize {
138 use unicode_width::UnicodeWidthStr;
139
140 let mut visible = String::with_capacity(s.len());
141 let mut in_esc = false;
142 for c in s.chars() {
143 if c == '\x1b' {
144 in_esc = true;
145 } else if in_esc {
146 if c.is_ascii_alphabetic() {
147 in_esc = false;
148 }
149 } else {
150 visible.push(c);
151 }
152 }
153 visible.width()
154}
155
156pub fn wrap_info_line(line: &str, max_width: usize) -> Vec<String> {
162 let vis_len = visible_len(line);
163 if vis_len <= max_width || max_width < 20 {
164 return vec![line.to_string()];
165 }
166
167 let prefix_len = if let Some(idx) = line.find(':') {
168 let prefix_sub = &line[..=idx];
169 let extra_space = if line[idx + 1..].starts_with(' ') {
170 1
171 } else {
172 0
173 };
174 visible_len(prefix_sub) + extra_space
175 } else {
176 4
177 };
178
179 let indent = " ".repeat(prefix_len.min(max_width / 2));
180
181 if line.contains(", ") {
183 let parts: Vec<&str> = line.split(", ").collect();
184 let mut lines = Vec::new();
185 let mut current = String::new();
186
187 for (i, part) in parts.iter().enumerate() {
188 let item = if i == 0 {
189 part.to_string()
190 } else {
191 format!(", {}", part)
192 };
193 let item_vis = visible_len(&item);
194
195 if current.is_empty() || visible_len(¤t) + item_vis <= max_width {
196 current.push_str(&item);
197 } else {
198 lines.push(format!("{current},"));
203 current = format!("{}{}", indent, part);
204 }
205 }
206 if !current.is_empty() {
207 lines.push(current);
208 }
209 if lines.iter().all(|l| visible_len(l) <= max_width + 10) {
210 return carry_sgr_across_lines(lines);
211 }
212 }
213
214 let raw_words: Vec<&str> = line.split_whitespace().collect();
216 let mut words: Vec<String> = Vec::new();
217 let mut idx = 0;
218 while idx < raw_words.len() {
219 if raw_words[idx] == "RX:"
220 && idx + 3 < raw_words.len()
221 && raw_words.iter().skip(idx).any(|&w| w == "TX:")
222 {
223 let rx_tx = format!(
224 "{} {} {} {} {} {}",
225 raw_words[idx],
226 raw_words[idx + 1],
227 raw_words[idx + 2],
228 raw_words[idx + 3],
229 raw_words.get(idx + 4).copied().unwrap_or(""),
230 raw_words.get(idx + 5).copied().unwrap_or("")
231 );
232 words.push(rx_tx.trim().to_string());
233 idx += if idx + 5 < raw_words.len() { 6 } else { 4 };
234 continue;
235 }
236 words.push(raw_words[idx].to_string());
237 idx += 1;
238 }
239
240 let mut lines = Vec::new();
241 let mut current = String::new();
242
243 for word in words {
244 let word_vis = visible_len(&word);
245 if current.is_empty() {
246 current.push_str(&word);
247 } else if visible_len(¤t) + 1 + word_vis <= max_width {
248 current.push(' ');
249 current.push_str(&word);
250 } else {
251 lines.push(current);
252 current = format!("{}{}", indent, word);
253 }
254 }
255 if !current.is_empty() {
256 lines.push(current);
257 }
258
259 if lines.is_empty() {
260 vec![line.to_string()]
261 } else {
262 carry_sgr_across_lines(lines)
265 }
266}
267
268fn active_sgr_after(s: &str, entry: Option<String>) -> Option<String> {
275 let mut active = entry;
276 let bytes = s.as_bytes();
277 let mut i = 0;
278 while i < bytes.len() {
279 if bytes[i] != 0x1b {
280 i += 1;
281 continue;
282 }
283 let start = i;
284 i += 1;
285 while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
286 i += 1;
287 }
288 if i < bytes.len() {
289 let seq = &s[start..=i];
290 if seq.ends_with('m') {
291 active = if seq == "\x1b[0m" || seq == "\x1b[39m" {
292 None
293 } else {
294 Some(seq.to_string())
295 };
296 }
297 i += 1;
298 }
299 }
300 active
301}
302
303fn carry_sgr_across_lines(lines: Vec<String>) -> Vec<String> {
316 let mut active: Option<String> = None;
317 let mut out = Vec::with_capacity(lines.len());
318 for line in lines {
319 let reopened = match &active {
320 Some(sgr) => format!("{sgr}{line}"),
321 None => line.clone(),
322 };
323 let end_state = active_sgr_after(&line, active.clone());
324 active = end_state.clone();
325 out.push(match end_state {
326 Some(_) => format!("{reopened}\x1b[39m"),
328 None => reopened,
329 });
330 }
331 out
332}
333
334fn split_wifi_line(wifi: &str) -> (&str, Option<&str>) {
343 match wifi.split_once(" - ") {
344 Some((hardware, connection)) => (hardware, Some(connection)),
345 None => (wifi, None),
346 }
347}
348
349fn compose_side_by_side_row(info_line: &str, logo_line: &str, logo_column: usize) -> String {
364 let vis_len = visible_len(info_line);
365 if logo_line.is_empty() || vis_len >= logo_column {
366 return format!("{info_line}{logo_line}");
367 }
368 format!(
369 "{info_line}{}{logo_line}",
370 " ".repeat(logo_column - vis_len)
371 )
372}
373
374fn graphical_side_by_side_prelude(logo_column: usize, logo_rows: usize) -> String {
389 let mut prelude = String::new();
390 if logo_rows > 0 {
391 prelude.push_str(&"\n".repeat(logo_rows));
392 prelude.push_str(&format!("\x1b[{}A", logo_rows));
393 }
394 prelude.push_str(&format!("\x1b[{}C\x1b7", logo_column));
395 prelude
396}
397
398fn render_graphical_side_by_side(
414 logo_column: usize,
415 info_lines: &[String],
416 logo_rows: usize,
417 draw: impl FnOnce(),
418) {
419 use std::io::Write;
420 print!("{}", graphical_side_by_side_prelude(logo_column, logo_rows));
423 draw(); print!("\x1b8\r");
425 for line in info_lines {
426 println!("{}", line);
427 }
428 for _ in info_lines.len()..logo_rows {
431 println!();
432 }
433 let _ = std::io::stdout().flush();
434}
435
436pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
442 let _config = config;
443 let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
444 let mut theme = match theme_name {
445 Some(name) => Theme::from_name(name),
446 None => Theme::detect_system_theme(), };
448
449 if let Some(custom) = &_config.custom_theme {
451 theme = Theme::with_custom_overrides(theme, custom);
452 }
453
454 let term_size = terminal_size::terminal_size();
456 let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
457 w as usize
458 } else {
459 80
460 };
461 let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
464
465 let show_logo = should_show_logo(
466 _config.show_logo,
467 cli.no_logo,
468 cli.ascii_logo,
469 stdout_is_tty,
470 );
471
472 let allowed_fields: Option<Vec<String>> = if cli.full {
477 Some(fields::fields_for(Mode::Full))
478 } else if cli.long {
479 Some(fields::fields_for(Mode::Long))
480 } else if cli.short {
481 Some(fields::fields_for(Mode::Short))
482 } else if let Some(fields) = &_config.fields {
483 Some(fields.iter().map(|s| s.to_lowercase()).collect())
484 } else {
485 Some(fields::fields_for(Mode::Standard))
486 };
487
488 let should_show = |label: &str| -> bool {
489 match &allowed_fields {
490 Some(fields) => {
491 let norm_label = label.to_lowercase().replace(['-', '_'], " ");
492 let norm_label_no_spaces = norm_label.replace(' ', "");
493 fields.iter().any(|f| {
494 let norm_f = f.to_lowercase().replace(['-', '_'], " ");
495 norm_f == norm_label
496 || norm_f.replace(' ', "") == norm_label_no_spaces
497 || (norm_label == "dns server" && norm_f == "dns")
499 || (norm_label == "memory usage" && norm_f == "memory")
501 || (norm_label == "wi fi link" && norm_f == "wifi")
503 })
504 }
505 None => true,
506 }
507 };
508
509 let label_width = 10;
511 let mut info_lines = Vec::new();
512 let mut print_line = |label: &str, value: &str| {
513 if should_show(label) {
514 info_lines.push(format!(
515 "{:>width$}{} {}",
516 theme.color_label(label),
517 theme.color_separator(":"),
518 theme.color_value(value),
519 width = label_width
520 ));
521 }
522 };
523
524 print_line("OS", &info.os);
526 if let Some(kernel) = &info.kernel {
527 print_line("Kernel", kernel);
528 }
529 if let Some(host) = &info.hostname {
530 print_line("Host", host);
531 }
532 if let Some(domain) = &info.domain {
533 print_line("Domain", domain);
534 }
535 if should_show("domain-search") {
536 for entry in &info.domain_search {
537 print_line("Domain Search", entry);
538 }
539 }
540 if let Some(chassis) = &info.chassis {
541 print_line("Chassis", chassis);
542 }
543 if let Some(init) = &info.init_system {
544 print_line("Init", init);
545 }
546 if let Some(locale) = &info.locale {
547 print_line("Locale", locale);
548 }
549 print_line("Arch", &info.arch);
550 if info.users > 0 {
554 print_line("Users", &info.users.to_string());
555 }
556 if let Some(pkgs) = info.packages {
557 if pkgs > 0 {
558 print_line("Packages", &pkgs.to_string());
559 }
560 }
561 if let Some(user) = &info.current_user {
562 print_line("User", user);
563 }
564 let uptime_str = format_uptime(&info.uptime);
566 let boot_display = format!("{} since {}", uptime_str, info.boot_time);
567 print_line("Uptime", &boot_display);
568
569 print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
571 if let Some(freq) = &info.cpu_freq {
572 print_line("CPU Freq", freq);
573 }
574 if let Some(cache) = &info.cpu_cache {
575 print_line("CPU Cache", cache);
576 }
577 if let Some(usage) = &info.cpu_usage {
578 print_line("CPU Usage", usage);
579 }
580 if let Some(motherboard) = &info.motherboard {
581 print_line("Motherboard", motherboard);
582 }
583 if let Some(bios) = &info.bios {
584 print_line("BIOS", bios);
585 }
586 if let Some(bootmgr) = &info.bootmgr {
587 print_line("Bootmgr", bootmgr);
588 }
589 if let Some(tpm) = &info.tpm {
590 print_line("TPM", tpm);
591 }
592 if should_show("GPU") {
593 for gpu in &info.gpu {
594 print_line("GPU", gpu);
595 }
596 }
597 if should_show("Display") {
598 for display in &info.displays {
599 print_line("Display", display);
600 }
601 }
602 if let Some(brightness) = &info.brightness {
603 print_line("Brightness", brightness);
604 }
605 if let Some(audio) = &info.audio {
606 print_line("Audio", audio);
607 }
608 if should_show("Camera") {
609 for cam in &info.camera {
610 print_line("Camera", cam);
611 }
612 }
613 if should_show("Gamepad") {
614 for gp in &info.gamepad {
615 print_line("Gamepad", gp);
616 }
617 }
618 if should_show("Keyboard") {
619 for kb in &info.keyboard {
620 print_line("Keyboard", kb);
621 }
622 }
623 if should_show("Mouse") {
624 for m in &info.mouse {
625 print_line("Mouse", m);
626 }
627 }
628 if let Some(wifi) = &info.wifi {
629 let (hardware, connection) = split_wifi_line(wifi);
632 print_line("Wi-Fi", hardware);
633 if let Some(conn) = connection {
634 print_line("Wi-Fi Link", conn);
635 }
636 }
637 if let Some(bt) = &info.bluetooth {
638 print_line("Bluetooth", bt);
639 }
640 if let Some(bat) = &info.battery {
641 print_line("Battery", bat);
642 }
643 if let Some(power) = &info.power_adapter {
644 print_line("Power Adapter", power);
645 }
646 print_line("Memory Usage", &info.memory);
647 if let Some(phys_mem) = &info.physical_memory {
648 print_line("Phys Mem", phys_mem);
649 }
650 print_line("Swap", &info.swap);
651 print_line("Procs", &info.processes.to_string());
652 if let Some(load) = &info.load_avg {
653 print_line("Load", load);
654 }
655 if should_show("Disk") {
656 for disk in &info.disks {
657 print_line("Disk", disk);
658 }
659 }
660 if should_show("Phys Disk") {
661 for disk in &info.physical_disks {
662 print_line("Phys Disk", disk);
663 }
664 }
665 if should_show("Disk IO") {
666 for io in &info.disk_io {
667 print_line("Disk IO", io);
668 }
669 }
670 if should_show("Btrfs") {
671 for vol in &info.btrfs {
672 print_line("Btrfs", vol);
673 }
674 }
675 if should_show("Zpool") {
676 for pool in &info.zpool {
677 print_line("Zpool", pool);
678 }
679 }
680 if should_show("Temp") {
681 if cli.full {
682 for temp in &info.temps {
683 print_line("Temp", temp);
684 }
685 } else {
686 for temp in consolidate_temps(&info.temps) {
687 print_line("Temp", &temp);
688 }
689 }
690 }
691
692 if should_show("Net") {
694 if cli.long || cli.full {
695 for net in &info.networks {
696 if let Some(ref active) = info.active_interface {
697 if net.contains(active) {
698 print_line("Net", &colorize_nested(net, ACTIVE_IFACE_PREFIX));
702 }
703 }
704 }
705 for net in &info.networks {
706 if let Some(ref active) = info.active_interface {
707 if net.contains(active) {
708 continue;
709 }
710 }
711 print_line("Net", net);
712 }
713 } else {
714 let mut printed = false;
715 if let Some(ref active) = info.active_interface {
716 for net in &info.networks {
717 if net.contains(active) {
718 print_line("Net", net);
719 printed = true;
720 break;
721 }
722 }
723 }
724 if !printed {
725 for net in &info.networks {
726 if net.contains("[Up]") {
727 print_line("Net", net);
728 break;
729 }
730 }
731 }
732 }
733 }
734 if should_show("Net IO") {
735 for io in &info.net_io {
736 print_line("Net IO", io);
737 }
738 }
739 if let Some(ip) = &info.public_ip {
740 print_line("Public IP", ip);
741 }
742 if !info.dns.is_empty() {
743 print_line("DNS Server", &info.dns.join(", "));
744 }
745
746 if let Some(shell) = &info.shell {
748 print_line("Shell", shell);
749 }
750 if let Some(editor) = &info.editor {
751 print_line("Editor", editor);
752 }
753 if let Some(term) = &info.terminal {
754 print_line("Terminal", term);
755 }
756 if let Some(ts) = &info.terminal_size {
757 print_line("Terminal Size", ts);
758 }
759 if let Some(de) = &info.desktop {
760 print_line("Desktop", de);
761 }
762 if let Some(wm) = &info.wm {
763 let duplicate = info
764 .desktop
765 .as_deref()
766 .map(|de| de.to_lowercase() == wm.to_lowercase())
767 .unwrap_or(false);
768 if !duplicate {
769 print_line("WM", wm);
770 }
771 }
772 if let Some(wm_theme) = &info.wm_theme {
773 print_line("WM Theme", wm_theme);
774 }
775 if let Some(wallpaper) = &info.wallpaper {
776 print_line("Wallpaper", wallpaper);
777 }
778 if let Some(lm) = &info.login_manager {
779 print_line("Login Manager", lm);
780 }
781 if let Some(player) = &info.player {
782 print_line("Player", player);
783 }
784 if let Some(media) = &info.media {
785 print_line("Media", media);
786 }
787 if let Some(ui_theme) = &info.ui_theme {
788 print_line("Theme", ui_theme);
789 }
790 if let Some(icons) = &info.icons {
791 print_line("Icons", icons);
792 }
793 if let Some(cursor) = &info.cursor {
794 print_line("Cursor", cursor);
795 }
796 if let Some(font) = &info.font {
797 print_line("Font", font);
798 }
799 if let Some(term_font) = &info.terminal_font {
800 print_line("Terminal Font", term_font);
801 }
802 if let Some(term_theme) = &info.terminal_theme {
803 print_line("Terminal Theme", term_theme);
804 }
805 if let Some(weather) = &info.weather {
806 print_line("Weather", weather);
807 }
808
809 enum ActiveLogo {
811 Lines(Vec<String>),
812 Kitty(Vec<u8>, usize, usize), Iterm2(Vec<u8>, usize, usize),
814 Sixel(Vec<u8>, usize, usize),
815 None,
816 }
817
818 let mut active_logo = ActiveLogo::None;
819
820 if show_logo {
821 let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
822 let user_logo = if let Some(config_dir) = dirs::config_dir() {
823 let p = config_dir.join("retch").join("logo.png");
824 if p.exists() {
825 Some(p)
826 } else {
827 None
828 }
829 } else {
830 None
831 };
832
833 if cli.ascii_logo {
834 active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
835 } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
836 let mut resolved = false;
837 if logo::chafa_available() {
838 if let Some(path) = &user_logo {
839 if let Some(lines) = logo::get_chafa_logo_lines(path) {
840 active_logo = ActiveLogo::Lines(lines);
841 resolved = true;
842 }
843 } else if let Some(distro) = &distro_hint {
844 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
845 let temp_path = std::env::temp_dir()
846 .join(format!("retch_logo_{}.png", std::process::id()));
847 if std::fs::write(&temp_path, bytes).is_ok() {
848 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
849 active_logo = ActiveLogo::Lines(lines);
850 resolved = true;
851 }
852 let _ = std::fs::remove_file(&temp_path);
853 }
854 }
855 }
856 }
857 if !resolved {
858 active_logo =
859 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
860 }
861 } else {
862 let mut resolved = false;
863
864 #[cfg(feature = "graphics")]
866 if !resolved && logo::supports_kitty() {
867 if let Some(path) = &user_logo {
868 if let Ok(bytes) = std::fs::read(path) {
869 let (cols, rows) = graphical_logo_cells(&bytes);
870 active_logo = ActiveLogo::Kitty(bytes, cols, rows);
871 resolved = true;
872 }
873 } else if let Some(distro) = &distro_hint {
874 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
875 let (cols, rows) = graphical_logo_cells(bytes);
876 active_logo = ActiveLogo::Kitty(bytes.to_vec(), cols, rows);
877 resolved = true;
878 }
879 }
880 }
881
882 #[cfg(feature = "graphics")]
884 if !resolved && logo::supports_iterm2() {
885 if let Some(path) = &user_logo {
886 if let Ok(bytes) = std::fs::read(path) {
887 let (cols, rows) = graphical_logo_cells(&bytes);
888 active_logo = ActiveLogo::Iterm2(bytes, cols, rows);
889 resolved = true;
890 }
891 } else if let Some(distro) = &distro_hint {
892 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
893 let (cols, rows) = graphical_logo_cells(bytes);
894 active_logo = ActiveLogo::Iterm2(bytes.to_vec(), cols, rows);
895 resolved = true;
896 }
897 }
898 }
899
900 #[cfg(feature = "graphics")]
902 if !resolved && logo::supports_sixel() {
903 if let Some(path) = &user_logo {
904 if let Ok(bytes) = std::fs::read(path) {
905 let (cols, rows) = graphical_logo_cells(&bytes);
906 active_logo = ActiveLogo::Sixel(bytes, cols, rows);
907 resolved = true;
908 }
909 } else if let Some(distro) = &distro_hint {
910 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
911 let (cols, rows) = graphical_logo_cells(bytes);
912 active_logo = ActiveLogo::Sixel(bytes.to_vec(), cols, rows);
913 resolved = true;
914 }
915 }
916 }
917
918 if !resolved && logo::chafa_available() {
920 if let Some(path) = &user_logo {
921 if let Some(lines) = logo::get_chafa_logo_lines(path) {
922 active_logo = ActiveLogo::Lines(lines);
923 resolved = true;
924 }
925 } else if let Some(distro) = &distro_hint {
926 if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
927 let temp_path = std::env::temp_dir()
929 .join(format!("retch_logo_{}.png", std::process::id()));
930 if std::fs::write(&temp_path, bytes).is_ok() {
931 if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
932 active_logo = ActiveLogo::Lines(lines);
933 resolved = true;
934 }
935 let _ = std::fs::remove_file(&temp_path);
936 }
937 }
938 }
939 }
940
941 if !resolved {
943 active_logo =
944 ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
945 }
946 }
947 }
948
949 let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
957
958 let (logo_height, max_logo_width) = match &active_logo {
962 ActiveLogo::Lines(logo_lines) => (
963 logo_lines.len(),
964 logo_lines
965 .iter()
966 .map(|line| visible_len(line))
967 .max()
968 .unwrap_or(0),
969 ),
970 ActiveLogo::Kitty(_, cols, rows)
971 | ActiveLogo::Iterm2(_, cols, rows)
972 | ActiveLogo::Sixel(_, cols, rows) => (*rows, *cols),
973 ActiveLogo::None => (0, 0),
974 };
975
976 let LayoutPlan {
979 side_by_side,
980 text_column_width,
981 logo_column,
982 } = plan_layout(
983 &info_widths,
984 logo_height,
985 max_logo_width,
986 term_width,
987 show_logo,
988 );
989
990 println!(); let formatted_info_lines: Vec<String> = if side_by_side && text_column_width > 15 {
993 let mut result = Vec::new();
994 for (i, line) in info_lines.iter().enumerate() {
995 let max_w = if i < logo_height {
1002 logo_column.saturating_sub(2)
1003 } else {
1004 term_width.saturating_sub(2)
1005 };
1006 result.extend(wrap_info_line(line, max_w));
1007 }
1008 result
1009 } else {
1010 info_lines.clone()
1011 };
1012
1013 if side_by_side {
1014 match active_logo {
1015 ActiveLogo::Lines(logo_lines) => {
1016 let max_lines = std::cmp::max(formatted_info_lines.len(), logo_lines.len());
1017 for i in 0..max_lines {
1018 let info_line = formatted_info_lines.get(i).cloned().unwrap_or_default();
1019 let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
1020 println!(
1021 "{}",
1022 compose_side_by_side_row(&info_line, &logo_line, logo_column)
1023 );
1024 }
1025 }
1026 ActiveLogo::Kitty(bytes, _, logo_rows) => {
1027 render_graphical_side_by_side(
1028 logo_column,
1029 &formatted_info_lines,
1030 logo_rows,
1031 || logo::print_graphical_logo(&bytes),
1032 );
1033 }
1034 ActiveLogo::Iterm2(bytes, _, logo_rows) => {
1035 render_graphical_side_by_side(
1036 logo_column,
1037 &formatted_info_lines,
1038 logo_rows,
1039 || logo::print_iterm2_logo(&bytes),
1040 );
1041 }
1042 ActiveLogo::Sixel(bytes, _, logo_rows) => {
1043 render_graphical_side_by_side(
1044 logo_column,
1045 &formatted_info_lines,
1046 logo_rows,
1047 || logo::print_sixel_logo(&bytes),
1048 );
1049 }
1050 ActiveLogo::None => {
1051 for line in &formatted_info_lines {
1052 println!("{}", line);
1053 }
1054 }
1055 }
1056 } else {
1057 match active_logo {
1059 ActiveLogo::Lines(logo_lines) => {
1060 for line in logo_lines {
1061 println!("{}", line);
1062 }
1063 println!();
1064 }
1065 ActiveLogo::Kitty(bytes, _, _) => {
1066 logo::print_graphical_logo(&bytes);
1067 println!();
1068 }
1069 ActiveLogo::Iterm2(bytes, _, _) => {
1070 logo::print_iterm2_logo(&bytes);
1071 println!();
1072 }
1073 ActiveLogo::Sixel(bytes, _, _) => {
1074 logo::print_sixel_logo(&bytes);
1075 println!();
1076 }
1077 ActiveLogo::None => {}
1078 }
1079 for line in &info_lines {
1080 println!("{}", line);
1081 }
1082 }
1083
1084 Ok(())
1085}
1086
1087fn consolidate_temps(temps: &[String]) -> Vec<String> {
1093 fn categorize(label: &str) -> &'static str {
1094 let l = label.to_lowercase();
1095 if l.contains("cpu")
1096 || l.contains("core")
1097 || l.contains("k10temp")
1098 || l.contains("k8temp")
1099 || l.contains("coretemp")
1100 || l.contains("tctl")
1101 || l.contains("tdie")
1102 || l.contains("tccd")
1103 || l.contains("package")
1104 {
1105 "CPU"
1106 } else if l.contains("gpu")
1107 || l.contains("nouveau")
1108 || l.contains("radeon")
1109 || l.contains("amdgpu")
1110 {
1111 "GPU"
1112 } else if l.contains("nvme") || l.contains("nand") {
1113 "NVMe"
1114 } else if l.contains("ath")
1115 || l.contains("wifi")
1116 || l.contains("wireless")
1117 || l.contains("wlan")
1118 || l.contains("iwl")
1119 {
1120 "WiFi"
1121 } else if l.contains("bat") {
1122 "Battery"
1123 } else {
1124 "System"
1125 }
1126 }
1127
1128 let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
1129 for s in temps {
1130 if let Some((label_part, val_part)) = s.rsplit_once(':') {
1132 let val_str = val_part.trim().trim_end_matches("°C");
1133 if let Ok(val) = val_str.parse::<f32>() {
1134 let cat = categorize(label_part.trim());
1135 let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
1136 if val > *entry {
1137 *entry = val;
1138 }
1139 }
1140 }
1141 }
1142
1143 const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
1144 ORDER
1145 .iter()
1146 .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
1147 .collect()
1148}
1149
1150fn format_uptime(uptime: &str) -> String {
1154 let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
1156
1157 let years = seconds / (365 * 24 * 3600);
1158 let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
1159 let hours = (seconds % (24 * 3600)) / 3600;
1160 let minutes = (seconds % 3600) / 60;
1161 let secs = seconds % 60;
1162
1163 let mut parts = Vec::new();
1164 if years > 0 {
1165 parts.push(format!("{}y", years));
1166 }
1167 if days > 0 {
1168 parts.push(format!("{}d", days));
1169 }
1170 if hours > 0 {
1171 parts.push(format!("{}h", hours));
1172 }
1173 if minutes > 0 {
1174 parts.push(format!("{}m", minutes));
1175 }
1176 if secs > 0 || parts.is_empty() {
1177 parts.push(format!("{}s", secs));
1178 }
1179
1180 parts.join(" ")
1181}
1182
1183#[cfg(feature = "graphics")]
1192fn graphical_logo_cells(bytes: &[u8]) -> (usize, usize) {
1193 let (img_w, img_h) = image::load_from_memory(bytes)
1194 .map(|img| (img.width(), img.height()))
1195 .unwrap_or((0, 0));
1196 let fit = logo::logo_cells_for(img_w, img_h);
1197 (fit.cols, fit.rows)
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202 use super::*;
1203
1204 #[test]
1207 fn test_show_logo_auto_requires_tty() {
1208 assert!(should_show_logo(None, false, false, true));
1210 assert!(!should_show_logo(None, false, false, false));
1211 }
1212
1213 #[test]
1214 fn test_show_logo_ascii_forces_without_tty() {
1215 assert!(should_show_logo(None, false, true, false));
1217 assert!(should_show_logo(None, false, true, true));
1218 }
1219
1220 #[test]
1221 fn test_show_logo_no_logo_always_wins() {
1222 assert!(!should_show_logo(None, true, true, true));
1224 assert!(!should_show_logo(None, true, false, true));
1225 }
1226
1227 #[test]
1228 fn test_show_logo_config_disable() {
1229 assert!(!should_show_logo(Some(false), false, false, true));
1231 assert!(should_show_logo(Some(false), false, true, false));
1233 }
1234
1235 #[test]
1238 fn test_visible_len_strips_every_escape_form_retch_emits() {
1239 assert_eq!(visible_len("plain"), 5);
1243 assert_eq!(visible_len("\x1b[38;2;1;2;3mabc\x1b[39m"), 3);
1244 assert_eq!(visible_len("\x1b[?25labc"), 3);
1245 assert_eq!(visible_len("\x1b(Babc"), 3);
1246 assert_eq!(visible_len("\x1b[0m \x1b[38;2;0;0;0m\u{2582}"), 2);
1247 }
1248
1249 #[test]
1250 fn test_visible_len_counts_columns_not_characters() {
1251 assert_eq!(visible_len("宇多田ヒカル"), 12); assert_eq!(visible_len("아이유"), 6); assert_eq!(visible_len("Media: 宇多田ヒカル - 花束を君に"), 32);
1257 assert_eq!(visible_len("Media: 아이유 - 밤편지"), 22);
1258
1259 assert_eq!(visible_len("cafe\u{301}"), 4);
1261 assert_eq!(visible_len("café"), 4);
1263
1264 assert_eq!(
1267 visible_len("\x1b[38;2;1;2;3m宇多田\x1b[39m"),
1268 visible_len("宇多田")
1269 );
1270 }
1271
1272 #[test]
1273 fn test_visible_len_ascii_art_and_chafa_symbols_are_one_column_each() {
1274 for line in logo::get_ascii_logo(Some("fedora")) {
1278 let stripped: String = strip_for_test(&line);
1279 assert_eq!(
1280 visible_len(&line),
1281 stripped.chars().count(),
1282 "fedora ASCII logo line is not one column per character: {stripped:?}"
1283 );
1284 }
1285 for sym in [
1287 '\u{2580}', '\u{2584}', '\u{2588}', '\u{258c}', '\u{2596}', '\u{2582}',
1288 ] {
1289 assert_eq!(visible_len(&sym.to_string()), 1, "{sym:?} is not 1 column");
1290 }
1291 }
1292
1293 fn strip_for_test(s: &str) -> String {
1296 let mut out = String::new();
1297 let mut in_esc = false;
1298 for c in s.chars() {
1299 if c == '\x1b' {
1300 in_esc = true;
1301 } else if in_esc {
1302 if c.is_ascii_alphabetic() {
1303 in_esc = false;
1304 }
1305 } else {
1306 out.push(c);
1307 }
1308 }
1309 out
1310 }
1311
1312 const CYAN: &str = "\x1b[38;2;0;255;255m";
1316 const RESET: &str = "\x1b[39m";
1317
1318 #[test]
1319 fn test_wrap_keeps_the_comma_it_split_on() {
1320 let out = wrap_info_line(
1324 "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)",
1325 40,
1326 );
1327 assert!(out.len() > 1, "expected a wrap, got {out:?}");
1328 assert!(
1329 out[0].ends_with(','),
1330 "separator lost at the break: {:?}",
1331 out[0]
1332 );
1333 let rejoined: String = out
1335 .iter()
1336 .map(|l| l.trim_start().to_string())
1337 .collect::<Vec<_>>()
1338 .join(" ");
1339 assert_eq!(
1340 rejoined,
1341 "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)"
1342 );
1343 }
1344
1345 #[test]
1346 fn test_wrap_reopens_the_colour_on_every_continuation_line() {
1347 let line =
1351 format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1352 let out = wrap_info_line(&line, 40);
1353 assert!(out.len() > 1, "expected a wrap, got {out:?}");
1354 for (i, l) in out.iter().enumerate().skip(1) {
1355 assert!(
1356 l.contains(CYAN),
1357 "continuation line {i} has no colour: {l:?}"
1358 );
1359 }
1360 for l in &out {
1362 if l.contains(CYAN) {
1363 assert!(l.ends_with(RESET), "colour left open on {l:?}");
1364 }
1365 }
1366 }
1367
1368 #[test]
1369 fn test_wrap_colour_carry_does_not_change_visible_width() {
1370 let plain = "BIOS: American Megatrends International, LLC. HN7306EAC.310";
1373 let coloured =
1374 format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1375 let a = wrap_info_line(plain, 40);
1376 let b = wrap_info_line(&coloured, 40);
1377 assert_eq!(a.len(), b.len());
1378 for (x, y) in a.iter().zip(b.iter()) {
1379 assert_eq!(visible_len(x), visible_len(y), "{x:?} vs {y:?}");
1380 }
1381 }
1382
1383 #[test]
1384 fn test_wrap_uncoloured_line_is_untouched_by_the_carry() {
1385 let out = wrap_info_line("Disk: aaaa, bbbb, cccc, dddd, eeee, ffff, gggg, hhhh", 24);
1386 assert!(out.len() > 1);
1387 assert!(
1388 out.iter().all(|l| !l.contains('\x1b')),
1389 "carry injected escapes into an uncoloured line: {out:?}"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_active_sgr_after_tracks_open_and_reset() {
1395 assert_eq!(active_sgr_after("plain", None), None);
1396 assert_eq!(active_sgr_after(CYAN, None), Some(CYAN.to_string()));
1397 assert_eq!(active_sgr_after(&format!("{CYAN}x{RESET}"), None), None);
1398 assert_eq!(active_sgr_after("\x1b[0m", Some(CYAN.into())), None);
1399 assert_eq!(
1401 active_sgr_after("more text", Some(CYAN.into())),
1402 Some(CYAN.to_string())
1403 );
1404 assert_eq!(
1406 active_sgr_after("\x1b[?25l", Some(CYAN.into())),
1407 Some(CYAN.to_string())
1408 );
1409 }
1410
1411 #[test]
1412 fn test_active_sgr_after_takes_the_last_colour_when_nested() {
1413 let green = "\x1b[32m";
1416 let s = format!("{CYAN}[{green}Up{RESET}] RX: 1 MB");
1417 assert_eq!(active_sgr_after(&s, None), None); let s2 = format!("{CYAN}[{green}Up{RESET}]{CYAN} RX: 1 MB");
1419 assert_eq!(active_sgr_after(&s2, None), Some(CYAN.to_string()));
1420 }
1421
1422 #[test]
1425 fn test_row_places_the_logo_at_the_logo_column() {
1426 let row = compose_side_by_side_row("OS: Fedora", "###", 20);
1427 assert_eq!(row, format!("OS: Fedora{}###", " ".repeat(10)));
1428 assert_eq!(visible_len(&row), 23);
1429 }
1430
1431 #[test]
1432 fn test_row_aligns_wide_characters_by_column_not_character_count() {
1433 let latin = compose_side_by_side_row("Locale: en_US.UTF-8", "###", 40);
1438 let cjk = compose_side_by_side_row("Locale: ja_JP.宇多田ヒカル", "###", 40);
1439 assert_eq!(visible_len(&latin), 43);
1440 assert_eq!(
1441 visible_len(&cjk),
1442 43,
1443 "a wide-character info line must not shift the logo column"
1444 );
1445 assert!(latin.ends_with(" ###") && cjk.ends_with(" ###"));
1447 }
1448
1449 #[test]
1450 fn test_row_without_a_logo_gets_no_trailing_padding() {
1451 assert_eq!(compose_side_by_side_row("Net: eth0", "", 40), "Net: eth0");
1453 }
1454
1455 #[test]
1456 fn test_row_with_overlong_info_does_not_underflow() {
1457 let row = compose_side_by_side_row("x".repeat(50).as_str(), "###", 40);
1459 assert_eq!(row, format!("{}###", "x".repeat(50)));
1460 }
1461
1462 #[test]
1463 fn test_row_ignores_ansi_colour_when_measuring() {
1464 let plain = compose_side_by_side_row("abc", "###", 10);
1465 let coloured = compose_side_by_side_row("\x1b[31mabc\x1b[39m", "###", 10);
1466 assert_eq!(visible_len(&plain), visible_len(&coloured));
1467 }
1468
1469 fn realistic_full_widths() -> Vec<usize> {
1474 let mut w = vec![40; 20]; w[13] = 54; w.extend([158, 91, 79, 60, 45, 62]); w
1478 }
1479
1480 #[test]
1481 fn test_layout_long_line_below_logo_stays_side_by_side() {
1482 let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
1484 assert!(p.side_by_side);
1485 assert_eq!(p.text_column_width, 58); }
1488
1489 #[test]
1490 fn test_layout_old_behavior_would_have_stacked() {
1491 let widths = realistic_full_widths();
1494 let old_text_col = std::cmp::max(widths.iter().copied().max().unwrap() + 4, 45);
1495 assert!(120 < old_text_col + 40); assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); }
1498
1499 #[test]
1500 fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() {
1501 let mut w = vec![40; 20];
1504 w[5] = 158;
1505 let p = plan_layout(&w, 20, 40, 120, true);
1506 assert!(p.side_by_side);
1507 assert_eq!(p.text_column_width, 65);
1508 }
1509
1510 #[test]
1511 fn test_layout_narrow_terminal_stacks() {
1512 assert!(!plan_layout(&[40; 30], 20, 40, 94, true).side_by_side); assert!(!plan_layout(&[40; 30], 20, 40, 80, true).side_by_side);
1514 }
1515
1516 #[test]
1517 fn test_layout_show_logo_false_stacks() {
1518 assert!(!plan_layout(&[40; 30], 20, 40, 200, false).side_by_side);
1519 }
1520
1521 #[test]
1522 fn test_layout_column_floor_and_graphical_width() {
1523 let p = plan_layout(&[10; 25], 20, 40, 100, true);
1525 assert!(p.side_by_side);
1526 assert_eq!(p.text_column_width, 45); }
1528
1529 #[test]
1530 fn test_layout_widened_logo_box_still_fits_at_the_side_by_side_threshold() {
1531 let p = plan_layout(&[10; 25], 10, logo::LOGO_MAX_COLS, 95, true);
1535 assert!(
1536 p.side_by_side,
1537 "a full-width logo must still sit beside the text at 95 columns"
1538 );
1539 assert!(p.text_column_width + logo::LOGO_MAX_COLS <= 95);
1540
1541 let wide = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, 169, true);
1543 assert!(wide.side_by_side);
1544 assert_eq!(wide.text_column_width, 65);
1545 }
1546
1547 #[test]
1548 fn test_layout_logo_taller_than_text() {
1549 let p = plan_layout(&[50, 30, 54], 20, 40, 120, true);
1551 assert!(p.side_by_side);
1552 assert_eq!(p.text_column_width, 58); }
1554
1555 #[test]
1556 fn test_layout_logo_is_flush_with_the_right_margin() {
1557 let p = plan_layout(&realistic_full_widths(), 20, 49, 138, true);
1562 assert!(p.side_by_side);
1563 assert_eq!(p.text_column_width, 58); assert_eq!(p.logo_column, 138 - 49); assert!(
1566 p.logo_column > p.text_column_width,
1567 "the pre-fix behaviour was logo_column == text_column_width"
1568 );
1569 }
1570
1571 #[test]
1572 fn test_layout_right_anchor_never_overlaps_the_text_column() {
1573 for term_width in 95..200 {
1576 let p = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, term_width, true);
1577 if p.side_by_side {
1578 assert!(
1579 p.logo_column >= p.text_column_width,
1580 "logo_column {} < text_column_width {} at {} cols",
1581 p.logo_column,
1582 p.text_column_width,
1583 term_width
1584 );
1585 assert_eq!(p.logo_column + logo::LOGO_MAX_COLS, term_width);
1586 }
1587 }
1588 }
1589
1590 #[test]
1591 fn test_layout_logo_column_does_not_underflow_on_an_oversized_logo() {
1592 let p = plan_layout(&[40; 10], 10, 200, 100, true);
1594 assert!(!p.side_by_side);
1595 assert_eq!(p.logo_column, p.text_column_width);
1596 }
1597
1598 #[test]
1601 fn test_prelude_reserves_rows_before_saving_cursor() {
1602 let p = graphical_side_by_side_prelude(52, 3);
1606 assert_eq!(p, "\n\n\n\x1b[3A\x1b[52C\x1b7");
1607 }
1608
1609 #[test]
1610 fn test_prelude_v068_shape_only_differs_by_reservation() {
1611 let p = graphical_side_by_side_prelude(45, 20);
1614 assert_eq!(
1615 p.replace(&format!("{}\x1b[20A", "\n".repeat(20)), ""),
1616 "\x1b[45C\x1b7"
1617 );
1618 }
1619
1620 #[test]
1621 fn test_prelude_zero_rows_skips_reservation_and_cursor_up() {
1622 let p = graphical_side_by_side_prelude(45, 0);
1625 assert_eq!(p, "\x1b[45C\x1b7");
1626 }
1627
1628 #[test]
1631 fn test_split_wifi_hardware_and_connection() {
1632 let s = "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0] - myssid (5.0 GHz ch36 [↓866 ↑866])";
1634 let (hw, conn) = split_wifi_line(s);
1635 assert_eq!(
1636 hw,
1637 "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0]"
1638 );
1639 assert_eq!(conn, Some("myssid (5.0 GHz ch36 [↓866 ↑866])"));
1640 }
1641
1642 #[test]
1643 fn test_split_wifi_splits_on_first_separator() {
1644 let (hw, conn) = split_wifi_line("Card X [wlan0] - Guest - 5G (5 GHz)");
1647 assert_eq!(hw, "Card X [wlan0]");
1648 assert_eq!(conn, Some("Guest - 5G (5 GHz)"));
1649 }
1650
1651 #[test]
1652 fn test_split_wifi_connection_only_fallback() {
1653 let (hw, conn) = split_wifi_line("myssid (300 Mbps)");
1655 assert_eq!(hw, "myssid (300 Mbps)");
1656 assert_eq!(conn, None);
1657 }
1658
1659 #[test]
1660 fn test_consolidate_temps_basic() {
1661 let raw = vec![
1662 "k10temp Tctl: 83°C".to_string(),
1663 "amdgpu edge: 65°C".to_string(),
1664 "nvme Composite: 62°C".to_string(),
1665 "ath11k_hwmon temp1: 58°C".to_string(),
1666 "acpitz temp1: 77°C".to_string(),
1667 ];
1668 let result = consolidate_temps(&raw);
1669 assert_eq!(
1670 result,
1671 vec![
1672 "CPU: 83°C",
1673 "GPU: 65°C",
1674 "NVMe: 62°C",
1675 "WiFi: 58°C",
1676 "System: 77°C"
1677 ]
1678 );
1679 }
1680
1681 #[test]
1682 fn test_consolidate_temps_highest_wins() {
1683 let raw = vec![
1684 "thinkpad CPU: 83°C".to_string(),
1685 "k10temp Tctl: 79°C".to_string(),
1686 "nvme Composite: 62°C".to_string(),
1687 "nvme Sensor 1: 59°C".to_string(),
1688 "nvme Sensor 2: 56°C".to_string(),
1689 ];
1690 let result = consolidate_temps(&raw);
1691 assert!(result.contains(&"CPU: 83°C".to_string()));
1692 assert!(result.contains(&"NVMe: 62°C".to_string()));
1693 assert!(!result
1694 .iter()
1695 .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
1696 }
1697
1698 #[test]
1699 fn test_consolidate_temps_order() {
1700 let raw = vec![
1701 "acpitz: 60°C".to_string(),
1702 "nvme: 55°C".to_string(),
1703 "amdgpu edge: 65°C".to_string(),
1704 "k10temp Tctl: 80°C".to_string(),
1705 ];
1706 let result = consolidate_temps(&raw);
1707 let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
1708 let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
1709 let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
1710 let sys_pos = result.iter().position(|s| s.starts_with("System"));
1711 assert!(cpu_pos < gpu_pos);
1712 assert!(gpu_pos < nvme_pos);
1713 assert!(nvme_pos < sys_pos);
1714 }
1715
1716 #[test]
1717 fn test_consolidate_temps_empty() {
1718 assert!(consolidate_temps(&[]).is_empty());
1719 }
1720
1721 #[test]
1722 fn test_format_uptime() {
1723 assert_eq!(format_uptime("60s"), "1m");
1724 assert_eq!(format_uptime("3600s"), "1h");
1725 assert_eq!(format_uptime("3661s"), "1h 1m 1s");
1726 assert_eq!(format_uptime("86400s"), "1d");
1727 assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
1728 assert_eq!(format_uptime("31536000s"), "1y");
1729 assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
1730 assert_eq!(format_uptime("0s"), "0s");
1731 }
1732
1733 #[test]
1734 fn test_wrap_info_line_short_line_unchanged() {
1735 let line = "Audio: Windows Audio (USB Audio Device)";
1736 let wrapped = wrap_info_line(line, 50);
1737 assert_eq!(wrapped, vec![line.to_string()]);
1738 }
1739
1740 #[test]
1741 fn test_wrap_info_line_wraps_and_indents() {
1742 let line = "Audio: Windows Audio (USB Audio Device, AMD High Definition Audio Device, AMD SoundWire Device)";
1743 let wrapped = wrap_info_line(line, 45);
1744 assert!(wrapped.len() > 1);
1745 assert!(wrapped[0].starts_with("Audio: Windows Audio"));
1746 assert!(wrapped[1].starts_with(" "));
1747 }
1748}