trackWork 0.15.0

A terminal-based time tracking application for managing work sessions
use ratatui::{
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Wrap},
    Frame,
};

use crate::app::App;
use crate::dashboard::utils::themed_rgb;

/// Get the changelog content by parsing APP_CHANGELOG.md
pub fn get_changelog() -> Vec<Line<'static>> {
    const CHANGELOG: &str = include_str!("../../APP_CHANGELOG.md");

    let mut lines = Vec::new();

    for line in CHANGELOG.lines() {
        let trimmed = line.trim();

        // Skip the title line
        if trimmed.starts_with("# ") {
            continue;
        }

        // Version header
        if trimmed.starts_with("## ") {
            let version = trimmed.trim_start_matches("## ");
            lines.push(Line::from(vec![
                Span::styled(
                    version.to_string(),
                    Style::default()
                        .fg(Color::Cyan)
                        .add_modifier(Modifier::BOLD),
                ),
            ]));
        }
        // Bullet point
        else if trimmed.starts_with("- ") {
            let text = trimmed.trim_start_matches("- ");
            lines.push(Line::from(vec![
                Span::styled("", Style::default().fg(Color::Green)),
                Span::raw(text.to_string()),
            ]));
        }
        // Empty line
        else if trimmed.is_empty() {
            lines.push(Line::from(""));
        }
    }

    lines
}

/// Draw the What's New modal
pub fn draw_whats_new(f: &mut Frame, app: &App) {
    let area = centered_rect(80, 80, f.area());

    // Create the modal block
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .title(vec![
            Span::raw(" "),
            Span::styled(
                format!("trackWork v{} - What's New", env!("CARGO_PKG_VERSION")),
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(" "),
        ]);

    // Split the modal into title, content, and footer
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(3),    // Content area
            Constraint::Length(3), // Footer
        ])
        .split(area);

    // Render the main block (border + title)...
    f.render_widget(block, area);
    // ...then sweep a shimmer wave across its frame + title cells, unless the
    // user has opted out of eye-candy animations.
    if !app.config.hide_eye_candy {
        shimmer_frame(f, area, app);
    }

    // Content area (changelog)
    let content_area = Rect {
        x: chunks[0].x + 2,
        y: chunks[0].y + 1,
        width: chunks[0].width.saturating_sub(4),
        height: chunks[0].height.saturating_sub(2),
    };

    let changelog = get_changelog();
    let content = Paragraph::new(changelog)
        .wrap(Wrap { trim: false })
        .alignment(Alignment::Left);

    f.render_widget(content, content_area);

    // Footer (help text)
    let footer_area = Rect {
        x: chunks[1].x + 2,
        y: chunks[1].y,
        width: chunks[1].width.saturating_sub(4),
        height: chunks[1].height,
    };

    let help_text = vec![Line::from(vec![
        Span::styled("Press ", Style::default().fg(Color::Gray)),
        Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
        Span::styled(" or ", Style::default().fg(Color::Gray)),
        Span::styled("Esc", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
        Span::styled(" to close", Style::default().fg(Color::Gray)),
    ])];

    let footer = Paragraph::new(help_text).alignment(Alignment::Center);
    f.render_widget(footer, footer_area);
}

/// Sweep one big diagonal brightness wave across the modal's border (and the
/// title cells sitting on the top border), brightening cells toward white as
/// the band passes. The content inside stays static. Cells redraw each frame
/// (100ms event loop), so the wave animates.
fn shimmer_frame(f: &mut Frame, area: Rect, app: &App) {
    const SWEEP_MS: f64 = 1100.0; // time for the wave to cross the frame
    const PAUSE_MS: f64 = 4000.0; // idle gap between sweeps
    const BAND: f64 = 70.0; // half-width of the bright band, in cells

    if area.width < 2 || area.height < 2 {
        return;
    }

    let base = themed_rgb(Color::Cyan, &app.term_palette);
    let ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as f64;

    // Diagonal coordinate runs 0..(w+h). During the sweep the head crosses the
    // whole span (plus a band of lead-in/out so it fully enters and exits);
    // during the pause it parks off the end and nothing shimmers.
    let span = (area.width + area.height) as f64;
    let t = ms % (SWEEP_MS + PAUSE_MS);
    let head = if t < SWEEP_MS {
        -BAND + (t / SWEEP_MS) * (span + 2.0 * BAND)
    } else {
        f64::INFINITY
    };

    let buf = f.buffer_mut();
    let (x0, y0) = (area.x, area.y);
    let (x1, y1) = (area.x + area.width - 1, area.y + area.height - 1);

    let mut paint = |x: u16, y: u16| {
        let diag = (x - x0) as f64 + (y - y0) as f64;
        let d = (head - diag).abs();
        if d >= BAND {
            return;
        }
        // Smooth cosine bell: 1.0 at the center, easing gently to 0 at the edges.
        let intensity = 0.5 * (1.0 + (std::f64::consts::PI * d / BAND).cos());
        let lighten = |c: u8| (c as f64 + (255.0 - c as f64) * (intensity * 0.85)).round() as u8;
        let cell = &mut buf[(x, y)];
        cell.set_fg(Color::Rgb(lighten(base.0), lighten(base.1), lighten(base.2)));
    };

    // Top and bottom borders (top row also carries the title).
    for x in x0..=x1 {
        paint(x, y0);
        paint(x, y1);
    }
    // Left and right borders.
    for y in y0..=y1 {
        paint(x0, y);
        paint(x1, y);
    }
}

/// Helper function to create a centered rectangle
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}