use ratatui::prelude::*;
use tsk_core::{Priority, Thread, ThreadState};
const W_ID: u16 = 6;
const W_STATE: u16 = 9;
const W_PRIO: u16 = 6;
const SLUG_FULL: u16 = 40;
pub struct ColWidths {
pub id: u16,
pub slug: u16,
pub state: u16,
pub prio: u16,
pub desc: u16,
}
impl ColWidths {
pub fn from_area(w: u16) -> Self {
let try5 = 2 + 4 + W_ID + W_STATE + W_PRIO;
let try4 = 2 + 3 + W_ID + W_STATE + W_PRIO;
let try3 = 2 + 2 + W_ID + W_STATE;
let try2 = 2 + 1 + W_ID;
if w > try5 + SLUG_FULL {
let desc = w - try5 - SLUG_FULL;
return Self {
id: W_ID,
slug: SLUG_FULL,
state: W_STATE,
prio: W_PRIO,
desc,
};
}
if w > try4 + SLUG_FULL {
return Self {
id: W_ID,
slug: SLUG_FULL,
state: W_STATE,
prio: W_PRIO,
desc: 0,
};
}
if w > try3 + SLUG_FULL {
return Self {
id: W_ID,
slug: SLUG_FULL,
state: W_STATE,
prio: 0,
desc: 0,
};
}
let slug = w.saturating_sub(try2).max(1);
Self {
id: W_ID,
slug,
state: 0,
prio: 0,
desc: 0,
}
}
pub fn active_widths(&self) -> Vec<u16> {
[self.id, self.slug, self.state, self.prio, self.desc]
.iter()
.copied()
.filter(|&w| w > 0)
.collect()
}
}
pub fn pad(s: &str, width: u16) -> String {
let w = width as usize;
let char_count = s.chars().count();
if char_count >= w {
s.chars().take(w).collect()
} else {
format!("{}{}", s, " ".repeat(w - char_count))
}
}
fn top_border(ws: &ColWidths) -> Line<'static> {
let inner: Vec<String> = ws
.active_widths()
.iter()
.map(|&w| "\u{2500}".repeat(w as usize))
.collect();
let line = format!("\u{250c}{}\u{2510}", inner.join("\u{252c}"));
Line::from(Span::raw(line))
}
fn bottom_border(ws: &ColWidths) -> Line<'static> {
let inner: Vec<String> = ws
.active_widths()
.iter()
.map(|&w| "\u{2500}".repeat(w as usize))
.collect();
let line = format!("\u{2514}{}\u{2518}", inner.join("\u{2534}"));
Line::from(Span::raw(line))
}
fn data_line<'a>(
id: &str,
slug: &str,
state: &str,
prio: &str,
desc: &str,
ws: &ColWidths,
style: Style,
) -> Line<'a> {
let mut cells: Vec<String> = vec![
format!(" {}", pad(id, ws.id.saturating_sub(1))),
format!(" {}", pad(slug, ws.slug.saturating_sub(1))),
];
if ws.state > 0 {
cells.push(format!(" {}", pad(state, ws.state.saturating_sub(1))));
}
if ws.prio > 0 {
cells.push(format!(" {}", pad(prio, ws.prio.saturating_sub(1))));
}
if ws.desc > 0 {
cells.push(format!(" {}", pad(desc, ws.desc.saturating_sub(1))));
}
let line = format!("\u{2502}{}\u{2502}", cells.join("\u{2502}"));
Line::from(Span::styled(line, style))
}
pub fn build_ordered_threads(threads: &[Thread]) -> Vec<&Thread> {
let mut ordered: Vec<&Thread> = Vec::new();
ordered.extend(threads.iter().filter(|t| t.state == ThreadState::Active));
let is_inactive =
|t: &&Thread| matches!(t.state, ThreadState::Paused | ThreadState::Waiting { .. });
let mut focus: Vec<&Thread> = threads
.iter()
.filter(|t| is_inactive(t) && matches!(t.priority, Priority::Incident | Priority::Priority))
.collect();
focus.sort_by_key(|t| match t.priority {
Priority::Incident => 0u8,
_ => 1,
});
ordered.extend(focus);
ordered.extend(
threads
.iter()
.filter(|t| is_inactive(t) && matches!(t.priority, Priority::Background)),
);
ordered
}
fn section_lines(
title: &str,
section_threads: &[&Thread],
ws: &ColWidths,
selected: usize,
start_idx: usize,
) -> Vec<Line<'static>> {
if section_threads.is_empty() {
return vec![];
}
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
format!("> {}", title),
Style::default().bold(),
)));
lines.push(top_border(ws));
for (i, t) in section_threads.iter().enumerate() {
let idx = start_idx + i;
let style = if idx == selected {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
Style::default()
};
lines.push(data_line(
&t.id_str(),
&t.slug,
&t.state.to_string(),
&t.priority.to_string(),
&t.description,
ws,
style,
));
}
lines.push(bottom_border(ws));
lines
}
pub fn count_rows(threads: &[Thread]) -> usize {
let is_inactive =
|t: &&Thread| matches!(t.state, ThreadState::Paused | ThreadState::Waiting { .. });
let active = threads
.iter()
.filter(|t| t.state == ThreadState::Active)
.count();
let focus = threads
.iter()
.filter(|t| is_inactive(t) && matches!(t.priority, Priority::Incident | Priority::Priority))
.count();
let bg = threads
.iter()
.filter(|t| is_inactive(t) && matches!(t.priority, Priority::Background))
.count();
let counts = [active, focus, bg];
let non_empty = counts.iter().filter(|&&n| n > 0).count();
let separators = non_empty.saturating_sub(1);
let mut rows = separators;
for n in counts {
if n > 0 {
rows += 1 + 1 + n + 1;
}
}
rows
}
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
pub fn render(
frame: &mut Frame,
threads: &[Thread],
error: Option<&str>,
scroll: usize,
selected: usize,
show_help: bool,
) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
let main_area = chunks[0];
let status_area = chunks[1];
if let Some(msg) = error {
frame.render_widget(Paragraph::new(msg), main_area);
} else {
let ws = ColWidths::from_area(main_area.width);
let ordered = build_ordered_threads(threads);
let active_threads: Vec<&Thread> = ordered
.iter()
.copied()
.filter(|t| t.state == ThreadState::Active)
.collect();
let focus_threads: Vec<&Thread> = ordered
.iter()
.copied()
.filter(|t| {
matches!(t.state, ThreadState::Paused | ThreadState::Waiting { .. })
&& matches!(t.priority, Priority::Incident | Priority::Priority)
})
.collect();
let bg_threads: Vec<&Thread> = ordered
.iter()
.copied()
.filter(|t| {
matches!(t.state, ThreadState::Paused | ThreadState::Waiting { .. })
&& matches!(t.priority, Priority::Background)
})
.collect();
let mut lines: Vec<Line> = Vec::new();
let mut idx_offset = 0;
let sections: Vec<(&str, &[&Thread])> = vec![
("Active", &active_threads),
("Priority & Incidents", &focus_threads),
("Background", &bg_threads),
];
for (title, section_threads) in §ions {
let s = section_lines(title, section_threads, &ws, selected, idx_offset);
if s.is_empty() {
continue;
}
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.extend(s);
idx_offset += section_threads.len();
}
frame.render_widget(Paragraph::new(lines).scroll((scroll as u16, 0)), main_area);
}
let active_thread = threads.iter().find(|t| t.state == ThreadState::Active);
let status_text = match active_thread {
Some(t) => format!(" #{} {} | ? help | gt tasks", t.id_str(), t.slug),
None => " (no active thread) | ? help | gt tasks".to_string(),
};
frame.render_widget(
Paragraph::new(status_text).style(Style::default().bg(Color::DarkGray)),
status_area,
);
if show_help {
render_help_popup(frame, area);
}
}
fn render_help_popup(frame: &mut Frame, area: Rect) {
const POPUP_W: u16 = 40;
const POPUP_H: u16 = 16;
let x = area.x + area.width.saturating_sub(POPUP_W) / 2;
let y = area.y + area.height.saturating_sub(POPUP_H) / 2;
let popup_area = Rect::new(x, y, POPUP_W.min(area.width), POPUP_H.min(area.height));
let key_style = Style::default().bold();
let bindings: &[(&str, &str)] = &[
("j / \u{2193}", "move down"),
("k / \u{2191}", "move up"),
("Enter", "view tasks for thread"),
("gt", "tasks for active thread"),
("ctrl-d", "page down"),
("ctrl-u", "page up"),
("gg", "jump to top"),
("G", "jump to bottom"),
("?", "toggle this help"),
("q / esc", "quit"),
];
let rows: Vec<Line> = bindings
.iter()
.map(|(key, desc)| {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{:<12}", key), key_style),
Span::raw(" "),
Span::styled(*desc, Style::default()),
])
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
frame.render_widget(Clear, popup_area);
frame.render_widget(Paragraph::new(rows).block(block), popup_area);
}