use crate::model::{KanbanColumns, Ticket};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn draw_ui(frame: &mut Frame, columns: &KanbanColumns) {
let size = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)])
.split(size);
draw_kanban_board(frame, chunks[0], columns);
}
fn draw_kanban_board(frame: &mut Frame, area: Rect, columns: &KanbanColumns) {
draw_horizontal_lanes(frame, area, columns);
}
fn draw_horizontal_lanes(frame: &mut Frame, area: Rect, columns: &KanbanColumns) {
let mut active_lanes = Vec::new();
if !columns.todo.is_empty() {
active_lanes.push(("TO DO", &columns.todo, Color::Cyan));
}
if !columns.in_progress.is_empty() {
active_lanes.push(("IN PROGRESS", &columns.in_progress, Color::Yellow));
}
if !columns.review.is_empty() {
active_lanes.push(("REVIEW", &columns.review, Color::Magenta));
}
if !columns.done.is_empty() {
active_lanes.push(("DONE", &columns.done, Color::Green));
}
if active_lanes.is_empty() {
let message = Paragraph::new("No tickets found! 🎉")
.block(Block::default()
.borders(Borders::ALL)
.title("🦀 KANBARS"))
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(message, area);
return;
}
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(0), ])
.split(area);
let lane_count = active_lanes.len();
let lane_constraints: Vec<Constraint> = (0..lane_count)
.map(|_| Constraint::Ratio(1, lane_count as u32))
.collect();
let lane_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(lane_constraints)
.split(main_chunks[1]);
let title = Block::default()
.borders(Borders::BOTTOM)
.title("🦀 KANBARS - JIRA Board (press 'q' to quit)");
frame.render_widget(title, main_chunks[0]);
for (i, (title, tickets, color)) in active_lanes.iter().enumerate() {
draw_lane(frame, lane_chunks[i], tickets, title, *color);
}
}
fn draw_lane(frame: &mut Frame, area: Rect, tickets: &[Ticket], title: &str, color: Color) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(12), Constraint::Min(0), ])
.split(area);
let label = Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(color))
.title(title)
.title_style(Style::default().fg(color).add_modifier(Modifier::BOLD));
frame.render_widget(label, chunks[0]);
let mut lines: Vec<Line> = Vec::new();
let content_width = chunks[1].width as usize;
for (i, ticket) in tickets.iter().enumerate() {
if i > 0 && lines.len() < area.height as usize - 1 {
lines.push(Line::from(""));
}
let emoji = ticket.ticket_type.emoji();
let key = &ticket.key;
let summary = &ticket.summary;
let assignee = ticket.assignee
.split('@')
.next()
.unwrap_or(&ticket.assignee)
.trim();
let prefix = if !assignee.is_empty() && assignee != "unassigned" {
format!("{} {} @{} ", emoji, key, assignee)
} else {
format!("{} {} ", emoji, key)
};
let prefix_len = prefix.len() + 3;
let available_for_summary = content_width.saturating_sub(prefix_len);
let mut main_line_spans = vec![
Span::raw(format!("{} ", emoji)),
Span::styled(key.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
];
if !assignee.is_empty() && assignee != "unassigned" {
main_line_spans.push(Span::styled(
format!(" @{}", assignee),
Style::default().fg(Color::Blue),
));
}
main_line_spans.push(Span::styled(" • ", Style::default().fg(Color::DarkGray)));
if summary.len() <= available_for_summary {
main_line_spans.push(Span::raw(summary.clone()));
lines.push(Line::from(main_line_spans));
} else {
let words: Vec<&str> = summary.split_whitespace().collect();
let mut first_line = String::new();
let mut second_line = String::new();
let mut current_len = 0;
for word in &words {
if current_len + word.len() + 1 <= available_for_summary {
if !first_line.is_empty() {
first_line.push(' ');
current_len += 1;
}
first_line.push_str(word);
current_len += word.len();
} else if second_line.is_empty() || second_line.len() + word.len() + 1 <= content_width - 4 {
if !second_line.is_empty() {
second_line.push(' ');
}
second_line.push_str(word);
}
}
main_line_spans.push(Span::raw(first_line));
lines.push(Line::from(main_line_spans));
if !second_line.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "), Span::styled(second_line, Style::default().fg(Color::Gray)),
]));
}
}
if lines.len() >= area.height as usize - 1 {
break;
}
}
if tickets.len() > tickets.iter().take_while(|_| lines.len() < area.height as usize - 1).count() {
let remaining = tickets.len() - tickets.iter().take_while(|_| lines.len() < area.height as usize - 1).count();
lines.push(Line::from(Span::styled(
format!(" ...and {} more", remaining),
Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
)));
}
let content = Paragraph::new(lines)
.block(Block::default().borders(Borders::NONE))
.style(Style::default());
frame.render_widget(content, chunks[1]);
}