use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Clear, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
};
use ratatui::Frame;
use crate::app::{App, Counts, Focus, HeaderZone, Mode};
use crate::theme::{
ACCENT, ACCENT_DIM, ACTIVE, BLOCKED, CODE, DIM, DONE, PLAIN, TODO,
};
use crate::icons::Icons;
use crate::dex::{self, age, local_time, Status, Task};
use crate::tree::{self, Progress};
const SHORTCUTS: &str =
" s start c done r rename e edit n new a sub d del f filter o sort , config ? help";
const METER_WIDTH: usize = 7;
const SEP: &str = " · ";
pub fn draw(frame: &mut Frame, app: &mut App, ic: &Icons) {
let [top, body, bottom] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(frame.area());
app.terminal_width = frame.area().width;
app.body_top = body.y;
app.body_bottom = body.y + body.height;
draw_header(frame, app, ic, top);
if app.single_pane() {
app.divider_x = 0;
match app.focus {
Focus::Tree => draw_tree(frame, app, ic, body),
Focus::Detail => draw_detail(frame, app, ic, body),
}
draw_status(frame, app, bottom);
draw_overlays(frame, app);
return;
}
let [left, right] =
Layout::horizontal([Constraint::Percentage(app.split_percent), Constraint::Fill(1)])
.areas(body);
app.divider_x = right.x;
draw_tree(frame, app, ic, left);
draw_detail(frame, app, ic, right);
draw_status(frame, app, bottom);
draw_overlays(frame, app);
}
fn draw_overlays(frame: &mut Frame, app: &App) {
match &app.mode {
Mode::Prompt(prompt) => draw_prompt(frame, prompt),
Mode::Confirm { message, .. } => draw_message(
frame,
"Delete task",
message,
"enter delete esc cancel",
BLOCKED,
),
Mode::ForceComplete { message, .. } => draw_message(
frame,
"Incomplete subtasks",
message,
"enter force esc cancel",
ACTIVE,
),
Mode::Error(e) => draw_message(frame, "dex error", e, "any key to dismiss", BLOCKED),
Mode::Help => draw_help(frame),
_ => {}
}
}
fn glyph(s: Status, ic: &Icons) -> &'static str {
match s {
Status::Completed => ic.done,
Status::InProgress => ic.active,
Status::Blocked => ic.blocked,
Status::Pending => ic.pending,
}
}
fn row_glyph(s: Status, ic: &Icons, frame: Option<usize>) -> &'static str {
match (s, frame) {
(Status::InProgress, Some(f)) if !ic.spin.is_empty() => ic.spin[f % ic.spin.len()],
_ => glyph(s, ic),
}
}
fn status_color(s: Status) -> Color {
match s {
Status::Completed => DONE,
Status::InProgress => ACTIVE,
Status::Blocked => BLOCKED,
Status::Pending => TODO,
}
}
fn status_style(s: Status) -> Style {
Style::default().fg(status_color(s))
}
#[derive(Debug, Clone, Copy)]
struct Bar {
done: usize,
active: usize,
partial: usize,
empty: usize,
}
impl Bar {
fn new(progress: Progress, width: usize, partials: bool) -> Bar {
let Progress {
done,
active,
total,
} = progress;
if total == 0 || done + active == 0 {
return Bar {
done: 0,
active: 0,
partial: 0,
empty: width,
};
}
let eighths = |n: usize| (n as f64 / total as f64 * width as f64 * 8.0).round() as usize;
let floor = (usize::from(done > 0) + usize::from(active > 0)) * 8;
let mut outer = eighths(done + active).clamp(floor, width * 8);
if !partials {
outer = (outer as f64 / 8.0).round() as usize * 8;
}
let whole = outer / 8;
let partial = outer % 8;
let done_cells = if done == 0 {
0
} else if active == 0 {
whole
} else {
let want = ((done as f64 / total as f64) * width as f64).round().max(1.0) as usize;
want.min(whole - 1)
};
Bar {
done: done_cells,
active: whole - done_cells,
partial,
empty: width - whole - usize::from(partial > 0),
}
}
}
fn cap(i: usize, width: usize) -> usize {
if i == 0 {
0
} else if i + 1 == width {
2
} else {
1
}
}
fn meter_spans(progress: Progress, ic: &Icons) -> Vec<Span<'static>> {
let mut spans = bar_spans(progress, ic, METER_WIDTH);
spans.push(Span::styled(
format!(" {}/{}", progress.done, progress.total),
Style::default().fg(DIM),
));
spans
}
fn bar_spans(progress: Progress, ic: &Icons, width: usize) -> Vec<Span<'static>> {
let m = &ic.meter;
let bar = Bar::new(progress, width, !m.partial.is_empty());
let run = |glyphs: [&'static str; 3], from: usize, len: usize| -> String {
(from..from + len).map(|i| glyphs[cap(i, width)]).collect()
};
let mut spans = Vec::new();
let mut at = 0;
if bar.done > 0 {
spans.push(Span::styled(
run(m.done, at, bar.done),
Style::default().fg(DONE),
));
at += bar.done;
}
if bar.active > 0 {
spans.push(Span::styled(
run(m.active, at, bar.active),
Style::default().fg(ACTIVE),
));
at += bar.active;
}
if bar.partial > 0 {
let fg = if bar.active > 0 { ACTIVE } else { DONE };
spans.push(Span::styled(m.partial[bar.partial - 1], Style::default().fg(fg)));
at += 1;
}
if bar.empty > 0 {
spans.push(Span::styled(
run(m.empty, at, bar.empty),
Style::default().fg(DIM),
));
}
spans
}
fn span_width(spans: &[Span]) -> usize {
spans.iter().map(|s| s.content.chars().count()).sum()
}
fn parts_width(parts: &[Vec<Span<'static>>]) -> usize {
parts.iter().map(|p| span_width(p)).sum::<usize>() + parts.len() * SEP.chars().count()
}
fn counts_floor(c: Counts, ic: &Icons) -> usize {
count_candidates(c, ic)
.iter()
.filter(|parts| !parts.is_empty())
.map(|parts| parts_width(parts))
.min()
.unwrap_or(0)
}
fn right_zones(right: &[Span], x0: u16, sort_label: &str) -> Vec<(u16, u16, HeaderZone)> {
let mut found: Vec<(u16, u16, HeaderZone)> = Vec::new();
let mut x = x0;
for span in right {
let w = span.content.chars().count() as u16;
if w > 0 {
let zone = if span.content == sort_label {
Some(HeaderZone::Sort)
} else if let Some(pane) = tab_zone(&span.content) {
Some(pane)
} else {
tree::Filter::MENU
.iter()
.find(|f| f.name() == span.content)
.map(|f| HeaderZone::Filter(*f))
};
if let Some(z) = zone {
found.push((x, x + w - 1, z));
}
}
x += w;
}
let filters = found
.iter()
.filter(|(_, _, z)| matches!(z, HeaderZone::Filter(_)))
.count();
if filters == 1 {
for entry in found.iter_mut() {
if matches!(entry.2, HeaderZone::Filter(_)) {
entry.2 = HeaderZone::FilterCycle;
}
}
}
found
}
fn tab_zone(content: &str) -> Option<HeaderZone> {
match content {
"[1]" | " 1 " => Some(HeaderZone::Pane(Focus::Tree)),
"[2]" | " 2 " => Some(HeaderZone::Pane(Focus::Detail)),
_ => None,
}
}
fn tab_spans(focus: Focus) -> Vec<Span<'static>> {
let mut out = vec![Span::raw(" ")];
for (n, f) in [(1, Focus::Tree), (2, Focus::Detail)] {
if f == focus {
out.push(Span::styled(
format!("[{n}]"),
Style::default().add_modifier(Modifier::BOLD),
));
} else {
out.push(Span::styled(format!(" {n} "), Style::default().fg(DIM)));
}
}
out
}
fn filter_name(f: tree::Filter, current: bool) -> Span<'static> {
if !current {
return Span::styled(f.name(), Style::default().fg(DIM));
}
let fg = match f {
tree::Filter::Pending => TODO,
tree::Filter::InProgress => ACTIVE,
tree::Filter::All => PLAIN,
};
Span::styled(f.name(), Style::default().fg(fg).add_modifier(Modifier::BOLD))
}
fn filter_menu(current: tree::Filter) -> Vec<Span<'static>> {
let dim = || Style::default().fg(DIM);
let mut spans = vec![Span::styled("[ ", dim())];
for (i, f) in tree::Filter::MENU.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(filter_name(*f, *f == current));
}
spans.push(Span::styled(" ]", dim()));
spans
}
fn icon_span(glyph: &str) -> Vec<Span<'static>> {
if glyph.is_empty() {
Vec::new()
} else {
vec![Span::styled(
format!("{glyph} "),
Style::default().fg(DIM),
)]
}
}
fn identity_store(store: &str, ic: &Icons) -> Vec<Span<'static>> {
[
vec![Span::raw(" ")],
icon_span(ic.project),
vec![Span::styled(store.to_string(), Style::default().fg(PLAIN))],
]
.concat()
}
fn header_identity(store: &str, ic: &Icons, room: usize) -> Vec<Span<'static>> {
let full = [
vec![Span::raw(" ")],
icon_span(ic.app),
vec![
Span::styled("dextui", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(SEP, Style::default().fg(DIM)),
],
icon_span(ic.project),
vec![Span::styled(store.to_string(), Style::default().fg(PLAIN))],
]
.concat();
for candidate in [full, identity_store(store, ic)] {
if span_width(&candidate) <= room {
return candidate;
}
}
let keep = room.saturating_sub(2); if keep == 0 {
return Vec::new();
}
let short: String = store.chars().take(keep).collect();
let text = if short.chars().count() < store.chars().count() {
format!("{short}…")
} else {
short
};
vec![
Span::raw(" "),
Span::styled(text, Style::default().fg(PLAIN)),
]
}
fn right_candidates(sort: &str, filter: tree::Filter) -> [Vec<Span<'static>>; 4] {
let dim = || Style::default().fg(DIM);
let with_sort = |rest: Vec<Span<'static>>| -> Vec<Span<'static>> {
[
vec![
Span::raw(" "),
Span::styled(sort.to_string(), dim()),
Span::raw(" "),
],
rest,
vec![Span::raw(" ")],
]
.concat()
};
[
with_sort(filter_menu(filter)),
with_sort(vec![filter_name(filter, true)]),
vec![Span::raw(" "), filter_name(filter, true), Span::raw(" ")],
Vec::new(),
]
}
fn header_sides(
store: &str,
ic: &Icons,
sort: &str,
filter: tree::Filter,
counts_floor: usize,
width: usize,
) -> (Vec<Span<'static>>, Vec<Span<'static>>) {
let full = header_identity(store, ic, usize::MAX);
let short = identity_store(store, ic);
let rights = right_candidates(sort, filter);
for (ident, right) in [
(&full, &rights[0]),
(&full, &rights[1]),
(&short, &rights[1]),
(&short, &rights[2]),
(&short, &rights[3]),
] {
if span_width(ident) + span_width(right) + counts_floor <= width {
return (ident.clone(), right.clone());
}
}
if span_width(&short) <= width {
return (short, Vec::new());
}
(header_identity(store, ic, width), Vec::new())
}
fn count_candidates(c: Counts, ic: &Icons) -> Vec<Vec<Vec<Span<'static>>>> {
const BAR: usize = 10;
let numbers = |worded: bool| -> Vec<Vec<Span<'static>>> {
let mut out: Vec<Vec<Span<'static>>> = Vec::new();
let mut push = |n: usize, word: &str, glyph: &'static str, fg: Color| {
let text = if worded {
format!("{n} {word}")
} else {
format!("{glyph} {n}")
};
out.push(vec![Span::styled(text, Style::default().fg(fg))]);
};
if c.active > 0 {
push(c.active, "active", ic.active, ACTIVE);
}
push(c.ready, "ready", ic.pending, TODO);
if c.blocked > 0 {
push(c.blocked, "blocked", ic.blocked, BLOCKED);
}
out
};
let pct = || -> Vec<Span<'static>> {
vec![Span::styled(
format!("{}%", c.percent),
Style::default().add_modifier(Modifier::BOLD),
)]
};
let bar = || -> Vec<Span<'static>> {
let mut s = bar_spans(
Progress {
done: c.completed,
active: c.active,
total: c.total,
},
ic,
BAR,
);
s.push(Span::raw(" "));
s.extend(pct());
s
};
vec![
[vec![bar()], numbers(true)].concat(),
[vec![pct()], numbers(true)].concat(),
numbers(true),
numbers(false),
vec![pct()],
vec![],
]
}
fn header_counts(c: Counts, room: usize, ic: &Icons) -> Vec<Vec<Span<'static>>> {
for parts in count_candidates(c, ic) {
if parts_width(&parts) <= room {
return parts;
}
}
Vec::new()
}
fn draw_header(frame: &mut Frame, app: &mut App, ic: &Icons, area: Rect) {
app.header_zones.clear();
if matches!(app.mode, Mode::Search) {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" search ", Style::default().fg(DIM)),
Span::styled(app.query.value.clone(), Style::default().fg(ACTIVE)),
])),
area,
);
frame.set_cursor_position(Position {
x: (area.x + 8 + app.query.cursor as u16).min(area.right().saturating_sub(1)),
y: area.y,
});
return;
}
let c = app.counts();
let sep = || Span::styled(SEP, Style::default().fg(DIM));
const IDENTITY_FLOOR: usize = 8;
let tabs = match app.single_pane() {
true => tab_spans(app.focus),
false => Vec::new(),
};
let tabs = if span_width(&tabs) + IDENTITY_FLOOR <= area.width as usize {
tabs
} else {
Vec::new()
};
let (mut spans, right) = header_sides(
&app.store_label,
ic,
app.sort.label(app.sort_reversed),
app.filter,
counts_floor(c, ic),
(area.width as usize).saturating_sub(span_width(&tabs)),
);
let right = [tabs, right].concat();
let [left_area, right_area] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(span_width(&right) as u16),
])
.areas(area);
let room = (left_area.width as usize).saturating_sub(span_width(&spans));
for part in header_counts(c, room, ic) {
spans.push(sep());
spans.extend(part);
}
app.header_zones = right_zones(&right, right_area.x, app.sort.label(app.sort_reversed));
frame.render_widget(Paragraph::new(Line::from(spans)), left_area);
frame.render_widget(Paragraph::new(Line::from(right)), right_area);
}
fn draw_tree(frame: &mut Frame, app: &mut App, ic: &Icons, area: Rect) {
let block = Block::bordered().border_style(Style::default().fg(if app.focus
== Focus::Tree
{
PLAIN
} else {
DIM
}));
let inner_width = area.width.saturating_sub(2) as usize;
let rows = tree::visible_rows(&app.tree, &app.expanded);
let selected = app.selected_row();
let accent = if app.focus == Focus::Tree {
ACCENT
} else {
ACCENT_DIM
};
let spin = app.is_animating().then_some(app.spin_frame);
let items: Vec<ListItem> = rows
.iter()
.enumerate()
.map(|(i, row)| {
let t = &row.node.task;
let is_selected = selected == Some(i);
let st = dex::status(t, &app.by_id);
let mut spans = vec![
if is_selected {
Span::styled(format!("{} ", ic.gutter), Style::default().fg(accent))
} else {
Span::raw(" ")
},
Span::styled(row.prefix.clone(), Style::default().fg(DIM)),
Span::styled(
format!("{} ", ic.marker(row.has_children, row.is_open)),
Style::default().fg(DIM),
),
Span::styled(
format!("{} ", row_glyph(st, ic, spin)),
status_style(st),
),
];
let mut name_style = if !row.node.is_match {
Style::default().fg(DIM)
} else if t.completed {
Style::default()
.fg(DIM)
.add_modifier(Modifier::CROSSED_OUT)
} else {
Style::default().fg(PLAIN)
};
if is_selected {
name_style = name_style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(t.name.clone(), name_style));
if st != Status::Blocked && dex::is_blocked(t, &app.by_id) {
spans.push(Span::styled(
format!(" {}", ic.blocked),
Style::default().fg(BLOCKED),
));
}
let trailing: Vec<Span> = match app.progress.get(&t.id) {
Some(progress) => meter_spans(*progress, ic),
None if t.is_in_progress() => match age(&t.started_at) {
Some(a) => vec![Span::styled(a, Style::default().fg(ACTIVE))],
None => vec![],
},
None => vec![],
};
if !trailing.is_empty() {
let used = span_width(&spans);
let tail = span_width(&trailing);
if used + tail + 2 <= inner_width {
spans.push(Span::raw(" ".repeat(inner_width - used - tail)));
spans.extend(trailing);
}
}
ListItem::new(Line::from(spans))
})
.collect();
let mut state = ListState::default().with_offset(app.tree_offset);
state.select(selected);
frame.render_stateful_widget(List::new(items).block(block), area, &mut state);
app.tree_offset = state.offset();
let visible = area.height.saturating_sub(2) as usize;
if rows.len() > visible {
let mut sb = ScrollbarState::new(rows.len()).position(app.selected_row().unwrap_or(0));
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_style(Style::default().fg(DIM))
.thumb_style(Style::default().fg(DIM)),
area,
&mut sb,
);
}
}
fn wrapped_height(line_widths: &[u16], width: u16, wrap: bool) -> u16 {
if !wrap || width == 0 {
return line_widths.len() as u16;
}
let rows: u16 = line_widths
.iter()
.map(|w| if *w == 0 { 1 } else { w.div_ceil(width) })
.sum();
rows.saturating_add(2)
}
fn draw_detail(frame: &mut Frame, app: &mut App, ic: &Icons, area: Rect) {
let focused = app.focus == Focus::Detail;
let block = Block::bordered()
.title(if app.wrap { "" } else { " no wrap " })
.title_style(Style::default().fg(DIM))
.border_style(Style::default().fg(if focused { PLAIN } else { DIM }));
let inner_w = area.width.saturating_sub(2);
let inner_h = area.height.saturating_sub(2);
let scroll = app.detail_scroll;
let wrap = app.wrap;
let Some(task) = app.selected_task().cloned() else {
let msg = if app.tasks.is_empty() {
"No tasks yet.\n\nPress n to create one."
} else {
"No tasks match the current filter.\n\nPress f to change it, or clear the search."
};
frame.render_widget(
Paragraph::new(msg)
.block(block)
.style(Style::default().fg(DIM))
.wrap(Wrap { trim: false }),
area,
);
app.detail_content_height = 0;
app.detail_viewport_height = inner_h;
return;
};
let content_h = {
let lines = detail_lines(&task, app, ic);
let widths: Vec<u16> = lines.iter().map(|l| l.width() as u16).collect();
let height = wrapped_height(&widths, inner_w, wrap);
let mut paragraph = Paragraph::new(lines).scroll(scroll);
if wrap {
paragraph = paragraph.wrap(Wrap { trim: false });
}
frame.render_widget(paragraph.block(block), area);
height
};
app.detail_content_height = content_h;
app.detail_viewport_height = inner_h;
if content_h > inner_h {
let mut sb = ScrollbarState::new(content_h.saturating_sub(inner_h) as usize)
.position(scroll.0 as usize);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_style(Style::default().fg(DIM))
.thumb_style(Style::default().fg(PLAIN)),
area,
&mut sb,
);
}
}
fn since(age: &str) -> String {
if age == "now" {
"just now".to_string()
} else {
format!("{age} ago")
}
}
fn detail_lines<'a>(t: &'a Task, app: &'a App, ic: &Icons) -> Vec<Line<'a>> {
let mut lines = vec![
Line::from(Span::styled(
t.name.clone(),
Style::default().fg(PLAIN).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"─".repeat(t.name.chars().count().clamp(8, 60)),
Style::default().fg(DIM),
)),
];
let st = dex::status(t, &app.by_id);
let mut summary = vec![Span::styled(
format!("{} {}", glyph(st, ic), st.label()),
Style::default().fg(status_color(st)),
)];
if t.is_in_progress()
&& let Some(a) = age(&t.started_at) {
summary.push(Span::styled(SEP, Style::default().fg(DIM)));
summary.push(Span::styled(
format!("started {}", since(&a)),
Style::default().fg(ACTIVE),
));
}
if let Some(took) = t.worked_duration() {
summary.push(Span::styled(SEP, Style::default().fg(DIM)));
summary.push(Span::styled(
format!("took {took}"),
Style::default().fg(DONE),
));
}
summary.push(Span::styled(SEP, Style::default().fg(DIM)));
summary.push(Span::styled(
format!("priority {}", t.priority),
Style::default().fg(DIM),
));
lines.push(Line::from(summary));
if let Some(progress) = app.progress.get(&t.id) {
lines.push(Line::from(""));
let mut row = meter_spans(*progress, ic);
row.push(Span::styled(
format!(
" subtask{} done",
if progress.total == 1 { "" } else { "s" }
),
Style::default().fg(DIM),
));
lines.push(Line::from(row));
}
lines.push(Line::from(""));
let mut field = |k: &str, v: String, style: Style| {
lines.push(Line::from(vec![
Span::styled(format!("{k:<10}"), Style::default().fg(DIM)),
Span::styled(v, style),
]));
};
field("id", t.id.clone(), Style::default().fg(DIM));
if let Some(parent) = t.parent_id.as_ref().and_then(|id| app.by_id.get(id)) {
field("parent", parent.name.clone(), Style::default().fg(PLAIN));
}
if dex::is_blocked(t, &app.by_id) {
let names: Vec<String> = t
.blocked_by
.iter()
.map(|id| {
app.by_id
.get(id)
.map(|b| b.name.clone())
.unwrap_or_else(|| id.clone())
})
.collect();
field("blocked", names.join(", "), Style::default().fg(BLOCKED));
}
if !t.blocks.is_empty() {
let names: Vec<String> = t
.blocks
.iter()
.map(|id| {
app.by_id
.get(id)
.map(|b| b.name.clone())
.unwrap_or_else(|| id.clone())
})
.collect();
field("blocks", names.join(", "), Style::default().fg(ACTIVE));
}
let stamp = |iso: &Option<String>| match age(iso) {
Some(a) => format!("{} ({})", local_time(iso), since(&a)),
None => local_time(iso),
};
field(
"created",
stamp(&t.created_at),
Style::default().fg(PLAIN),
);
if t.started_at.is_some() {
field("started", stamp(&t.started_at), Style::default().fg(ACTIVE));
}
if t.completed_at.is_some() {
field("done", stamp(&t.completed_at), Style::default().fg(DONE));
}
if t.has_distinct_update() {
field("updated", stamp(&t.updated_at), Style::default().fg(DIM));
}
if let Some(c) = t.commit() {
let mut parts = vec![Span::styled(
format!("{:<10}", "commit"),
Style::default().fg(DIM),
)];
parts.push(Span::styled(
c.short_sha().to_string(),
Style::default().fg(CODE).add_modifier(Modifier::BOLD),
));
if let Some(m) = c.message.as_ref().filter(|m| !m.trim().is_empty()) {
parts.push(Span::styled(
format!(" {m}"),
Style::default().fg(PLAIN),
));
}
if let Some(b) = c.branch.as_ref().filter(|b| !b.trim().is_empty()) {
parts.push(Span::styled(
format!(" ({b})"),
Style::default().fg(DIM),
));
}
lines.push(Line::from(parts));
}
if let Some(d) = t.description.as_ref().filter(|d| !d.trim().is_empty()) {
lines.push(Line::from(""));
lines.extend(markdown_lines(d));
}
if let Some(r) = t.result.as_ref().filter(|r| !r.trim().is_empty()) {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"result",
Style::default().fg(DIM),
)));
for line in r.lines() {
lines.push(Line::from(Span::styled(
line.to_string(),
Style::default().fg(DONE),
)));
}
}
lines
}
fn markdown_lines(text: &str) -> Vec<Line<'static>> {
crate::markdown::render(text)
}
fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
let (text, style) = if app.status.is_empty() {
(SHORTCUTS.to_string(), Style::default().fg(DIM))
} else {
(format!(" {}", app.status), Style::default().fg(ACTIVE))
};
frame.render_widget(Paragraph::new(text).style(style), area);
}
fn draw_prompt(frame: &mut Frame, prompt: &crate::app::Prompt) {
let area = centered(frame.area(), 70, 7);
frame.render_widget(Clear, area);
let block = Block::bordered()
.title(format!(" {} ", prompt.title))
.border_style(Style::default().fg(ACTIVE));
let inner = block.inner(area);
frame.render_widget(block, area);
let [label_area, input_area, hint_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
frame.render_widget(
Paragraph::new(prompt.label.clone()).style(Style::default().fg(DIM)),
label_area,
);
frame.render_widget(
Paragraph::new(prompt.input.value.clone()).style(Style::default().fg(PLAIN)),
input_area,
);
frame.render_widget(
Paragraph::new("enter confirm esc cancel").style(Style::default().fg(DIM)),
hint_area,
);
frame.set_cursor_position(Position {
x: (input_area.x + prompt.input.cursor as u16).min(input_area.right().saturating_sub(1)),
y: input_area.y,
});
}
fn draw_message(frame: &mut Frame, title: &str, body: &str, hint: &str, accent: Color) {
let area = centered(frame.area(), 66, 9);
frame.render_widget(Clear, area);
let block = Block::bordered()
.title(format!(" {title} "))
.border_style(Style::default().fg(accent));
let inner = block.inner(area);
frame.render_widget(block, area);
let [body_area, hint_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner);
frame.render_widget(
Paragraph::new(body.to_string())
.style(Style::default().fg(PLAIN))
.wrap(Wrap { trim: false }),
body_area,
);
frame.render_widget(
Paragraph::new(hint).style(Style::default().fg(DIM)),
hint_area,
);
}
const HELP: &str = "\
tab switch pane s start task
↑ ↓ j k move / scroll c complete (prompts for result)
→ ← h l expand / scroll r rename
g / G first / last e edit description in $EDITOR
w / z wrap / zoom n new top-level task
o / O sort / reverse a new subtask of selection
/ search d delete (with confirmation)
f cycle filter ^R refresh now
, edit config q quit
- / + collapse / expand all
Movement follows the focused pane, shown by its brighter border. Turn wrap
off (w) to scroll a wide table sideways -- wrapping removes the overflow
there would otherwise be to scroll to.
Zoom (z) shows one pane at a time, with [1] [2] tabs in the header -- press 1
or 2 to jump, or enter and left to cross over. Narrow terminals zoom on their
own below single_pane_below columns, which makes this usable on a phone.
Mouse: drag the divider to resize, wheel scrolls the pane under the pointer,
click selects. In the header, click a filter to switch to it, or the sort
label to cycle it -- right-click the sort to reverse. Hold Shift to select
text, as capture is enabled.
The view refreshes itself whenever the dex store changes, including when
another process or agent edits it. Your selection, expansion and any open
dialog are never disturbed.";
fn draw_help(frame: &mut Frame) {
let area = centered(frame.area(), 74, 16);
frame.render_widget(Clear, area);
let block = Block::bordered()
.title(" dextui ")
.border_style(Style::default().fg(ACTIVE));
let inner = block.inner(area);
frame.render_widget(block, area);
let [body, hint] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner);
frame.render_widget(
Paragraph::new(HELP).style(Style::default().fg(PLAIN)),
body,
);
frame.render_widget(
Paragraph::new("any key to dismiss").style(Style::default().fg(DIM)),
hint,
);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width.saturating_sub(2));
let h = height.min(area.height.saturating_sub(2));
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
pub fn selftest(app: &App) -> String {
use std::fmt::Write;
let mut out = String::new();
let ic = &crate::icons::UNICODE;
let c = app.counts();
let _ = writeln!(out, "label {}", app.store_label);
let _ = writeln!(
out,
"tasks {} ({} pending: {} active, {} ready, {} blocked; {}% complete)\n",
app.tasks.len(),
c.pending,
c.active,
c.ready,
c.blocked,
c.percent
);
for filter in [
tree::Filter::All,
tree::Filter::Pending,
tree::Filter::InProgress,
] {
let forest = tree::build(&app.tasks, "", filter, app.sort, app.sort_reversed);
let count = tree::flatten(&forest).len();
let _ = writeln!(out, "--- filter: {filter:?} ({count} visible) ---");
for node in &forest {
print_node(node, 0, app, ic, &mut out);
}
let _ = writeln!(out);
}
if let Some(first) = app.tasks.first() {
let _ = writeln!(out, "--- detail pane for {} ---", first.name);
for line in detail_lines(first, app, ic) {
let _ = writeln!(
out,
"{}",
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
);
}
}
out
}
fn print_node(node: &tree::Node, depth: usize, app: &App, ic: &Icons, out: &mut String) {
use std::fmt::Write;
let scaffold = if node.is_match { "" } else { " (scaffold)" };
let rollup = match app.progress.get(&node.task.id) {
Some(prog) => format!(" {}/{}", prog.done, prog.total),
None => String::new(),
};
let _ = writeln!(
out,
"{}{} {}{}{}",
" ".repeat(depth),
glyph(dex::status(&node.task, &app.by_id), ic),
node.task.name,
rollup,
scaffold
);
for c in &node.children {
print_node(c, depth + 1, app, ic, out);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dex::Task;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn task(id: &str, parent: Option<&str>, name: &str) -> Task {
Task {
id: id.into(),
parent_id: parent.map(str::to_string),
name: name.into(),
description: Some("a description".into()),
created_at: Some("2026-01-01T00:00:00Z".into()),
..Default::default()
}
}
fn render_tasks(tasks: Vec<Task>, w: u16, h: u16, ic: &Icons) -> Vec<String> {
let mut app = App::new(tasks, "demo".into(), crate::config::Config::default());
app.filter = tree::Filter::All;
app.rebuild();
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| draw(f, &mut app, ic)).unwrap();
let buf = terminal.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn the_trailing_blocked_marker_appears_only_when_the_glyph_cannot_say_it() {
let blocker = task("blocker", None, "Blocker");
let mut idle = task("idle", None, "Idle and blocked");
idle.blocked_by = vec!["blocker".into()];
let mut started = task("started", None, "Started but blocked");
started.blocked_by = vec!["blocker".into()];
started.started_at = Some("2026-01-01T00:00:00Z".into());
let ic = &crate::icons::UNICODE;
let rows = render_tasks(vec![blocker, idle, started], 100, 12, ic);
let row_for = |name: &str| -> String {
rows.iter()
.find(|r| r.contains(name))
.unwrap_or_else(|| panic!("no row for {name}:\n{}", rows.join("\n")))
.clone()
};
let idle_row = row_for("Idle and blocked");
assert_eq!(
idle_row.matches(ic.blocked).count(),
1,
"glyph already says blocked, so the marker should not repeat: {idle_row:?}"
);
let started_row = row_for("Started but blocked");
assert!(
started_row.contains(ic.spin[0]),
"a started task reads as in progress: {started_row:?}"
);
assert_eq!(
started_row.matches(ic.blocked).count(),
1,
"the glyph cannot say blocked here, so the marker must: {started_row:?}"
);
}
fn render(w: u16, h: u16, ic: &Icons) -> Vec<String> {
let mut app = App::new(
vec![
task("root", None, "Parent task"),
task("kid", Some("root"), "Child task"),
],
"demo".into(),
crate::config::Config::default(),
);
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal
.draw(|f| draw(f, &mut app, ic))
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn status_colours_match_the_dex_cli() {
assert_eq!(status_color(Status::Pending), Color::Yellow, "todo");
assert_eq!(status_color(Status::InProgress), Color::Blue, "in progress");
assert_eq!(status_color(Status::Completed), Color::Green, "done");
}
#[test]
fn every_theme_colour_adapts_to_the_terminal() {
for (name, c) in crate::theme::ALL {
let ok = matches!(
c,
Color::Reset
| Color::Black
| Color::Red
| Color::Green
| Color::Yellow
| Color::Blue
| Color::Magenta
| Color::Cyan
| Color::Gray
| Color::DarkGray
| Color::LightRed
| Color::LightGreen
| Color::LightYellow
| Color::LightBlue
| Color::LightMagenta
| Color::LightCyan
| Color::White
);
assert!(ok, "{name} is {c:?}: Indexed/Rgb cannot follow the theme");
}
}
#[test]
fn the_selection_accent_is_not_a_status_colour() {
use crate::theme::{ACCENT, ACCENT_DIM};
for (n, c) in [("ACCENT", ACCENT), ("ACCENT_DIM", ACCENT_DIM)] {
for (sn, s) in [
("TODO", TODO),
("ACTIVE", ACTIVE),
("DONE", DONE),
("BLOCKED", BLOCKED),
] {
assert_ne!(c, s, "{n} is the same colour as {sn}");
}
}
assert_ne!(ACCENT, ACCENT_DIM, "an unfocused pane must look different");
}
#[test]
fn a_frame_actually_draws_something() {
let rows = render(100, 20, &crate::icons::UNICODE);
let text = rows.join("\n");
assert!(
text.contains("Parent task"),
"nothing was drawn:\n{text}"
);
}
#[test]
fn the_header_shows_identity_context_and_counts() {
let rows = render(100, 20, &crate::icons::UNICODE);
assert!(rows[0].contains("dextui"), "header row: {:?}", rows[0]);
assert!(rows[0].contains("demo"), "header row: {:?}", rows[0]);
assert!(rows[0].contains("ready"), "header row: {:?}", rows[0]);
}
#[test]
fn the_shortcut_strip_and_the_help_dialog_agree() {
for (key, action) in [
("s", "start"),
("c", "done"),
("r", "rename"),
("e", "edit"),
("n", "new"),
("a", "sub"),
("d", "del"),
("f", "filter"),
("o", "sort"),
] {
assert!(
SHORTCUTS.contains(&format!("{key} {action}")),
"the strip does not advertise {key} for {action}: {SHORTCUTS}"
);
}
assert!(HELP.contains("- / + collapse / expand all"), "help: -/+");
assert!(HELP.contains("w / z"), "help: z zooms");
assert!(!HELP.contains("z Z"), "the old collapse keys are gone");
assert!(!SHORTCUTS.contains("E edit"), "`E` is gone: {SHORTCUTS}");
assert!(!HELP.contains("E edit"), "`E` is gone from the help");
assert!(
!HELP.contains("f cycle filter r refresh"),
"bare `r` no longer refreshes"
);
assert!(HELP.contains("r rename"), "help: r renames");
assert!(HELP.contains("e edit description"), "help: e edits");
assert!(HELP.contains("^R refresh now"), "help: Ctrl-R refreshes");
}
#[test]
fn the_pane_tabs_appear_only_when_a_pane_is_hidden() {
let zoomed = screen(60, 80, Focus::Tree);
assert!(zoomed.contains("[1]"), "no tabs in zoom mode: {zoomed}");
assert!(zoomed.contains(" 2 "), "no second tab: {zoomed}");
let split = screen(100, 80, Focus::Tree);
assert!(!split.contains("[1]"), "tabs drawn beside both panes: {split}");
}
#[test]
fn the_current_pane_is_the_marked_tab() {
let on_tree = screen(60, 80, Focus::Tree);
assert!(on_tree.contains("[1]"), "{on_tree}");
assert!(!on_tree.contains("[2]"), "two tabs marked at once: {on_tree}");
let on_detail = screen(60, 80, Focus::Detail);
assert!(on_detail.contains("[2]"), "{on_detail}");
assert!(!on_detail.contains("[1]"), "two tabs marked at once: {on_detail}");
}
#[test]
fn switching_tabs_does_not_move_anything_else() {
assert_eq!(
span_width(&tab_spans(Focus::Tree)),
span_width(&tab_spans(Focus::Detail))
);
}
#[test]
fn the_tabs_survive_a_terminal_too_narrow_for_anything_else() {
for w in [60u16, 50, 40, 30, 24] {
let s = screen(w, 80, Focus::Detail);
assert!(
s.contains("[2]"),
"{w} columns dropped the tabs, hiding the way back:\n{s}"
);
}
}
#[test]
fn the_tabs_yield_to_the_store_label_at_absurd_widths() {
for w in [4u16, 6, 8, 10] {
let s = screen(w, 80, Focus::Tree);
let head = s.lines().next().unwrap_or("");
assert!(
!head.contains("[1]") || head.trim().len() > 4,
"{w} columns: tabs took the whole row: {head:?}"
);
}
}
fn zones_for(w: u16, filter: tree::Filter, mode: Mode) -> Vec<(u16, u16, HeaderZone)> {
let mut app = App::new(
vec![task("root", None, "Parent task")],
"demo".into(),
crate::config::Config::default(),
);
app.filter = filter;
app.mode = mode;
app.rebuild();
let mut terminal = Terminal::new(TestBackend::new(w, 12)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
app.header_zones.clone()
}
#[test]
fn exactly_one_filter_is_marked_and_it_is_the_current_one() {
for current in tree::Filter::MENU {
let menu = filter_menu(current);
let marked: Vec<&str> = menu
.iter()
.filter(|s| s.style.add_modifier.contains(Modifier::BOLD))
.map(|s| s.content.as_ref())
.collect();
assert_eq!(marked, vec![current.name()], "current = {current:?}");
for f in tree::Filter::MENU {
if f == current {
continue;
}
let span = menu.iter().find(|s| s.content == f.name()).unwrap();
assert_eq!(span.style.fg, Some(DIM), "{f:?} should be dim");
}
}
assert_eq!(filter_name(tree::Filter::Pending, true).style.fg, Some(TODO));
assert_eq!(filter_name(tree::Filter::InProgress, true).style.fg, Some(ACTIVE));
assert_eq!(filter_name(tree::Filter::All, true).style.fg, Some(PLAIN));
}
#[test]
fn every_menu_word_is_clickable_where_it_is_drawn() {
let zones = zones_for(120, tree::Filter::Pending, Mode::Normal);
for f in tree::Filter::MENU {
let z = zones
.iter()
.find(|(_, _, z)| *z == HeaderZone::Filter(f))
.unwrap_or_else(|| panic!("no zone for {f:?} in {zones:?}"));
assert_eq!(
(z.1 - z.0 + 1) as usize,
f.name().chars().count(),
"zone for {f:?} is not the width of its word"
);
}
assert!(
zones.iter().any(|(_, _, z)| *z == HeaderZone::Sort),
"the sort label is clickable too: {zones:?}"
);
let mut spans: Vec<(u16, u16)> = zones.iter().map(|(a, b, _)| (*a, *b)).collect();
spans.sort();
for pair in spans.windows(2) {
assert!(pair[0].1 < pair[1].0, "zones overlap: {spans:?}");
}
}
#[test]
fn the_header_offers_nothing_to_click_while_searching() {
assert!(zones_for(120, tree::Filter::Pending, Mode::Search).is_empty());
}
#[test]
fn the_collapsed_filter_label_cycles_instead_of_picking() {
let zones = zones_for(46, tree::Filter::Pending, Mode::Normal);
assert!(
zones.iter().any(|(_, _, z)| *z == HeaderZone::FilterCycle),
"narrow header should offer a cycling zone: {zones:?}"
);
assert!(
!zones
.iter()
.any(|(_, _, z)| matches!(z, HeaderZone::Filter(_))),
"nothing to pick from when only one word is drawn: {zones:?}"
);
}
#[test]
fn the_counts_floor_is_the_narrowest_layout_that_still_says_something() {
let ic = &crate::icons::UNICODE;
let small = Counts {
total: 10,
completed: 4,
pending: 6,
active: 1,
blocked: 2,
ready: 3,
percent: 40,
};
let busy = Counts {
total: 4000,
completed: 1200,
pending: 2800,
active: 137,
blocked: 421,
ready: 2242,
percent: 30,
};
for c in [small, busy] {
let floor = counts_floor(c, ic);
assert!(floor > 0, "reserved nothing for {c:?}");
assert!(
!header_counts(c, floor, ic).is_empty(),
"floor {floor} draws nothing for {c:?}"
);
assert!(
header_counts(c, floor - 1, ic).is_empty(),
"floor {floor} is not the narrowest for {c:?}"
);
}
assert_eq!(
counts_floor(small, ic),
counts_floor(busy, ic),
"the floor must not grow with the store"
);
}
#[test]
fn the_header_counts_give_way_as_the_terminal_narrows() {
let c = Counts {
total: 10,
completed: 4,
pending: 6,
active: 1,
blocked: 2,
ready: 3,
percent: 40,
};
let ic = &crate::icons::UNICODE;
let width = parts_width;
let mut seen: Vec<usize> = Vec::new();
for room in (0..=60).rev() {
let parts = header_counts(c, room, ic);
let w = width(&parts);
assert!(w <= room, "room={room} produced {w} cells: {parts:?}");
seen.push(w);
}
assert!(seen[0] > 0, "nothing drawn even at 60 cells");
assert_eq!(*seen.last().unwrap(), 0, "something drawn at zero room");
assert!(
seen.windows(2).all(|w| w[0] >= w[1]),
"width must never grow as room shrinks: {seen:?}"
);
let widest: String = header_counts(c, 60, ic)
.iter()
.flatten()
.map(|s| s.content.to_string())
.collect();
assert!(widest.contains("40%"), "{widest:?}");
assert!(widest.contains("3 ready"), "{widest:?}");
assert!(widest.contains("2 blocked"), "{widest:?}");
}
#[test]
fn the_header_omits_states_with_nothing_in_them() {
let c = Counts {
total: 4,
completed: 1,
pending: 3,
active: 0,
blocked: 0,
ready: 3,
percent: 25,
};
let text: String = header_counts(c, 60, &crate::icons::UNICODE)
.iter()
.flatten()
.map(|s| s.content.to_string())
.collect();
assert!(text.contains("3 ready"), "{text:?}");
assert!(!text.contains("active"), "nothing is active: {text:?}");
assert!(!text.contains("blocked"), "nothing is blocked: {text:?}");
}
fn render_header(store: &str, w: u16, ic: &Icons) -> String {
let mut app = App::new(
vec![task("root", None, "Parent task")],
store.into(),
crate::config::Config::default(),
);
app.single_pane_below = 0;
let mut terminal = Terminal::new(TestBackend::new(w, 8)).unwrap();
terminal.draw(|f| draw(f, &mut app, ic)).unwrap();
let buf = terminal.backend().buffer().clone();
(0..buf.area.width)
.map(|x| buf[(x, 0)].symbol())
.collect::<String>()
}
#[test]
fn the_headers_two_blocks_never_overwrite_each_other() {
for ic in crate::icons::ALL {
for store in ["demo", "a-rather-long-project-name-here"] {
let floor = span_width(&identity_store(store, &ic));
for w in 4u16..=120 {
let head = render_header(store, w, &ic);
let seen = head.trim_end();
let why = format!(
"tier {} store {store:?} width {w}: {seen:?}",
crate::icons::name(ic.tier)
);
assert!(
!seen.ends_with('·'),
"a separator with nothing after it -- {why}"
);
assert_eq!(
seen.contains('['),
seen.contains(']'),
"half a filter menu -- {why}"
);
if w as usize >= floor {
assert!(
seen.contains(store),
"the store label did not survive -- {why}"
);
} else {
assert!(
seen.is_empty() || seen.contains('…') || seen.contains(store),
"clipped with nothing to say so -- {why}"
);
}
}
}
}
}
#[test]
fn the_identity_gives_up_its_own_name_before_the_store() {
let ic = &crate::icons::UNICODE;
let text = |room: usize| -> String {
header_identity("my-project", ic, room)
.iter()
.map(|s| s.content.to_string())
.collect()
};
assert!(text(30).contains("dextui"), "{:?}", text(30));
assert!(text(30).contains("my-project"), "{:?}", text(30));
let tight = text(11);
assert!(tight.contains("my-project"), "{tight:?}");
assert!(!tight.contains("dextui"), "{tight:?}");
let mut seen: Vec<usize> = Vec::new();
for room in (0..=40).rev() {
let w = span_width(&header_identity("my-project", ic, room));
assert!(w <= room, "room={room} produced {w} cells");
seen.push(w);
}
assert!(
seen.windows(2).all(|w| w[0] >= w[1]),
"width must never grow as room shrinks: {seen:?}"
);
assert_eq!(*seen.last().unwrap(), 0, "something drawn at zero room");
}
#[test]
fn the_right_hand_block_keeps_the_active_filter_longest() {
let filter = tree::Filter::Pending;
let cs = right_candidates("priority", filter);
let text = |i: usize| -> String {
cs[i].iter().map(|s| s.content.to_string()).collect()
};
assert!(text(0).contains("[ all pending active ]"), "{:?}", text(0));
assert!(!text(1).contains('['), "the menu should have gone: {:?}", text(1));
assert!(text(1).contains("priority"), "{:?}", text(1));
assert!(text(1).contains(filter.name()), "{:?}", text(1));
assert!(!text(2).contains("priority"), "sort should have gone: {:?}", text(2));
assert!(text(2).contains(filter.name()), "{:?}", text(2));
assert!(cs[3].is_empty(), "the last rung draws nothing: {:?}", text(3));
let widths: Vec<usize> = cs.iter().map(|c| span_width(c)).collect();
assert!(
widths.windows(2).all(|w| w[0] > w[1]),
"candidates must strictly narrow: {widths:?}"
);
}
#[test]
fn the_header_never_brings_back_what_it_has_already_dropped() {
for ic in crate::icons::ALL {
for store in ["demo", "a-rather-long-project-name-here"] {
let markers = ["dextui", "[", "priority", tree::Filter::Pending.name()];
let mut last_seen = [0u16; 4];
for w in 4u16..=140 {
let head = render_header(store, w, &ic);
for (i, m) in markers.iter().enumerate() {
if head.contains(m) {
last_seen[i] = w;
} else if last_seen[i] != 0 {
panic!(
"{m:?} was drawn at {} columns and is back to being \
absent at {w} -- tier {}, store {store:?}: {head:?}",
last_seen[i],
crate::icons::name(ic.tier)
);
}
}
}
}
}
}
#[test]
fn a_task_started_moments_ago_reads_just_now_not_now_ago() {
let mut t = task("t", None, "Fresh task");
t.started_at = Some(chrono::Utc::now().to_rfc3339());
let rows = render_tasks(vec![t], 120, 16, &crate::icons::UNICODE);
let text = rows.join("\n");
assert!(
!text.contains("now ago"),
"\"now ago\" is not a duration:\n{text}"
);
let summary = rows
.iter()
.find(|r| r.contains("in progress"))
.unwrap_or_else(|| panic!("no status line:\n{text}"));
assert!(
summary.contains("started just now"),
"status line: {summary:?}"
);
}
#[test]
fn every_icon_tier_renders() {
for ic in crate::icons::ALL {
let text = render(100, 20, &ic).join("\n");
assert!(
text.contains("Parent task"),
"tier {} drew nothing",
crate::icons::name(ic.tier)
);
}
}
fn render_description(md: &str, w: u16, h: u16) -> String {
let mut app = App::new(
vec![Task {
id: "t".into(),
name: "Task".into(),
description: Some(md.to_string()),
created_at: Some("2026-01-01T00:00:00Z".into()),
..Default::default()
}],
"demo".into(),
crate::config::Config::default(),
);
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn markdown_tables_are_drawn_as_tables_not_raw_pipes() {
let text = render_description(
"| option | cost |\n|---|---:|\n| hand-rolled | low |\n",
110,
24,
);
assert!(text.contains('┌') && text.contains('┼'), "no table borders:\n{text}");
assert!(
!text.contains("|---"),
"the delimiter row leaked through:\n{text}"
);
}
#[test]
fn a_wide_table_in_a_narrow_pane_does_not_panic() {
let wide = "| a very long column header here | and another one |\n |---|---|\n| some long cell value | another long value |\n";
for w in [40u16, 60, 80] {
let _ = render_description(wide, w, 20);
}
}
#[test]
fn a_very_narrow_pane_does_not_panic() {
for ic in crate::icons::ALL {
for w in [20u16, 30, 40] {
let _ = render(w, 12, &ic);
}
}
}
fn every_bar(mut f: impl FnMut(Progress, Bar, bool)) {
for total in 1..=60usize {
for done in 0..=total {
for active in 0..=(total - done) {
let p = Progress {
done,
active,
total,
};
for partials in [false, true] {
f(p, Bar::new(p, METER_WIDTH, partials), partials);
}
}
}
}
}
#[test]
fn a_bar_always_fills_exactly_the_meter_width() {
every_bar(|p, b, partials| {
assert_eq!(
b.done + b.active + usize::from(b.partial > 0) + b.empty,
METER_WIDTH,
"{p:?} partials={partials} -> {b:?}"
);
assert!(
b.done + b.active <= METER_WIDTH,
"coloured runs overflow the bar: {p:?} partials={partials} -> {b:?}"
);
});
}
#[test]
fn a_zero_count_is_never_drawn() {
every_bar(|p, b, partials| {
if p.active == 0 {
assert_eq!(b.active, 0, "phantom in-flight: {p:?} partials={partials} -> {b:?}");
}
if p.done == 0 {
assert_eq!(b.done, 0, "phantom done: {p:?} partials={partials} -> {b:?}");
}
});
let b = Bar::new(
Progress {
done: 7,
active: 0,
total: 9,
},
METER_WIDTH,
false,
);
assert_eq!(b.active, 0, "7 of 9 done, none started -> {b:?}");
}
#[test]
fn a_non_zero_count_never_rounds_away_to_nothing() {
every_bar(|p, b, partials| {
if p.done > 0 {
assert!(b.done >= 1, "{p:?} partials={partials} -> {b:?}");
}
if p.active > 0 {
assert!(b.active >= 1, "{p:?} partials={partials} -> {b:?}");
}
});
let one = Bar::new(Progress { done: 1, active: 0, total: 100 }, METER_WIDTH, true);
assert_eq!((one.done, one.partial), (1, 0), "{one:?}");
let both = Bar::new(Progress { done: 1, active: 1, total: 100 }, METER_WIDTH, true);
assert_eq!((both.done, both.active), (1, 1), "{both:?}");
}
#[test]
fn the_partial_cell_never_exceeds_seven_eighths() {
every_bar(|p, b, partials| {
assert!(b.partial <= 7, "{p:?} partials={partials} -> {b:?}");
});
let b = Bar::new(Progress { done: 16, active: 16, total: 45 }, METER_WIDTH, true);
assert!(b.partial <= 7, "{b:?}");
}
#[test]
fn a_tier_without_partial_glyphs_snaps_to_whole_cells() {
every_bar(|p, b, partials| {
if !partials {
assert_eq!(b.partial, 0, "{p:?} -> {b:?}");
}
});
let b = Bar::new(Progress { done: 3, active: 0, total: 8 }, METER_WIDTH, false);
assert_eq!((b.done, b.active, b.partial, b.empty), (3, 0, 0, 4), "{b:?}");
}
#[test]
fn the_partial_sits_at_the_outer_edge_not_the_done_active_boundary() {
let b = Bar::new(Progress { done: 1, active: 1, total: 3 }, METER_WIDTH, true);
assert_eq!((b.done, b.active, b.partial, b.empty), (2, 2, 5, 2), "{b:?}");
}
#[test]
fn the_outer_edge_carries_the_sub_cell_remainder() {
let b = Bar::new(Progress { done: 3, active: 0, total: 8 }, METER_WIDTH, true);
assert_eq!((b.done, b.active, b.partial, b.empty), (2, 0, 5, 4), "{b:?}");
let nearly = Bar::new(Progress { done: 13, active: 0, total: 14 }, METER_WIDTH, true);
assert_eq!(nearly.done, 6, "{nearly:?}");
assert!(nearly.partial > 0, "a full bar would read as finished: {nearly:?}");
assert_eq!(nearly.empty, 0, "{nearly:?}");
}
#[test]
fn an_untouched_parent_is_all_trough_and_a_finished_one_is_all_bar() {
let none = Bar::new(Progress { done: 0, active: 0, total: 4 }, METER_WIDTH, true);
assert_eq!((none.done, none.active, none.partial, none.empty), (0, 0, 0, METER_WIDTH));
let all = Bar::new(Progress { done: 7, active: 0, total: 7 }, METER_WIDTH, true);
assert_eq!((all.done, all.partial, all.empty), (METER_WIDTH, 0, 0));
}
fn meter_bar(p: Progress, ic: &Icons) -> Vec<(String, Option<Color>)> {
let mut spans = meter_spans(p, ic);
spans.pop();
spans
.into_iter()
.map(|s| (s.content.into_owned(), s.style.fg))
.collect()
}
#[test]
fn the_meter_is_exactly_seven_cells_wide_in_every_tier() {
let cases = [
Progress { done: 0, active: 0, total: 4 },
Progress { done: 3, active: 0, total: 8 },
Progress { done: 1, active: 1, total: 3 },
Progress { done: 1, active: 0, total: 100 },
Progress { done: 13, active: 0, total: 14 },
Progress { done: 7, active: 0, total: 7 },
];
for ic in crate::icons::ALL {
for p in cases {
let bar = meter_bar(p, &ic);
let cells: usize = bar.iter().map(|(t, _)| Span::raw(t.clone()).width()).sum();
let chars: usize = bar.iter().map(|(t, _)| t.chars().count()).sum();
assert_eq!(
cells,
METER_WIDTH,
"tier {} {p:?}: {bar:?}",
crate::icons::name(ic.tier)
);
assert_eq!(
chars,
METER_WIDTH,
"tier {} {p:?}: the gutter is measured in chars: {bar:?}",
crate::icons::name(ic.tier)
);
}
}
}
#[test]
fn the_meter_paints_done_in_flight_and_untouched_in_the_status_colours() {
let p = Progress { done: 2, active: 2, total: 7 };
for ic in crate::icons::ALL {
let fgs: Vec<_> = meter_bar(p, &ic).into_iter().map(|(_, fg)| fg).collect();
assert_eq!(
fgs,
vec![Some(DONE), Some(ACTIVE), Some(DIM)],
"tier {}",
crate::icons::name(ic.tier)
);
}
let spans = meter_spans(p, &crate::icons::UNICODE);
assert_eq!(spans.last().unwrap().style.fg, Some(DIM), "the fraction is secondary");
}
#[test]
fn the_partial_cell_takes_the_colour_of_the_run_it_extends() {
let ic = &crate::icons::UNICODE;
let done_only = meter_bar(Progress { done: 3, active: 0, total: 8 }, ic);
assert_eq!(
done_only.iter().map(|(_, fg)| *fg).collect::<Vec<_>>(),
vec![Some(DONE), Some(DONE), Some(DIM)],
"{done_only:?}"
);
assert_eq!(done_only[1].0, "\u{258b}", "5/8 of a cell: {done_only:?}");
let mixed = meter_bar(Progress { done: 1, active: 1, total: 3 }, ic);
assert_eq!(
mixed.iter().map(|(_, fg)| *fg).collect::<Vec<_>>(),
vec![Some(DONE), Some(ACTIVE), Some(ACTIVE), Some(DIM)],
"{mixed:?}"
);
}
#[test]
fn the_nerd_meter_is_capped_at_both_ends() {
let bar: String = meter_bar(Progress { done: 2, active: 0, total: 7 }, &crate::icons::NERD)
.into_iter()
.map(|(t, _)| t)
.collect();
assert_eq!(
bar,
"\u{ee03}\u{ee04}\u{ee01}\u{ee01}\u{ee01}\u{ee01}\u{ee02}",
"{bar:?}"
);
}
#[test]
fn the_fraction_stays_beside_the_bar() {
let spans = meter_spans(Progress { done: 3, active: 0, total: 8 }, &crate::icons::UNICODE);
assert_eq!(spans.last().unwrap().content.as_ref(), " 3/8");
}
fn started(id: &str, name: &str) -> Task {
Task {
started_at: Some("2026-01-01T00:00:00Z".into()),
..task(id, None, name)
}
}
fn render_frame(tasks: Vec<Task>, frame: usize, ic: &Icons) -> ratatui::buffer::Buffer {
let mut app = App::new(tasks, "demo".into(), crate::config::Config::default());
app.filter = tree::Filter::All;
app.rebuild();
app.spin_frame = frame;
let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
terminal.draw(|f| draw(f, &mut app, ic)).unwrap();
terminal.backend().buffer().clone()
}
fn screen(width: u16, single_pane_below: u16, focus: Focus) -> String {
let mut app = App::new(
vec![
Task {
description: Some("DETAIL-ONLY-MARKER".into()),
..task("a", None, "A task in the tree")
},
task("b", None, "Another one"),
],
"demo".into(),
crate::config::Config::default(),
);
app.filter = tree::Filter::All;
app.single_pane_below = single_pane_below;
app.focus = focus;
app.selected = Some("a".into());
app.rebuild();
let mut terminal = Terminal::new(TestBackend::new(width, 14)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn below_the_threshold_focus_decides_which_pane_is_drawn() {
let tree_view = screen(60, 80, Focus::Tree);
assert!(tree_view.contains("Another one"), "no tree: {tree_view}");
assert!(
!tree_view.contains("DETAIL-ONLY-MARKER"),
"the detail pane leaked in: {tree_view}"
);
let detail_view = screen(60, 80, Focus::Detail);
assert!(
detail_view.contains("DETAIL-ONLY-MARKER"),
"no detail pane: {detail_view}"
);
assert!(
!detail_view.contains("Another one"),
"the tree leaked in: {detail_view}"
);
}
#[test]
fn above_the_threshold_both_panes_are_drawn_whichever_has_focus() {
for focus in [Focus::Tree, Focus::Detail] {
let s = screen(100, 80, focus);
assert!(s.contains("Another one"), "{focus:?}: no tree: {s}");
assert!(
s.contains("DETAIL-ONLY-MARKER"),
"{focus:?}: no detail: {s}"
);
}
}
#[test]
fn dialogs_still_draw_over_a_single_pane() {
let mut app = App::new(
vec![task("a", None, "A task")],
"demo".into(),
crate::config::Config::default(),
);
app.single_pane_below = 80;
app.mode = Mode::Help;
app.rebuild();
let mut terminal = Terminal::new(TestBackend::new(60, 14)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer().clone();
let text: String = (0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.map(|(x, y)| buf[(x, y)].symbol())
.collect();
assert!(text.contains("switch pane"), "the help dialog is missing");
}
#[test]
fn the_spinner_turns_without_moving_the_column() {
for ic in [
&crate::icons::NERD,
&crate::icons::UNICODE,
&crate::icons::ASCII,
] {
let tasks = || vec![task("a", None, "Idle task"), started("b", "Running task")];
let mut columns = std::collections::HashSet::new();
let mut seen = std::collections::HashSet::new();
for f in 0..ic.spin.len() {
let buf = render_frame(tasks(), f, ic);
let row = (0..buf.area.height)
.find(|&y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.contains("Running task")
})
.unwrap_or_else(|| panic!("{:?}: no row for the running task", ic.tier));
let (at, style) = (0..buf.area.width)
.map(|x| ((x, row), buf[(x, row)].style()))
.find(|(_, s)| s.fg == Some(ACTIVE))
.unwrap_or_else(|| panic!("{:?}: no in-progress marker drawn", ic.tier));
assert_eq!(
buf[at].symbol(),
ic.spin[f],
"{:?}: frame {f} drew the wrong glyph",
ic.tier
);
columns.insert(at.0);
seen.insert(ic.spin[f]);
assert!(
!style.add_modifier.contains(Modifier::BOLD),
"{:?}: frame {f} changed weight",
ic.tier
);
}
assert_eq!(
columns.len(),
1,
"{:?}: the marker moved between frames: {columns:?}",
ic.tier
);
assert_eq!(
seen.len(),
ic.spin.len(),
"{:?}: frames repeated within one cycle",
ic.tier
);
}
}
#[test]
fn with_animation_off_the_marker_is_the_still_glyph() {
for ic in [
&crate::icons::NERD,
&crate::icons::UNICODE,
&crate::icons::ASCII,
] {
assert_eq!(row_glyph(Status::InProgress, ic, None), ic.active);
assert!(
!ic.spin.contains(&ic.active),
"{:?}: the still glyph is also a spinner frame",
ic.tier
);
}
}
#[test]
fn every_spinner_frame_is_a_single_character() {
for ic in [
&crate::icons::NERD,
&crate::icons::UNICODE,
&crate::icons::ASCII,
] {
for f in ic.spin {
assert_eq!(
f.chars().count(),
1,
"{:?}: frame {f:?} is not one character",
ic.tier
);
}
assert!(ic.spin.len() >= 2, "{:?}: nothing to animate", ic.tier);
}
}
#[test]
fn only_the_in_progress_glyph_pulses() {
let mut done = task("done", None, "Finished task");
done.completed = true;
let mut blocked = task("blocked", None, "Blocked task");
blocked.blocked_by = vec!["pending".into()];
let tasks = || {
vec![
task("pending", None, "Pending task"),
done.clone(),
blocked.clone(),
]
};
for ic in crate::icons::ALL {
assert_eq!(
render_frame(tasks(), 0, &ic),
render_frame(tasks(), 3, &ic),
"tier {} repaints with nothing running",
crate::icons::name(ic.tier)
);
}
}
#[test]
fn a_pulse_repaint_does_not_disturb_an_open_prompt() {
let ic = &crate::icons::UNICODE;
let frame = |pulse_on: bool| {
let mut app = App::new(
vec![started("b", "Running task")],
"demo".into(),
crate::config::Config::default(),
);
app.mode = Mode::Prompt(crate::app::Prompt {
title: "Rename: Running task".into(),
label: "Name".into(),
input: crate::app::TextInput::new("half-typed"),
pending: crate::app::Pending::EditName { id: "b".into() },
});
app.spin_frame = if pulse_on { 1 } else { 0 };
let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
terminal.draw(|f| draw(f, &mut app, ic)).unwrap();
let cursor = terminal.get_cursor_position().unwrap();
(terminal.backend().buffer().clone(), cursor)
};
let (off, off_cursor) = frame(false);
let (on, on_cursor) = frame(true);
assert_eq!(off_cursor, on_cursor, "the cursor moved mid-typing");
let text = |b: &ratatui::buffer::Buffer| {
(0..b.area.height)
.map(|y| {
(0..b.area.width)
.map(|x| b[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
};
assert_eq!(text(&off), text(&on), "the prompt redrew differently");
}
fn render_selection(
tasks: Vec<Task>,
select: &str,
focus: Focus,
ic: &Icons,
w: u16,
h: u16,
) -> (ratatui::buffer::Buffer, App) {
let mut app = App::new(tasks, "demo".into(), crate::config::Config::default());
app.filter = tree::Filter::All;
app.rebuild();
app.selected = Some(select.to_string());
app.focus = focus;
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| draw(f, &mut app, ic)).unwrap();
let buf = terminal.backend().buffer().clone();
(buf, app)
}
fn row_of(buf: &ratatui::buffer::Buffer, name: &str) -> u16 {
for y in 0..buf.area.height {
let line: String = (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect();
if line.contains(name) {
return y;
}
}
panic!("{name:?} was never drawn");
}
fn col_of(buf: &ratatui::buffer::Buffer, y: u16, name: &str) -> usize {
let cells: Vec<&str> = (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect();
let line: String = cells.concat();
let byte = line
.find(name)
.unwrap_or_else(|| panic!("{name:?} is not on row {y}: {line:?}"));
let mut at = 0;
for (i, c) in cells.iter().enumerate() {
if at == byte {
return i;
}
at += c.len();
}
panic!("{name:?} does not start on a cell boundary on row {y}")
}
fn tree_cells<'a>(
buf: &'a ratatui::buffer::Buffer,
app: &App,
y: u16,
) -> Vec<&'a ratatui::buffer::Cell> {
(1..app.divider_x).map(|x| &buf[(x, y)]).collect()
}
#[test]
fn the_selected_row_is_marked_by_a_gutter_not_by_inverting_it() {
let ic = &crate::icons::UNICODE;
let tasks = vec![
task("alpha", None, "Alpha task"),
task("beta", None, "Beta task"),
];
let (buf, app) = render_selection(tasks, "alpha", Focus::Tree, ic, 100, 20);
let sel = row_of(&buf, "Alpha task");
let other = row_of(&buf, "Beta task");
assert_eq!(buf[(1, sel)].symbol(), ic.gutter, "no gutter on the selection");
assert_eq!(buf[(1, other)].symbol(), " ", "an unselected row drew a gutter");
for cell in tree_cells(&buf, &app, sel) {
assert!(
!cell.style().add_modifier.contains(Modifier::REVERSED),
"the selected row still inverts: {cell:?}"
);
}
assert_eq!(
col_of(&buf, sel, "Alpha task"),
col_of(&buf, other, "Beta task"),
"selecting a row moved its name out of the column"
);
let name_x = col_of(&buf, sel, "Alpha task") as u16;
assert!(
buf[(name_x, sel)]
.style()
.add_modifier
.contains(Modifier::BOLD),
"the selected name is not bold"
);
assert!(
!buf[(col_of(&buf, other, "Beta task") as u16, other)]
.style()
.add_modifier
.contains(Modifier::BOLD),
"an unselected name is bold"
);
}
#[test]
fn the_selection_gutter_dims_when_the_tree_is_unfocused() {
let ic = &crate::icons::UNICODE;
let tasks = || vec![task("alpha", None, "Alpha task")];
let (focused, _) = render_selection(tasks(), "alpha", Focus::Tree, ic, 100, 20);
let y = row_of(&focused, "Alpha task");
assert_eq!(focused[(1, y)].style().fg, Some(crate::theme::ACCENT));
let (unfocused, _) = render_selection(tasks(), "alpha", Focus::Detail, ic, 100, 20);
let y = row_of(&unfocused, "Alpha task");
assert_eq!(unfocused[(1, y)].style().fg, Some(crate::theme::ACCENT_DIM));
assert_eq!(
unfocused[(1, y)].symbol(),
ic.gutter,
"an unfocused pane still knows where the cursor is"
);
}
#[test]
fn selection_does_not_recolour_the_meter_or_the_status_glyph() {
let ic = &crate::icons::UNICODE;
let mut finished = task("done", Some("root"), "Finished child");
finished.completed = true;
let mut running = task("run", Some("root"), "Running child");
running.started_at = Some("2026-01-01T00:00:00Z".into());
let tasks = vec![
task("root", None, "Parent task"),
finished,
running,
task("todo", Some("root"), "Pending child"),
];
let (buf, app) = render_selection(tasks, "root", Focus::Tree, ic, 120, 20);
let y = row_of(&buf, "Parent task");
let cells = tree_cells(&buf, &app, y);
assert_eq!(buf[(1, y)].symbol(), ic.gutter, "fixture: the parent is selected");
let fg_of = |sym: &str| -> Vec<Option<Color>> {
cells
.iter()
.filter(|c| c.symbol() == sym)
.map(|c| c.style().fg)
.collect()
};
assert!(
fg_of("\u{2588}").contains(&Some(DONE)),
"no green in the meter: {:?}",
fg_of("\u{2588}")
);
assert!(
fg_of("\u{2588}").contains(&Some(ACTIVE)),
"no blue in the meter: {:?}",
fg_of("\u{2588}")
);
assert_eq!(
fg_of("\u{2591}"),
vec![Some(DIM); 2],
"the untouched remainder lost its colour"
);
assert_eq!(
fg_of(ic.pending),
vec![Some(status_color(Status::Pending))],
"the status glyph was recoloured by the selection"
);
for cell in &cells {
assert!(
!cell.style().add_modifier.contains(Modifier::REVERSED),
"inversion would swap every one of those foregrounds: {cell:?}"
);
}
}
#[test]
fn a_click_still_selects_the_row_that_was_drawn() {
let ic = &crate::icons::UNICODE;
let tasks = vec![
task("alpha", None, "Alpha task"),
task("beta", None, "Beta task"),
];
let (buf, mut app) = render_selection(tasks, "alpha", Focus::Tree, ic, 100, 20);
let y = row_of(&buf, "Beta task");
app.select_at_row(y);
assert_eq!(
app.selected.as_deref(),
Some("beta"),
"clicking row {y} selected {:?}",
app.selected
);
}
fn tree_rows(rows: &[String]) -> Vec<String> {
rows.iter()
.map(|r| {
let mut it = r.match_indices('│');
match (it.next(), it.next()) {
(Some((a, _)), Some((b, _))) => r[a + '│'.len_utf8()..b].to_string(),
_ => String::new(),
}
})
.collect()
}
#[test]
fn a_leaf_gets_no_meter() {
let rows = tree_rows(&render(120, 20, &crate::icons::UNICODE));
let row = |name: &str| {
rows.iter()
.find(|r| r.contains(name))
.unwrap_or_else(|| panic!("no row for {name}:\n{}", rows.join("\n")))
.clone()
};
assert!(row("Parent task").contains('░'), "{:?}", row("Parent task"));
assert!(!row("Child task").contains('░'), "{:?}", row("Child task"));
}
#[test]
fn a_meter_counts_the_unfiltered_tree() {
let mut finished = task("done", Some("root"), "Finished child");
finished.completed = true;
let app_tasks = vec![
task("root", None, "Parent task"),
finished,
task("kid", Some("root"), "Pending child"),
];
let mut app = App::new(app_tasks, "demo".into(), crate::config::Config::default());
assert_eq!(app.filter, tree::Filter::Pending, "fixture assumes the default filter");
let mut terminal = Terminal::new(TestBackend::new(120, 20)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer().clone();
let rows: Vec<String> = (0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect();
let rows = tree_rows(&rows);
let text = rows.join("\n");
assert!(!text.contains("Finished child"), "the filter should hide it:\n{text}");
let parent = rows.iter().find(|r| r.contains("Parent task")).unwrap();
assert!(
parent.contains("1/2"),
"the rollup must count the hidden child: {parent:?}"
);
}
}