xbp 10.46.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use super::app::{App, Mode, View};
use crate::commands::linear_cmd::priority_label;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;

pub fn draw(frame: &mut Frame, app: &App) {
    let area = frame.area();
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Min(5),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(area);

    draw_header(frame, chunks[0], app);

    match app.view {
        View::List => draw_list(frame, chunks[1], app),
        View::Detail => draw_detail(frame, chunks[1], app),
    }

    draw_status(frame, chunks[2], app);
    draw_footer(frame, chunks[3], app);

    if app.mode == Mode::Help {
        draw_help_modal(frame, area);
    }
    if app.comments_popup.is_some() {
        draw_comments_modal(frame, area, app);
    }
}

fn draw_header(frame: &mut Frame, area: Rect, app: &App) {
    let title = format!(" xbp linear  │  {}{} ", app.filter_chips(), app.status);
    let p = Paragraph::new(title).style(
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD),
    );
    frame.render_widget(p, area);
}

fn draw_list(frame: &mut Frame, area: Rect, app: &App) {
    let page = app.page_size().max(1);
    let end = (app.list_offset + page).min(app.visible.len());
    let mut items = Vec::new();
    for (row, vis_idx) in app.visible[app.list_offset..end].iter().enumerate() {
        let issue = &app.issues[*vis_idx];
        let abs = app.list_offset + row;
        let selected = abs == app.selected;
        let pri = priority_label(issue.priority);
        let state = issue.state_name.as_deref().unwrap_or("?");
        let assignee = issue.assignee_name.as_deref().unwrap_or("-");
        let line = Line::from(vec![
            Span::styled(
                format!("{:<8} ", issue.identifier),
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                format!("{:>6} ", pri),
                priority_style(issue.priority).add_modifier(Modifier::BOLD),
            ),
            Span::styled(format!("{:<12} ", truncate(state, 12)), state_style(state)),
            Span::styled(
                format!("{:<12} ", truncate(assignee, 12)),
                Style::default().fg(Color::Magenta),
            ),
            Span::raw(issue.title.clone()),
        ]);
        let style = if selected {
            Style::default()
                .bg(Color::DarkGray)
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default()
        };
        items.push(ListItem::new(line).style(style));
    }
    if items.is_empty() {
        items.push(ListItem::new(
            "No issues match filters / search.".to_string(),
        ));
    }
    let list = List::new(items).block(
        Block::default()
            .borders(Borders::ALL)
            .title(format!(
                " Issues {}/{} ",
                if app.visible.is_empty() {
                    0
                } else {
                    app.selected + 1
                },
                app.visible.len()
            ))
            .border_style(Style::default().fg(Color::Gray)),
    );
    frame.render_widget(list, area);
}

fn draw_detail(frame: &mut Frame, area: Rect, app: &App) {
    let Some(issue) = app.selected_issue() else {
        frame.render_widget(
            Paragraph::new("No issue selected.").block(Block::default().borders(Borders::ALL)),
            area,
        );
        return;
    };
    let labels = issue
        .labels
        .iter()
        .map(|(_, n)| n.as_str())
        .collect::<Vec<_>>()
        .join(", ");
    let body = format!(
        "{id}  ·  {state}  ·  pri {pri}  ·  {assignee}\n\
Team: {team}\n\
Labels: {labels}\n\
Updated: {updated}  Created: {created}\n\
URL: {url}\n\
\n\
{title}\n\
\n\
{desc}",
        id = issue.identifier,
        state = issue.state_name.as_deref().unwrap_or("?"),
        pri = priority_label(issue.priority),
        assignee = issue.assignee_name.as_deref().unwrap_or("unassigned"),
        team = issue
            .team_key
            .as_deref()
            .or(issue.team_name.as_deref())
            .unwrap_or("?"),
        labels = if labels.is_empty() { "" } else { &labels },
        updated = issue.updated_at.as_deref().unwrap_or("?"),
        created = issue.created_at.as_deref().unwrap_or("?"),
        url = issue.url.as_deref().unwrap_or(""),
        title = issue.title,
        desc = issue.description.as_deref().unwrap_or("(no description)"),
    );
    let p = Paragraph::new(body).wrap(Wrap { trim: false }).block(
        Block::default()
            .borders(Borders::ALL)
            .title(format!(" {} ", issue.identifier))
            .border_style(Style::default().fg(Color::Cyan)),
    );
    frame.render_widget(p, area);
}

fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
    let text = if let Some(err) = &app.error {
        Line::from(Span::styled(
            format!(" ERR: {err} "),
            Style::default().fg(Color::Black).bg(Color::Red),
        ))
    } else {
        Line::from(Span::raw(format!(" {} ", app.status)))
    };
    frame.render_widget(Paragraph::new(text), area);
}

fn priority_style(priority: i32) -> Style {
    match priority {
        1 => Style::default().fg(Color::Red),
        2 => Style::default().fg(Color::LightRed),
        3 => Style::default().fg(Color::Yellow),
        4 => Style::default().fg(Color::Blue),
        _ => Style::default().fg(Color::DarkGray),
    }
}

fn state_style(state: &str) -> Style {
    match state.to_ascii_lowercase().as_str() {
        "done" | "completed" | "complete" => Style::default().fg(Color::Green),
        "canceled" | "cancelled" | "duplicate" => Style::default().fg(Color::DarkGray),
        "started" | "in progress" | "in_progress" => Style::default().fg(Color::Yellow),
        "backlog" | "todo" | "unstarted" => Style::default().fg(Color::Blue),
        "triage" => Style::default().fg(Color::LightRed),
        _ => Style::default().fg(Color::White),
    }
}

fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
    let mode = match app.mode {
        Mode::Normal => "NORMAL",
        Mode::Search => "SEARCH",
        Mode::Command => "COMMAND",
        Mode::Help => "HELP",
        Mode::EditTitle => "EDIT TITLE",
        Mode::EditComment => "COMMENT",
    };
    let line = match app.mode {
        Mode::Search => format!("/{}_", app.input),
        Mode::Command => format!(":{}_", app.input),
        Mode::EditTitle => format!("title> {}_", app.input),
        Mode::EditComment => format!("comment> {}_", app.input),
        Mode::Normal | Mode::Help => {
            if app.view == View::List {
                format!(
                    "{mode}  j/k move  / search  :cmd  s sort  m me  Enter open  t status  p pri  a assign  e edit  c comment  r reload  q quit"
                )
            } else {
                format!("{mode}  b back  e edit  t status  p pri  a assign  c comment  r reload")
            }
        }
    };
    frame.render_widget(
        Paragraph::new(line).style(Style::default().fg(Color::DarkGray)),
        area,
    );
}

fn draw_help_modal(frame: &mut Frame, area: Rect) {
    let help = "\
Linear TUI keys\n\
\n\
Navigation:  j/k  ↑/↓   g/G top/bottom   Ctrl-d/u page\n\
Open:        Enter / o    Back: Esc / b / h\n\
Search:      / text   n/N next/prev match   Esc clear\n\
Command:     :me  :nome  :sort priority  :state started\n\
             :team KEY  :assignee me  :limit 100  :completed  :open  :q\n\
Filters:     m toggle me   s cycle sort   S reverse sort\n\
Mutations:   t cycle status  p cycle priority  a assign me\n\
             e edit title  c add comment  r reload\n\
\n\
Press Esc or ? to close.";
    let popup = centered_rect(82, 88, area);
    frame.render_widget(Clear, popup);
    let help = format!(
        "{help}\n\n\
Additional commands\n\
\n\
  :status Done          Set the selected issue status directly\n\
  :priority high        Set priority: urgent, high, medium, low, none\n\
  :comments             Open a readable comments popup\n\
  :undo                 Undo the last TUI mutation\n\
\n\
Additional keys\n\
\n\
  v                     View comments popup\n\
  Ctrl-z                Undo the last TUI mutation\n\
  Mouse wheel           Scroll the issue list"
    );
    frame.render_widget(
        Paragraph::new(help).wrap(Wrap { trim: false }).block(
            Block::default()
                .borders(Borders::ALL)
                .title(" Help ")
                .border_style(Style::default().fg(Color::Yellow)),
        ),
        popup,
    );
}

fn draw_comments_modal(frame: &mut Frame, area: Rect, app: &App) {
    let popup = centered_rect(82, 78, area);
    frame.render_widget(Clear, popup);
    let text = app.comments_popup.as_deref().unwrap_or("");
    frame.render_widget(
        Paragraph::new(text).wrap(Wrap { trim: false }).block(
            Block::default()
                .borders(Borders::ALL)
                .title(" Comments ")
                .border_style(Style::default().fg(Color::Cyan)),
        ),
        popup,
    );
}

fn centered_rect(percent_x: u16, percent_y: u16, area: 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(area);
    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]
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
    out.push('');
    out
}