void-focus 0.3.0-alpha.3

A feature-rich terminal focus timer with task tracking
Documentation
use super::*;

pub(crate) fn draw_tasks(f: &mut Frame, app: &mut App, area: Rect) {
    let theme = &app.theme;
    let icons = app.icons;
    let chunks = Layout::default()
        .direction(Direction::Horizontal)
        .margin(1)
        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
        .split(area);

    let indices = app.filtered_task_indices();
    let filtered_count = indices.len();
    let total_count = app.data.tasks.len();
    let selected_idx = app.task_state.selected();
    let title_max = chunks[0].width.saturating_sub(22) as usize;
    let items: Vec<ListItem> = indices
        .iter()
        .enumerate()
        .map(|(list_idx, &idx)| {
            let t = &app.data.tasks[idx];
            let marker = task_status_icon(icons, t.status);
            let prio_color = match t.priority {
                crate::model::Priority::High => theme.warning,
                crate::model::Priority::Medium => theme.info,
                crate::model::Priority::Low => theme.dim,
            };
            let is_active = app.active_task == Some(t.id);
            let is_cursor = selected_idx == Some(list_idx);
            let style = if is_active && !is_cursor {
                Style::default()
                    .bg(theme.active_bg)
                    .fg(theme.active_fg)
                    .add_modifier(Modifier::BOLD)
            } else if t.is_overdue() && !is_cursor {
                Style::default().fg(theme.error)
            } else {
                Style::default().fg(theme.text)
            };
            let overdue_mark = if t.is_overdue() { icons.alert } else { " " };
            let today_mark = if t.today { icons.star } else { " " };
            let active_mark = if is_active { icons.task_active } else { " " };
            let active_style = if is_active {
                Style::default()
                    .fg(theme.accent)
                    .add_modifier(Modifier::BOLD)
            } else {
                style
            };
            let tags_label = if t.tags.is_empty() {
                String::new()
            } else {
                format!(" #{}", truncate(&t.tags.join(", "), 12))
            };
            let mut spans = vec![
                Span::styled(
                    format!("{}{}{}{} ", active_mark, overdue_mark, today_mark, marker),
                    if is_active && is_cursor {
                        Style::default()
                            .fg(theme.accent)
                            .add_modifier(Modifier::BOLD)
                    } else if is_active {
                        active_style
                    } else {
                        style
                    },
                ),
                Span::styled(
                    format!("{:<3} ", t.priority.label()),
                    Style::default().fg(prio_color),
                ),
                Span::styled(format!("{} ", truncate(&t.title, title_max.max(8))), style),
                Span::styled(
                    format!("{:>3}/{:<3}m", t.actual_minutes, t.estimated_minutes),
                    Style::default().fg(theme.dim),
                ),
            ];
            if !tags_label.is_empty() {
                spans.push(Span::styled(tags_label, Style::default().fg(theme.info)));
            }
            ListItem::new(Line::from(spans))
        })
        .collect();

    let filter_label = if app.task_search.is_empty() {
        app.task_filter.label().to_string()
    } else {
        format!("'{}'", app.task_search)
    };

    let visible_height = chunks[0].height.saturating_sub(2) as usize;
    let has_overflow = filtered_count > visible_height;
    let at_bottom = app
        .task_state
        .selected()
        .map(|sel| sel + 1 >= filtered_count)
        .unwrap_or(true);
    let more_indicator = if has_overflow && !at_bottom {
        " ↓ more "
    } else {
        ""
    };

    let block = themed_panel(
        theme,
        Line::from(vec![
            Span::styled(
                format!(
                    " {} Tasks [{}] ({}/{}) ",
                    icons.tasks, filter_label, filtered_count, total_count
                ),
                Style::default()
                    .fg(theme.accent)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(more_indicator, Style::default().fg(theme.dim)),
        ]),
    );
    let list = List::new(items)
        .block(block)
        .highlight_style(
            Style::default()
                .bg(theme.select_bg)
                .fg(theme.select_fg)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("");
    f.render_stateful_widget(list, chunks[0], &mut app.task_state);

    let detail_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(0)])
        .split(chunks[1]);
    let progress_ratio = app
        .task_state
        .selected()
        .and_then(|sel| indices.get(sel).copied())
        .map(|idx| app.data.tasks[idx].progress_ratio())
        .unwrap_or(0.0);
    f.render_widget(
        Gauge::default()
            .gauge_style(Style::default().fg(theme.accent).bg(theme.dim))
            .ratio(progress_ratio)
            .label(format!("Progress {}%", (progress_ratio * 100.0) as u32))
            .block(themed_panel(
                theme,
                Line::from(Span::styled(
                    " Progress ",
                    Style::default().fg(theme.accent),
                )),
            )),
        detail_layout[0],
    );
    let detail = build_task_detail(app);
    let detail_block = themed_panel(
        theme,
        Line::from(Span::styled(" Details ", Style::default().fg(theme.accent))),
    );
    f.render_widget(
        Paragraph::new(detail)
            .block(detail_block)
            .wrap(Wrap { trim: false }),
        detail_layout[1],
    );

    if app.searching {
        let search_area = centered_rect(50, 20, area);
        f.render_widget(Clear, search_area);
        f.render_widget(
            Paragraph::new(vec![
                Line::from(Span::styled(
                    "Search tasks (title, notes, tags)",
                    Style::default()
                        .fg(theme.accent)
                        .add_modifier(Modifier::BOLD),
                )),
                Line::from(format!("{}|", app.task_search)),
                Line::from(Span::styled(
                    "Enter confirm · Esc cancel",
                    Style::default().fg(theme.dim),
                )),
            ])
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_type(BorderType::Rounded)
                    .border_style(Style::default().fg(theme.accent)),
            ),
            search_area,
        );
    }
}

