use omp_core::{Str, fmts};
use smallvec::SmallVec;
use crate::{
component::{Component, PaintCtx, Slot, next_slot},
context::UiContext,
frame::{Rect, Style},
markup::Border,
props::{Prop, PropValue, Props},
rich::cell_width,
};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TaskStatus {
#[default]
Pending,
Active,
Done,
Dropped,
Blocked,
}
impl TaskStatus {
pub fn parse(name: &str) -> Option<Self> {
Some(match name {
"pending" | "open" => Self::Pending,
"active" | "in-progress" | "in_progress" => Self::Active,
"done" | "completed" => Self::Done,
"dropped" | "abandoned" => Self::Dropped,
"blocked" => Self::Blocked,
_ => return None,
})
}
}
pub struct TodoTask {
props: Props,
label: Str,
children: Vec<Self>,
}
impl TodoTask {
pub fn new() -> Self {
Self { props: Props::new(), label: Str::default(), children: Vec::new() }
}
pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
self.props.set(prop, value);
self
}
pub fn label(mut self, label: impl Into<Str>) -> Self {
let suffix = label.into();
if self.label.is_empty() {
self.label = suffix;
} else {
self.label = fmts!("{}{}", self.label, suffix);
}
self
}
pub fn status(mut self, status: TaskStatus) -> Self {
let name = match status {
TaskStatus::Pending => "pending",
TaskStatus::Active => "active",
TaskStatus::Done => "done",
TaskStatus::Dropped => "dropped",
TaskStatus::Blocked => "blocked",
};
self.props.set(Prop::Status, name);
self
}
pub fn task(mut self, task: Self) -> Self {
self.children.push(task);
self
}
fn effective_label(&self) -> &str {
if self.label.is_empty() {
self.props.str_of(Prop::Label).map_or("", Str::as_str)
} else {
&self.label
}
}
fn effective_status(&self) -> TaskStatus {
self
.props
.str_of(Prop::Status)
.and_then(|name| TaskStatus::parse(name))
.unwrap_or_default()
}
}
impl Default for TodoTask {
fn default() -> Self {
Self::new()
}
}
pub struct Todo {
props: Props,
slot: Slot,
tasks: Vec<TodoTask>,
}
impl Todo {
pub fn new() -> Self {
Self { props: Props::new(), slot: next_slot(), tasks: Vec::new() }
}
pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
self.props.set(prop, value);
self
}
pub fn task(mut self, task: TodoTask) -> Self {
self.tasks.push(task);
self
}
pub fn counts(&self) -> (usize, usize) {
leaf_counts(&self.tasks)
}
fn family(&self) -> Border {
self.props.guides().unwrap_or(Border::Square)
}
fn row_count(tasks: &[TodoTask]) -> u16 {
let mut rows = 0u16;
for task in tasks {
rows = rows
.saturating_add(1)
.saturating_add(Self::row_count(&task.children));
}
rows
}
fn max_width(tasks: &[TodoTask], depth: u16) -> u16 {
let mut widest = 0u16;
for task in tasks {
let width = cell_width(task.effective_label())
.saturating_add(depth.saturating_mul(2))
.saturating_add(10);
widest = widest
.max(width)
.max(Self::max_width(&task.children, depth + 1));
}
widest
}
}
fn leaf_counts(tasks: &[TodoTask]) -> (usize, usize) {
let (mut done, mut total) = (0, 0);
for task in tasks {
if task.children.is_empty() {
total += 1;
done += usize::from(task.effective_status() == TaskStatus::Done);
} else {
let (child_done, child_total) = leaf_counts(&task.children);
done += child_done;
total += child_total;
}
}
(done, total)
}
impl Default for Todo {
fn default() -> Self {
Self::new()
}
}
impl Component for Todo {
fn props(&self) -> &Props {
&self.props
}
fn props_mut(&mut self) -> &mut Props {
&mut self.props
}
fn slot(&self) -> Slot {
self.slot
}
fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
(12, Self::max_width(&self.tasks, 0).max(12))
}
fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
Self::row_count(&self.tasks)
}
fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
let (branch, last, cont) = pc.ctx.charset.guides(self.family());
let glyphs = Glyphs {
branch,
last,
cont,
checked: pc.ctx.charset.checkbox(true),
unchecked: pc.ctx.charset.checkbox(false),
};
let mut y = rect.y;
let mut trail: SmallVec<bool, 8> = SmallVec::new();
paint_tasks(pc, rect, &glyphs, &self.tasks, &mut trail, &mut y);
}
}
struct Glyphs {
branch: &'static str,
last: &'static str,
cont: &'static str,
checked: &'static str,
unchecked: &'static str,
}
fn paint_tasks(
pc: &mut PaintCtx<'_>,
rect: Rect,
glyphs: &Glyphs,
tasks: &[TodoTask],
trail: &mut SmallVec<bool, 8>,
y: &mut u16,
) {
let bottom = rect.y.saturating_add(rect.height).min(pc.clip);
let count = tasks.len();
for (index, task) in tasks.iter().enumerate() {
if *y >= bottom {
return;
}
let is_last = index + 1 == count;
let mut x = rect.x;
let guide = Style::new().fg(pc.ctx.theme.muted);
if !trail.is_empty() {
for &more in &trail[1..] {
x = pc
.frame
.put(x, *y, if more { glyphs.cont } else { " " }, guide);
}
x = pc
.frame
.put(x, *y, if is_last { glyphs.last } else { glyphs.branch }, guide);
x = pc.frame.put(x, *y, " ", guide);
}
let label = task.effective_label();
if task.children.is_empty() {
let theme = &pc.ctx.theme;
let status = task.effective_status();
let (glyph, style) = match status {
TaskStatus::Done => (glyphs.checked, Style::new().fg(theme.ok)),
TaskStatus::Active => (glyphs.unchecked, Style::new().fg(theme.accent)),
TaskStatus::Dropped => (glyphs.unchecked, Style::new().fg(theme.err)),
TaskStatus::Blocked => (glyphs.unchecked, Style::new().fg(theme.warn)),
TaskStatus::Pending => (glyphs.unchecked, Style::new().dim()),
};
x = pc.frame.put(x, *y, glyph, style);
x = pc.frame.put(x, *y, " ", style);
let label_style = match status {
TaskStatus::Done | TaskStatus::Dropped => style.strikethrough(),
_ => style,
};
x = pc.frame.put(x, *y, label, label_style);
if status == TaskStatus::Blocked {
let note = task.props.str_of(Prop::Desc).map_or_else(
|| Str::new_static(" (blocked)"),
|reason| fmts!(" (blocked: {reason})"),
);
pc.frame.put(x, *y, ¬e, Style::new().dim());
}
} else {
let (done, total) = leaf_counts(&task.children);
x = pc
.frame
.put(x, *y, label, Style::new().fg(pc.ctx.theme.fg).bold());
let counter = fmts!(" {done}/{total}");
pc.frame.put(x, *y, &counter, Style::new().dim());
}
*y = y.saturating_add(1);
if !task.children.is_empty() {
trail.push(!is_last);
paint_tasks(pc, rect, glyphs, &task.children, trail, y);
trail.pop();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_walk_nested_leaves_only() {
let todo = Todo::new()
.task(
TodoTask::new()
.label("phase")
.task(TodoTask::new().label("a").status(TaskStatus::Done))
.task(TodoTask::new().label("b")),
)
.task(TodoTask::new().label("flat").status(TaskStatus::Done));
assert_eq!(todo.counts(), (2, 3));
}
#[test]
fn status_parse_accepts_agent_aliases_and_rejects_junk() {
assert_eq!(TaskStatus::parse("in_progress"), Some(TaskStatus::Active));
assert_eq!(TaskStatus::parse("completed"), Some(TaskStatus::Done));
assert_eq!(TaskStatus::parse("abandoned"), Some(TaskStatus::Dropped));
assert_eq!(TaskStatus::parse("nope"), None);
}
}