linear-tui 0.3.1

A TUI client for Linear.app — manage issues, projects, and cycles from your terminal
use ratatui::{
    Frame,
    layout::{Constraint, Flex, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, List, ListItem, ListState},
};

use crate::app::{App, FilterKind, Popup};
use crate::config::Theme;

pub fn draw(f: &mut Frame, app: &mut App) {
    match app.popup {
        Popup::TeamSelect => draw_team_select(f, app),
        Popup::Filter => draw_filter(f, app),
        Popup::StatusChange => draw_status_change(f, app),
        Popup::PriorityChange => draw_priority_change(f, app),
        Popup::AssigneeChange => draw_assignee_change(f, app),
        Popup::None => {}
    }
}

fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
    let vertical = Layout::vertical([Constraint::Length(height)])
        .flex(Flex::Center)
        .split(area);
    let horizontal = Layout::horizontal([Constraint::Length(width)])
        .flex(Flex::Center)
        .split(vertical[0]);
    horizontal[0]
}

fn render_popup_list(
    f: &mut Frame,
    title: &str,
    items: Vec<ListItem>,
    popup_index: usize,
    width: u16,
    th: &Theme,
) {
    let height = (items.len() as u16 + 2).min(20);
    let area = centered_rect(width, height, f.area());

    f.render_widget(Clear, area);
    let list = List::new(items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(title.to_string())
                .title_style(Style::default().fg(th.accent)),
        )
        .highlight_style(
            Style::default()
                .add_modifier(Modifier::REVERSED)
                .fg(th.accent),
        )
        .highlight_symbol("> ");

    let mut state = ListState::default();
    state.select(Some(popup_index));
    f.render_stateful_widget(list, area, &mut state);
}

fn numbered_item(index: usize, text: &str, is_current: bool, th: &Theme) -> ListItem<'static> {
    ListItem::new(Line::from(vec![
        Span::styled(format!("{}. ", index + 1), Style::default().fg(th.muted)),
        Span::raw(text.to_string()),
        if is_current {
            Span::styled(" *", Style::default().fg(th.success))
        } else {
            Span::raw("")
        },
    ]))
}

fn draw_team_select(f: &mut Frame, app: &mut App) {
    let th = &app.theme;
    let items: Vec<ListItem> = app
        .teams
        .iter()
        .enumerate()
        .map(|(i, team)| {
            numbered_item(
                i,
                &format!("{} [{}]", team.name, team.key),
                i == app.selected_team_index,
                th,
            )
        })
        .collect();
    render_popup_list(f, " Select Team ", items, app.popup_index, 40, th);
}

fn draw_filter(f: &mut Frame, app: &mut App) {
    let th = &app.theme;
    let (title, items) = match app.filter_kind {
        FilterKind::Status => {
            let mut items = vec![numbered_item(0, "All", app.filters.status.is_none(), th)];
            for (i, state) in app.workflow_states.iter().enumerate() {
                let is_current = app
                    .filters
                    .status
                    .as_ref()
                    .is_some_and(|s| s == &state.name);
                items.push(numbered_item(i + 1, &state.name, is_current, th));
            }
            (" Filter: Status ", items)
        }
        FilterKind::Priority => {
            let labels = ["All", "Urgent", "High", "Medium", "Low", "None"];
            let items: Vec<ListItem> = labels
                .iter()
                .enumerate()
                .map(|(i, label)| {
                    let is_current = if i == 0 {
                        app.filters.priority.is_none()
                    } else {
                        app.filters.priority == Some(crate::api::types::Priority::from_index(i))
                    };
                    numbered_item(i, label, is_current, th)
                })
                .collect();
            (" Filter: Priority ", items)
        }
    };
    render_popup_list(f, title, items, app.popup_index, 35, th);
}

fn draw_status_change(f: &mut Frame, app: &mut App) {
    let th = &app.theme;
    let current_state_id = app
        .focused_issue()
        .and_then(|i| i.state.as_ref())
        .map(|s| s.id.as_str());

    let items: Vec<ListItem> = app
        .workflow_states
        .iter()
        .enumerate()
        .map(|(i, state)| {
            numbered_item(
                i,
                &state.name,
                current_state_id == Some(state.id.as_str()),
                th,
            )
        })
        .collect();
    render_popup_list(f, " Change Status ", items, app.popup_index, 35, th);
}

fn draw_priority_change(f: &mut Frame, app: &mut App) {
    use crate::api::types::Priority;
    let th = &app.theme;
    let current_pri = app.focused_issue().map(|i| i.priority);
    let priorities = [
        Priority::None,
        Priority::Urgent,
        Priority::High,
        Priority::Medium,
        Priority::Low,
    ];
    let items: Vec<ListItem> = priorities
        .iter()
        .enumerate()
        .map(|(i, pri)| numbered_item(i, pri.label(), current_pri == Some(*pri), th))
        .collect();
    render_popup_list(f, " Change Priority ", items, app.popup_index, 30, th);
}

fn draw_assignee_change(f: &mut Frame, app: &mut App) {
    let th = &app.theme;
    let current_assignee_id = app
        .focused_issue()
        .and_then(|i| i.assignee.as_ref())
        .map(|a| a.id.as_str());

    let mut items = vec![numbered_item(
        0,
        "Unassign",
        current_assignee_id.is_none(),
        th,
    )];
    for (i, member) in app.team_members.iter().enumerate() {
        let display = member
            .display_name
            .as_deref()
            .unwrap_or(member.name.as_str());
        items.push(numbered_item(
            i + 1,
            display,
            current_assignee_id == Some(member.id.as_str()),
            th,
        ));
    }
    render_popup_list(f, " Change Assignee ", items, app.popup_index, 40, th);
}