diskwatch 0.5.6

Single-host, read-only disk diagnostics TUI. Eight tabs across devices, volumes, filesystems, IO, SMART, hot files, and insights. Sibling to netwatch and syswatch.
use chrono::Local;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use ratatui::Frame;

use crate::app::{HostInfo, LiveState};
use crate::tabs::{TabId, ALL_TABS};
use crate::ui::palette as p;

pub fn draw_header(f: &mut Frame, area: Rect, host: &HostInfo, live: LiveState) {
    let mut left: Vec<Span> = Vec::new();
    left.push(Span::styled(" \u{25cf}", Style::default().fg(p::green())));
    left.push(Span::styled(
        " DiskWatch",
        Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
    ));
    left.push(Span::styled(
        format!(" v{}", env!("CARGO_PKG_VERSION")),
        Style::default().fg(p::dim()),
    ));
    left.push(Span::styled(
        "  \u{2502}  ",
        Style::default().fg(p::faint()),
    ));
    left.push(Span::styled("host ", Style::default().fg(p::dim())));
    left.push(Span::styled(
        host.hostname.clone(),
        Style::default().fg(p::fg()),
    ));
    left.push(Span::styled("  ", Style::default().fg(p::dim())));
    left.push(Span::styled(host.os.clone(), Style::default().fg(p::fg())));
    left.push(Span::styled("  up ", Style::default().fg(p::dim())));
    left.push(Span::styled(
        format_uptime(host.uptime_secs),
        Style::default().fg(p::fg()),
    ));
    left.push(Span::styled("  ", Style::default().fg(p::dim())));
    left.push(Span::styled(
        format!("{} devs", host.device_count),
        Style::default().fg(p::fg()),
    ));
    left.push(Span::styled(" attached", Style::default().fg(p::dim())));

    let (label, color) = match live {
        LiveState::Live => ("LIVE", p::green()),
        LiveState::Paused => ("PAUSE", p::yellow()),
    };
    let ts = Local::now().format("%H:%M:%S").to_string();
    let right_text = format!("\u{25cf} {}  {}", label, ts);
    let right_w = right_text.chars().count() as u16 + 1;
    let right = vec![Span::styled(
        right_text,
        Style::default().fg(color).add_modifier(Modifier::BOLD),
    )];

    let left_area = Rect {
        x: area.x,
        y: area.y,
        width: area.width.saturating_sub(right_w),
        height: 1,
    };
    let right_area = Rect {
        x: area.x + area.width.saturating_sub(right_w),
        y: area.y,
        width: right_w,
        height: 1,
    };

    f.render_widget(
        Paragraph::new(Line::from(left)).style(Style::default().bg(p::bg()).fg(p::fg())),
        left_area,
    );
    f.render_widget(
        Paragraph::new(Line::from(right)).style(Style::default().bg(p::bg())),
        right_area,
    );
}

/// Two-row tab bar: numbered labels on row 0, underline on row 1 with
/// corner glyphs around the active tab (matches `dwDrawTabBar`).
pub fn draw_tab_bar(f: &mut Frame, area: Rect, active: TabId, insight_count: usize) {
    let mut spans: Vec<Span> = Vec::new();
    let mut underline = String::new();
    let mut active_start: Option<usize> = None;
    let mut active_end: Option<usize> = None;
    let mut col: usize = 0;

    for tab in ALL_TABS {
        let badge = if *tab == TabId::Insights && insight_count > 0 {
            format!(" {}", insight_count)
        } else {
            String::new()
        };
        let label = format!("[{}] {}{}", tab.number(), tab.label(), badge);
        let cell_w = label.chars().count() + 2;

        if *tab == active {
            spans.push(Span::styled(" ", Style::default().bg(p::bg())));
            spans.push(Span::styled(
                label.clone(),
                Style::default()
                    .fg(p::cyan())
                    .bg(p::bg())
                    .add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::styled(" ", Style::default().bg(p::bg())));
            active_start = Some(col);
            active_end = Some(col + cell_w);
        } else {
            spans.push(Span::styled(" ", Style::default().fg(p::dim())));
            spans.push(Span::styled(
                format!("[{}] ", tab.number()),
                Style::default().fg(p::dim()),
            ));
            spans.push(Span::styled(
                tab.label().to_string(),
                Style::default().fg(p::fg()),
            ));
            if !badge.is_empty() {
                spans.push(Span::styled(badge, Style::default().fg(p::dim())));
            }
            spans.push(Span::styled(" ", Style::default().fg(p::dim())));
        }

        col += cell_w;
    }

    // Underline (row 1): horizontal rule across full width with `┘ … └`
    // corners breaking it under the active tab.
    let total_w = area.width as usize;
    for i in 0..total_w {
        let c = if Some(i) == active_start {
            '\u{2518}'
        } else if Some(i + 1) == active_end {
            '\u{2514}'
        } else if let (Some(s), Some(e)) = (active_start, active_end) {
            if i > s && i + 1 < e {
                ' '
            } else {
                '\u{2500}'
            }
        } else {
            '\u{2500}'
        };
        underline.push(c);
    }

    let labels_area = Rect {
        x: area.x,
        y: area.y,
        width: area.width,
        height: 1,
    };
    let underline_area = Rect {
        x: area.x,
        y: area.y + 1,
        width: area.width,
        height: 1,
    };
    f.render_widget(
        Paragraph::new(Line::from(spans)).style(Style::default().bg(p::bg())),
        labels_area,
    );
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            underline,
            Style::default().fg(p::faint()).bg(p::bg()),
        ))),
        underline_area,
    );
}

