diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorBlocks.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorBlocks.kt new file mode 100644 index 0000000..052e13b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorBlocks.kt @@ -0,0 +1,307 @@ +package com.fabledsword.thoughtsync.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.fabledsword.thoughtsync.R +import com.fabledsword.thoughtsync.core.checklistItems +import com.fabledsword.thoughtsync.core.checklistRender + +/** + * One piece of a note body, as the editor DRAWS it. + * + * The note is still one markdown string underneath (M304) — this is a rendering and + * input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the + * string back together on every edit. + * + * A run of prose lines is ONE block rather than one per line. Typing a paragraph has + * to feel like typing a paragraph, and a separate field under every sentence would + * break the caret in the middle of writing. Only a checklist item earns a block of its + * own, because only a checklist item needs a widget. + * + * The block owns its [TextFieldValue], not just its text, so a caret survives an edit + * to some other block. And [id] is stable across edits: Compose keys fields by + * position unless told otherwise, so inserting an item above one would otherwise move + * everyone's caret up a row. Content cannot serve as that key — two empty items are + * identical and neither is the other. + */ +data class EditorBlock( + val id: Long, + val value: TextFieldValue, + /** null for prose; ticked-or-not for a checklist item. */ + val checked: Boolean?, +) { + val isTask: Boolean get() = checked != null +} + +/** + * Split a body into blocks, numbering them from [firstId]. + * + * Which lines are items comes from the core, not from a pattern here — the grammar is + * written three times already and Kotlin is not going to be the fourth. + */ +fun splitBlocks( + body: String, + firstId: Long = 0, +): List { + val itemAt = checklistItems(body).associateBy { it.line.toInt() } + val out = mutableListOf() + val prose = mutableListOf() + var id = firstId + + fun flushProse() { + if (prose.isNotEmpty()) { + out += EditorBlock(id++, TextFieldValue(prose.joinToString("\n")), null) + prose.clear() + } + } + + body.split("\n").forEachIndexed { n, line -> + val item = itemAt[n] + if (item == null) { + prose += line + } else { + flushProse() + out += EditorBlock(id++, TextFieldValue(item.text), item.checked) + } + } + flushProse() + + // Never empty: an empty note still needs one field to type into. + return out.ifEmpty { listOf(EditorBlock(id, TextFieldValue(""), null)) } +} + +/** + * The body those blocks stand for — byte-identical to what [splitBlocks] was given, + * for a body already in canonical form. A non-canonical one (`- [X]`, an odd bullet) + * comes back canonical, which is the same rule every other rewriter in `derive` + * follows. + */ +fun joinBlocks(blocks: List): String = + blocks.joinToString("\n") { block -> + val checked = block.checked + if (checked == null) block.value.text else checklistRender(block.value.text, checked) + } + +/** + * Rotation carries the TEXT and re-derives the shape. + * + * Blocks are not parcelable and their ids are meaningless across a process death, so + * the body string is the honest thing to save — it is the real state, and everything + * else about a block is derived from it. + */ +val blocksSaver: Saver, String> = + Saver(save = { joinBlocks(it) }, restore = { splitBlocks(it) }) + +/** + * The note's body, as fields and checkboxes rather than as markup. + * + * The point of the whole shape: a box you can tick while looking at the note, rather + * than `- [ ] ` to read and edit around. What the note IS never changed. + */ +@Composable +fun BlockBody( + blocks: List, + readOnly: Boolean, + focus: Long?, + onChange: (List) -> Unit, + onFocus: (Long?) -> Unit, + modifier: Modifier = Modifier, +) { + // Focus is addressed by block ID, never by position — the id is the only thing + // about a block that survives one being inserted above it. Hoisted to the caller + // rather than kept here, because the TOOLBAR also asks for a focus when its button + // appends an item, and two owners of one cursor is one too many. + val requesters = remember { mutableMapOf() } + + LaunchedEffect(focus) { + val id = focus ?: return@LaunchedEffect + // Honoured after the composition that created the field: a FocusRequester not + // yet attached to anything throws when asked. + requesters[id]?.requestFocus() + onFocus(null) + } + + fun replace( + index: Int, + block: EditorBlock, + ) = onChange(blocks.toMutableList().also { it[index] = block }) + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + blocks.forEachIndexed { index, block -> + val requester = requesters.getOrPut(block.id) { FocusRequester() } + if (block.isTask) { + TaskBlock( + block = block, + readOnly = readOnly, + requester = requester, + onChange = { replace(index, it) }, + onEnter = { + val next = blocks.nextId() + onChange(afterEnter(blocks, index, next)) + // The new item if there was one; otherwise the block that just + // became prose, which keeps the caret where the person left it. + onFocus(if (blocks[index].value.text.isBlank()) block.id else next) + }, + onDelete = { + onChange(blocks.withoutIndex(index)) + onFocus(blocks.getOrNull(index - 1)?.id) + }, + ) + } else { + ProseBlock( + block = block, + readOnly = readOnly, + requester = requester, + onChange = { replace(index, it) }, + ) + } + } + } +} + +/** A run of prose: one ordinary multi-line field, exactly as the editor always had. */ +@Composable +private fun ProseBlock( + block: EditorBlock, + readOnly: Boolean, + requester: FocusRequester, + onChange: (EditorBlock) -> Unit, +) { + PlainTextField( + value = block.value, + onValueChange = { onChange(block.copy(value = it)) }, + modifier = Modifier.focusRequester(requester), + hint = R.string.editor_body_hint, + enabled = !readOnly, + textStyle = MaterialTheme.typography.bodyLarge, + ) +} + +/** + * One checklist item: a real box, and the item's text beside it. + * + * Single-line with [ImeAction.Next], which is what turns the keyboard's return key + * into "next item" — the reason a list can be typed straight through rather than a + * marker at a time. + */ +@Composable +private fun TaskBlock( + block: EditorBlock, + readOnly: Boolean, + requester: FocusRequester, + onChange: (EditorBlock) -> Unit, + onEnter: () -> Unit, + onDelete: () -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = block.checked == true, + onCheckedChange = { onChange(block.copy(checked = it)) }, + enabled = !readOnly, + ) + PlainTextField( + value = block.value, + onValueChange = { onChange(block.copy(value = it)) }, + modifier = Modifier.weight(1f).focusRequester(requester), + enabled = !readOnly, + singleLine = true, + textStyle = + MaterialTheme.typography.bodyLarge.copy( + // Struck through when done, matching the card and the web. + textDecoration = + if (block.checked == true) TextDecoration.LineThrough else null, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { onEnter() }), + ) + if (!readOnly) { + IconButton(onClick = onDelete) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.editor_remove_item), + ) + } + } + } +} + +/** + * What the return key does on a checklist item. + * + * On one with words in it, a new empty item below. On an EMPTY one, the item becomes + * prose — which is how a list ENDS, and the same rule the plain text field used + * before this: without it a list is impossible to get out of. + * + * Deliberately 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 this key. + */ +private fun afterEnter( + blocks: List, + index: Int, + newId: Long, +): List { + val block = blocks[index] + val out = blocks.toMutableList() + if (block.value.text.isBlank()) { + out[index] = block.copy(value = TextFieldValue(""), checked = null) + } else { + out.add(index + 1, EditorBlock(newId, TextFieldValue(""), false)) + } + return out +} + +/** Drop a block, leaving at least one field to type into. */ +private fun List.withoutIndex(index: Int): List { + val out = toMutableList().also { it.removeAt(index) } + return out.ifEmpty { listOf(EditorBlock(nextId(), TextFieldValue(""), null)) } +} + +/** An id nothing else is using. Monotonic within a session, which is all it has to be. */ +private fun List.nextId(): Long = (maxOfOrNull { it.id } ?: -1L) + 1L + +/** + * One more empty checklist item at the end, and the id to put the caret in. + * + * What the toolbar's checklist button does. It appends rather than inserting at the + * caret because a block editor has no single caret to insert at — the field that had + * focus may not even be the one being looked at by the time this runs. + */ +fun List.plusTask(): Pair, Long> { + val id = nextId() + return (this + EditorBlock(id, TextFieldValue(""), false)) to id +} + +/** + * Put the caret at the end of the last block, for an editor that has just opened. + * + * Opening an existing note means continuing it, and a caret at offset zero would put + * the cursor before the first character of the wrong field. + */ +fun List.focusedAtEnd(): List { + if (isEmpty()) return this + val last = last() + return dropLast(1) + last.copy(value = last.value.copy(selection = TextRange(last.value.text.length))) +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index 3b92d66..a0ea671 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -25,10 +25,10 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R -import com.fabledsword.thoughtsync.core.ChecklistItem +import com.fabledsword.thoughtsync.core.BodyItem import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.NoteLabel -import com.fabledsword.thoughtsync.core.checklistLines +import com.fabledsword.thoughtsync.core.checklistItems @Composable fun NoteCard( @@ -99,21 +99,21 @@ private fun NoteBody( onToggleItem: (Int, Boolean) -> Unit, ) { val lines = remember(note.body) { note.body.split("\n") } + // Read from the BODY rather than from note.items, which is the same list by a + // longer route — and one that can lag the text by a save. val itemAtLine = remember(note.body) { - checklistLines(note.body).withIndex().associate { (i, line) -> line.toInt() to i } + checklistItems(note.body) + .mapIndexed { index, item -> item.line.toInt() to (index to item) } + .toMap() } Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line -> - val index = itemAtLine[n] - val item = index?.let { note.items.getOrNull(it) } + val found = itemAtLine[n] when { - // Both in the condition, not just `item`: Kotlin will not infer that a - // non-null item implies a non-null index, and `onToggleItem` needs the - // index smart-cast to act on it. - index != null && item != null -> - ChecklistRow(item) { onToggleItem(index, !item.checked) } + found != null -> + ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) } // Kept as a gap rather than dropped: it is the paragraph break // somebody typed, and the card reads as a wall without it. line.isBlank() -> Spacer(Modifier.height(4.dp)) @@ -147,7 +147,7 @@ private fun NoteBody( */ @Composable private fun ChecklistRow( - item: ChecklistItem, + item: BodyItem, onToggle: () -> Unit, ) { Row(verticalAlignment = Alignment.Top) { diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 4c39129..5aa8dcf 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -1,7 +1,6 @@ package com.fabledsword.thoughtsync.ui import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,16 +27,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.Label import com.fabledsword.thoughtsync.core.Note -import com.fabledsword.thoughtsync.core.checklistContinuation import kotlinx.coroutines.delay /** @@ -73,22 +67,26 @@ fun NoteEditorScreen( // Keyed by the SESSION, not by note.id: the editor is reused across notes, so it // needs a key — but a draft's id changes the moment it is first saved, and - // re-keying on that would reset this field to whatever the store just returned, + // re-keying on that would reset this state to whatever the store just returned, // throwing away every character typed during the write. // - // Saveable, because a new note has nothing to fall back on. Rotating the phone - // mid-capture used to be survivable only in the old capture sheet, which used - // rememberSaveable for exactly this reason; the editor inherits the requirement - // along with the job. + // BLOCKS rather than one string, because a checklist item is drawn as a real + // checkbox now and a widget cannot live inside a text field. The note is still one + // markdown body underneath — see EditorBlocks.kt — and `bodyText` is what is saved. // - // TextFieldValue rather than String so the CARET can start at the end of the - // text. A String field always begins its selection at offset zero, which would - // drop the cursor before the first character — the wrong place for "carry on - // writing this note", which is what opening an existing one usually means. - var body by - rememberSaveable(sessionKey, stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length))) + // Saveable, because a new note has nothing to fall back on if the phone rotates + // mid-capture. The saver carries the TEXT and re-derives the shape, since a block's + // id means nothing across a process death. + var blocks by + rememberSaveable(sessionKey, stateSaver = blocksSaver) { + mutableStateOf(splitBlocks(note.body).focusedAtEnd()) } + // Which field the caret is wanted in, or null. Held HERE rather than inside + // BlockBody because the toolbar's checklist button also asks for one. + var focus by remember(sessionKey) { mutableStateOf(null) } + + val bodyText = remember(blocks) { joinBlocks(blocks) } + var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) } var confirmingDelete by remember(sessionKey) { mutableStateOf(false) } @@ -102,8 +100,8 @@ fun NoteEditorScreen( // would bump `updated_at`, mark the note dirty for sync, and snapshot a // revision identical to the one before it. val flush = { - if (!readOnly && body.text != note.body) { - onAction(EditorAction.SaveText(body.text)) + if (!readOnly && bodyText != note.body) { + onAction(EditorAction.SaveText(bodyText)) } } val leave = { @@ -112,15 +110,12 @@ fun NoteEditorScreen( } // Opening an existing note means continuing it. Without this the note arrives - // unfocused, and carrying on costs a tap into the body and often a second to - // drag the caret to the end — the friction the operator reported. + // unfocused, and carrying on costs a tap into the last field. // - // Not for a trashed note: it renders read-only, and a keyboard over a record - // you cannot edit is noise. `note.id` as the key so the request fires again - // when the reused editor is pointed at a different note. - val bodyFocus = remember { FocusRequester() } + // Not for a trashed note: it renders read-only, and a keyboard over a record you + // cannot edit is noise. LaunchedEffect(sessionKey) { - if (!readOnly) bodyFocus.requestFocus() + if (!readOnly) focus = blocks.lastOrNull()?.id } // Idle-debounced autosave. LaunchedEffect cancels and restarts on every @@ -134,10 +129,10 @@ fun NoteEditorScreen( // For a note that does not exist yet this is also what CREATES it, which is why // every toolbar button works moments after the first keystroke rather than // needing the note to be saved by hand first. - LaunchedEffect(body.text, sessionKey) { - if (readOnly || body.text == note.body) return@LaunchedEffect + LaunchedEffect(bodyText, sessionKey) { + if (readOnly || bodyText == note.body) return@LaunchedEffect delay(AUTOSAVE_IDLE_MS) - onAction(EditorAction.SaveText(body.text)) + onAction(EditorAction.SaveText(bodyText)) } BackHandler(onBack = leave) @@ -179,7 +174,11 @@ fun NoteEditorScreen( readOnly = readOnly, tint = tint, onClose = leave, - onStartChecklist = { body = insertChecklistMarker(body) }, + onStartChecklist = { + val (next, id) = blocks.plusTask() + blocks = next + focus = id + }, onPicker = { picker = it }, onConfirmDelete = { confirmingDelete = true }, onAction = onAction, @@ -222,16 +221,17 @@ fun NoteEditorScreen( ) } - // One field. A note is its body; its NAME is that body's first - // line, so there is nothing separate to type into and nothing to - // render bolder than the line beneath it (M13 steps 3 and 4). - EditorField( - value = body, - onValueChange = { body = continueChecklist(body, it) }, - hint = R.string.editor_body_hint, - enabled = !readOnly, - minLines = MIN_BODY_LINES, - modifier = Modifier.focusRequester(bodyFocus), + // A note is its body; its NAME is that body's first line, so there + // is nothing separate to type into and nothing rendered bolder than + // the line beneath it (M13 steps 3 and 4). What 2992 changed is only + // how the body is DRAWN — checklist items as boxes rather than as + // the markup for boxes. + BlockBody( + blocks = blocks, + readOnly = readOnly, + focus = focus, + onChange = { blocks = it }, + onFocus = { focus = it }, ) // No checklist section. The items ARE lines of the field above @@ -327,104 +327,6 @@ private fun EditorOverlays( } } -/** - * The note's body field. - * - * Undecorated, via the shared [PlainTextField]: the screen is already painted in - * the note's colour, and a filled field would draw a second surface over the first - * and turn a note into a form. - * - * One weight throughout. The first line is the note's name, but it is not a - * different KIND of text from the line after it, and typing it should not feel like - * filling in a header. - */ -@Composable -private fun EditorField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - @StringRes hint: Int, - enabled: Boolean, - minLines: Int = 1, - modifier: Modifier = Modifier, -) { - PlainTextField( - value = value, - onValueChange = onValueChange, - modifier = modifier, - hint = hint, - enabled = enabled, - minLines = minLines, - textStyle = MaterialTheme.typography.bodyLarge, - ) -} - -/** - * Insert `- [ ] ` at the caret, on a line of its own. - * - * The whole of "add a checklist" now. It writes nothing to the store, needs no saved - * note, and works on an empty compose box the moment it opens — because an item is - * text, and this screen already knows how to hold text. - */ -private fun insertChecklistMarker(value: TextFieldValue): TextFieldValue { - val start = value.selection.start - val before = value.text.take(start) - // A new line unless the caret is already at the start of one: a marker in the - // middle of a sentence is not a list item, it is a typo. - val prefix = if (before.isEmpty() || before.endsWith("\n")) "" else "\n" - val text = before + prefix + CHECKLIST_MARKER + value.text.substring(start) - return TextFieldValue(text, TextRange(start + prefix.length + CHECKLIST_MARKER.length)) -} - -/** - * Enter on a task line starts the next item; on an EMPTY one it ends the list. - * - * Both halves are needed. Without the first, a list costs six characters a row on a - * phone keyboard; without the second, there is no way out of one except deleting the - * marker by hand. - * - * The grammar lives in the core ([checklistContinuation]) rather than here. It is - * already written three times — Rust, Python, TypeScript — and a fourth copy in - * Compose would be a fourth place for a checklist to change shape when it syncs. - * - * Recognised by SHAPE rather than by a key event: this runs inside onValueChange, and - * a plain Enter is exactly one more character than before, and that character is a - * newline. Anything else — a paste, a selection replaced, an autocorrect — falls - * through untouched. - */ -private fun continueChecklist( - old: TextFieldValue, - new: TextFieldValue, -): TextFieldValue { - val caret = new.selection.start - val typedNewline = - new.selection.length == 0 && - new.text.length == old.text.length + 1 && - caret > 0 && - new.text[caret - 1] == '\n' - // Both guarded on `typedNewline`, because caret - 1 is only a real index once it - // is known to be the newline that was just typed. - val lineStart = if (typedNewline) new.text.take(caret - 1).lastIndexOf('\n') + 1 else 0 - val marker = - if (typedNewline) checklistContinuation(new.text.substring(lineStart, caret - 1)) else null - - return when { - marker == null -> new - // An empty item: take the marker line away rather than adding another. - marker.isEmpty() -> - TextFieldValue( - new.text.take(lineStart) + new.text.substring(caret), - TextRange(lineStart), - ) - else -> - TextFieldValue( - new.text.take(caret) + marker + new.text.substring(caret), - TextRange(caret + marker.length), - ) - } -} - -private const val CHECKLIST_MARKER = "- [ ] " - /** * How long typing has to stop before the note is written. * @@ -440,4 +342,3 @@ private const val AUTOSAVE_IDLE_MS = 1_000L */ private val SHEET_CORNER = 28.dp -private const val MIN_BODY_LINES = 6 diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 5a45573..a03ed23 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore; use thoughtsync_core::sync::{client, compat, engine, push, state}; use models::{ - patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, - RevokeOutcome, SyncOutcome, SyncStatus, + patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, + ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, }; uniffi::setup_scaffolding!(); @@ -511,11 +511,21 @@ impl ThoughtSync { // already being paid (Rust, Python, TypeScript); a fourth in Compose would be one // more place for a checklist to change shape when it syncs. -/// Which body lines carry checklist items, in item order — so a renderer walking the -/// body line by line knows which of them to draw a checkbox on. +/// 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. #[uniffi::export] -pub fn checklist_lines(body: String) -> Vec { - local::derive::item_lines(&body) +pub fn checklist_render(text: String, checked: bool) -> String { + local::derive::render_item(&text, checked) +} + +/// Every checklist item in a body, with the line each one sits on — so a renderer +/// walking the body line by line knows which lines are boxes and what is in them. +#[uniffi::export] +pub fn checklist_items(body: String) -> Vec { + local::derive::extract_items(&body) + .into_iter() + .map(BodyItem::from) + .collect() } /// The body with the item at `line`/`column` ticked or unticked, or null if that is diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index 55c5ddc..70f9e50 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -49,6 +49,33 @@ pub struct Note { pub updated_at: Option, } +/// A checklist item as it sits in a note's body. +/// +/// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks +/// a body line by line needs the text, the state and the position together — the card +/// to draw a box in the right place, the block editor to know where one block ends. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BodyItem { + pub line: u32, + pub text: String, + pub checked: bool, +} + +impl From for BodyItem { + fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self { + let thoughtsync_core::local::derive::DerivedItem { + text, + checked, + line, + } = i; + BodyItem { + line, + text, + checked, + } + } +} + /// An Android build the linked server is offering, already judged to be newer. /// /// A mirror rather than a re-export of `client::ClientRelease`, for the same diff --git a/core/src/local/derive.rs b/core/src/local/derive.rs index dedff20..85cd9e6 100644 --- a/core/src/local/derive.rs +++ b/core/src/local/derive.rs @@ -76,6 +76,13 @@ fn push_unique(out: &mut Vec, 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> { }) } +/// 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 { 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 { - 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![1, 3]); } #[test]