linear-tui 0.4.0

A TUI client for Linear.app — manage issues, projects, and cycles from your terminal
//! A team's projects: status, health, progress, lead, and target date.

use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::Paragraph,
};

use super::widgets::{fit, progress_bar, row, short_date, truncate, user_name};
use crate::api::types::{Project, hex_color};
use crate::app::App;
use crate::config::Theme;

pub fn project_state_color(state: Option<&str>) -> Color {
    match state {
        Some("started") => Color::Yellow,
        Some("planned") => Color::Blue,
        Some("completed") => Color::Green,
        Some("cancelled") | Some("canceled") => Color::DarkGray,
        Some("paused") => Color::Magenta,
        Some("backlog") => Color::DarkGray,
        _ => Color::White,
    }
}

/// Linear's project health, as a coloured word.
pub fn health(project: &Project, th: &Theme) -> Span<'static> {
    match project.health.as_deref() {
        Some("onTrack") => Span::styled("On track", Style::default().fg(th.success)),
        Some("atRisk") => Span::styled("At risk", Style::default().fg(th.warning)),
        Some("offTrack") => Span::styled("Off track", Style::default().fg(th.error)),
        _ => Span::styled("", Style::default()),
    }
}

pub fn draw(f: &mut Frame, app: &mut App, area: Rect) {
    let th = app.theme;
    let width = area.width as usize;

    let header = Line::from(Span::styled(
        format!(
            " {}{}",
            fit("Name", width.saturating_sub(60)),
            "  Health       Progress         Lead         Target"
        ),
        Style::default().fg(th.muted),
    ));
    f.render_widget(Paragraph::new(header), Rect { height: 1, ..area });

    let list = Rect {
        y: area.y + 2,
        height: area.height.saturating_sub(2),
        ..area
    };
    app.list_area = list;
    app.list_viewport = list.height;

    if app.projects.is_empty() {
        app.row_targets.clear();
        let message = if app.loading() {
            format!("{} Loading projects\u{2026}", app.spinner_symbol())
        } else {
            "No projects in this team".to_string()
        };
        f.render_widget(
            Paragraph::new(Span::styled(message, Style::default().fg(th.muted)))
                .alignment(ratatui::layout::Alignment::Center),
            Rect {
                y: list.y + list.height / 3,
                height: 1,
                ..list
            },
        );
        return;
    }

    let height = list.height as usize;
    let mut offset = app.tables.projects.offset();
    let sel = app.selected_project_index;
    if sel < offset {
        offset = sel;
    } else if height > 0 && sel >= offset + height {
        offset = sel + 1 - height;
    }
    *app.tables.projects.offset_mut() = offset;

    let lines: Vec<Line> = app
        .projects
        .iter()
        .enumerate()
        .skip(offset)
        .take(height)
        .map(|(index, project)| project_row(project, width, index == sel, &th))
        .collect();
    app.row_targets = (offset..offset + lines.len()).map(Some).collect();
    f.render_widget(Paragraph::new(lines), list);
}

fn project_row(project: &Project, width: usize, selected: bool, th: &Theme) -> Line<'static> {
    let color = project
        .color
        .as_deref()
        .and_then(hex_color)
        .unwrap_or_else(|| project_state_color(project.state.as_deref()));
    let progress = project.progress.unwrap_or(0.0);
    let lead = project
        .lead
        .as_ref()
        .map(|u| user_name(u).to_string())
        .unwrap_or_else(|| "-".into());

    let mut right = vec![Span::raw("  ")];
    let mut h = health(project, th);
    h.content = fit(&h.content, 11).into();
    right.push(h);
    right.push(Span::raw("  "));
    right.extend(progress_bar(progress, 10, color, th));
    right.push(Span::styled(
        format!(" {:>3.0}%  ", progress * 100.0),
        Style::default().fg(th.text_dim),
    ));
    right.push(Span::styled(
        fit(&lead, 12),
        Style::default().fg(th.text_dim),
    ));
    right.push(Span::styled(
        format!(" {:>7} ", short_date(project.target_date.as_deref())),
        Style::default().fg(th.muted),
    ));

    let state = project.state.as_deref().unwrap_or("");
    let name_room = width.saturating_sub(62 + state.len());
    let left = vec![
        Span::styled("\u{25a3} ", Style::default().fg(color)),
        Span::styled(
            truncate(&project.name, name_room),
            Style::default().fg(th.text).add_modifier(if selected {
                Modifier::BOLD
            } else {
                Modifier::empty()
            }),
        ),
        Span::styled(
            format!("  {state}"),
            Style::default().fg(project_state_color(Some(state))),
        ),
    ];
    row(left, right, width, selected, th)
}