use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
use super::widgets::{
fit, initials, label_chip, person_color, priority_glyph, short_date, state_glyph, truncate,
user_name,
};
use crate::api::types::{Issue, hex_color};
use crate::app::{App, IssueSource, ListRow};
use crate::config::Theme;
use crate::grouping::{GroupBy, Preset};
pub fn draw(f: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(area);
draw_toolbar(f, app, chunks[0]);
draw_list(f, app, chunks[2]);
}
pub fn draw_toolbar(f: &mut Frame, app: &mut App, area: Rect) {
let th = app.theme;
let mut spans = vec![Span::raw(" ")];
let mut x = area.x + 1;
app.chip_areas.clear();
for preset in Preset::all() {
let label = format!(" {} ", preset.label());
let width = label.width() as u16;
let style = if *preset == app.preset() {
Style::default()
.fg(th.text)
.bg(th.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.muted)
};
app.chip_areas.push((
Rect {
x,
y: area.y,
width,
height: 1,
},
*preset,
));
spans.push(Span::styled(label, style));
spans.push(Span::raw(" "));
x += width + 1;
}
let count = app.visible_issues().len();
let mut right = vec![];
if app.filters.is_active() {
right.push(Span::styled(
format!("\u{25bc} {} ", app.filters.summary()),
Style::default().fg(th.secondary),
));
}
if !app.search.is_empty() {
right.push(Span::styled(
format!("/{} ", app.search.value),
Style::default().fg(th.warning),
));
}
right.push(Span::styled(
format!("{} ", app.group_by.label()),
Style::default().fg(th.text_dim),
));
right.push(Span::styled("\u{00b7} ", Style::default().fg(th.muted)));
right.push(Span::styled(
format!("{count} issues "),
Style::default().fg(th.text_dim),
));
let left_w: usize = spans.iter().map(|s| s.width()).sum();
let right_w: usize = right.iter().map(|s| s.width()).sum();
let pad = (area.width as usize).saturating_sub(left_w + right_w);
spans.push(Span::raw(" ".repeat(pad)));
spans.extend(right);
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub fn draw_list(f: &mut Frame, app: &mut App, area: Rect) {
let th = app.theme;
let rows = app.list_layout();
let selected = app.selected_index();
let selected_row = rows
.iter()
.position(|r| matches!(r, ListRow::Issue { ordinal, .. } if *ordinal == selected));
app.list_viewport = area.height;
app.list_area = area;
if rows.is_empty() {
app.list_rows.clear();
let message = if app.loading() {
format!("{} Loading issues\u{2026}", app.spinner_symbol())
} else if !app.search.is_empty() || app.filters.is_active() {
"No issues match the current filter".to_string()
} else if app.preset() != Preset::All {
format!(
"No {} issues \u{2014} Shift+Tab or g e shows all issues",
app.preset().label().to_lowercase()
)
} else {
"No issues".to_string()
};
let y = area.y + area.height / 3;
f.render_widget(
Paragraph::new(Line::from(Span::styled(
message,
Style::default().fg(th.muted),
)))
.alignment(ratatui::layout::Alignment::Center),
Rect {
y,
height: 1,
..area
},
);
return;
}
let height = area.height as usize;
let mut offset = app.active_table_state().offset();
if let Some(sel) = selected_row {
let want_top = if sel > 0 && matches!(rows[sel - 1], ListRow::Group { .. }) {
sel - 1
} else {
sel
};
if want_top < offset {
offset = want_top;
} else if height > 0 && sel >= offset + height {
offset = sel + 1 - height;
}
}
offset = offset.min(rows.len().saturating_sub(height));
*app.active_table_state().offset_mut() = offset;
let issues = app.visible_issues();
let id_width = issues
.iter()
.map(|i| i.identifier.width())
.max()
.unwrap_or(6)
.min(10);
let width = area.width as usize;
let columns = Columns {
project: app.issue_source() != IssueSource::Project && app.group_by != GroupBy::Project,
assignee: app.group_by != GroupBy::Assignee,
};
let lines: Vec<Line> = rows
.iter()
.enumerate()
.skip(offset)
.take(height)
.map(|(index, row)| match row {
ListRow::Group {
label,
color,
glyph,
count,
collapsed,
..
} => group_line(label, *color, glyph, *count, *collapsed, width, &th),
ListRow::Issue { ordinal, depth } => match issues.get(*ordinal) {
Some(issue) => issue_line(
issue,
*depth,
id_width,
width,
Some(index) == selected_row,
columns,
&th,
),
None => Line::from(""),
},
})
.collect();
app.list_rows = rows.into_iter().skip(offset).take(height).collect();
f.render_widget(Paragraph::new(lines), area);
}
fn group_line(
label: &str,
color: ratatui::style::Color,
glyph: &str,
count: usize,
collapsed: bool,
width: usize,
th: &Theme,
) -> Line<'static> {
let chevron = if collapsed { "\u{25b8}" } else { "\u{25be}" };
let spans = vec![
Span::styled(format!(" {chevron} "), Style::default().fg(th.muted)),
Span::styled(format!("{glyph} "), Style::default().fg(color)),
Span::styled(
label.to_string(),
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {count}"), Style::default().fg(th.muted)),
];
let used: usize = spans.iter().map(|s| s.width()).sum();
let mut spans = spans;
spans.push(Span::raw(" ".repeat(width.saturating_sub(used))));
for span in &mut spans {
span.style = span.style.bg(th.surface);
}
Line::from(spans)
}
#[derive(Debug, Clone, Copy)]
pub struct Columns {
pub project: bool,
pub assignee: bool,
}
pub fn issue_line(
issue: &Issue,
depth: u8,
id_width: usize,
width: usize,
selected: bool,
columns: Columns,
th: &Theme,
) -> Line<'static> {
let show_labels = width >= 110;
let show_project = width >= 90 && columns.project;
let show_estimate = width >= 80;
let show_date = width >= 64;
let show_assignee = width >= 44 && columns.assignee;
let mut right: Vec<Span<'static>> = Vec::new();
if show_labels && let Some(labels) = &issue.labels {
for label in labels.nodes.iter().take(2) {
let mut chip = label_chip(label, th);
if let Some(name) = chip.get_mut(1) {
name.content = format!(" {} ", truncate(&label.name, 14)).into();
}
right.extend(chip);
}
if labels.nodes.len() > 2 {
right.push(Span::styled(
format!("+{} ", labels.nodes.len() - 2),
Style::default().fg(th.muted),
));
}
}
if show_project && let Some(project) = &issue.project {
let dot = project
.color
.as_deref()
.and_then(hex_color)
.unwrap_or(th.secondary);
right.push(Span::styled("\u{25a3} ", Style::default().fg(dot)));
right.push(Span::styled(
format!("{} ", truncate(&project.name, 18)),
Style::default().fg(th.text_dim),
));
}
if show_estimate {
let est = issue
.estimate
.map(|e| {
if e.fract() == 0.0 {
format!("{e:.0}")
} else {
format!("{e}")
}
})
.unwrap_or_default();
right.push(Span::styled(
format!("{est:>2} "),
Style::default().fg(th.muted),
));
}
if show_assignee {
match &issue.assignee {
Some(user) => {
let name = user_name(user);
right.push(Span::styled(
initials(name),
Style::default()
.fg(ratatui::style::Color::Black)
.bg(person_color(name))
.add_modifier(Modifier::BOLD),
));
}
None => right.push(Span::styled("\u{25cc} ", Style::default().fg(th.muted))),
}
right.push(Span::raw(" "));
}
if show_date {
right.push(Span::styled(
format!("{:>6} ", short_date(issue.created_at.as_deref())),
Style::default().fg(th.muted),
));
}
let right_w: usize = right.iter().map(|s| s.width()).sum();
let marker = if selected {
Span::styled("\u{258c}", Style::default().fg(th.accent))
} else {
Span::raw(" ")
};
let nest = if depth > 0 {
Span::styled(
format!("{}\u{2570} ", " ".repeat(depth as usize - 1)),
Style::default().fg(th.border),
)
} else {
Span::raw("")
};
let mut spans = vec![
marker,
nest,
priority_glyph(issue.priority, th),
Span::raw(" "),
Span::styled(
fit(&issue.identifier, id_width),
Style::default().fg(th.muted),
),
Span::raw(" "),
state_glyph(issue.state.as_ref(), th),
Span::raw(" "),
];
let left_w: usize = spans.iter().map(|s| s.width()).sum();
let parent_hint = match (&issue.parent, depth) {
(Some(parent), 0) => format!(" \u{2190} {}", parent.identifier),
_ => String::new(),
};
let title_room = width.saturating_sub(left_w + right_w + 1);
let title = truncate(&issue.title, title_room.saturating_sub(parent_hint.width()));
let title_style = if selected {
Style::default().fg(th.text).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
let title_w = title.width();
spans.push(Span::styled(title, title_style));
let hint = truncate(&parent_hint, title_room.saturating_sub(title_w));
let hint_w = hint.width();
spans.push(Span::styled(hint, Style::default().fg(th.muted)));
spans.push(Span::raw(
" ".repeat(width.saturating_sub(left_w + title_w + hint_w + right_w)),
));
spans.extend(right);
if selected {
for span in &mut spans {
span.style = span.style.bg(th.selection_bg);
}
}
Line::from(spans)
}