pub(crate) fn build_task_detail(app: &App) -> Vec<Line<'_>> {
    let theme = &app.theme;
    let indices = app.filtered_task_indices();
    if indices.is_empty() {
        let msg = match app.task_filter {
            TaskFilter::All => "No tasks yet. Press 'a' to add one.",
            TaskFilter::Pending => "All tasks done! Great work.",
            TaskFilter::Done => "No completed tasks yet.",
            TaskFilter::Today => "Nothing queued for today. Press 't' to tag tasks.",
        };
        return vec![Line::from(Span::styled(
            msg,
            Style::default().fg(theme.dim),
        ))];
    }
    let sel = app
        .task_state
        .selected()
        .unwrap_or(0)
        .min(indices.len() - 1);
    let t = &app.data.tasks[indices[sel]];
    let mut lines = Vec::new();
    if t.is_overdue() {
        lines.push(Line::from(Span::styled(
            "OVERDUE",
            Style::default()
                .fg(theme.error)
                .add_modifier(Modifier::BOLD),
        )));
        lines.push(Line::from(""));
    }
    let status_color = match t.status {
        crate::model::TaskStatus::Done => theme.success,
        crate::model::TaskStatus::InProgress => theme.warning,
        crate::model::TaskStatus::Pending => theme.dim,
    };
    lines.push(Line::from(Span::styled(
        t.title.clone(),
        Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
    )));
    lines.extend(vec![
        Line::from(""),
        Line::from(vec![
            Span::styled("ID:        ", Style::default().fg(theme.dim)),
            Span::styled(format!("{}", t.id), Style::default().fg(theme.text)),
        ]),
        Line::from(vec![
            Span::styled("Priority:  ", Style::default().fg(theme.dim)),
            Span::styled(t.priority.label(), Style::default().fg(theme.warning)),
        ]),
        Line::from(vec![
            Span::styled("Status:    ", Style::default().fg(theme.dim)),
            Span::styled(t.status.label(), Style::default().fg(status_color)),
        ]),
        Line::from(vec![
            Span::styled("Estimate:  ", Style::default().fg(theme.dim)),
            Span::styled(
                format_minutes(t.estimated_minutes),
                Style::default().fg(theme.text),
            ),
        ]),
        Line::from(vec![
            Span::styled("Logged:    ", Style::default().fg(theme.dim)),
            Span::styled(
                format!(
                    "{} across {} sessions",
                    format_minutes(t.actual_minutes),
                    t.sessions
                ),
                Style::default().fg(theme.success),
            ),
        ]),
        Line::from(vec![
            Span::styled("Remaining: ", Style::default().fg(theme.dim)),
            Span::styled(
                format!(
                    "~{} sessions ({}m each)",
                    crate::storage::sessions_remaining_hint(t, app.data.focus_minutes),
                    app.data.focus_minutes
                ),
                Style::default().fg(theme.info),
            ),
        ]),
        Line::from(vec![
            Span::styled("Today:     ", Style::default().fg(theme.dim)),
            Span::styled(
                if t.today { "yes" } else { "no" },
                Style::default().fg(if t.today { theme.success } else { theme.dim }),
            ),
        ]),
        Line::from(vec![
            Span::styled("Created:   ", Style::default().fg(theme.dim)),
            Span::styled(
                t.created_at.format("%Y-%m-%d %H:%M").to_string(),
                Style::default().fg(theme.text),
            ),
        ]),
    ]);
    if let Some(c) = t.completed_at {
        lines.push(Line::from(vec![
            Span::styled("Done:      ", Style::default().fg(theme.dim)),
            Span::styled(
                c.format("%Y-%m-%d %H:%M").to_string(),
                Style::default().fg(theme.success),
            ),
        ]));
    }
    if let Some(ref due) = t.due_date {
        let overdue = t.is_overdue();
        lines.push(Line::from(vec![
            Span::styled("Due:       ", Style::default().fg(theme.dim)),
            Span::styled(
                due.clone(),
                Style::default().fg(if overdue { theme.error } else { theme.text }),
            ),
        ]));
    }
    if !t.tags.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("Tags:      ", Style::default().fg(theme.dim)),
            Span::styled(t.tags.join(", "), Style::default().fg(theme.info)),
        ]));
    }
    if !t.notes.is_empty() {
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            "Notes:",
            Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
        )));
        for l in t.notes.lines() {
            lines.push(Line::from(Span::styled(
                l.to_string(),
                Style::default().fg(theme.text),
            )));
        }
    }
    if app.active_task == Some(t.id) {
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            format!("{} ACTIVE — press [f] to focus", app.icons.focus),
            Style::default()
                .fg(theme.accent)
                .add_modifier(Modifier::BOLD),
        )));
    }
    lines
}