acorn-tui 0.2.0

ACORN terminal user interface (TUI)
use crate::app::App;
use crate::event::{to_internal_event, Event};
use crate::export;
use crate::Screen;
use crossterm::event;
use jiff::Timestamp;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;

const CATEGORIES: &[&str] = &["System", "Memory", "Network", "Software", "GPU"];

pub fn render(f: &mut Frame, app: &mut App) {
    app.load_doctor_data();
    let area = f.area();
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(3)])
        .split(area);
    let main_chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Length(30), Constraint::Min(1)])
        .split(chunks[0]);
    render_category_list(f, main_chunks[0], app);
    render_detail_pane(f, main_chunks[1], app);
    render_status_bar(f, chunks[1], app);
}
fn render_category_list(f: &mut Frame, area: Rect, app: &App) {
    let items: Vec<Line> = CATEGORIES
        .iter()
        .enumerate()
        .map(|(i, name)| {
            let is_selected = i == app.doctor.selected_category;
            let prefix = if is_selected { ">" } else { " " };
            let style = if is_selected {
                Style::default()
                    .fg(app.theme.selection_fg)
                    .bg(app.theme.selection_bg)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(app.theme.text)
            };
            Line::from(Span::styled(format!(" {prefix} [{i}] {name}"), style))
        })
        .collect();
    let paragraph = Paragraph::new(items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(" Diagnostics ")
                .title_alignment(Alignment::Center)
                .border_style(Style::default().fg(app.theme.accent)),
        )
        .alignment(Alignment::Left);
    f.render_widget(paragraph, area);
}
fn render_detail_pane(f: &mut Frame, area: Rect, app: &App) {
    let Some(ref data) = app.doctor.data else {
        let paragraph = Paragraph::new(" Loading diagnostics...")
            .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(app.theme.border)))
            .alignment(Alignment::Center);
        f.render_widget(paragraph, area);
        return;
    };
    let lines: Vec<Line> = match app.doctor.selected_category {
        | 0 => render_system(data, app),
        | 1 => render_memory(data, app),
        | 2 => render_network(data, app),
        | 3 => render_software(data, app),
        | 4 => render_gpu(app),
        | _ => vec![Line::from(" Unknown category")],
    };
    let paragraph = Paragraph::new(lines)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(format!(" {} ", CATEGORIES[app.doctor.selected_category]))
                .title_alignment(Alignment::Center)
                .border_style(Style::default().fg(app.theme.accent)),
        )
        .alignment(Alignment::Left);
    f.render_widget(paragraph, area);
    if let Some(ref msg) = app.doctor.export_message {
        let popup_area = centered_rect(50, 3, area);
        let popup = Paragraph::new(msg.as_str())
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .title(" Export ")
                    .border_style(Style::default().fg(app.theme.accent)),
            )
            .alignment(Alignment::Center);
        f.render_widget(Clear, popup_area);
        f.render_widget(popup, popup_area);
    }
}
fn render_system(data: &crate::app::DoctorData, app: &App) -> Vec<Line<'static>> {
    let Some(ref sys) = data.system else {
        return vec![Line::from(" No system data available")];
    };
    vec![
        Line::from(vec![
            Span::styled(" Name:             ", Style::default().fg(app.theme.text_muted)),
            Span::styled(sys.name.clone(), Style::default().fg(app.theme.text)),
        ]),
        Line::from(vec![
            Span::styled(" Kernel Version:   ", Style::default().fg(app.theme.text_muted)),
            Span::styled(sys.kernel.clone(), Style::default().fg(app.theme.text)),
        ]),
        Line::from(vec![
            Span::styled(" OS Version:       ", Style::default().fg(app.theme.text_muted)),
            Span::styled(sys.os_version.clone(), Style::default().fg(app.theme.text)),
        ]),
        Line::from(vec![
            Span::styled(" Host Name:        ", Style::default().fg(app.theme.text_muted)),
            Span::styled(sys.host_name.clone(), Style::default().fg(app.theme.text)),
        ]),
        Line::from(vec![
            Span::styled(" CPU Architecture: ", Style::default().fg(app.theme.text_muted)),
            Span::styled(sys.cpu_arch.clone(), Style::default().fg(app.theme.text)),
        ]),
        Line::from(vec![
            Span::styled(" CPU Count:        ", Style::default().fg(app.theme.text_muted)),
            Span::styled(sys.cpu_count.clone(), Style::default().fg(app.theme.text)),
        ]),
    ]
}
fn render_memory(data: &crate::app::DoctorData, app: &App) -> Vec<Line<'static>> {
    let Some(ref mem) = data.memory else {
        return vec![Line::from(" No memory data available")];
    };
    vec![
        Line::from(vec![
            Span::styled(" Total:     ", Style::default().fg(app.theme.text_muted)),
            Span::styled(mem.total.clone(), Style::default().fg(app.theme.text)),
        ]),
        Line::from(vec![
            Span::styled(" Available: ", Style::default().fg(app.theme.text_muted)),
            Span::styled(mem.available.clone(), Style::default().fg(app.theme.success)),
        ]),
        Line::from(vec![
            Span::styled(" Used:      ", Style::default().fg(app.theme.text_muted)),
            Span::styled(mem.used.clone(), Style::default().fg(app.theme.warning)),
        ]),
        Line::from(vec![
            Span::styled(" Swap:      ", Style::default().fg(app.theme.text_muted)),
            Span::styled(mem.swap.clone(), Style::default().fg(app.theme.text)),
        ]),
    ]
}
fn render_network(data: &crate::app::DoctorData, app: &App) -> Vec<Line<'static>> {
    let Some(ref net) = data.network else {
        return vec![Line::from(" No network data available")];
    };
    if net.interfaces.is_empty() {
        return vec![Line::from(" No active network interfaces found")];
    }
    let mut lines = Vec::new();
    for (i, iface) in net.interfaces.iter().enumerate() {
        if i > 0 {
            lines.push(Line::from(""));
        }
        lines.push(Line::from(Span::styled(
            format!(" Interface {}:", i + 1),
            Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD),
        )));
        for ip in &iface.ip_addresses {
            lines.push(Line::from(Span::styled(format!("   IP:  {ip}"), Style::default().fg(app.theme.text))));
        }
        lines.push(Line::from(Span::styled(
            format!("   MAC: {}", iface.mac_address),
            Style::default().fg(app.theme.text),
        )));
        lines.push(Line::from(Span::styled(
            format!("   MTU: {}", iface.mtu),
            Style::default().fg(app.theme.text),
        )));
    }
    lines
}
fn render_software(data: &crate::app::DoctorData, app: &App) -> Vec<Line<'static>> {
    let Some(ref sw) = data.software else {
        return vec![Line::from(" No software data available")];
    };
    let mut lines = vec![Line::from(vec![
        Span::styled(
            format!(" {:<12}", "Name"),
            Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!(" {:>9}", "Installed"),
            Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!(" {:>12}", "Version"),
            Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!("  {}", "Path"),
            Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD),
        ),
    ])];
    lines.push(Line::from(Span::styled("-".repeat(60), Style::default().fg(app.theme.text_muted))));
    for item in &sw.items {
        let status = if item.installed { "✓" } else { "✗" };
        let status_color = if item.installed { app.theme.success } else { app.theme.error };
        let path_display = if item.path.len() > 30 {
            format!("...{}", &item.path[item.path.len().saturating_sub(27)..])
        } else {
            item.path.clone()
        };
        lines.push(Line::from(vec![
            Span::styled(format!(" {:<12}", item.name), Style::default().fg(app.theme.text)),
            Span::styled(format!(" {:>9}", status), Style::default().fg(status_color)),
            Span::styled(format!(" {:>12}", item.version), Style::default().fg(app.theme.warning)),
            Span::styled(format!("  {}", path_display), Style::default().fg(app.theme.text_muted)),
        ]));
    }
    lines
}
fn render_gpu(app: &App) -> Vec<Line<'static>> {
    vec![Line::from(Span::styled(
        " GPU diagnostics not implemented",
        Style::default().fg(app.theme.text_muted),
    ))]
}
fn render_status_bar(f: &mut Frame, area: Rect, app: &App) {
    let controls = Line::from(vec![
        Span::styled(" [↑/↓] Navigate  ", Style::default().fg(app.theme.text_muted)),
        Span::styled("[R] Refresh  ", Style::default().fg(app.theme.text_muted)),
        Span::styled("[E] Export  ", Style::default().fg(app.theme.text_muted)),
        Span::styled("[Esc] Back  ", Style::default().fg(app.theme.text_muted)),
        Span::styled("[Q] Quit", Style::default().fg(app.theme.text_muted)),
    ]);
    let paragraph = Paragraph::new(controls)
        .block(Block::default().borders(Borders::TOP).border_style(Style::default().fg(app.theme.border)))
        .alignment(Alignment::Center);
    f.render_widget(paragraph, area);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(area.height.saturating_sub(percent_y) / 2),
            Constraint::Length(percent_y),
            Constraint::Min(0),
        ])
        .split(area);
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Length(area.width.saturating_sub(percent_x) / 2),
            Constraint::Length(percent_x),
            Constraint::Min(0),
        ])
        .split(popup_layout[1])[1]
}
pub fn handle_event(app: &mut App, evt: event::Event) {
    let event = match evt {
        | event::Event::Key(key) => to_internal_event(key),
        | _ => None,
    };
    let Some(event) = event else {
        return;
    };
    match event {
        | Event::Quit => app.should_quit = true,
        | Event::Back => app.navigate_to(Screen::Dashboard),
        | Event::Up => {
            app.doctor.selected_category = app.doctor.selected_category.saturating_sub(1).min(CATEGORIES.len() - 1);
        }
        | Event::Down => {
            app.doctor.selected_category = app.doctor.selected_category.saturating_add(1).min(CATEGORIES.len() - 1);
        }
        | Event::Number(n) => {
            let idx = (n as usize).saturating_sub(1);
            if idx < CATEGORIES.len() {
                app.doctor.selected_category = idx;
            }
        }
        | Event::Refresh => {
            app.refresh_doctor();
        }
        | Event::Export => {
            if let Some(ref data) = app.doctor.data {
                let timestamp = Timestamp::now().strftime("%Y%m%d_%H%M%S");
                let filename = format!("acorn_doctor_report_{timestamp}.json");
                match export::export_doctor_report(data, Some(std::path::Path::new(&filename))) {
                    | Ok(path) => {
                        app.doctor.export_message = Some(format!(" Report saved to {path} "));
                    }
                    | Err(e) => {
                        app.doctor.export_message = Some(format!(" Export failed: {e} "));
                    }
                }
            } else {
                app.doctor.export_message = Some(" No data to export ".into());
            }
        }
        | Event::Enter => {
            app.doctor.export_message = None;
        }
        | _ => {}
    }
}