1use omp_core::{Str, fmts};
2use smallvec::SmallVec;
3
4use crate::{
5 component::{Component, PaintCtx, Slot, next_slot},
6 context::UiContext,
7 frame::{Rect, Style},
8 markup::Border,
9 props::{Prop, PropValue, Props},
10 rich::cell_width,
11};
12
13#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16pub enum TaskStatus {
17 #[default]
19 Pending,
20 Active,
22 Done,
24 Dropped,
26 Blocked,
28}
29
30impl TaskStatus {
31 pub fn parse(name: &str) -> Option<Self> {
33 Some(match name {
34 "pending" | "open" => Self::Pending,
35 "active" | "in-progress" | "in_progress" => Self::Active,
36 "done" | "completed" => Self::Done,
37 "dropped" | "abandoned" => Self::Dropped,
38 "blocked" => Self::Blocked,
39 _ => return None,
40 })
41 }
42}
43
44pub struct TodoTask {
51 props: Props,
52 label: Str,
53 children: Vec<Self>,
54}
55
56impl TodoTask {
57 pub fn new() -> Self {
59 Self { props: Props::new(), label: Str::default(), children: Vec::new() }
60 }
61
62 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
64 self.props.set(prop, value);
65 self
66 }
67
68 pub fn label(mut self, label: impl Into<Str>) -> Self {
70 let suffix = label.into();
71 if self.label.is_empty() {
72 self.label = suffix;
73 } else {
74 self.label = fmts!("{}{}", self.label, suffix);
75 }
76 self
77 }
78
79 pub fn status(mut self, status: TaskStatus) -> Self {
81 let name = match status {
82 TaskStatus::Pending => "pending",
83 TaskStatus::Active => "active",
84 TaskStatus::Done => "done",
85 TaskStatus::Dropped => "dropped",
86 TaskStatus::Blocked => "blocked",
87 };
88 self.props.set(Prop::Status, name);
89 self
90 }
91
92 pub fn task(mut self, task: Self) -> Self {
94 self.children.push(task);
95 self
96 }
97
98 fn effective_label(&self) -> &str {
99 if self.label.is_empty() {
100 self.props.str_of(Prop::Label).map_or("", Str::as_str)
101 } else {
102 &self.label
103 }
104 }
105
106 fn effective_status(&self) -> TaskStatus {
107 self
108 .props
109 .str_of(Prop::Status)
110 .and_then(|name| TaskStatus::parse(name))
111 .unwrap_or_default()
112 }
113}
114
115impl Default for TodoTask {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121pub struct Todo {
127 props: Props,
128 slot: Slot,
129 tasks: Vec<TodoTask>,
130}
131
132impl Todo {
133 pub fn new() -> Self {
135 Self { props: Props::new(), slot: next_slot(), tasks: Vec::new() }
136 }
137
138 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
140 self.props.set(prop, value);
141 self
142 }
143
144 pub fn task(mut self, task: TodoTask) -> Self {
146 self.tasks.push(task);
147 self
148 }
149
150 pub fn counts(&self) -> (usize, usize) {
153 leaf_counts(&self.tasks)
154 }
155
156 fn family(&self) -> Border {
157 self.props.guides().unwrap_or(Border::Square)
158 }
159
160 fn row_count(tasks: &[TodoTask]) -> u16 {
161 let mut rows = 0u16;
162 for task in tasks {
163 rows = rows
164 .saturating_add(1)
165 .saturating_add(Self::row_count(&task.children));
166 }
167 rows
168 }
169
170 fn max_width(tasks: &[TodoTask], depth: u16) -> u16 {
171 let mut widest = 0u16;
172 for task in tasks {
173 let width = cell_width(task.effective_label())
175 .saturating_add(depth.saturating_mul(2))
176 .saturating_add(10);
177 widest = widest
178 .max(width)
179 .max(Self::max_width(&task.children, depth + 1));
180 }
181 widest
182 }
183}
184fn leaf_counts(tasks: &[TodoTask]) -> (usize, usize) {
186 let (mut done, mut total) = (0, 0);
187 for task in tasks {
188 if task.children.is_empty() {
189 total += 1;
190 done += usize::from(task.effective_status() == TaskStatus::Done);
191 } else {
192 let (child_done, child_total) = leaf_counts(&task.children);
193 done += child_done;
194 total += child_total;
195 }
196 }
197 (done, total)
198}
199
200impl Default for Todo {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206impl Component for Todo {
207 fn props(&self) -> &Props {
208 &self.props
209 }
210
211 fn props_mut(&mut self) -> &mut Props {
212 &mut self.props
213 }
214
215 fn slot(&self) -> Slot {
216 self.slot
217 }
218
219 fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
220 (12, Self::max_width(&self.tasks, 0).max(12))
221 }
222
223 fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
224 Self::row_count(&self.tasks)
225 }
226
227 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
228 let (branch, last, cont) = pc.ctx.charset.guides(self.family());
229 let glyphs = Glyphs {
230 branch,
231 last,
232 cont,
233 checked: pc.ctx.charset.checkbox(true),
234 unchecked: pc.ctx.charset.checkbox(false),
235 };
236 let mut y = rect.y;
237 let mut trail: SmallVec<bool, 8> = SmallVec::new();
238 paint_tasks(pc, rect, &glyphs, &self.tasks, &mut trail, &mut y);
239 }
240}
241
242struct Glyphs {
243 branch: &'static str,
244 last: &'static str,
245 cont: &'static str,
246 checked: &'static str,
247 unchecked: &'static str,
248}
249
250fn paint_tasks(
251 pc: &mut PaintCtx<'_>,
252 rect: Rect,
253 glyphs: &Glyphs,
254 tasks: &[TodoTask],
255 trail: &mut SmallVec<bool, 8>,
256 y: &mut u16,
257) {
258 let bottom = rect.y.saturating_add(rect.height).min(pc.clip);
259 let count = tasks.len();
260 for (index, task) in tasks.iter().enumerate() {
261 if *y >= bottom {
262 return;
263 }
264 let is_last = index + 1 == count;
265 let mut x = rect.x;
266 let guide = Style::new().fg(pc.ctx.theme.muted);
267 if !trail.is_empty() {
270 for &more in &trail[1..] {
271 x = pc
272 .frame
273 .put(x, *y, if more { glyphs.cont } else { " " }, guide);
274 }
275 x = pc
276 .frame
277 .put(x, *y, if is_last { glyphs.last } else { glyphs.branch }, guide);
278 x = pc.frame.put(x, *y, " ", guide);
279 }
280 let label = task.effective_label();
281 if task.children.is_empty() {
282 let theme = &pc.ctx.theme;
283 let status = task.effective_status();
284 let (glyph, style) = match status {
285 TaskStatus::Done => (glyphs.checked, Style::new().fg(theme.ok)),
286 TaskStatus::Active => (glyphs.unchecked, Style::new().fg(theme.accent)),
287 TaskStatus::Dropped => (glyphs.unchecked, Style::new().fg(theme.err)),
288 TaskStatus::Blocked => (glyphs.unchecked, Style::new().fg(theme.warn)),
289 TaskStatus::Pending => (glyphs.unchecked, Style::new().dim()),
290 };
291 x = pc.frame.put(x, *y, glyph, style);
292 x = pc.frame.put(x, *y, " ", style);
293 let label_style = match status {
294 TaskStatus::Done | TaskStatus::Dropped => style.strikethrough(),
295 _ => style,
296 };
297 x = pc.frame.put(x, *y, label, label_style);
298 if status == TaskStatus::Blocked {
299 let note = task.props.str_of(Prop::Desc).map_or_else(
300 || Str::new_static(" (blocked)"),
301 |reason| fmts!(" (blocked: {reason})"),
302 );
303 pc.frame.put(x, *y, ¬e, Style::new().dim());
304 }
305 } else {
306 let (done, total) = leaf_counts(&task.children);
309 x = pc
310 .frame
311 .put(x, *y, label, Style::new().fg(pc.ctx.theme.fg).bold());
312 let counter = fmts!(" {done}/{total}");
313 pc.frame.put(x, *y, &counter, Style::new().dim());
314 }
315 *y = y.saturating_add(1);
316 if !task.children.is_empty() {
317 trail.push(!is_last);
318 paint_tasks(pc, rect, glyphs, &task.children, trail, y);
319 trail.pop();
320 }
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn counts_walk_nested_leaves_only() {
330 let todo = Todo::new()
331 .task(
332 TodoTask::new()
333 .label("phase")
334 .task(TodoTask::new().label("a").status(TaskStatus::Done))
335 .task(TodoTask::new().label("b")),
336 )
337 .task(TodoTask::new().label("flat").status(TaskStatus::Done));
338 assert_eq!(todo.counts(), (2, 3));
339 }
340
341 #[test]
342 fn status_parse_accepts_agent_aliases_and_rejects_junk() {
343 assert_eq!(TaskStatus::parse("in_progress"), Some(TaskStatus::Active));
344 assert_eq!(TaskStatus::parse("completed"), Some(TaskStatus::Done));
345 assert_eq!(TaskStatus::parse("abandoned"), Some(TaskStatus::Dropped));
346 assert_eq!(TaskStatus::parse("nope"), None);
347 }
348}