void-focus 0.3.0-alpha.6

A feature-rich terminal focus timer with task tracking
Documentation
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders};

use super::icons::IconSet;
use crate::app::{App, Theme};
use crate::model::TaskStatus;

pub fn format_minutes(mins: u32) -> String {
    if mins >= 60 {
        format!("{}h {}m", mins / 60, mins % 60)
    } else {
        format!("{}m", mins)
    }
}

#[allow(dead_code)]
pub const GRID_LABEL_W: usize = 3;

#[allow(dead_code)]
pub fn push_sized_slot(
    spans: &mut Vec<Span<'_>>,
    text: &str,
    style: Style,
    col_w: usize,
    gap: usize,
    last_col: bool,
) {
    spans.push(Span::styled(center_display(text, col_w), style));
    if !last_col {
        spans.push(Span::raw(" ".repeat(gap)));
    }
}

#[allow(dead_code)]
pub fn pad_display(text: &str, width: usize) -> String {
    let w = unicode_width::UnicodeWidthStr::width(text);
    if w > width {
        truncate_display(text, width)
    } else {
        format!("{text}{}", " ".repeat(width - w))
    }
}

#[allow(dead_code)]
pub fn center_display(text: &str, width: usize) -> String {
    let w = unicode_width::UnicodeWidthStr::width(text);
    if w > width {
        truncate_display(text, width)
    } else {
        let left = (width - w) / 2;
        let right = width - w - left;
        format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
    }
}

fn truncate_display(text: &str, max_width: usize) -> String {
    let mut out = String::new();
    let mut used = 0;
    for ch in text.chars() {
        let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
        if used + cw > max_width {
            break;
        }
        out.push(ch);
        used += cw;
    }
    out
}

pub fn themed_panel<'a>(theme: &Theme, title: Line<'a>) -> Block<'a> {
    dense_panel(theme, title)
}

pub fn dense_panel<'a>(theme: &Theme, title: Line<'a>) -> Block<'a> {
    Block::default()
        .title(title)
        .borders(Borders::TOP)
        .border_style(Style::default().fg(theme.panel_border))
        .style(Style::default().bg(theme.bg).fg(theme.text))
}

/// Bordered stats section with title (top + sides).
pub fn section_panel<'a>(theme: &Theme, title: Line<'a>) -> Block<'a> {
    Block::default()
        .title(title)
        .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
        .border_style(Style::default().fg(theme.panel_border))
        .style(Style::default().bg(theme.bg).fg(theme.text))
}

/// Bottom cap for the last section in a column.
pub fn section_panel_bottom<'a>(theme: &Theme) -> Block<'a> {
    Block::default()
        .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
        .border_style(Style::default().fg(theme.panel_border))
        .style(Style::default().bg(theme.bg).fg(theme.text))
}

pub fn timer_panel<'a>(theme: &Theme, title: Line<'a>, border: Color) -> Block<'a> {
    Block::default()
        .title(title)
        .borders(Borders::TOP)
        .border_style(Style::default().fg(border))
        .style(Style::default().bg(theme.bg).fg(theme.text))
}

pub fn task_status_color(theme: &Theme, status: TaskStatus) -> Color {
    match status {
        TaskStatus::Done => theme.success,
        TaskStatus::InProgress => theme.warning,
        TaskStatus::Pending => theme.dim,
    }
}

pub fn task_status_icon(icons: IconSet, status: TaskStatus) -> &'static str {
    match status {
        TaskStatus::Done => icons.task_done,
        TaskStatus::InProgress => icons.task_progress,
        TaskStatus::Pending => icons.task_todo,
    }
}

pub fn active_task_spans(app: &App, theme: &Theme) -> Option<Vec<Span<'static>>> {
    let id = app.active_task?;
    let task = app.data.tasks.iter().find(|t| t.id == id)?;
    let icons = app.icons;
    let status_color = task_status_color(theme, task.status);
    Some(vec![
        Span::styled(
            format!("{} ", icons.task_active),
            Style::default().fg(theme.accent),
        ),
        Span::styled(truncate(&task.title, 22), Style::default().fg(theme.text)),
        Span::styled(
            format!(
                "  {} {}",
                task_status_icon(icons, task.status),
                task.status.short_label()
            ),
            Style::default().fg(status_color),
        ),
        Span::styled(
            format!("  {}/{}m", task.actual_minutes, task.estimated_minutes),
            Style::default().fg(theme.success),
        ),
    ])
}

pub fn chip<'a>(icon: &str, text: String, fg: Color, bg: Color) -> Span<'a> {
    Span::styled(
        format!(" {icon} {text} "),
        Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
    )
}

pub fn truncate(s: &str, max: usize) -> String {
    let width = unicode_width::UnicodeWidthStr::width(s);
    if width <= max {
        return s.to_string();
    }
    let mut out = String::new();
    let mut w = 0;
    for ch in s.chars() {
        let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
        if w + cw + 1 > max {
            out.push('');
            break;
        }
        out.push(ch);
        w += cw;
    }
    out
}

pub fn centered_rect(
    percent_x: u16,
    percent_y: u16,
    r: ratatui::layout::Rect,
) -> ratatui::layout::Rect {
    let popup_layout = ratatui::layout::Layout::default()
        .direction(ratatui::layout::Direction::Vertical)
        .constraints([
            ratatui::layout::Constraint::Percentage((100 - percent_y) / 2),
            ratatui::layout::Constraint::Percentage(percent_y),
            ratatui::layout::Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);
    ratatui::layout::Layout::default()
        .direction(ratatui::layout::Direction::Horizontal)
        .constraints([
            ratatui::layout::Constraint::Percentage((100 - percent_x) / 2),
            ratatui::layout::Constraint::Percentage(percent_x),
            ratatui::layout::Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}