pub fn draw_footer(f: &mut Frame, area: Rect, extra: &[(char, &str)]) {
    let mut spans: Vec<Span> = Vec::new();
    spans.push(Span::raw(" "));

    // Arrow-key navigation hint — always shown so users discover the
    // per-tab picker (Devices, SMART, FS, Overview) on first launch.
    spans.push(Span::styled(
        "\u{2191}\u{2193}\u{2190}\u{2192}".to_string(),
        Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
    ));
    spans.push(Span::styled(":Nav ", Style::default().fg(p::dim())));

    // Only bindings that are actually handled in `handle_key` are shown —
    // diskwatch has no Snapshot/Diff/Profile/Rec/Filter features (those were
    // inherited from the netwatch footer), and advertising them was issue #6
    // ("dead shortcuts"). The SMART refresh/interval keys are appended via
    // `extra`.
    let groups: &[&[(char, &str)]] = &[
        &[('p', "Pause"), (',', "Settings")],
        &[('q', "Quit"), ('1', "Tab")],
        &[('?', "Help")],
    ];
    for (gi, g) in groups.iter().enumerate() {
        if gi > 0 {
            spans.push(Span::styled(" \u{2502} ", Style::default().fg(p::faint())));
        }
        for (k, label) in g.iter() {
            let key_str = if *k == '1' {
                "1-9".to_string()
            } else {
                k.to_string()
            };
            spans.push(Span::styled(
                key_str,
                Style::default().fg(p::cyan()).add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::styled(
                format!(":{} ", label),
                Style::default().fg(p::dim()),
            ));
        }
    }
    if !extra.is_empty() {
        spans.push(Span::styled(" \u{2502} ", Style::default().fg(p::faint())));
        for (k, label) in extra.iter() {
            spans.push(Span::styled(
                k.to_string(),
                Style::default()
                    .fg(p::yellow())
                    .add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::styled(
                format!(":{} ", label),
                Style::default().fg(p::dim()),
            ));
        }
    }
    // Divider row above the footer text.
    let divider_area = Rect {
        x: area.x,
        y: area.y,
        width: area.width,
        height: 1,
    };
    let text_area = Rect {
        x: area.x,
        y: area.y + 1,
        width: area.width,
        height: 1,
    };
    let divider: String = "\u{2500}".repeat(area.width as usize);
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            divider,
            Style::default().fg(p::faint()).bg(p::bg()),
        ))),
        divider_area,
    );
    f.render_widget(
        Paragraph::new(Line::from(spans)).style(Style::default().bg(p::bg())),
        text_area,
    );
}

fn format_uptime(secs: u64) -> String {
    let d = secs / 86_400;
    let h = (secs % 86_400) / 3600;
    let m = (secs % 3600) / 60;
    if d > 0 {
        format!("{}d {:02}:{:02}", d, h, m)
    } else {
        format!("{:02}:{:02}", h, m)
    }
}

/// Tints a Rect's background — used for selected rows / warning rows.
#[allow(dead_code)]
pub fn tint_rect(buf: &mut Buffer, area: Rect, bg: ratatui::style::Color) {
    for y in area.y..area.y.saturating_add(area.height) {
        for x in area.x..area.x.saturating_add(area.width) {
            if let Some(cell) = buf.cell_mut((x, y)) {
                cell.set_bg(bg);
            }
        }
    }
}

/// `Widget` adapter so callers can `render_widget` a tint over an area —
/// kept around for future tabs even though chrome doesn't use it directly.
#[allow(dead_code)]
pub struct Tint(pub ratatui::style::Color);
impl Widget for Tint {
    fn render(self, area: Rect, buf: &mut Buffer) {
        tint_rect(buf, area, self.0);
    }
}