anvilsign in

collin/anvil · af60789f

feat: TODO.md board — nested-heading tickets, cleaner cards

Collin Richards · 2026-06-10 10:46 UTC · af60789f200e8aa4c78e5bcaf93c4c0c705545a9 · parent 632e6157 · browse files

modifiedcrates/anvil-web/src/todomd.rs+181 −78
11 //! todo-md parsing and kanban rendering.
22 //!
3-//! Implements the todo-md spec (the `todo-md` repo): a `TODO.md` is split
4-//! into sections by ATX headings; `- [ ]` / `- [x]` list items in a section
5-//! are tasks; lines indented under a task are its details (opaque markdown).
6-//! A heading containing `[x]` marks a done section — every task in it counts
7-//! as done. Sections that contain tasks render as kanban columns; prose-only
8-//! sections render as ordinary markdown below the board.
3+//! Implements the todo-md spec (the `todo-md` repo). A `TODO.md` is split into
4+//! sections by the shallowest heading level used (the *column level*):
95 //!
10-//! This module is the first "custom renderer" for a well-known filename;
11-//! `ui::custom_renderer` is the registry that routes filenames here.
6+//! - **Sections** — headings at the column level (e.g. every `#`). They become
7+//! kanban columns. A heading containing `[x]` marks a done section; every
8+//! task in it counts as done.
9+//! - **Tasks** come in two forms inside a section:
10+//! - **checkbox tasks** — `- [ ]` / `- [x]` list items; lines indented under
11+//! them are their details (opaque markdown).
12+//! - **tickets** — a heading nested *deeper* than the column level (e.g. a
13+//! `## Implement Navbar` under `# Done`). The heading is the ticket title
14+//! and everything beneath it, until the next heading, is its detail body.
15+//! A ticket is done if its section is done or its own heading carries
16+//! `[x]`.
17+//! - Prose-only sections render as ordinary markdown below the board.
18+//!
19+//! This module is the first "custom renderer" for a well-known filename; the
20+//! filename → renderer match in `ui::blob` is the registry that routes here.
1221
1322 use maud::{
1423 Markup,
⋯ 17 unchanged lines
3241 pub struct Task {
3342 pub done: bool,
3443 pub title: String,
35- /// Raw markdown block of nested/indented lines, dedented one level.
44+ /// Raw markdown block: a checkbox task's indented lines (dedented one
45+ /// level) or a ticket's body beneath its heading.
3646 pub details: String,
47+ /// True when the task came from a nested heading (a ticket) rather than a
48+ /// `- [ ]` checkbox line.
49+ pub ticket: bool,
3750 }
3851
52+/// Where subsequent non-structural lines accumulate.
53+#[derive(Clone, Copy, Eq, PartialEq)]
54+enum Mode {
55+ /// Section prose (no open task).
56+ Prose,
57+ /// Indented detail lines of a checkbox task (dedented one level).
58+ TaskDetail,
59+ /// Body of a ticket — runs until the next heading; verbatim.
60+ TicketBody,
61+}
62+
3963 /// Whether a path names a `TODO.md` (any directory, any case).
4064 pub fn is_todo_md(path: &str) -> bool {
4165 std::path::Path::new(path)
⋯ 1 unchanged line
4367 .is_some_and(|n| n.eq_ignore_ascii_case("TODO.md"))
4468 }
4569
70+/// The shallowest heading level in the document (the column level), or 0 if
71+/// there are no headings. Fenced regions are skipped.
72+fn column_level(text: &str) -> u8 {
73+ let mut in_fence = false;
74+ let mut min = 0u8;
75+ for line in text.lines() {
76+ if line.trim_start().starts_with("```") {
77+ in_fence = !in_fence;
78+ continue;
79+ }
80+ if in_fence {
81+ continue;
82+ }
83+ if let Some((level, _)) = heading(line)
84+ && (min == 0 || level < min)
85+ {
86+ min = level;
87+ }
88+ }
89+ min
90+}
91+
4692 /// Parse a todo-md document into sections. The preamble (content before the
4793 /// first heading) becomes a level-0 section with an empty title.
4894 pub fn parse(text: &str) -> Vec<Section> {
95+ let col = column_level(text);
4996 let mut sections = vec![Section {
5097 level: 0,
5198 title: String::new(),
⋯ 2 unchanged lines
54101 prose: String::new(),
55102 }];
56103 let mut in_fence = false;
57- // Index into the current section's tasks while detail lines may still
58- // attach; None once a non-indented line ends the task block.
59- let mut open_task = false;
104+ let mut mode = Mode::Prose;
60105
61106 for line in text.lines() {
62- let cur = sections.last_mut().expect("never empty");
63- if line.trim_start().starts_with("```") {
107+ let fence_toggle = line.trim_start().starts_with("```");
108+ let structural = !in_fence && !fence_toggle;
109+ if fence_toggle {
64110 in_fence = !in_fence;
65111 }
66- if in_fence || line.trim_start().starts_with("```") {
67- // Fenced content is never structural.
68- append_line(cur, open_task, line);
69- continue;
70- }
71112
72- if let Some((level, rest)) = heading(line) {
73- let done = rest.contains("[x]") || rest.contains("[X]") || rest.contains('✓');
74- let title = rest
75- .replace("[x]", "")
76- .replace("[X]", "")
77- .replace('✓', "")
78- .trim()
79- .to_string();
80- sections.push(Section {
81- level,
82- title,
83- done,
84- tasks: Vec::new(),
85- prose: String::new(),
86- });
87- open_task = false;
88- continue;
89- }
113+ if structural {
114+ if let Some((level, rest)) = heading(line) {
115+ let cur = sections.last_mut().expect("never empty");
116+ if col != 0 && level > col {
117+ // A heading deeper than the column level is a ticket in
118+ // the current section.
119+ cur.tasks.push(Task {
120+ done: cur.done || marked_done(rest),
121+ title: strip_marker(rest),
122+ details: String::new(),
123+ ticket: true,
124+ });
125+ mode = Mode::TicketBody;
126+ } else {
127+ sections.push(Section {
128+ level,
129+ title: strip_marker(rest),
130+ done: marked_done(rest),
131+ tasks: Vec::new(),
132+ prose: String::new(),
133+ });
134+ mode = Mode::Prose;
135+ }
136+ continue;
137+ }
90138
91- if let Some((done, title)) = task_line(line) {
92- cur.tasks.push(Task {
93- done,
94- title: title.to_string(),
95- details: String::new(),
96- });
97- open_task = true;
98- continue;
139+ // Inside a ticket body only a heading (handled above) ends it;
140+ // everything else — prose, checkbox lines, blanks — is body.
141+ if mode != Mode::TicketBody {
142+ if let Some((done, title)) = task_line(line) {
143+ sections.last_mut().expect("never empty").tasks.push(Task {
144+ done,
145+ title: title.to_string(),
146+ details: String::new(),
147+ ticket: false,
148+ });
149+ mode = Mode::TaskDetail;
150+ continue;
151+ }
152+ // A non-indented, non-blank line ends a checkbox task's
153+ // details and reverts to section prose.
154+ if mode == Mode::TaskDetail && !line.is_empty() && !line.starts_with(" ") {
155+ mode = Mode::Prose;
156+ }
157+ }
99158 }
100159
101- // Indented (or blank) lines under a task are its details; anything
102- // else is section prose and closes the task block.
103- if open_task && !line.is_empty() && !line.starts_with(" ") {
104- open_task = false;
105- }
106- append_line(cur, open_task, line);
160+ append_line(sections.last_mut().expect("never empty"), mode, line);
107161 }
108162
109163 for s in &mut sections {
⋯ 12 unchanged lines
122176 sections
123177 }
124178
125-fn append_line(section: &mut Section, to_task: bool, line: &str) {
126- let target = if to_task {
127- let t = section.tasks.last_mut().expect("open task exists");
128- &mut t.details
129- } else {
130- &mut section.prose
131- };
132- // Dedent detail lines one level (2–4 spaces) so they render as their own
133- // markdown rather than a code block.
134- let line = if to_task {
135- let spaces = line.len() - line.trim_start_matches(' ').len();
136- &line[spaces.min(4).min(line.len())..]
137- } else {
138- line
139- };
140- target.push_str(line);
141- target.push('\n');
179+fn append_line(section: &mut Section, mode: Mode, line: &str) {
180+ match mode {
181+ Mode::Prose => {
182+ section.prose.push_str(line);
183+ section.prose.push('\n');
184+ }
185+ Mode::TaskDetail => {
186+ let t = section.tasks.last_mut().expect("open task exists");
187+ // Dedent one level (2–4 spaces) so details render as their own
188+ // markdown rather than a code block.
189+ let spaces = line.len() - line.trim_start_matches(' ').len();
190+ t.details.push_str(&line[spaces.min(4).min(line.len())..]);
191+ t.details.push('\n');
192+ }
193+ Mode::TicketBody => {
194+ let t = section.tasks.last_mut().expect("open ticket exists");
195+ t.details.push_str(line);
196+ t.details.push('\n');
197+ }
198+ }
142199 }
143200
144201 fn heading(line: &str) -> Option<(u8, &str)> {
⋯ 5 unchanged lines
150207 }
151208 }
152209
210+/// Whether a heading's text carries a done marker (`[x]`, ASCII; `✓` accepted
211+/// for todomd compatibility).
212+fn marked_done(heading_rest: &str) -> bool {
213+ heading_rest.contains("[x]") || heading_rest.contains("[X]") || heading_rest.contains('✓')
214+}
215+
216+fn strip_marker(heading_rest: &str) -> String {
217+ heading_rest
218+ .replace("[x]", "")
219+ .replace("[X]", "")
220+ .replace('✓', "")
221+ .trim()
222+ .to_string()
223+}
224+
153225 fn task_line(line: &str) -> Option<(bool, &str)> {
154226 let open = line.strip_prefix("- [ ] ");
155227 let done = line.strip_prefix("- [x] ").or(line.strip_prefix("- [X] "));
⋯ 49 unchanged lines
205277 }
206278 @for t in &s.tasks {
207279 div.card.done[t.done] {
208- div.title {
209- input type="checkbox" disabled checked[t.done];
210- span { (render_markdown(&t.title)) }
211- }
280+ div.title { (render_markdown(&t.title)) }
212281 @if !t.details.trim().is_empty() {
213282 details {
214283 summary { "details" }
⋯ 32 unchanged lines
247316
248317 - [ ] moved here, checkbox stale
249318
319+## Implement Navbar
320+
321+Sticky top bar with the repo switcher.
322+
323+- shipped behind a flag
324+- needs a follow-up for mobile
325+
250326 # Notes
251327
252328 just prose, no tasks
⋯ 7 unchanged lines
260336 #[test]
261337 fn parses_sections_tasks_details() {
262338 let s = parse(DOC);
263- assert_eq!(s.len(), 4); // preamble + 3 headings
339+ assert_eq!(s.len(), 4); // preamble + 3 column headings (## is a ticket)
264340 assert_eq!(s[0].level, 0);
265341 assert_eq!(s[0].prose.trim(), "intro prose");
266342
⋯ 2 unchanged lines
269345 assert_eq!(s[1].tasks.len(), 2);
270346 assert_eq!(s[1].tasks[0].title, "first task");
271347 assert!(!s[1].tasks[0].done);
348+ assert!(!s[1].tasks[0].ticket);
272349 assert_eq!(s[1].tasks[0].details, "- a detail line\n- another `detail`");
273350 assert!(s[1].tasks[1].done);
274351
275352 // Done section: heading marker wins over the task's own checkbox.
276353 assert_eq!(s[2].title, "Done");
277354 assert!(s[2].done);
355+ assert_eq!(s[2].tasks.len(), 2); // checkbox task + the ticket
278356 assert!(s[2].tasks[0].done);
357+ assert!(!s[2].tasks[0].ticket);
358+ }
359+
360+ #[test]
361+ fn nested_heading_is_a_ticket() {
362+ let s = parse(DOC);
363+ let ticket = &s[2].tasks[1];
364+ assert!(ticket.ticket);
365+ assert_eq!(ticket.title, "Implement Navbar");
366+ // Done inherited from the `# Done [x]` section.
367+ assert!(ticket.done);
368+ assert!(ticket.details.contains("Sticky top bar"));
369+ assert!(ticket.details.contains("- needs a follow-up for mobile"));
370+ // The body must not leak into a sibling section.
371+ assert!(!ticket.details.contains("just prose"));
372+ }
279373
280- // Fenced content is not structural.
281- assert_eq!(s[3].title, "Notes");
282- assert!(s[3].tasks.is_empty());
283- assert!(s[3].prose.contains("# not a heading"));
374+ #[test]
375+ fn ticket_own_done_marker() {
376+ let s = parse("# Backlog\n\n## Fix login [x]\n\nbody\n\n## Add search\n\nbody\n");
377+ assert!(!s[1].done); // section is open
378+ assert_eq!(s[1].tasks.len(), 2);
379+ assert!(s[1].tasks[0].done); // ticket marked done on its own heading
380+ assert_eq!(s[1].tasks[0].title, "Fix login");
381+ assert!(!s[1].tasks[1].done);
284382 }
285383
286384 #[test]
⋯ 5 unchanged lines
292390 }
293391
294392 #[test]
295- fn board_renders_columns_and_falls_back() {
393+ fn board_renders_columns_tickets_and_falls_back() {
296394 let board = render_board(DOC).expect("has tasks").into_string();
297395 assert!(board.contains("kanban"));
298396 assert!(board.contains("Now"));
299397 assert!(board.contains("first task"));
398+ // Tickets render as ordinary cards — no checkbox input, no special
399+ // card class. (The DOC text mentions "checkbox", so check elements.)
400+ assert!(board.contains("Implement Navbar"));
401+ assert!(!board.contains("<input"), "no checkbox clutter: {board}");
402+ assert!(!board.contains("ticket\""), "no ticket card class: {board}");
300403 assert!(board.contains("just prose"), "notes area kept: {board}");
301404 assert!(render_board("# readme\n\nonly prose\n").is_none());
302405 }
⋯ 1 unchanged line
modifiedcrates/anvil-web/src/ui.rs+30 −20
⋯ 166 unchanged lines
167167 .btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
168168 .readme { margin-top:16px; }
169169 .readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
170-.kanban { display:flex; gap:12px; align-items:flex-start; overflow-x:auto; padding:12px; }
171-.kanban .col { flex:1 1 0; min-width:230px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:8px; }
172-.kanban .col h3 { margin:2px 4px 8px; font-size:13px; display:flex; align-items:baseline; gap:8px; }
173-.kanban .col h3 .count { font-weight:400; font-size:12px; color:var(--muted); margin-left:auto; }
174-.kanban .card { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; margin-bottom:8px; font-size:13px; }
175-.kanban .card:last-child { margin-bottom:0; }
176-.kanban .card .title { display:flex; gap:6px; align-items:baseline; }
177-.kanban .card .title p { margin:0; display:inline; }
178-.kanban .card.done .title { color:var(--muted); text-decoration:line-through; }
179-.kanban .card details { margin-top:4px; }
180-.kanban .card summary { cursor:pointer; font-size:12px; color:var(--muted); }
181-.kanban .card .card-details { padding:4px 0 0 6px; font-size:12px; }
182-.kanban .card .card-details p { margin:0 0 4px; }
183-.todo-notes { margin:0 12px 12px; }
170+/* Kanban: cards are the only boxes. Columns are headers + whitespace, no
171+ nested frames. */
172+.kanban { display:flex; gap:20px; align-items:flex-start; overflow-x:auto; padding:4px 2px 8px; }
173+.kanban .col { flex:1 1 0; min-width:240px; }
174+.kanban .col h3 { margin:0 0 12px; padding:0 2px 8px; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); display:flex; align-items:baseline; gap:8px; border-bottom:1px solid var(--border); }
175+.kanban .col h3 .count { font-weight:400; letter-spacing:0; text-transform:none; font-size:12px; margin-left:auto; }
176+.kanban .card { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:9px 12px; margin-bottom:8px; font-size:13px; line-height:1.45; box-shadow:0 1px 2px rgba(27,31,36,.05); }
177+.kanban .card .title p { margin:0; font-weight:500; }
178+.kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
179+.kanban .card details { margin-top:7px; }
180+.kanban .card summary { cursor:pointer; font-size:12px; color:var(--accent); list-style:none; }
181+.kanban .card summary::-webkit-details-marker { display:none; }
182+.kanban .card summary::before { content:"›"; display:inline-block; width:12px; transition:transform .15s ease; }
183+.kanban .card details[open] summary::before { transform:rotate(90deg); }
184+.kanban .card .card-details { padding:7px 0 1px; font-size:12px; color:var(--muted); }
185+.kanban .card .card-details p { margin:0 0 5px; }
186+.kanban .card .card-details ul { margin:4px 0; padding-left:16px; }
187+.kanban .card .card-details > :last-child { margin-bottom:0; }
188+.todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; }
189+.todo-notes { margin:8px 2px; }
184190 .todo-notes > summary { cursor:pointer; font-size:13px; color:var(--muted); }
185191 .latest-commit { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); border:1px solid var(--border); border-radius:6px 6px 0 0; border-bottom:0; padding:8px 16px; }
186192 .latest-commit + .box { border-radius:0 0 6px 6px; }
⋯ 787 unchanged lines
974980 }
975981 }
976982 @if let Some((board, name)) = &todo_board {
977- div.box.readme {
978- div.readme-head {
979- a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
980- }
981- (board)
983+ p.todo-board-head {
984+ a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
982985 }
986+ (board)
983987 }
984988 },
985989 ))
⋯ 114 unchanged lines
11001104 }
11011105 }
11021106 }
1103- div.box style="overflow-x:auto" { (body) }
1107+ @if board.is_some() {
1108+ // The board supplies its own column structure; an enclosing
1109+ // box would just nest frames.
1110+ (body)
1111+ } @else {
1112+ div.box style="overflow-x:auto" { (body) }
1113+ }
11041114 },
11051115 ))
11061116 }
⋯ 740 unchanged lines