android: a checklist is lines of the note here too
M304 step 6, and the surface with the least room to hide: Android has no markdown renderer at all, so the card was about to show every list twice — once as literal `- [ ] milk` in the body preview, and again as the glyph rows underneath. Same bug the web had, one commit later. The card now renders the body LINE BY LINE and draws a checkbox where one belongs, which is what puts a list between two paragraphs instead of always after them. The glyphs became tappable while they were being rewritten: ticking something off from the board without opening the note is the common gesture, and the web just gained it. The tap target is the glyph, not the row — tapping the TEXT still opens the note, the way tapping anywhere else on a card does. Kotlin gets no parser. Three implementations of the grammar is the price already paid; a fourth in Compose would be a fourth place for a checklist to change shape when it syncs. So the core exposes three pure functions instead — `checklist_lines`, `checklist_continuation`, `checklist_toggle_at` — and Kotlin does the caret arithmetic around them. Those are FREE functions, not methods, and that is the interesting constraint. The editor's body field is LOCAL state on an idle-debounced autosave, so anything that edits a checklist there has to rewrite the text the field is holding, not a row the store would hand back a moment later. Going through the store would overwrite whatever was being typed. The BOARD has no such problem — nothing there is holding a half-typed body — so the card's toggle goes through the store as usual. `toggle_at` addresses an item by LINE and COLUMN rather than a text offset, because the two sides do not count the same way: Compose measures in UTF-16 units and Rust in bytes, so the same number means different places in a note with an emoji in it. A line number is identical in every encoding, and so is a column inside the marker, which is ASCII at the start of its line. In the editor: the toolbar button inserts `- [ ] ` at the caret — the only toolbar action needing no saved note, so it works on an empty compose box the moment it opens — and Enter continues the list, or ends it on an empty item. Continuation is recognised by SHAPE inside onValueChange (exactly one more character, and it is a newline) rather than by a key event, so a paste or an autocorrect falls through untouched. EditorChecklist.kt and the four item actions are gone (rule 22). Adding, renaming, ticking or deleting an item is editing text now, and the editor already does that — through SaveText, with the same autosave and the same revision window as any other edit. KNOWN GAP, not an oversight: tapping a checkbox inside the EDITOR does nothing yet. Material3's TextField does not expose onTextLayout, so mapping a tap to a character offset means either moving the body to BasicTextField or intercepting pointer events ahead of the field — both real changes to the surface this operator uses most, and neither verifiable without a device. `checklist_toggle_at` lands here, tested, so that task is pure UI. Ticking from the board works today.
This commit is contained in:
@@ -241,6 +241,69 @@ 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.
|
||||
///
|
||||
/// Addressed by LINE and COLUMN rather than by a text offset, because the two sides of
|
||||
/// the FFI do not count the same way: Compose measures an offset in UTF-16 units and
|
||||
/// Rust in bytes, so the same number means different places in a note with an emoji in
|
||||
/// it. A line number is identical in every encoding. So is a column inside the marker,
|
||||
/// which is ASCII and sits at the start of its line — and that is the only range this
|
||||
/// function looks at.
|
||||
///
|
||||
/// Only the marker toggles, not the whole line: the rest of it is text somebody needs
|
||||
/// to be able to put a caret into.
|
||||
pub fn toggle_at(body: &str, line: usize, column: usize) -> Option<String> {
|
||||
let target = body.split('\n').nth(line)?;
|
||||
let parsed = parse_task_line(target)?;
|
||||
// One column of slack past the `]`, because a checkbox on a phone should forgive
|
||||
// a near miss.
|
||||
let marker_end = target.chars().position(|c| c == ']')? + 1;
|
||||
if column > marker_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut index = 0;
|
||||
for earlier in body.split('\n').take(line) {
|
||||
if parse_task_line(earlier).is_some() {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
Some(set_item_checked(body, index, !parsed.checked))
|
||||
}
|
||||
|
||||
/// What pressing Enter at the end of `line` should leave behind.
|
||||
///
|
||||
/// * `None` — not a task line. Enter does what Enter always does.
|
||||
/// * `Some("")` — an EMPTY item: clear the marker and end the list. Without this half
|
||||
/// a list is impossible to get out of without deleting characters by hand.
|
||||
/// * `Some(marker)` — start the next item. The indent and bullet are carried over
|
||||
/// rather than normalised, because continuing someone's `*` list with a `-` is an
|
||||
/// edit they did not ask for.
|
||||
pub fn continuation(line: &str) -> Option<String> {
|
||||
let parsed = parse_task_line(line)?;
|
||||
if parsed.text.trim().is_empty() {
|
||||
return Some(String::new());
|
||||
}
|
||||
Some(format!("{}{} [ ] ", parsed.indent, parsed.bullet))
|
||||
}
|
||||
|
||||
/// Add an item at the end of the body.
|
||||
///
|
||||
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
|
||||
@@ -413,6 +476,41 @@ mod tests {
|
||||
assert_eq!(strip_marker("just prose"), "just prose");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_at_only_fires_inside_the_marker() {
|
||||
let body = "note\n- [ ] milk\n- [x] eggs";
|
||||
// Column 0 is the bullet, 4 is the `]`, 5 the slack past it.
|
||||
assert_eq!(
|
||||
toggle_at(body, 1, 0).as_deref(),
|
||||
Some("note\n- [x] milk\n- [x] eggs")
|
||||
);
|
||||
assert_eq!(
|
||||
toggle_at(body, 2, 5).as_deref(),
|
||||
Some("note\n- [ ] milk\n- [ ] eggs")
|
||||
);
|
||||
// Past the marker is text someone wants to put a caret in.
|
||||
assert!(toggle_at(body, 1, 9).is_none());
|
||||
// Not a task line, and off the end.
|
||||
assert!(toggle_at(body, 0, 0).is_none());
|
||||
assert!(toggle_at(body, 99, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_starts_the_next_item_or_ends_the_list() {
|
||||
assert_eq!(continuation("- [x] milk").as_deref(), Some("- [ ] "));
|
||||
assert_eq!(continuation(" * [ ] milk").as_deref(), Some(" * [ ] "));
|
||||
// An empty item ends the list rather than adding another.
|
||||
assert_eq!(continuation("- [ ]").as_deref(), Some(""));
|
||||
assert_eq!(continuation("- [ ] ").as_deref(), Some(""));
|
||||
assert!(continuation("just prose").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_index_does_nothing() {
|
||||
// The index comes from a UI that may be a moment behind the store. A tap
|
||||
|
||||
Reference in New Issue
Block a user