use crate::cli::Cli;
use crate::config::Config;
use crate::fetch::SystemInfo;
use crate::fields::{self, Mode};
use crate::logo;
use crate::theme::{colorize_nested, Theme, ACTIVE_IFACE_PREFIX};
fn should_show_logo(
config_show_logo: Option<bool>,
no_logo: bool,
ascii_logo: bool,
stdout_is_tty: bool,
) -> bool {
if no_logo {
return false; }
if ascii_logo {
return true; }
config_show_logo.unwrap_or(true) && stdout_is_tty }
struct LayoutPlan {
side_by_side: bool,
text_column_width: usize,
logo_column: usize,
}
fn plan_layout(
info_widths: &[usize],
logo_height: usize,
logo_width: usize,
term_width: usize,
show_logo: bool,
) -> LayoutPlan {
let beside_count = info_widths.len().min(logo_height);
let max_beside_width = info_widths[..beside_count]
.iter()
.copied()
.max()
.unwrap_or(0);
let text_column_width = if term_width >= 95 {
(term_width.saturating_sub(logo_width + 4))
.min(std::cmp::max(max_beside_width + 4, 45))
.clamp(45, 65)
} else {
std::cmp::max(max_beside_width + 4, 45)
};
let side_by_side =
show_logo && term_width >= 95 && term_width >= text_column_width + logo_width;
let logo_column = term_width.saturating_sub(logo_width).max(text_column_width);
LayoutPlan {
side_by_side,
text_column_width,
logo_column,
}
}
pub fn visible_len(s: &str) -> usize {
use unicode_width::UnicodeWidthStr;
let mut visible = String::with_capacity(s.len());
let mut in_esc = false;
for c in s.chars() {
if c == '\x1b' {
in_esc = true;
} else if in_esc {
if c.is_ascii_alphabetic() {
in_esc = false;
}
} else {
visible.push(c);
}
}
visible.width()
}
pub fn wrap_info_line(line: &str, max_width: usize) -> Vec<String> {
let vis_len = visible_len(line);
if vis_len <= max_width || max_width < 20 {
return vec![line.to_string()];
}
let prefix_len = if let Some(idx) = line.find(':') {
let prefix_sub = &line[..=idx];
let extra_space = if line[idx + 1..].starts_with(' ') {
1
} else {
0
};
visible_len(prefix_sub) + extra_space
} else {
4
};
let indent = " ".repeat(prefix_len.min(max_width / 2));
if line.contains(", ") {
let parts: Vec<&str> = line.split(", ").collect();
let mut lines = Vec::new();
let mut current = String::new();
for (i, part) in parts.iter().enumerate() {
let item = if i == 0 {
part.to_string()
} else {
format!(", {}", part)
};
let item_vis = visible_len(&item);
if current.is_empty() || visible_len(¤t) + item_vis <= max_width {
current.push_str(&item);
} else {
lines.push(format!("{current},"));
current = format!("{}{}", indent, part);
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.iter().all(|l| visible_len(l) <= max_width + 10) {
return carry_sgr_across_lines(lines);
}
}
let raw_words: Vec<&str> = line.split_whitespace().collect();
let mut words: Vec<String> = Vec::new();
let mut idx = 0;
while idx < raw_words.len() {
if raw_words[idx] == "RX:"
&& idx + 3 < raw_words.len()
&& raw_words.iter().skip(idx).any(|&w| w == "TX:")
{
let rx_tx = format!(
"{} {} {} {} {} {}",
raw_words[idx],
raw_words[idx + 1],
raw_words[idx + 2],
raw_words[idx + 3],
raw_words.get(idx + 4).copied().unwrap_or(""),
raw_words.get(idx + 5).copied().unwrap_or("")
);
words.push(rx_tx.trim().to_string());
idx += if idx + 5 < raw_words.len() { 6 } else { 4 };
continue;
}
words.push(raw_words[idx].to_string());
idx += 1;
}
let mut lines = Vec::new();
let mut current = String::new();
for word in words {
let word_vis = visible_len(&word);
if current.is_empty() {
current.push_str(&word);
} else if visible_len(¤t) + 1 + word_vis <= max_width {
current.push(' ');
current.push_str(&word);
} else {
lines.push(current);
current = format!("{}{}", indent, word);
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
vec![line.to_string()]
} else {
carry_sgr_across_lines(lines)
}
}
fn active_sgr_after(s: &str, entry: Option<String>) -> Option<String> {
let mut active = entry;
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != 0x1b {
i += 1;
continue;
}
let start = i;
i += 1;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
let seq = &s[start..=i];
if seq.ends_with('m') {
active = if seq == "\x1b[0m" || seq == "\x1b[39m" {
None
} else {
Some(seq.to_string())
};
}
i += 1;
}
}
active
}
fn carry_sgr_across_lines(lines: Vec<String>) -> Vec<String> {
let mut active: Option<String> = None;
let mut out = Vec::with_capacity(lines.len());
for line in lines {
let reopened = match &active {
Some(sgr) => format!("{sgr}{line}"),
None => line.clone(),
};
let end_state = active_sgr_after(&line, active.clone());
active = end_state.clone();
out.push(match end_state {
Some(_) => format!("{reopened}\x1b[39m"),
None => reopened,
});
}
out
}
fn split_wifi_line(wifi: &str) -> (&str, Option<&str>) {
match wifi.split_once(" - ") {
Some((hardware, connection)) => (hardware, Some(connection)),
None => (wifi, None),
}
}
fn compose_side_by_side_row(info_line: &str, logo_line: &str, logo_column: usize) -> String {
let vis_len = visible_len(info_line);
if logo_line.is_empty() || vis_len >= logo_column {
return format!("{info_line}{logo_line}");
}
format!(
"{info_line}{}{logo_line}",
" ".repeat(logo_column - vis_len)
)
}
fn graphical_side_by_side_prelude(logo_column: usize, logo_rows: usize) -> String {
let mut prelude = String::new();
if logo_rows > 0 {
prelude.push_str(&"\n".repeat(logo_rows));
prelude.push_str(&format!("\x1b[{}A", logo_rows));
}
prelude.push_str(&format!("\x1b[{}C\x1b7", logo_column));
prelude
}
fn render_graphical_side_by_side(
logo_column: usize,
info_lines: &[String],
logo_rows: usize,
draw: impl FnOnce(),
) {
use std::io::Write;
print!("{}", graphical_side_by_side_prelude(logo_column, logo_rows));
draw(); print!("\x1b8\r");
for line in info_lines {
println!("{}", line);
}
for _ in info_lines.len()..logo_rows {
println!();
}
let _ = std::io::stdout().flush();
}
pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
let _config = config;
let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
let mut theme = match theme_name {
Some(name) => Theme::from_name(name),
None => Theme::detect_system_theme(), };
if let Some(custom) = &_config.custom_theme {
theme = Theme::with_custom_overrides(theme, custom);
}
let term_size = terminal_size::terminal_size();
let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
w as usize
} else {
80
};
let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
let show_logo = should_show_logo(
_config.show_logo,
cli.no_logo,
cli.ascii_logo,
stdout_is_tty,
);
let allowed_fields: Option<Vec<String>> = if cli.full {
Some(fields::fields_for(Mode::Full))
} else if cli.long {
Some(fields::fields_for(Mode::Long))
} else if cli.short {
Some(fields::fields_for(Mode::Short))
} else if let Some(fields) = &_config.fields {
Some(fields.iter().map(|s| s.to_lowercase()).collect())
} else {
Some(fields::fields_for(Mode::Standard))
};
let should_show = |label: &str| -> bool {
match &allowed_fields {
Some(fields) => {
let norm_label = label.to_lowercase().replace(['-', '_'], " ");
let norm_label_no_spaces = norm_label.replace(' ', "");
fields.iter().any(|f| {
let norm_f = f.to_lowercase().replace(['-', '_'], " ");
norm_f == norm_label
|| norm_f.replace(' ', "") == norm_label_no_spaces
|| (norm_label == "dns server" && norm_f == "dns")
|| (norm_label == "memory usage" && norm_f == "memory")
|| (norm_label == "wi fi link" && norm_f == "wifi")
})
}
None => true,
}
};
let label_width = 10;
let mut info_lines = Vec::new();
let mut print_line = |label: &str, value: &str| {
if should_show(label) {
info_lines.push(format!(
"{:>width$}{} {}",
theme.color_label(label),
theme.color_separator(":"),
theme.color_value(value),
width = label_width
));
}
};
print_line("OS", &info.os);
if let Some(kernel) = &info.kernel {
print_line("Kernel", kernel);
}
if let Some(host) = &info.hostname {
print_line("Host", host);
}
if let Some(domain) = &info.domain {
print_line("Domain", domain);
}
if should_show("domain-search") {
for entry in &info.domain_search {
print_line("Domain Search", entry);
}
}
if let Some(chassis) = &info.chassis {
print_line("Chassis", chassis);
}
if let Some(init) = &info.init_system {
print_line("Init", init);
}
if let Some(locale) = &info.locale {
print_line("Locale", locale);
}
print_line("Arch", &info.arch);
if info.users > 0 {
print_line("Users", &info.users.to_string());
}
if let Some(pkgs) = info.packages {
if pkgs > 0 {
print_line("Packages", &pkgs.to_string());
}
}
if let Some(user) = &info.current_user {
print_line("User", user);
}
let uptime_str = format_uptime(&info.uptime);
let boot_display = format!("{} since {}", uptime_str, info.boot_time);
print_line("Uptime", &boot_display);
print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
if let Some(freq) = &info.cpu_freq {
print_line("CPU Freq", freq);
}
if let Some(cache) = &info.cpu_cache {
print_line("CPU Cache", cache);
}
if let Some(usage) = &info.cpu_usage {
print_line("CPU Usage", usage);
}
if let Some(motherboard) = &info.motherboard {
print_line("Motherboard", motherboard);
}
if let Some(bios) = &info.bios {
print_line("BIOS", bios);
}
if let Some(bootmgr) = &info.bootmgr {
print_line("Bootmgr", bootmgr);
}
if let Some(tpm) = &info.tpm {
print_line("TPM", tpm);
}
if should_show("GPU") {
for gpu in &info.gpu {
print_line("GPU", gpu);
}
}
if should_show("Display") {
for display in &info.displays {
print_line("Display", display);
}
}
if let Some(brightness) = &info.brightness {
print_line("Brightness", brightness);
}
if let Some(audio) = &info.audio {
print_line("Audio", audio);
}
if should_show("Camera") {
for cam in &info.camera {
print_line("Camera", cam);
}
}
if should_show("Gamepad") {
for gp in &info.gamepad {
print_line("Gamepad", gp);
}
}
if should_show("Keyboard") {
for kb in &info.keyboard {
print_line("Keyboard", kb);
}
}
if should_show("Mouse") {
for m in &info.mouse {
print_line("Mouse", m);
}
}
if let Some(wifi) = &info.wifi {
let (hardware, connection) = split_wifi_line(wifi);
print_line("Wi-Fi", hardware);
if let Some(conn) = connection {
print_line("Wi-Fi Link", conn);
}
}
if let Some(bt) = &info.bluetooth {
print_line("Bluetooth", bt);
}
if let Some(bat) = &info.battery {
print_line("Battery", bat);
}
if let Some(power) = &info.power_adapter {
print_line("Power Adapter", power);
}
print_line("Memory Usage", &info.memory);
if let Some(phys_mem) = &info.physical_memory {
print_line("Phys Mem", phys_mem);
}
print_line("Swap", &info.swap);
print_line("Procs", &info.processes.to_string());
if let Some(load) = &info.load_avg {
print_line("Load", load);
}
if should_show("Disk") {
for disk in &info.disks {
print_line("Disk", disk);
}
}
if should_show("Phys Disk") {
for disk in &info.physical_disks {
print_line("Phys Disk", disk);
}
}
if should_show("Btrfs") {
for vol in &info.btrfs {
print_line("Btrfs", vol);
}
}
if should_show("Zpool") {
for pool in &info.zpool {
print_line("Zpool", pool);
}
}
if should_show("Temp") {
if cli.full {
for temp in &info.temps {
print_line("Temp", temp);
}
} else {
for temp in consolidate_temps(&info.temps) {
print_line("Temp", &temp);
}
}
}
if should_show("Net") {
if cli.long || cli.full {
for net in &info.networks {
if let Some(ref active) = info.active_interface {
if net.contains(active) {
print_line("Net", &colorize_nested(net, ACTIVE_IFACE_PREFIX));
}
}
}
for net in &info.networks {
if let Some(ref active) = info.active_interface {
if net.contains(active) {
continue;
}
}
print_line("Net", net);
}
} else {
let mut printed = false;
if let Some(ref active) = info.active_interface {
for net in &info.networks {
if net.contains(active) {
print_line("Net", net);
printed = true;
break;
}
}
}
if !printed {
for net in &info.networks {
if net.contains("[Up]") {
print_line("Net", net);
break;
}
}
}
}
}
if let Some(ip) = &info.public_ip {
print_line("Public IP", ip);
}
if !info.dns.is_empty() {
print_line("DNS Server", &info.dns.join(", "));
}
if let Some(shell) = &info.shell {
print_line("Shell", shell);
}
if let Some(editor) = &info.editor {
print_line("Editor", editor);
}
if let Some(term) = &info.terminal {
print_line("Terminal", term);
}
if let Some(ts) = &info.terminal_size {
print_line("Terminal Size", ts);
}
if let Some(de) = &info.desktop {
print_line("Desktop", de);
}
if let Some(wm) = &info.wm {
let duplicate = info
.desktop
.as_deref()
.map(|de| de.to_lowercase() == wm.to_lowercase())
.unwrap_or(false);
if !duplicate {
print_line("WM", wm);
}
}
if let Some(wm_theme) = &info.wm_theme {
print_line("WM Theme", wm_theme);
}
if let Some(wallpaper) = &info.wallpaper {
print_line("Wallpaper", wallpaper);
}
if let Some(lm) = &info.login_manager {
print_line("Login Manager", lm);
}
if let Some(player) = &info.player {
print_line("Player", player);
}
if let Some(media) = &info.media {
print_line("Media", media);
}
if let Some(ui_theme) = &info.ui_theme {
print_line("Theme", ui_theme);
}
if let Some(icons) = &info.icons {
print_line("Icons", icons);
}
if let Some(cursor) = &info.cursor {
print_line("Cursor", cursor);
}
if let Some(font) = &info.font {
print_line("Font", font);
}
if let Some(term_font) = &info.terminal_font {
print_line("Terminal Font", term_font);
}
if let Some(term_theme) = &info.terminal_theme {
print_line("Terminal Theme", term_theme);
}
if let Some(weather) = &info.weather {
print_line("Weather", weather);
}
enum ActiveLogo {
Lines(Vec<String>),
Kitty(Vec<u8>, usize, usize), Iterm2(Vec<u8>, usize, usize),
Sixel(Vec<u8>, usize, usize),
None,
}
let mut active_logo = ActiveLogo::None;
if show_logo {
let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
let user_logo = if let Some(config_dir) = dirs::config_dir() {
let p = config_dir.join("retch").join("logo.png");
if p.exists() {
Some(p)
} else {
None
}
} else {
None
};
if cli.ascii_logo {
active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
} else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
let mut resolved = false;
if logo::chafa_available() {
if let Some(path) = &user_logo {
if let Some(lines) = logo::get_chafa_logo_lines(path) {
active_logo = ActiveLogo::Lines(lines);
resolved = true;
}
} else if let Some(distro) = &distro_hint {
if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
let temp_path = std::env::temp_dir()
.join(format!("retch_logo_{}.png", std::process::id()));
if std::fs::write(&temp_path, bytes).is_ok() {
if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
active_logo = ActiveLogo::Lines(lines);
resolved = true;
}
let _ = std::fs::remove_file(&temp_path);
}
}
}
}
if !resolved {
active_logo =
ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
}
} else {
let mut resolved = false;
#[cfg(feature = "graphics")]
if !resolved && logo::supports_kitty() {
if let Some(path) = &user_logo {
if let Ok(bytes) = std::fs::read(path) {
let (cols, rows) = graphical_logo_cells(&bytes);
active_logo = ActiveLogo::Kitty(bytes, cols, rows);
resolved = true;
}
} else if let Some(distro) = &distro_hint {
if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
let (cols, rows) = graphical_logo_cells(bytes);
active_logo = ActiveLogo::Kitty(bytes.to_vec(), cols, rows);
resolved = true;
}
}
}
#[cfg(feature = "graphics")]
if !resolved && logo::supports_iterm2() {
if let Some(path) = &user_logo {
if let Ok(bytes) = std::fs::read(path) {
let (cols, rows) = graphical_logo_cells(&bytes);
active_logo = ActiveLogo::Iterm2(bytes, cols, rows);
resolved = true;
}
} else if let Some(distro) = &distro_hint {
if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
let (cols, rows) = graphical_logo_cells(bytes);
active_logo = ActiveLogo::Iterm2(bytes.to_vec(), cols, rows);
resolved = true;
}
}
}
#[cfg(feature = "graphics")]
if !resolved && logo::supports_sixel() {
if let Some(path) = &user_logo {
if let Ok(bytes) = std::fs::read(path) {
let (cols, rows) = graphical_logo_cells(&bytes);
active_logo = ActiveLogo::Sixel(bytes, cols, rows);
resolved = true;
}
} else if let Some(distro) = &distro_hint {
if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
let (cols, rows) = graphical_logo_cells(bytes);
active_logo = ActiveLogo::Sixel(bytes.to_vec(), cols, rows);
resolved = true;
}
}
}
if !resolved && logo::chafa_available() {
if let Some(path) = &user_logo {
if let Some(lines) = logo::get_chafa_logo_lines(path) {
active_logo = ActiveLogo::Lines(lines);
resolved = true;
}
} else if let Some(distro) = &distro_hint {
if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
let temp_path = std::env::temp_dir()
.join(format!("retch_logo_{}.png", std::process::id()));
if std::fs::write(&temp_path, bytes).is_ok() {
if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
active_logo = ActiveLogo::Lines(lines);
resolved = true;
}
let _ = std::fs::remove_file(&temp_path);
}
}
}
}
if !resolved {
active_logo =
ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
}
}
}
let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
let (logo_height, max_logo_width) = match &active_logo {
ActiveLogo::Lines(logo_lines) => (
logo_lines.len(),
logo_lines
.iter()
.map(|line| visible_len(line))
.max()
.unwrap_or(0),
),
ActiveLogo::Kitty(_, cols, rows)
| ActiveLogo::Iterm2(_, cols, rows)
| ActiveLogo::Sixel(_, cols, rows) => (*rows, *cols),
ActiveLogo::None => (0, 0),
};
let LayoutPlan {
side_by_side,
text_column_width,
logo_column,
} = plan_layout(
&info_widths,
logo_height,
max_logo_width,
term_width,
show_logo,
);
println!();
let formatted_info_lines: Vec<String> = if side_by_side && text_column_width > 15 {
let mut result = Vec::new();
for (i, line) in info_lines.iter().enumerate() {
let max_w = if i < logo_height {
logo_column.saturating_sub(2)
} else {
term_width.saturating_sub(2)
};
result.extend(wrap_info_line(line, max_w));
}
result
} else {
info_lines.clone()
};
if side_by_side {
match active_logo {
ActiveLogo::Lines(logo_lines) => {
let max_lines = std::cmp::max(formatted_info_lines.len(), logo_lines.len());
for i in 0..max_lines {
let info_line = formatted_info_lines.get(i).cloned().unwrap_or_default();
let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
println!(
"{}",
compose_side_by_side_row(&info_line, &logo_line, logo_column)
);
}
}
ActiveLogo::Kitty(bytes, _, logo_rows) => {
render_graphical_side_by_side(
logo_column,
&formatted_info_lines,
logo_rows,
|| logo::print_graphical_logo(&bytes),
);
}
ActiveLogo::Iterm2(bytes, _, logo_rows) => {
render_graphical_side_by_side(
logo_column,
&formatted_info_lines,
logo_rows,
|| logo::print_iterm2_logo(&bytes),
);
}
ActiveLogo::Sixel(bytes, _, logo_rows) => {
render_graphical_side_by_side(
logo_column,
&formatted_info_lines,
logo_rows,
|| logo::print_sixel_logo(&bytes),
);
}
ActiveLogo::None => {
for line in &formatted_info_lines {
println!("{}", line);
}
}
}
} else {
match active_logo {
ActiveLogo::Lines(logo_lines) => {
for line in logo_lines {
println!("{}", line);
}
println!();
}
ActiveLogo::Kitty(bytes, _, _) => {
logo::print_graphical_logo(&bytes);
println!();
}
ActiveLogo::Iterm2(bytes, _, _) => {
logo::print_iterm2_logo(&bytes);
println!();
}
ActiveLogo::Sixel(bytes, _, _) => {
logo::print_sixel_logo(&bytes);
println!();
}
ActiveLogo::None => {}
}
for line in &info_lines {
println!("{}", line);
}
}
Ok(())
}
fn consolidate_temps(temps: &[String]) -> Vec<String> {
fn categorize(label: &str) -> &'static str {
let l = label.to_lowercase();
if l.contains("cpu")
|| l.contains("core")
|| l.contains("k10temp")
|| l.contains("k8temp")
|| l.contains("coretemp")
|| l.contains("tctl")
|| l.contains("tdie")
|| l.contains("tccd")
|| l.contains("package")
{
"CPU"
} else if l.contains("gpu")
|| l.contains("nouveau")
|| l.contains("radeon")
|| l.contains("amdgpu")
{
"GPU"
} else if l.contains("nvme") || l.contains("nand") {
"NVMe"
} else if l.contains("ath")
|| l.contains("wifi")
|| l.contains("wireless")
|| l.contains("wlan")
|| l.contains("iwl")
{
"WiFi"
} else if l.contains("bat") {
"Battery"
} else {
"System"
}
}
let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
for s in temps {
if let Some((label_part, val_part)) = s.rsplit_once(':') {
let val_str = val_part.trim().trim_end_matches("°C");
if let Ok(val) = val_str.parse::<f32>() {
let cat = categorize(label_part.trim());
let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
if val > *entry {
*entry = val;
}
}
}
}
const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
ORDER
.iter()
.filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
.collect()
}
fn format_uptime(uptime: &str) -> String {
let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
let years = seconds / (365 * 24 * 3600);
let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
let hours = (seconds % (24 * 3600)) / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
let mut parts = Vec::new();
if years > 0 {
parts.push(format!("{}y", years));
}
if days > 0 {
parts.push(format!("{}d", days));
}
if hours > 0 {
parts.push(format!("{}h", hours));
}
if minutes > 0 {
parts.push(format!("{}m", minutes));
}
if secs > 0 || parts.is_empty() {
parts.push(format!("{}s", secs));
}
parts.join(" ")
}
#[cfg(feature = "graphics")]
fn graphical_logo_cells(bytes: &[u8]) -> (usize, usize) {
let (img_w, img_h) = image::load_from_memory(bytes)
.map(|img| (img.width(), img.height()))
.unwrap_or((0, 0));
let fit = logo::logo_cells_for(img_w, img_h);
(fit.cols, fit.rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_show_logo_auto_requires_tty() {
assert!(should_show_logo(None, false, false, true));
assert!(!should_show_logo(None, false, false, false));
}
#[test]
fn test_show_logo_ascii_forces_without_tty() {
assert!(should_show_logo(None, false, true, false));
assert!(should_show_logo(None, false, true, true));
}
#[test]
fn test_show_logo_no_logo_always_wins() {
assert!(!should_show_logo(None, true, true, true));
assert!(!should_show_logo(None, true, false, true));
}
#[test]
fn test_show_logo_config_disable() {
assert!(!should_show_logo(Some(false), false, false, true));
assert!(should_show_logo(Some(false), false, true, false));
}
#[test]
fn test_visible_len_strips_every_escape_form_retch_emits() {
assert_eq!(visible_len("plain"), 5);
assert_eq!(visible_len("\x1b[38;2;1;2;3mabc\x1b[39m"), 3);
assert_eq!(visible_len("\x1b[?25labc"), 3);
assert_eq!(visible_len("\x1b(Babc"), 3);
assert_eq!(visible_len("\x1b[0m \x1b[38;2;0;0;0m\u{2582}"), 2);
}
#[test]
fn test_visible_len_counts_columns_not_characters() {
assert_eq!(visible_len("宇多田ヒカル"), 12); assert_eq!(visible_len("아이유"), 6); assert_eq!(visible_len("Media: 宇多田ヒカル - 花束を君に"), 32);
assert_eq!(visible_len("Media: 아이유 - 밤편지"), 22);
assert_eq!(visible_len("cafe\u{301}"), 4);
assert_eq!(visible_len("café"), 4);
assert_eq!(
visible_len("\x1b[38;2;1;2;3m宇多田\x1b[39m"),
visible_len("宇多田")
);
}
#[test]
fn test_visible_len_ascii_art_and_chafa_symbols_are_one_column_each() {
for line in logo::get_ascii_logo(Some("fedora")) {
let stripped: String = strip_for_test(&line);
assert_eq!(
visible_len(&line),
stripped.chars().count(),
"fedora ASCII logo line is not one column per character: {stripped:?}"
);
}
for sym in [
'\u{2580}', '\u{2584}', '\u{2588}', '\u{258c}', '\u{2596}', '\u{2582}',
] {
assert_eq!(visible_len(&sym.to_string()), 1, "{sym:?} is not 1 column");
}
}
fn strip_for_test(s: &str) -> String {
let mut out = String::new();
let mut in_esc = false;
for c in s.chars() {
if c == '\x1b' {
in_esc = true;
} else if in_esc {
if c.is_ascii_alphabetic() {
in_esc = false;
}
} else {
out.push(c);
}
}
out
}
const CYAN: &str = "\x1b[38;2;0;255;255m";
const RESET: &str = "\x1b[39m";
#[test]
fn test_wrap_keeps_the_comma_it_split_on() {
let out = wrap_info_line(
"BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)",
40,
);
assert!(out.len() > 1, "expected a wrap, got {out:?}");
assert!(
out[0].ends_with(','),
"separator lost at the break: {:?}",
out[0]
);
let rejoined: String = out
.iter()
.map(|l| l.trim_start().to_string())
.collect::<Vec<_>>()
.join(" ");
assert_eq!(
rejoined,
"BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)"
);
}
#[test]
fn test_wrap_reopens_the_colour_on_every_continuation_line() {
let line =
format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
let out = wrap_info_line(&line, 40);
assert!(out.len() > 1, "expected a wrap, got {out:?}");
for (i, l) in out.iter().enumerate().skip(1) {
assert!(
l.contains(CYAN),
"continuation line {i} has no colour: {l:?}"
);
}
for l in &out {
if l.contains(CYAN) {
assert!(l.ends_with(RESET), "colour left open on {l:?}");
}
}
}
#[test]
fn test_wrap_colour_carry_does_not_change_visible_width() {
let plain = "BIOS: American Megatrends International, LLC. HN7306EAC.310";
let coloured =
format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
let a = wrap_info_line(plain, 40);
let b = wrap_info_line(&coloured, 40);
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(b.iter()) {
assert_eq!(visible_len(x), visible_len(y), "{x:?} vs {y:?}");
}
}
#[test]
fn test_wrap_uncoloured_line_is_untouched_by_the_carry() {
let out = wrap_info_line("Disk: aaaa, bbbb, cccc, dddd, eeee, ffff, gggg, hhhh", 24);
assert!(out.len() > 1);
assert!(
out.iter().all(|l| !l.contains('\x1b')),
"carry injected escapes into an uncoloured line: {out:?}"
);
}
#[test]
fn test_active_sgr_after_tracks_open_and_reset() {
assert_eq!(active_sgr_after("plain", None), None);
assert_eq!(active_sgr_after(CYAN, None), Some(CYAN.to_string()));
assert_eq!(active_sgr_after(&format!("{CYAN}x{RESET}"), None), None);
assert_eq!(active_sgr_after("\x1b[0m", Some(CYAN.into())), None);
assert_eq!(
active_sgr_after("more text", Some(CYAN.into())),
Some(CYAN.to_string())
);
assert_eq!(
active_sgr_after("\x1b[?25l", Some(CYAN.into())),
Some(CYAN.to_string())
);
}
#[test]
fn test_active_sgr_after_takes_the_last_colour_when_nested() {
let green = "\x1b[32m";
let s = format!("{CYAN}[{green}Up{RESET}] RX: 1 MB");
assert_eq!(active_sgr_after(&s, None), None); let s2 = format!("{CYAN}[{green}Up{RESET}]{CYAN} RX: 1 MB");
assert_eq!(active_sgr_after(&s2, None), Some(CYAN.to_string()));
}
#[test]
fn test_row_places_the_logo_at_the_logo_column() {
let row = compose_side_by_side_row("OS: Fedora", "###", 20);
assert_eq!(row, format!("OS: Fedora{}###", " ".repeat(10)));
assert_eq!(visible_len(&row), 23);
}
#[test]
fn test_row_aligns_wide_characters_by_column_not_character_count() {
let latin = compose_side_by_side_row("Locale: en_US.UTF-8", "###", 40);
let cjk = compose_side_by_side_row("Locale: ja_JP.宇多田ヒカル", "###", 40);
assert_eq!(visible_len(&latin), 43);
assert_eq!(
visible_len(&cjk),
43,
"a wide-character info line must not shift the logo column"
);
assert!(latin.ends_with(" ###") && cjk.ends_with(" ###"));
}
#[test]
fn test_row_without_a_logo_gets_no_trailing_padding() {
assert_eq!(compose_side_by_side_row("Net: eth0", "", 40), "Net: eth0");
}
#[test]
fn test_row_with_overlong_info_does_not_underflow() {
let row = compose_side_by_side_row("x".repeat(50).as_str(), "###", 40);
assert_eq!(row, format!("{}###", "x".repeat(50)));
}
#[test]
fn test_row_ignores_ansi_colour_when_measuring() {
let plain = compose_side_by_side_row("abc", "###", 10);
let coloured = compose_side_by_side_row("\x1b[31mabc\x1b[39m", "###", 10);
assert_eq!(visible_len(&plain), visible_len(&coloured));
}
fn realistic_full_widths() -> Vec<usize> {
let mut w = vec![40; 20]; w[13] = 54; w.extend([158, 91, 79, 60, 45, 62]); w
}
#[test]
fn test_layout_long_line_below_logo_stays_side_by_side() {
let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
assert!(p.side_by_side);
assert_eq!(p.text_column_width, 58); }
#[test]
fn test_layout_old_behavior_would_have_stacked() {
let widths = realistic_full_widths();
let old_text_col = std::cmp::max(widths.iter().copied().max().unwrap() + 4, 45);
assert!(120 < old_text_col + 40); assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); }
#[test]
fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() {
let mut w = vec![40; 20];
w[5] = 158;
let p = plan_layout(&w, 20, 40, 120, true);
assert!(p.side_by_side);
assert_eq!(p.text_column_width, 65);
}
#[test]
fn test_layout_narrow_terminal_stacks() {
assert!(!plan_layout(&[40; 30], 20, 40, 94, true).side_by_side); assert!(!plan_layout(&[40; 30], 20, 40, 80, true).side_by_side);
}
#[test]
fn test_layout_show_logo_false_stacks() {
assert!(!plan_layout(&[40; 30], 20, 40, 200, false).side_by_side);
}
#[test]
fn test_layout_column_floor_and_graphical_width() {
let p = plan_layout(&[10; 25], 20, 40, 100, true);
assert!(p.side_by_side);
assert_eq!(p.text_column_width, 45); }
#[test]
fn test_layout_widened_logo_box_still_fits_at_the_side_by_side_threshold() {
let p = plan_layout(&[10; 25], 10, logo::LOGO_MAX_COLS, 95, true);
assert!(
p.side_by_side,
"a full-width logo must still sit beside the text at 95 columns"
);
assert!(p.text_column_width + logo::LOGO_MAX_COLS <= 95);
let wide = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, 169, true);
assert!(wide.side_by_side);
assert_eq!(wide.text_column_width, 65);
}
#[test]
fn test_layout_logo_taller_than_text() {
let p = plan_layout(&[50, 30, 54], 20, 40, 120, true);
assert!(p.side_by_side);
assert_eq!(p.text_column_width, 58); }
#[test]
fn test_layout_logo_is_flush_with_the_right_margin() {
let p = plan_layout(&realistic_full_widths(), 20, 49, 138, true);
assert!(p.side_by_side);
assert_eq!(p.text_column_width, 58); assert_eq!(p.logo_column, 138 - 49); assert!(
p.logo_column > p.text_column_width,
"the pre-fix behaviour was logo_column == text_column_width"
);
}
#[test]
fn test_layout_right_anchor_never_overlaps_the_text_column() {
for term_width in 95..200 {
let p = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, term_width, true);
if p.side_by_side {
assert!(
p.logo_column >= p.text_column_width,
"logo_column {} < text_column_width {} at {} cols",
p.logo_column,
p.text_column_width,
term_width
);
assert_eq!(p.logo_column + logo::LOGO_MAX_COLS, term_width);
}
}
}
#[test]
fn test_layout_logo_column_does_not_underflow_on_an_oversized_logo() {
let p = plan_layout(&[40; 10], 10, 200, 100, true);
assert!(!p.side_by_side);
assert_eq!(p.logo_column, p.text_column_width);
}
#[test]
fn test_prelude_reserves_rows_before_saving_cursor() {
let p = graphical_side_by_side_prelude(52, 3);
assert_eq!(p, "\n\n\n\x1b[3A\x1b[52C\x1b7");
}
#[test]
fn test_prelude_v068_shape_only_differs_by_reservation() {
let p = graphical_side_by_side_prelude(45, 20);
assert_eq!(
p.replace(&format!("{}\x1b[20A", "\n".repeat(20)), ""),
"\x1b[45C\x1b7"
);
}
#[test]
fn test_prelude_zero_rows_skips_reservation_and_cursor_up() {
let p = graphical_side_by_side_prelude(45, 0);
assert_eq!(p, "\x1b[45C\x1b7");
}
#[test]
fn test_split_wifi_hardware_and_connection() {
let s = "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0] - myssid (5.0 GHz ch36 [↓866 ↑866])";
let (hw, conn) = split_wifi_line(s);
assert_eq!(
hw,
"MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0]"
);
assert_eq!(conn, Some("myssid (5.0 GHz ch36 [↓866 ↑866])"));
}
#[test]
fn test_split_wifi_splits_on_first_separator() {
let (hw, conn) = split_wifi_line("Card X [wlan0] - Guest - 5G (5 GHz)");
assert_eq!(hw, "Card X [wlan0]");
assert_eq!(conn, Some("Guest - 5G (5 GHz)"));
}
#[test]
fn test_split_wifi_connection_only_fallback() {
let (hw, conn) = split_wifi_line("myssid (300 Mbps)");
assert_eq!(hw, "myssid (300 Mbps)");
assert_eq!(conn, None);
}
#[test]
fn test_consolidate_temps_basic() {
let raw = vec![
"k10temp Tctl: 83°C".to_string(),
"amdgpu edge: 65°C".to_string(),
"nvme Composite: 62°C".to_string(),
"ath11k_hwmon temp1: 58°C".to_string(),
"acpitz temp1: 77°C".to_string(),
];
let result = consolidate_temps(&raw);
assert_eq!(
result,
vec![
"CPU: 83°C",
"GPU: 65°C",
"NVMe: 62°C",
"WiFi: 58°C",
"System: 77°C"
]
);
}
#[test]
fn test_consolidate_temps_highest_wins() {
let raw = vec![
"thinkpad CPU: 83°C".to_string(),
"k10temp Tctl: 79°C".to_string(),
"nvme Composite: 62°C".to_string(),
"nvme Sensor 1: 59°C".to_string(),
"nvme Sensor 2: 56°C".to_string(),
];
let result = consolidate_temps(&raw);
assert!(result.contains(&"CPU: 83°C".to_string()));
assert!(result.contains(&"NVMe: 62°C".to_string()));
assert!(!result
.iter()
.any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
}
#[test]
fn test_consolidate_temps_order() {
let raw = vec![
"acpitz: 60°C".to_string(),
"nvme: 55°C".to_string(),
"amdgpu edge: 65°C".to_string(),
"k10temp Tctl: 80°C".to_string(),
];
let result = consolidate_temps(&raw);
let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
let sys_pos = result.iter().position(|s| s.starts_with("System"));
assert!(cpu_pos < gpu_pos);
assert!(gpu_pos < nvme_pos);
assert!(nvme_pos < sys_pos);
}
#[test]
fn test_consolidate_temps_empty() {
assert!(consolidate_temps(&[]).is_empty());
}
#[test]
fn test_format_uptime() {
assert_eq!(format_uptime("60s"), "1m");
assert_eq!(format_uptime("3600s"), "1h");
assert_eq!(format_uptime("3661s"), "1h 1m 1s");
assert_eq!(format_uptime("86400s"), "1d");
assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
assert_eq!(format_uptime("31536000s"), "1y");
assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
assert_eq!(format_uptime("0s"), "0s");
}
#[test]
fn test_wrap_info_line_short_line_unchanged() {
let line = "Audio: Windows Audio (USB Audio Device)";
let wrapped = wrap_info_line(line, 50);
assert_eq!(wrapped, vec![line.to_string()]);
}
#[test]
fn test_wrap_info_line_wraps_and_indents() {
let line = "Audio: Windows Audio (USB Audio Device, AMD High Definition Audio Device, AMD SoundWire Device)";
let wrapped = wrap_info_line(line, 45);
assert!(wrapped.len() > 1);
assert!(wrapped[0].starts_with("Audio: Windows Audio"));
assert!(wrapped[1].starts_with(" "));
}
}