Skip to main content

omp_tui/components/
todo.rs

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/// Lifecycle state of a [`TodoTask`], mirroring the coding agent's todo
14/// tracker: open work, the one active item, and the three closed shapes.
15#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16pub enum TaskStatus {
17	/// Not started; renders dim with an empty checkbox.
18	#[default]
19	Pending,
20	/// Currently being worked; renders accent.
21	Active,
22	/// Finished; renders ok with a checked box and struck label.
23	Done,
24	/// Abandoned; renders err with a struck label.
25	Dropped,
26	/// Waiting on something external; renders warn with the blocker note.
27	Blocked,
28}
29
30impl TaskStatus {
31	/// Parses a markup `status=` value, accepting the agent-side aliases.
32	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
44/// One row of a [`Todo`] list, backing the `<task>` markup tag.
45///
46/// A task with children renders as a group header with an automatic
47/// `done/total` count over its direct children; a leaf renders a status
48/// checkbox and its label. `status=` sets [`TaskStatus`]; `desc=` carries
49/// the blocker note shown by [`TaskStatus::Blocked`].
50pub struct TodoTask {
51	props:    Props,
52	label:    Str,
53	children: Vec<Self>,
54}
55
56impl TodoTask {
57	/// Creates a pending, empty task.
58	pub fn new() -> Self {
59		Self { props: Props::new(), label: Str::default(), children: Vec::new() }
60	}
61
62	/// Sets one task property.
63	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
64		self.props.set(prop, value);
65		self
66	}
67
68	/// Appends label text.
69	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	/// Sets the lifecycle state.
80	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	/// Appends a child task, turning this task into a group header.
93	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
121/// A static todo list backing the `<todo>` markup tag.
122///
123/// Children are [`TodoTask`] records; nesting produces tree guides in the
124/// family chosen by `guides=` (square by default). Unlike [`crate::Tree`],
125/// the list is display-only: no focus, keys, or collapse state.
126pub struct Todo {
127	props: Props,
128	slot:  Slot,
129	tasks: Vec<TodoTask>,
130}
131
132impl Todo {
133	/// Creates an empty todo list.
134	pub fn new() -> Self {
135		Self { props: Props::new(), slot: next_slot(), tasks: Vec::new() }
136	}
137
138	/// Sets one list property.
139	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
140		self.props.set(prop, value);
141		self
142	}
143
144	/// Appends a root task.
145	pub fn task(mut self, task: TodoTask) -> Self {
146		self.tasks.push(task);
147		self
148	}
149
150	/// Leaf `(done, total)` across the whole list, for host-built headers
151	/// like `3/14 tasks`.
152	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			// gutter columns + connector + checkbox/count slack
174			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}
184/// Leaf `(done, total)` under `tasks`; groups contribute their descendants.
185fn 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		// Ancestor gutters, then this row's connector. Roots draw neither,
268		// and the root level's continuation bit is never displayed.
269		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, &note, Style::new().dim());
304			}
305		} else {
306			// Group header: bold label plus an automatic done/total count
307			// over its descendant leaves.
308			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}