linutil_tui 26.5.21

Chris Titus Tech's Linux Toolbox - Linutil is a distro-agnostic toolbox designed to simplify everyday Linux tasks.
use crate::theme::Theme;
use ratatui::{
    style::Style,
    text::{Line, Span},
};
use std::process::Command;

const FASTFETCH_ARGS: [&str; 6] = [
    "--logo",
    "none",
    "--separator",
    " : ",
    "--structure",
    "cpu:disk:memory:gpu",
];

pub struct SystemInfo {
    entries: Vec<InfoEntry>,
}

struct InfoEntry {
    label: &'static str,
    value: String,
}

impl SystemInfo {
    pub fn gather() -> Option<Self> {
        fastfetch_entries().map(|entries| Self { entries })
    }

    pub fn entries_len(&self) -> usize {
        self.entries.len()
    }

    pub fn render_lines(&self, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
        self.entries
            .iter()
            .map(|entry| {
                let prefix = format!("{:>4}: ", entry.label);
                let value_width = max_width.saturating_sub(prefix.len());
                let value = truncate_value(&entry.value, value_width);
                Line::from(vec![
                    Span::styled(
                        format!("{:>4}", entry.label),
                        Style::default().fg(theme.tab_color()).bold(),
                    ),
                    Span::styled(": ", Style::default().fg(theme.unfocused_color())),
                    Span::styled(value, Style::default().fg(theme.cmd_color())),
                ])
            })
            .collect()
    }
}

fn fastfetch_entries() -> Option<Vec<InfoEntry>> {
    let output = Command::new("fastfetch")
        .args(FASTFETCH_ARGS)
        .env("NO_COLOR", "1")
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let cleaned = strip_ansi_codes(&stdout);

    let mut cpu = None;
    let mut disk = None;
    let mut ram = None;
    let mut gpu = None;

    for line in cleaned.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }

        let mut parts = line.splitn(2, ':');
        let label = parts.next().unwrap_or("").trim();
        let value = parts.next().unwrap_or("").trim();
        if value.is_empty() {
            continue;
        }

        let label_lower = label.to_ascii_lowercase();
        if cpu.is_none() && label_lower.starts_with("cpu") {
            cpu = Some(normalize_value("cpu", value));
        } else if disk.is_none() && label_lower.starts_with("disk") {
            disk = Some(normalize_value("disk", value));
        } else if ram.is_none()
            && (label_lower.starts_with("memory") || label_lower.starts_with("ram"))
        {
            ram = Some(normalize_value("ram", value));
        } else if gpu.is_none() && label_lower.starts_with("gpu") {
            gpu = Some(normalize_value("gpu", value));
        }
    }

    if cpu.is_none() && disk.is_none() && ram.is_none() && gpu.is_none() {
        return None;
    }

    Some(vec![
        InfoEntry {
            label: "CPU",
            value: cpu.unwrap_or_else(|| "n/a".to_string()),
        },
        InfoEntry {
            label: "DISK",
            value: disk.unwrap_or_else(|| "n/a".to_string()),
        },
        InfoEntry {
            label: "RAM",
            value: ram.unwrap_or_else(|| "n/a".to_string()),
        },
        InfoEntry {
            label: "GPU",
            value: gpu.unwrap_or_else(|| "n/a".to_string()),
        },
    ])
}

fn strip_ansi_codes(input: &str) -> String {
    let mut output = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '\x1b' && matches!(chars.peek(), Some('[')) {
            chars.next();
            for next in chars.by_ref() {
                if next == 'm' {
                    break;
                }
            }
            continue;
        }
        output.push(c);
    }

    output
}

fn normalize_value(label: &str, value: &str) -> String {
    match label {
        "cpu" => strip_device_details(value),
        "gpu" => strip_device_details(value),
        "disk" => extract_total(value),
        "ram" => extract_total(value),
        _ => value.trim().to_string(),
    }
}

fn strip_device_details(value: &str) -> String {
    let mut trimmed = value.trim().to_string();
    if let Some(idx) = trimmed.find(" @") {
        trimmed.truncate(idx);
        trimmed = trimmed.trim().to_string();
    }
    if let Some(idx) = trimmed.find(" (") {
        trimmed.truncate(idx);
        trimmed = trimmed.trim().to_string();
    }
    if let Some(idx) = trimmed.find(" [") {
        trimmed.truncate(idx);
        trimmed = trimmed.trim().to_string();
    }
    trimmed
}

fn extract_total(value: &str) -> String {
    let mut parts = value.splitn(2, '/');
    let _used = parts.next();
    let Some(total_part) = parts.next() else {
        return value.trim().to_string();
    };
    let mut tokens = total_part.split_whitespace();
    let Some(amount) = tokens.next() else {
        return total_part.trim().to_string();
    };
    let Some(unit) = tokens.next() else {
        return amount.to_string();
    };
    format!("{amount} {unit}")
}

fn truncate_value(value: &str, max_width: usize) -> String {
    if max_width == 0 {
        return String::new();
    }

    let value_chars: Vec<char> = value.chars().collect();
    if value_chars.len() <= max_width {
        return value.to_string();
    }

    if max_width <= 3 {
        return value_chars.iter().take(max_width).collect();
    }

    let slice: String = value_chars.iter().take(max_width - 3).collect();
    format!("{slice}...")
}