android: the editor draws the checklist instead of the markup for one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android / Kotlin + Rust (APK) (push) Failing after 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m53s
Desktop (Tauri) / Update manifest (push) Successful in 4s

2992. A checklist item is a real Checkbox with its text beside it, so a box can be
ticked while looking at the note — which is what M304 left undone. It changed where
a checklist is STORED and never changed what the editor draws.

The body is split into blocks and joined back on every edit, so the note underneath
is the same markdown string it was this morning. Nothing below the editor can tell
this exists: no migration, no protocol change, no new shape on the wire.

A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
like typing a paragraph, and a separate field under every sentence would break the
caret mid-sentence. Only a checklist item earns a block, because only a checklist
item needs a widget.

Two things that look like detail and are not:

  * A block carries its own TextFieldValue, and an ID that survives insertion.
    Compose keys fields by position unless told otherwise, so adding an item would
    otherwise move every caret below it up a row. Content cannot be that key —
    two empty items are identical and neither is the other.
  * Focus is hoisted to the screen rather than kept inside BlockBody, because the
    toolbar's checklist button also asks for one. Two owners of one cursor is one
    too many.

Return on an item makes the next item and puts the caret in it; on an EMPTY item
the block becomes prose, which is how a list ends and how you get a paragraph after
one — the same rule the plain text field used, now with somewhere to land. It
appends rather than splitting at the caret: splitting an item in two is a rarity,
and the caret is at the end for every ordinary use of that key.

The core gains `render_item` and `DerivedItem.line`; `item_lines` and
`checklist_lines` are gone, subsumed. Every renderer that walks a body line by line
needs the text, the state and the position TOGETHER — asking for them separately is
how two calls come to disagree about a body that changed between them. The card now
reads its items from the body for the same reason, instead of from note.items,
which is the same list by a longer route and one save behind.

WANTS A DEVICE PASS, and the focus behaviours are what to look at: return making a
row and landing in it, return twice at the end of a list getting you a paragraph,
and rotation restoring the right field. CI can only prove this compiles.
This commit is contained in:
2026-08-24 10:14:53 -04:00
parent 9a3c4ec377
commit b2435d97b6
6 changed files with 442 additions and 184 deletions
+40 -27
View File
@@ -76,6 +76,13 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
pub struct DerivedItem {
pub text: String,
pub checked: bool,
/// Which body line it sits on.
///
/// Carried here rather than offered as a second function, because every renderer
/// that walks a body line by line — the Android card, the block editor — needs the
/// text, the state AND the position together, and asking for them separately is
/// how two calls come to disagree about a body that changed between them.
pub line: u32,
}
/// One parsed task line, holding enough to put it back exactly as it was found.
@@ -143,6 +150,16 @@ fn parse_task_line(line: &str) -> Option<TaskLine<'_>> {
})
}
/// One item as the line that stores it, in canonical form.
///
/// Public because a block editor has to write a line back after someone edits it in a
/// widget that never showed them the marker. Rendering is trivial where PARSING is
/// not, but it still belongs here: this is the file that decides what canonical looks
/// like, and a caller inventing its own `- [x] ` would be a fourth opinion on it.
pub fn render_item(text: &str, checked: bool) -> String {
render_task_line("", '-', checked, text)
}
fn render_task_line(indent: &str, bullet: char, checked: bool, text: &str) -> String {
// Always lowercase `x`, whatever was parsed: one canonical output is what makes
// a round trip stable, so `- [X]` normalises the first time it is touched and
@@ -169,11 +186,12 @@ pub fn strip_marker(line: &str) -> &str {
/// Every checklist item in `body`, in the order they appear.
pub fn extract_items(body: &str) -> Vec<DerivedItem> {
let mut out = Vec::new();
for line in body.split('\n') {
for (n, line) in body.split('\n').enumerate() {
if let Some(t) = parse_task_line(line) {
out.push(DerivedItem {
text: t.text.to_string(),
checked: t.checked,
line: n as u32,
});
}
}
@@ -241,22 +259,6 @@ pub fn remove_item(body: &str, index: usize) -> String {
map_task_line(body, index, |_| None)
}
/// The body line each checklist item sits on, in item order.
///
/// For a renderer that walks the body line by line and has to know which of them are
/// items — the Android card does exactly that, and this is what saves it from
/// carrying a fourth copy of the grammar. Line numbers rather than text offsets, for
/// the same encoding reason [toggle_at] gives.
pub fn item_lines(body: &str) -> Vec<u32> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
if parse_task_line(line).is_some() {
out.push(n as u32);
}
}
out
}
/// The body with the item on `line` toggled — or None when that line is not a task
/// line, or when `column` falls outside its `[ ]` marker.
///
@@ -362,10 +364,11 @@ mod tests {
// ── checklist items ─────────────────────────────────────────────────────
fn item(text: &str, checked: bool) -> DerivedItem {
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
DerivedItem {
text: text.to_string(),
checked,
line,
}
}
@@ -374,7 +377,7 @@ mod tests {
let body = "shopping\n\n- [ ] milk\n- [x] eggs";
assert_eq!(
extract_items(body),
vec![item("milk", false), item("eggs", true)]
vec![item("milk", false, 2), item("eggs", true, 3)]
);
}
@@ -383,7 +386,7 @@ mod tests {
// The whole reason the body owns the list: a table of rows could only ever
// render after the prose.
let body = "before\n- [ ] middle\nafter";
assert_eq!(extract_items(body), vec![item("middle", false)]);
assert_eq!(extract_items(body), vec![item("middle", false, 1)]);
}
#[test]
@@ -407,20 +410,20 @@ mod tests {
let body = "* [ ] star\n - [x] indented";
assert_eq!(
extract_items(body),
vec![item("star", false), item("indented", true)]
vec![item("star", false, 0), item("indented", true, 1)]
);
}
#[test]
fn an_empty_item_is_still_an_item() {
// What pressing Enter on a list leaves behind.
assert_eq!(extract_items("- [ ]"), vec![item("", false)]);
assert_eq!(extract_items("- [ ] "), vec![item("", false)]);
assert_eq!(extract_items("- [ ]"), vec![item("", false, 0)]);
assert_eq!(extract_items("- [ ] "), vec![item("", false, 0)]);
}
#[test]
fn uppercase_x_parses_and_normalises_on_rewrite() {
assert_eq!(extract_items("- [X] done"), vec![item("done", true)]);
assert_eq!(extract_items("- [X] done"), vec![item("done", true, 0)]);
// Touching it once canonicalises it, and never again.
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
}
@@ -477,9 +480,19 @@ mod tests {
}
#[test]
fn item_lines_maps_items_to_the_lines_they_sit_on() {
assert_eq!(item_lines("a\n- [ ] x\nb\n- [x] y"), vec![1, 3]);
assert!(item_lines("no items here").is_empty());
fn render_item_is_what_extract_reads_back() {
assert_eq!(render_item("milk", false), "- [ ] milk");
assert_eq!(render_item("done", true), "- [x] done");
// An empty item has no trailing space, so a round trip does not grow it.
assert_eq!(render_item("", false), "- [ ]");
let line = render_item("milk", true);
assert_eq!(extract_items(&line), vec![item("milk", true, 0)]);
}
#[test]
fn items_carry_the_line_they_sit_on() {
let found = extract_items("a\n- [ ] x\nb\n- [x] y");
assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]);
}
#[test]