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
}