use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Wrap},
};
use super::super::app::App;
use crate::domain::repository::TaskRepository;
const NARROW_THRESHOLD: u16 = 100;
pub fn render<R: TaskRepository>(f: &mut Frame, area: Rect, app: &App<R>) {
if area.width < NARROW_THRESHOLD {
if app.detail.is_some() {
render_detail(f, area, app);
} else {
render_task_table(f, area, app);
}
} else {
let chunks = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
render_task_table(f, chunks[0], app);
render_detail(f, chunks[1], app);
}
}
fn render_task_table<R: TaskRepository>(f: &mut Frame, area: Rect, app: &App<R>) {
let header = Row::new(vec![
Cell::from("ID"),
Cell::from("Type"),
Cell::from("Title"),
Cell::from("Pri"),
Cell::from("Status"),
Cell::from("Claimed"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let filtered = app.filtered_tasks();
let filter_label = match app.status_filter {
Some(crate::domain::task::TaskStatus::Open) => " [filter: open] ",
Some(crate::domain::task::TaskStatus::InProgress) => " [filter: in-progress] ",
Some(crate::domain::task::TaskStatus::Done) => " [filter: done] ",
None => " Tasks ",
};
let rows: Vec<Row> = filtered
.iter()
.enumerate()
.map(|(i, task)| {
let style = if i == app.selected_index {
Style::default().bg(Color::DarkGray).fg(Color::White)
} else {
status_style(task.status)
};
let title = if task.title.len() > 30 {
let end = task.title.floor_char_boundary(27);
format!("{}...", &task.title[..end])
} else {
task.title.clone()
};
Row::new(vec![
Cell::from(task.id.clone()),
Cell::from(task.task_type.to_string()),
Cell::from(title),
Cell::from(task.priority.to_string()),
Cell::from(task.status.to_string()),
Cell::from(task.claimed_by.clone().unwrap_or_else(|| "-".into())),
])
.style(style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(8),
Constraint::Length(7),
Constraint::Min(20),
Constraint::Length(4),
Constraint::Length(12),
Constraint::Length(10),
],
)
.header(header)
.block(
Block::default()
.title(filter_label)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.row_highlight_style(Style::default().bg(Color::DarkGray));
let mut state = TableState::default();
state.select(Some(app.selected_index));
f.render_stateful_widget(table, area, &mut state);
}
fn render_detail<R: TaskRepository>(f: &mut Frame, area: Rect, app: &App<R>) {
let block = Block::default()
.title(" Detail ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
if let Some(ref detail) = app.detail {
let task = &detail.task;
let label = Style::default().fg(Color::Yellow);
let mut lines = vec![
Line::from(vec![Span::styled("ID: ", label), Span::raw(&task.id)]),
Line::from(vec![Span::styled("Title: ", label), Span::raw(&task.title)]),
Line::from(vec![
Span::styled("Type: ", label),
Span::raw(task.task_type.to_string()),
]),
Line::from(vec![
Span::styled("Priority: ", label),
Span::raw(task.priority.to_string()),
]),
Line::from(vec![
Span::styled("Status: ", label),
Span::styled(task.status.to_string(), status_style(task.status)),
]),
];
if let Some(ref parent) = task.parent {
lines.push(Line::from(vec![
Span::styled("Parent: ", label),
Span::raw(parent),
]));
}
if let Some(ref claimed) = task.claimed_by {
lines.push(Line::from(vec![
Span::styled("Claimed: ", label),
Span::raw(claimed),
]));
}
if !task.blocked_by.is_empty() {
lines.push(Line::from(vec![
Span::styled("Blocked by: ", label),
Span::raw(task.blocked_by.join(", ")),
]));
}
if let Some(ref created) = task.created_at {
lines.push(Line::from(vec![
Span::styled("Created: ", label),
Span::raw(crate::presentation::shared::fmt_timestamp(created)),
]));
}
if task.awaiting_approval == Some(true) {
lines.push(Line::from(vec![
Span::styled("Approval: ", label),
Span::styled("awaiting", Style::default().fg(Color::Yellow)),
]));
} else if task.approved == Some(true) {
let approved_text = if let Some(ref at) = task.approved_at {
format!(
"approved at {}",
crate::presentation::shared::fmt_timestamp(at)
)
} else {
"approved".to_string()
};
lines.push(Line::from(vec![
Span::styled("Approval: ", label),
Span::styled(approved_text, Style::default().fg(Color::Green)),
]));
}
if let Some(ref description) = task.description {
lines.push(Line::from(""));
lines.push(Line::styled(
"Description:",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
render_markdown(description, &mut lines);
}
if let Some(ref reason) = task.done_reason {
lines.push(Line::from(vec![
Span::styled("Reason: ", label),
Span::raw(reason),
]));
}
if let Some(ref done_at) = task.done_at {
lines.push(Line::from(vec![
Span::styled("Done at: ", label),
Span::raw(crate::presentation::shared::fmt_timestamp(done_at)),
]));
}
if !detail.timeline.is_empty() {
lines.push(Line::from(""));
lines.push(Line::styled(
"Timeline:",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
for entry in &detail.timeline {
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", entry.timestamp),
Style::default().fg(Color::DarkGray),
),
Span::raw(&entry.event_type),
]));
}
}
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
} else {
let hint = Paragraph::new("Press Enter to load task detail")
.style(Style::default().fg(Color::DarkGray))
.block(block);
f.render_widget(hint, area);
}
}
fn status_style(status: crate::domain::task::TaskStatus) -> Style {
crate::presentation::shared::status_style(status)
}
fn render_markdown<'a>(text: &'a str, lines: &mut Vec<Line<'a>>) {
let mut in_code_block = false;
for raw_line in text.lines() {
if raw_line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(raw_line, Style::default().fg(Color::Green)),
]));
continue;
}
let trimmed = raw_line.trim_start();
if let Some(rest) = trimmed.strip_prefix("### ") {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(rest, Style::default().fg(Color::Cyan)),
]));
} else if let Some(rest) = trimmed.strip_prefix("## ") {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
rest,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]));
} else if let Some(rest) = trimmed.strip_prefix("# ") {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
rest,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
),
]));
}
else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
let content = &trimmed[2..];
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("• ", Style::default().fg(Color::Cyan)),
Span::raw(content),
]));
}
else {
lines.push(Line::from(render_inline_markdown(raw_line)));
}
}
}
fn render_inline_markdown(text: &str) -> Vec<Span<'_>> {
let mut spans = vec![Span::raw(" ")];
let mut rest = text;
while !rest.is_empty() {
if let Some(start) = rest.find("**") {
if start > 0 {
spans.push(Span::raw(&rest[..start]));
}
let after = &rest[start + 2..];
if let Some(end) = after.find("**") {
spans.push(Span::styled(
&after[..end],
Style::default().add_modifier(Modifier::BOLD),
));
rest = &after[end + 2..];
} else {
spans.push(Span::raw(&rest[start..]));
break;
}
}
else if let Some(start) = rest.find('`') {
if start > 0 {
spans.push(Span::raw(&rest[..start]));
}
let after = &rest[start + 1..];
if let Some(end) = after.find('`') {
spans.push(Span::styled(
&after[..end],
Style::default().fg(Color::Green),
));
rest = &after[end + 1..];
} else {
spans.push(Span::raw(&rest[start..]));
break;
}
} else {
spans.push(Span::raw(rest));
break;
}
}
spans
}