tui_breath 0.3.0

Terminal breathing guide built with Rust + Ratatui. Smooth eased animations, 4-7-8 / box / diaphragmatic patterns, JSON session tracking.
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table};

use crate::app::{App, AppState};
use crate::engine::session::SessionOutcome;

pub fn draw(f: &mut Frame, app: &App) {
    let AppState::Results(results_state) = &app.state else {
        return;
    };

    let engine = &results_state.manager.engine;
    let pattern = engine.pattern;
    let area = f.size();

    let title = match results_state.manager.session_status() {
        Some(SessionOutcome::Completed) => "Session Complete",
        _ => "Session Ended",
    };

    let title_block = Block::default()
        .title(title)
        .title_alignment(Alignment::Center)
        .borders(Borders::ALL);

    f.render_widget(title_block, area);

    let inner = Rect {
        x: area.x + 2,
        y: area.y + 2,
        width: area.width.saturating_sub(4),
        height: area.height.saturating_sub(5),
    };

    let elapsed_secs = engine.total_elapsed_secs as u32;
    let elapsed_mins = elapsed_secs / 60;
    let elapsed_secs_remainder = elapsed_secs % 60;

    let target_secs = engine.duration_target_secs as u32;
    let target_mins = target_secs / 60;
    let target_secs_remainder = target_secs % 60;

    let best_hold = results_state
        .manager
        .best_hold_seconds()
        .map(|secs| format!("{secs:.1}s"))
        .unwrap_or_else(|| "--".to_string());

    let rows = vec![
        Row::new(vec![
            Cell::from("Pattern"),
            Cell::from(pattern.display_name).style(Style::default().fg(Color::Cyan)),
        ]),
        Row::new(vec![
            Cell::from("Duration"),
            Cell::from(format!(
                "{}:{:02} / {}:{:02}",
                elapsed_mins, elapsed_secs_remainder, target_mins, target_secs_remainder
            ))
            .style(Style::default().fg(Color::Green)),
        ]),
        Row::new(vec![
            Cell::from("Cycles Completed"),
            Cell::from(format!("{}", engine.cycle_count)).style(Style::default().fg(Color::Yellow)),
        ]),
        Row::new(vec![
            Cell::from("Pauses"),
            Cell::from(format!("{}", engine.pause_count))
                .style(Style::default().fg(Color::Magenta)),
        ]),
        Row::new(vec![
            Cell::from("Best Hold"),
            Cell::from(best_hold).style(Style::default().fg(Color::LightRed)),
        ]),
        Row::new(vec![
            Cell::from("Hold Attempts"),
            Cell::from(format!("{}", results_state.manager.hold_attempt_count()))
                .style(Style::default().fg(Color::LightBlue)),
        ]),
    ];

    let table = Table::new(
        rows,
        &[Constraint::Percentage(40), Constraint::Percentage(60)],
    )
    .block(
        Block::default()
            .borders(Borders::ALL)
            .title("Session Metrics"),
    );

    let table_area = Rect {
        x: inner.x,
        y: inner.y,
        width: inner.width,
        height: 10,
    };
    f.render_widget(table, table_area);

    let completion = engine.completion_percent() as u16;
    let gauge = Gauge::default()
        .block(Block::default().title("Completion"))
        .gauge_style(Style::default().fg(Color::Blue))
        .percent(completion);

    let gauge_area = Rect {
        x: inner.x,
        y: inner.y + 11,
        width: inner.width,
        height: 2,
    };
    f.render_widget(gauge, gauge_area);

    let footer = Paragraph::new("[s] Save Again  [Enter] Main Menu  [Esc] Main Menu")
        .alignment(Alignment::Center)
        .style(Style::default().dim());

    let footer_area = Rect {
        x: area.x + 1,
        y: area.bottom().saturating_sub(2),
        width: area.width.saturating_sub(2),
        height: 1,
    };
    f.render_widget(footer, footer_area);
}