use ratatui::layout::{Constraint, Layout, Margin, 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, Panes};
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 1 repos , config ? help";
const REPO_SHORTCUTS: &str =
" enter tasks a save this repo A save by path D unsave b hide ? 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);
match app.panes() {
Panes::One => {
app.divider_x = 0;
app.repos_right = 0;
match app.focus {
Focus::Tree => draw_tree(frame, app, ic, body),
Focus::Detail => draw_detail(frame, app, ic, body),
Focus::Repos => draw_repos(frame, app, ic, body),
}
}
Panes::Two if app.drawn_panes()[0] == Focus::Repos => {
let [repos, left] =
Layout::horizontal([Constraint::Length(app.repos_width), Constraint::Fill(1)])
.areas(body);
app.divider_x = 0;
app.repos_right = left.x;
draw_repos(frame, app, ic, repos);
draw_tree(frame, app, ic, left);
}
Panes::Two => {
let [left, right] = Layout::horizontal([
Constraint::Percentage(app.split_percent),
Constraint::Fill(1),
])
.areas(body);
app.divider_x = right.x;
app.repos_right = 0;
draw_tree(frame, app, ic, left);
draw_detail(frame, app, ic, right);
}
Panes::Three => {
let [repos, rest] =
Layout::horizontal([Constraint::Length(app.repos_width), Constraint::Fill(1)])
.areas(body);
let [left, right] = Layout::horizontal([
Constraint::Percentage(app.split_percent),
Constraint::Fill(1),
])
.areas(rest);
app.divider_x = right.x;
app.repos_right = left.x;
draw_repos(frame, app, ic, repos);
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: &mut App) {
if matches!(app.mode, Mode::Help) {
draw_help(frame, app);
return;
}
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),
_ => {}
}
}
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> {
TABS.iter()
.find(|(n, _)| content == format!("[{n}]") || content == format!(" {n} "))
.map(|(_, f)| HeaderZone::Pane(*f))
}
pub const TABS: [(u8, Focus); 3] = [(1, Focus::Repos), (2, Focus::Tree), (3, Focus::Detail)];
fn pane_number(focus: Focus) -> String {
TABS.iter()
.find(|(_, f)| *f == focus)
.map(|(n, _)| format!("[{n}]"))
.unwrap_or_default()
}
fn pane_block(title: &str, pane: Focus, focus: Focus) -> Block<'static> {
let focused = pane == focus;
Block::bordered()
.title_top(Line::styled(
format!(" {title} "),
Style::default().fg(DIM),
))
.title_top(
Line::styled(
pane_number(pane),
if focused {
Style::default().fg(PLAIN).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(DIM)
},
)
.right_aligned(),
)
.border_style(Style::default().fg(if focused { PLAIN } else { DIM }))
}
fn tab_spans(focus: Focus) -> Vec<Span<'static>> {
let mut out = vec![Span::raw(" ")];
for (n, f) in TABS {
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 repo_stat_spans(c: Counts, room: usize, ic: &Icons) -> Vec<Span<'static>> {
let sep = || Span::raw(" ");
let mut numbers: Vec<Span<'static>> = Vec::new();
let mut push = |n: usize, fg: Color| {
if n > 0 {
if !numbers.is_empty() {
numbers.push(sep());
}
numbers.push(Span::styled(n.to_string(), Style::default().fg(fg)));
}
};
push(c.active, ACTIVE);
push(c.ready, TODO);
push(c.blocked, BLOCKED);
push(c.completed, DONE);
let bar = |width: usize| {
bar_spans(
Progress { done: c.completed, active: c.active, total: c.total },
ic,
width,
)
};
let pending_only = || -> Vec<Span<'static>> {
if c.pending == 0 {
return Vec::new();
}
vec![Span::styled(c.pending.to_string(), Style::default().fg(TODO))]
};
let mut ladder: Vec<Vec<Span<'static>>> = Vec::new();
if c.total > 0 {
ladder.push([numbers.clone(), vec![sep()], bar(METER_WIDTH)].concat());
ladder.push([numbers.clone(), vec![sep()], bar(4)].concat());
}
ladder.push(numbers);
ladder.push(pending_only());
ladder.push(Vec::new());
ladder
.into_iter()
.find(|spans| span_width(spans) <= room)
.unwrap_or_default()
}
fn draw_scrollbar(frame: &mut Frame, area: Rect, thumb: Color, state: &mut ScrollbarState) {
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(thumb)),
area.inner(Margin { vertical: 1, horizontal: 0 }),
state,
);
}
fn draw_repos(frame: &mut Frame, app: &mut App, ic: &Icons, area: Rect) {
let block = pane_block("repos", Focus::Repos, app.focus);
let rows = app.repo_rows();
let items: Vec<ListItem> = rows
.iter()
.enumerate()
.map(|(i, row)| {
let selected = i == app.selected_repo_row;
let gutter = if selected {
Span::styled(format!("{} ", ic.gutter), Style::default().fg(ACCENT))
} else {
Span::raw(" ")
};
match row {
crate::repos::Row::Heading(label) => {
return ListItem::new(Line::from(Span::styled(
format!(" {label}"),
Style::default().fg(DIM),
)));
}
crate::repos::Row::Hint(text) => {
return ListItem::new(Line::from(Span::styled(
format!(" {text}"),
Style::default().fg(DIM).add_modifier(Modifier::ITALIC),
)));
}
_ => {}
}
let mut spans = vec![gutter];
match row {
crate::repos::Row::Heading(_) | crate::repos::Row::Hint(_) => {
unreachable!("handled above")
}
crate::repos::Row::Repo { index } => {
let r = &app.repos[*index];
spans.push(Span::styled(
format!("{} {}", ic.marker(true, r.open), r.name),
Style::default().fg(PLAIN).add_modifier(Modifier::BOLD),
));
}
crate::repos::Row::Worktree { repo, index } => {
let r = &app.repos[*repo];
let w = &r.worktrees[*index];
let has = crate::repos::has_store(&w.path);
spans.push(Span::styled(
format!(" {}", w.branch),
Style::default().fg(if has { PLAIN } else { DIM }),
));
}
}
let store = match row {
crate::repos::Row::Heading(_) | crate::repos::Row::Hint(_) => {
unreachable!("handled above")
}
crate::repos::Row::Repo { index } => app.repos[*index].store(None),
crate::repos::Row::Worktree { repo, index } => {
let r = &app.repos[*repo];
r.store(Some(&r.worktrees[*index]))
}
};
if let Some(c) = app.counts_for_store(&store) {
let inner = area.width.saturating_sub(2) as usize;
let room = inner.saturating_sub(span_width(&spans) + 1);
let stats = repo_stat_spans(c, room, ic);
if !stats.is_empty() {
let pad = inner - span_width(&spans) - span_width(&stats);
spans.push(Span::raw(" ".repeat(pad)));
spans.extend(stats);
}
}
ListItem::new(Line::from(spans))
})
.collect();
let selected = (!rows.is_empty()).then_some(app.selected_repo_row);
let mut state = ListState::default().with_offset(app.repos_offset);
state.select(selected);
frame.render_stateful_widget(List::new(items).block(block), area, &mut state);
app.repos_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_repo_row);
draw_scrollbar(frame, area, DIM, &mut sb);
}
}
fn draw_tree(frame: &mut Frame, app: &mut App, ic: &Icons, area: Rect) {
let block = pane_block("tasks", Focus::Tree, app.focus);
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(if app.needs_tree_reveal {
selected
} else {
Some(app.tree_offset.min(rows.len().saturating_sub(1)))
});
app.needs_tree_reveal = false;
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));
draw_scrollbar(frame, area, DIM, &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 fold(text: &str, width: u16) -> Vec<String> {
if width == 0 {
return text.lines().map(str::to_string).collect();
}
let width = width as usize;
let mut out = Vec::new();
for line in text.lines() {
if line.chars().count() <= width {
out.push(line.to_string());
continue;
}
let mut row = String::new();
let mut row_len = 0;
for word in line.split(' ') {
let mut word = word;
while word.chars().count() > width {
if row_len > 0 {
out.push(std::mem::take(&mut row));
row_len = 0;
}
let head: String = word.chars().take(width).collect();
word = &word[head.len()..];
out.push(head);
}
let len = word.chars().count();
let sep = usize::from(row_len > 0);
if row_len + sep + len > width {
out.push(std::mem::take(&mut row));
row_len = 0;
} else if sep == 1 {
row.push(' ');
row_len += 1;
}
row.push_str(word);
row_len += len;
}
out.push(row);
}
out
}
fn draw_detail(frame: &mut Frame, app: &mut App, ic: &Icons, area: Rect) {
let block = pane_block(
if app.wrap { "detail" } else { "detail · no wrap" },
Focus::Detail,
app.focus,
);
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);
draw_scrollbar(frame, area, PLAIN, &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() {
let keys = match app.focus {
Focus::Repos => REPO_SHORTCUTS,
_ => SHORTCUTS,
};
(keys.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 / ⇧tab next / prev 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
^L redraw the screen (if the terminal has corrupted it)
In the repo sidebar these keys act on repos, not on tasks:
1 focus repos b show / hide the sidebar
a save the repo you are in, moving its row into `saved`
A save a repo by path, for one you are not in (~ works)
D forget a saved repo (the worktree and its store are untouched)
`here` is the repo you launched in, while it is still unsaved; a moves it
down into `saved`, the list you can switch to from anywhere. D moves it back.
Moving the cursor switches the tree and detail panes to that worktree at
once, just as moving the tree cursor changes the detail; enter and l just
follow it over to the tasks.
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.
Each pane shows its key in its top right corner -- [1] repos, [2] tasks,
[3] detail, left to right -- and the number jumps straight there. Zoom (z)
shows one at a time, with the same [1] [2] [3] as header tabs. Narrow
terminals zoom on their own below single_pane_below columns.
Mouse: drag the divider to resize, wheel scrolls the pane under the pointer,
click selects -- and on a task's expand marker it opens or closes it too.
In the header, click a filter, or the sort label to cycle it -- right-click
the sort to reverse. Shift bypasses capture to select text.
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, app: &mut App) {
let widest = HELP.lines().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
let area = centered(frame.area(), widest + 2, HELP.lines().count() as u16 + 3);
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);
let folded = fold(HELP, body.width);
app.help_content_height = folded.len() as u16;
app.help_viewport_height = body.height;
app.help_scroll = app.help_scroll.min(app.help_max_scroll());
let scroll = app.help_scroll;
frame.render_widget(
Paragraph::new(folded.iter().map(|s| Line::from(s.as_str())).collect::<Vec<_>>())
.style(Style::default().fg(PLAIN))
.scroll((scroll, 0)),
body,
);
let marks = match (scroll > 0, scroll < app.help_max_scroll()) {
(true, true) => "\u{2191}\u{2193}",
(true, false) => "\u{2191}",
(false, true) => "\u{2193}",
(false, false) => "",
};
let mark_w = marks.chars().count() as u16;
let [text, arrows] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(mark_w)]).areas(hint);
let ladder: &[&str] = if app.help_max_scroll() > 0 {
&["j / k scroll \u{b7} any other key dismisses", "j / k scroll"]
} else {
&["any key to dismiss", "any key"]
};
let label = ladder
.iter()
.copied()
.find(|s| s.chars().count() as u16 <= text.width)
.unwrap_or("");
frame.render_widget(
Paragraph::new(label).style(Style::default().fg(DIM)),
text,
);
frame.render_widget(
Paragraph::new(marks).style(Style::default().fg(ACTIVE)),
arrows,
);
}
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()
}
}
#[test]
fn clicking_a_row_selects_the_task_drawn_on_it_and_nothing_otherwise() {
let tasks: Vec<Task> = (0..12)
.map(|i| task(&format!("t{i}"), None, &format!("TASK-{i:02}")))
.collect();
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(120, 12)).unwrap();
for scroll in [0isize, 3, 6] {
app.scroll_tree(scroll);
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer().clone();
let line = |y: u16| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
};
for row in app.body_top..app.body_bottom {
let drawn = line(row);
let shown = drawn
.split_whitespace()
.find(|w| w.starts_with("TASK-"))
.map(str::to_string);
let before = app.selected_task().map(|t| t.name.clone());
app.select_at_row(row);
let after = app.selected_task().map(|t| t.name.clone());
match shown {
Some(name) => assert_eq!(
after.as_deref(),
Some(name.as_str()),
"row {row} (offset {}) draws {name:?}, click selected {after:?}",
app.tree_offset
),
None => assert_eq!(
after, before,
"row {row} draws no task, but clicking it moved the selection:\n{drawn}"
),
}
}
}
}
#[test]
fn a_tree_scroll_survives_repeated_idle_renders() {
let tasks: Vec<Task> = (0..30)
.map(|i| task(&format!("t{i}"), None, &format!("TASK-{i:02}")))
.collect();
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(120, 12)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let selected_before = app.selected.clone();
let top_row = |app: &mut App, terminal: &mut Terminal<TestBackend>| {
terminal
.draw(|f| draw(f, app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..buf.area.width)
.map(|x| buf[(x, app.body_top + 1)].symbol())
.collect::<String>()
};
let before = top_row(&mut app, &mut terminal);
app.scroll_tree(6);
let offset_after_scroll = app.tree_offset;
assert_ne!(offset_after_scroll, 0, "scroll_tree did not move the offset");
for n in 1..=3 {
let drawn = top_row(&mut app, &mut terminal);
assert_eq!(
app.tree_offset, offset_after_scroll,
"render {n} after the scroll pulled the offset back to {} from {offset_after_scroll}",
app.tree_offset
);
assert_ne!(
drawn, before,
"render {n}: the top row never changed, so nothing actually scrolled"
);
assert_eq!(
app.selected, selected_before,
"render {n}: the wheel must never change the selected task"
);
}
}
#[test]
fn clicking_a_sidebar_row_selects_the_worktree_drawn_on_it() {
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
app.repos = (0..6)
.map(|i| crate::repos::Repo {
name: format!("REPO-{i}"),
path: format!("/tmp/r{i}"),
open: true,
registered: true,
is_global: false,
worktrees: vec![crate::worktree::Worktree {
path: format!("/tmp/r{i}"),
branch: format!("BR-{i}"),
is_main: true,
is_locked: false,
is_detached: false,
}],
})
.collect();
app.focus = Focus::Repos;
let mut terminal = Terminal::new(TestBackend::new(120, 12)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer().clone();
let rows = app.repo_rows();
for row in app.body_top..app.body_bottom {
let drawn = (0..buf.area.width)
.map(|x| buf[(x, row)].symbol())
.collect::<String>();
let shown = drawn
.split_whitespace()
.find(|w| w.starts_with("REPO-") || w.starts_with("BR-"))
.map(str::to_string);
let before = app.selected_repo_row;
app.select_repo_at_row(row);
let after = app.selected_repo_row;
match shown {
Some(label) => {
let picked = match &rows[after] {
crate::repos::Row::Heading(h) | crate::repos::Row::Hint(h) => {
(*h).to_string()
}
crate::repos::Row::Repo { index } => app.repos[*index].name.clone(),
crate::repos::Row::Worktree { repo, index } => {
app.repos[*repo].worktrees[*index].branch.clone()
}
};
assert_eq!(picked, label, "row {row} draws {label:?}, click picked {picked:?}");
}
None => assert_eq!(
after, before,
"row {row} draws no repo, but clicking it moved the cursor:\n{drawn}"
),
}
}
}
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(),
);
app.repos_pane_above = 0;
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");
assert!(HELP.contains("^L redraw the screen"), "help: Ctrl-L redraws");
}
#[test]
fn both_surfaces_advertise_the_repo_sidebar_keys() {
assert!(SHORTCUTS.contains("1 repos"), "the strip hides the sidebar: {SHORTCUTS}");
for key in ["a save", "A save by path", "D unsave", "b hide"] {
assert!(
REPO_SHORTCUTS.contains(key),
"the sidebar strip does not advertise {key}: {REPO_SHORTCUTS}"
);
}
assert!(
!REPO_SHORTCUTS.contains("a sub"),
"the sidebar strip still claims `a` makes a subtask: {REPO_SHORTCUTS}"
);
assert!(HELP.contains("1 focus repos"), "help: 1 focuses the sidebar");
assert!(HELP.contains("a save the repo you are in"), "help: a saves");
assert!(HELP.contains("A save a repo by path"), "help: A saves by path");
assert!(
HELP.contains("b show / hide the sidebar"),
"help: b hides the sidebar"
);
assert!(
HELP.contains("switches the tree and detail panes to that worktree"),
"the help still implies the sidebar needs a commit key"
);
assert!(HELP.contains("D forget a saved repo"), "help: D forgets");
assert!(
HELP.contains("follow it over to the tasks"),
"help: enter follows the cursor to the tasks"
);
assert!(HELP.contains("[1] [2] [3]"), "help: the third pane has a tab");
}
#[test]
fn the_help_dialog_shows_all_of_the_help() {
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
app.open_help();
let mut terminal = Terminal::new(TestBackend::new(90, 20)).unwrap();
let mut seen = String::new();
loop {
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer().clone();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
seen.push_str(buf[(x, y)].symbol());
}
seen.push('\n');
}
if app.help_scroll >= app.help_max_scroll() {
break;
}
app.scroll_help(1);
}
for line in [
"tab / ⇧tab next / prev pane",
"D forget a saved repo",
"b show / hide the sidebar",
"follow it over to the tasks.",
"dialog are never disturbed.",
] {
assert!(
seen.contains(line),
"no scroll position reaches {line:?} -- a key nobody can read is not documented"
);
}
}
fn help_screen(width: u16, height: u16, scroll_to_end: bool) -> String {
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
app.open_help();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let mut render = |app: &mut App| {
terminal
.draw(|f| draw(f, 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")
};
let first = render(&mut app);
if !scroll_to_end {
return first;
}
app.scroll_help(i32::MAX);
render(&mut app)
}
#[test]
fn the_last_line_of_the_help_is_reachable_on_a_short_terminal() {
const LAST: &str = "dialog are never disturbed.";
let top = help_screen(80, 24, false);
assert!(
!top.contains(LAST),
"80x24 is not short enough to test scrolling:\n{top}"
);
assert!(top.contains("tab / ⇧tab next / prev pane"), "help starts at the top:\n{top}");
let end = help_screen(80, 24, true);
assert!(end.contains(LAST), "the last line stayed out of reach:\n{end}");
}
#[test]
fn folding_never_returns_a_row_wider_than_the_width() {
for width in [1u16, 3, 7, 12, 40, 79, 200] {
let rows = fold(HELP, width);
for r in &rows {
assert!(
r.chars().count() <= width as usize,
"{width}: row overruns: {r:?}"
);
}
assert!(rows.len() >= HELP.lines().count(), "{width}: rows went missing");
}
}
#[test]
fn folding_keeps_every_word_and_breaks_only_what_cannot_fit() {
assert_eq!(fold("one two three", 20), ["one two three"]);
assert_eq!(fold("one two three", 7), ["one two", "three"]);
assert_eq!(fold("abcde", 5), ["abcde"]);
assert_eq!(fold("ab abcdefgh ij", 4), ["ab", "abcd", "efgh", "ij"]);
assert_eq!(fold("a\n\nb", 10), ["a", "", "b"]);
assert_eq!(fold("anything at all", 0), ["anything at all"]);
}
#[test]
fn the_sidebar_stats_escalate_with_the_room_and_only_ever_shed() {
let c = Counts {
total: 13,
completed: 4,
pending: 9,
active: 1,
blocked: 0,
ready: 8,
percent: 30,
};
let ic = &crate::icons::UNICODE;
let mut widths: Vec<(usize, usize)> = Vec::new();
for room in 0..40 {
let w = span_width(&repo_stat_spans(c, room, ic));
assert!(w <= room, "room {room} overrun by {w}");
widths.push((room, w));
}
for pair in widths.windows(2) {
assert!(
pair[1].1 >= pair[0].1,
"room {} showed {} cells, room {} showed {} -- it went backwards",
pair[0].0,
pair[0].1,
pair[1].0,
pair[1].1
);
}
let at = |room| {
repo_stat_spans(c, room, ic)
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
};
assert_eq!(at(0), "", "no room means nothing");
assert_eq!(at(1), "9", "the narrowest rung is the unfinished count");
assert_eq!(at(6), "1 8 4", "then every state, colour-coded");
let widest = at(30);
assert!(
span_width(&repo_stat_spans(c, 30, ic)) > 6,
"the widest rung should add the bar: {widest:?}"
);
assert!(
widest.starts_with("1 8 4 "),
"the numbers should lead so the bar stays pinned: {widest:?}"
);
}
fn help_hint(screen: &str) -> &str {
screen
.lines()
.find(|l| l.contains("dismiss"))
.expect("the hint row is always drawn at these widths")
}
#[test]
fn the_help_says_when_it_is_hiding_something() {
let short = help_screen(80, 24, false);
let hint = help_hint(&short);
assert!(hint.contains('\u{2193}'), "no more-below marker: {hint:?}");
assert!(
hint.contains("any other key dismisses"),
"the hint never says the movement keys stopped dismissing: {hint:?}"
);
let end = help_screen(80, 24, true);
let hint = help_hint(&end);
assert!(hint.contains('\u{2191}'), "no more-above marker at the end: {hint:?}");
assert!(!hint.contains('\u{2193}'), "still claims more below at the end: {hint:?}");
let fits_h = HELP.lines().count() as u16 + 6;
let fits_w = HELP.lines().map(|l| l.chars().count()).max().unwrap_or(0) as u16 + 8;
let whole = help_screen(fits_w, fits_h, false);
let hint = help_hint(&whole);
assert!(
!hint.contains('\u{2193}') && !hint.contains('\u{2191}'),
"markers drawn over a dialog that fits: {hint:?}"
);
assert!(
hint.contains("any key to dismiss") && !hint.contains("scroll"),
"a dialog that fits should still promise any key: {hint:?}"
);
}
#[test]
fn a_narrow_help_folds_its_lines_instead_of_cutting_them() {
let narrow = help_screen(60, 40, false);
for tail in ["result)", "$EDITOR", "confirmation)"] {
assert!(
narrow.contains(tail),
"{tail:?} was truncated rather than folded:\n{narrow}"
);
}
}
fn header_row(screen: &str) -> &str {
screen.lines().next().unwrap_or_default()
}
#[test]
fn the_pane_tabs_appear_only_when_a_pane_is_hidden() {
let zoom = screen(60, 80, Focus::Tree);
let zoomed = header_row(&zoom);
assert!(zoomed.contains("[2]"), "no tabs in zoom mode: {zoomed}");
assert!(zoomed.contains(" 3 "), "no third tab: {zoomed}");
let wide = screen(100, 80, Focus::Tree);
let split = header_row(&wide);
for (n, _) in TABS {
assert!(
!split.contains(&format!("[{n}]")),
"tabs drawn beside both panes: {split}"
);
}
}
#[test]
fn the_current_pane_is_the_marked_tab() {
for (focus, marked) in [
(Focus::Repos, "[1]"),
(Focus::Tree, "[2]"),
(Focus::Detail, "[3]"),
] {
let s = screen(60, 80, focus);
let row = header_row(&s);
assert!(row.contains(marked), "{focus:?} should mark {marked}: {row}");
assert_eq!(
row.matches('[').count(),
1,
"two tabs marked at once: {row}"
);
}
}
#[test]
fn a_panes_own_number_matches_its_header_tab() {
for (focus, marked) in [
(Focus::Repos, "[1]"),
(Focus::Tree, "[2]"),
(Focus::Detail, "[3]"),
] {
assert_eq!(pane_number(focus), marked);
let s = screen(60, 80, focus);
let row = header_row(&s);
assert!(
row.contains(&pane_number(focus)),
"the header marks a different pane than {focus:?} draws: {row}"
);
}
}
#[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!(
header_row(&s).contains("[3]"),
"{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("next / prev 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.repos_pane_above = 0;
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 clicking_where_the_marker_was_really_drawn_closes_the_row() {
for ic in crate::icons::ALL.iter() {
let tasks = vec![
task("parent", None, "Parent task"),
task("child", Some("parent"), "Child task"),
task("grandchild", Some("child"), "Grandchild task"),
];
let (buf, mut app) = render_selection(tasks, "parent", Focus::Tree, ic, 100, 20);
assert_eq!(
app.row_ids(),
vec!["parent", "child", "grandchild"],
"{:?}: the fixture did not render open",
ic.tier
);
let y = row_of(&buf, "Child task");
let x = (0..buf.area.width)
.find(|&x| buf[(x, y)].symbol() == ic.expanded)
.unwrap_or_else(|| panic!("{:?}: no open marker was drawn", ic.tier));
app.click_tree(x - 1, y);
assert_eq!(
app.row_ids().len(),
3,
"{:?}: the branch character acted as a marker",
ic.tier
);
app.click_tree(x, y);
assert_eq!(
app.row_ids(),
vec!["parent", "child"],
"{:?}: a click on the drawn marker did not close the row",
ic.tier
);
}
}
#[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");
app.repos_pane_above = 0;
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:?}"
);
}
#[test]
fn the_sidebar_shows_repos_with_their_worktrees() {
let mut app = App::new(
vec![task("a", None, "A task")],
"demo".into(),
crate::config::Config::default(),
);
app.terminal_width = 140;
app.repos_pane_above = 110;
app.repos_visible = true; app.repos = vec![crate::repos::Repo {
name: "dextui".into(),
path: "/x/dextui".into(),
worktrees: vec![crate::worktree::Worktree {
path: "/x/dextui".into(),
branch: "main".into(),
is_main: true,
is_locked: false,
is_detached: false,
}],
open: true,
registered: true,
is_global: false,
}];
app.rebuild();
let mut terminal = Terminal::new(TestBackend::new(140, 14)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
let buf = terminal.backend().buffer();
let text: String = (0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("dextui"), "no repo row: {text}");
assert!(text.contains("main"), "no worktree row: {text}");
}
#[test]
fn selecting_a_row_below_the_fold_scrolls_the_sidebar_to_show_it() {
let mut app = App::new(
vec![task("a", None, "A task")],
"demo".into(),
crate::config::Config::default(),
);
app.terminal_width = 140;
app.repos_pane_above = 110;
app.repos_visible = true; app.repos = (0..20)
.map(|i| crate::repos::Repo {
name: format!("repo{i}"),
path: format!("/x/repo{i}"),
worktrees: vec![],
open: false,
registered: true,
is_global: false,
})
.collect();
app.rebuild();
let render = |app: &mut App| -> String {
let mut terminal = Terminal::new(TestBackend::new(140, 14)).unwrap();
terminal.draw(|f| draw(f, 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")
};
let first_frame = render(&mut app);
assert!(first_frame.contains("repo0"), "the top row should be visible initially");
assert!(
!first_frame.contains("repo19"),
"the fixture should not already fit everything: {first_frame}"
);
app.select_last_repo_row();
let scrolled = render(&mut app);
assert!(
scrolled.contains("repo19"),
"the selected row must have scrolled into view: {scrolled}"
);
assert!(
!scrolled.contains("repo0"),
"the old top row should have scrolled out of view: {scrolled}"
);
}
#[test]
fn single_pane_repos_focus_draws_the_repos_pane_not_the_tree() {
let mut app = App::new(
vec![task("a", None, "Only Task")],
"demo".into(),
crate::config::Config::default(),
);
app.single_pane_below = 9999; app.focus = Focus::Repos;
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();
let text: String = (0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("repos"), "repos pane title missing:\n{text}");
assert!(
!text.contains("Only Task"),
"the tree, not the repos pane, was drawn:\n{text}"
);
}
#[test]
fn every_width_draws_without_panicking() {
for w in [40u16, 79, 80, 109, 110, 160] {
let mut app = App::new(
vec![task("a", None, "A task")],
"demo".into(),
crate::config::Config::default(),
);
app.terminal_width = w;
app.rebuild();
let mut terminal = Terminal::new(TestBackend::new(w, 14)).unwrap();
terminal
.draw(|f| draw(f, &mut app, &crate::icons::UNICODE))
.unwrap();
}
}
}