core: drop the two helpers the block editor made unnecessary
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s

`toggle_at` existed to map a tap on `[ ]` inside a plain text field to an item, and
`continuation` to make Return start the next one. The block editor needs neither: a
checkbox is a real Checkbox, so it is tapped rather than located, and Return is the
field's own IME action rather than a shape recognised in a string.

Removed rather than kept for later (rule 22). Both were exported over the FFI with
no Kotlin caller, which is API surface promising something nothing does — and their
tests were weight on code nothing runs.

The section comment above them described the tap-in-a-text-field problem, which is
no longer the problem this pair solves. Rewritten to say what is actually there:
one function to read a body apart, one to put a line back together, and between them
Kotlin renders checkboxes without owning the grammar.
This commit is contained in:
2026-08-24 10:42:26 -04:00
parent 32ec29fc4a
commit a88f7c2dd0
2 changed files with 10 additions and 99 deletions
+10 -23
View File
@@ -501,15 +501,17 @@ impl ThoughtSync {
// ── checklist text, as pure functions ─────────────────────────────────────── // ── checklist text, as pure functions ───────────────────────────────────────
// //
// Free functions rather than methods, because these touch no database. The editor's // The pair the block editor is built on: one to read a body apart, one to put a line
// body field is LOCAL state — it is autosaved on an idle debounce, not written on // back together. Between them, Kotlin can render a checklist as real checkboxes and
// every keystroke — so a checkbox tapped in the editor has to rewrite the text the // write the markdown back without owning the grammar — which is the point. Three
// field is holding, not a row the store would hand back a moment later. Routing that // implementations of it is the price already being paid (Rust, Python, TypeScript);
// through the store would overwrite whatever was being typed. // a fourth in Compose would be one more place for a checklist to change shape when
// it syncs.
// //
// They also keep the grammar out of Kotlin. Three implementations of it is the price // Free functions rather than methods, because they touch no database. The editor's
// already being paid (Rust, Python, TypeScript); a fourth in Compose would be one // body is LOCAL state — autosaved on an idle debounce, not written per keystroke —
// more place for a checklist to change shape when it syncs. // so editing a checklist there has to rewrite the text the editor is holding, not a
// row the store would hand back a moment later and overwrite the typing with.
/// One checklist item as the body line that stores it. For an editor that shows a /// One checklist item as the body line that stores it. For an editor that shows a
/// checkbox instead of the markup and has to write the markup back. /// checkbox instead of the markup and has to write the markup back.
@@ -528,21 +530,6 @@ pub fn checklist_items(body: String) -> Vec<BodyItem> {
.collect() .collect()
} }
/// The body with the item at `line`/`column` ticked or unticked, or null if that is
/// not a checkbox. See `derive::toggle_at` for why the address is line + column and
/// not a text offset.
#[uniffi::export]
pub fn checklist_toggle_at(body: String, line: u32, column: u32) -> Option<String> {
local::derive::toggle_at(&body, line as usize, column as usize)
}
/// What pressing Enter at the end of `line` should leave behind: null to let Enter be
/// Enter, "" to end the list, or the marker to start the next item.
#[uniffi::export]
pub fn checklist_continuation(line: String) -> Option<String> {
local::derive::continuation(&line)
}
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side. /// block names, so these stay Rust-side.
impl ThoughtSync { impl ThoughtSync {
-76
View File
@@ -259,53 +259,6 @@ pub fn remove_item(body: &str, index: usize) -> String {
map_task_line(body, index, |_| None) map_task_line(body, index, |_| None)
} }
/// 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. /// Add an item at the end of the body.
/// ///
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line /// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
@@ -495,35 +448,6 @@ mod tests {
assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]); assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]);
} }
#[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] #[test]
fn a_stale_index_does_nothing() { fn a_stale_index_does_nothing() {
// The index comes from a UI that may be a moment behind the store. A tap // The index comes from a UI that may be a moment behind the store. A tap