From 68b2a5dc8d2d3285805d4464f1508b22172e4f14 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 24 Aug 2026 08:16:57 -0400 Subject: [PATCH] android: a checklist is lines of the note here too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fabledsword/thoughtsync/MainActivity.kt | 1 + .../fabledsword/thoughtsync/ui/BoardScreen.kt | 7 +- .../thoughtsync/ui/BoardViewModel.kt | 33 ++-- .../thoughtsync/ui/EditorAction.kt | 33 +--- .../thoughtsync/ui/EditorChecklist.kt | 164 ------------------ .../thoughtsync/ui/EditorChrome.kt | 22 +-- .../fabledsword/thoughtsync/ui/NoteCard.kt | 133 +++++++++----- .../thoughtsync/ui/NoteEditorScreen.kt | 85 ++++++--- android/app/src/main/res/values/strings.xml | 4 - android/ffi/src/lib.rs | 34 ++++ core/src/local/derive.rs | 98 +++++++++++ 11 files changed, 321 insertions(+), 293 deletions(-) delete mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChecklist.kt diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index bd691e6..2de73b8 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -218,6 +218,7 @@ private fun App( onOpenSync = { showingSync = true }, onSearch = board::search, onCompose = board::compose, + onToggleItem = board::toggleItem, onDismissError = board::dismissError, ) } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt index a20c4b3..ed226ee 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -64,6 +64,7 @@ fun BoardScreen( onOpenSync: () -> Unit, onSearch: (String) -> Unit, onCompose: () -> Unit, + onToggleItem: (Note, Int, Boolean) -> Unit, onDismissError: () -> Unit, ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -336,7 +337,11 @@ private fun NoteBoard( // rebuilding them — and so a newly captured note slides in instead of // making every card below it flicker. items(items = notes, key = { it.id }) { note -> - NoteCard(note = note, onOpen = { onOpenNote(note) }) + NoteCard( + note = note, + onOpen = { onOpenNote(note) }, + onToggleItem = { index, checked -> onToggleItem(note, index, checked) }, + ) } } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index eaa4ac5..dece4d3 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -263,14 +263,6 @@ class BoardViewModel( } EditorAction.DismissError -> dismissError() is EditorAction.SaveText -> createFromDraft(action.body) - // The first checklist item is the one toolbar action that means something - // on a note with no text: a note whose whole content is its items is a - // note this app already has, named from the first of them. So it may - // create a body-less one — anything else needs words first. - is EditorAction.AddItem -> - createFromDraft(draft.body, allowEmpty = true) { created -> - onEditorAction(created, action) - } // Colour, reminder, pin, labels: attributes OF a note, so there has to be // a note. With autosave at a second, "typed something" is true by the time // anyone reaches the toolbar; before that there is nothing to attribute. @@ -386,16 +378,6 @@ class BoardViewModel( null } - is EditorAction.AddItem -> - action.text.trim().takeIf { it.isNotEmpty() }?.let { text -> - mutate { it.addItem(id, text) } - } - is EditorAction.SetItemChecked -> - mutate { it.setItemChecked(id, action.itemId, action.checked) } - is EditorAction.SetItemText -> - mutate { it.setItemText(id, action.itemId, action.text) } - is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) } - is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) } is EditorAction.CreateLabel -> @@ -482,6 +464,21 @@ class BoardViewModel( } } + /** + * Tick or untick one item from the BOARD, without opening the note. + * + * The common gesture on a checklist, and the reason it goes through the store + * rather than the pure text helpers the editor uses: nothing here is holding a + * half-typed body, so the reloaded note is simply the truth. + * + * `index` is the item's ordinal, which is what its id is now (M304). + */ + fun toggleItem( + note: Note, + index: Int, + checked: Boolean, + ) = mutate { it.setItemChecked(note.id, index.toString(), checked) } + fun dismissError() { state = state.copy(error = null) } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt index a5589c3..74182a9 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt @@ -43,33 +43,12 @@ sealed interface EditorAction { data object DeleteForever : EditorAction - /** - * Add an item, given its text. - * - * There is deliberately no "add an EMPTY item" action. Starting a checklist used - * to be one, and it wrote a blank row to the store the moment the toolbar button - * was tapped — so the note grew an empty item that had to be typed into while the - * add-row sat below it, also empty and also asking to be typed into. Showing the - * checklist is now UI state, and the store hears nothing until there is an item - * with words in it. - */ - data class AddItem( - val text: String, - ) : EditorAction - - data class SetItemChecked( - val itemId: String, - val checked: Boolean, - ) : EditorAction - - data class SetItemText( - val itemId: String, - val text: String, - ) : EditorAction - - data class DeleteItem( - val itemId: String, - ) : EditorAction + // No checklist actions at all any more (M304). An item is a `- [ ] ` line of the + // body, so adding, renaming, ticking or deleting one is editing text — which the + // editor already does, through SaveText, with the same autosave and the same + // revision window as any other edit. Routing them through the store would have + // meant the store handing back a note whose body disagreed with the field the + // person was typing in. /** * The note's MANUAL labels, replacing whatever was there. diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChecklist.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChecklist.kt deleted file mode 100644 index a0c9c05..0000000 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChecklist.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.fabledsword.thoughtsync.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -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.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -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.focus.onFocusChanged -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import com.fabledsword.thoughtsync.R -import com.fabledsword.thoughtsync.core.ChecklistItem -import com.fabledsword.thoughtsync.core.Note - -/** - * The checklist, with real checkboxes this time. - * - * The card renders glyphs because it is a preview; here every row is live. This is - * the other half of the answer to how a list gets typed on a phone: the capture - * sheet takes a whole list at once, one item per line, because at capture time the - * list is already in your head and a tap per row would be the slow part. The - * editor is where a list is REVISED, and revising is item-at-a-time — so this is - * where the per-row control lives. - * - * No empty state: a checklist with no items already shows the add row with its - * hint, which says the same thing an empty state would and can be typed into. - */ -@Composable -fun ChecklistEditor( - note: Note, - readOnly: Boolean, - focusAddRow: Boolean, - onAction: (EditorAction) -> Unit, -) { - Column { - note.items.forEach { item -> - ChecklistRow(item = item, readOnly = readOnly, onAction = onAction) - } - if (!readOnly) { - AddItemRow( - autoFocus = focusAddRow, - onAdd = { onAction(EditorAction.AddItem(it)) }, - ) - } - } -} - -/** - * One row: a live checkbox, editable text, and a remove button. - * - * The text commits on FOCUS LOSS rather than per keystroke. Every commit is a - * store write that reloads the note, so per-keystroke saving would both hammer - * SQLite and race the reload against the next character. - */ -@Composable -private fun ChecklistRow( - item: ChecklistItem, - readOnly: Boolean, - onAction: (EditorAction) -> Unit, -) { - // Keyed by item id, so a reload after some OTHER row's edit doesn't reset the - // text being typed here. - var text by remember(item.id) { mutableStateOf(item.text) } - val commit = { if (text != item.text) onAction(EditorAction.SetItemText(item.id, text)) } - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = item.checked, - onCheckedChange = { onAction(EditorAction.SetItemChecked(item.id, it)) }, - enabled = !readOnly, - ) - PlainTextField( - value = text, - onValueChange = { text = it }, - modifier = - Modifier - .weight(1f) - .onFocusChanged { if (!it.isFocused) commit() }, - enabled = !readOnly, - singleLine = true, - textStyle = - MaterialTheme.typography.bodyLarge.copy( - // Struck through when done, matching the card and the web. - textDecoration = if (item.checked) TextDecoration.LineThrough else null, - ), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { commit() }), - ) - if (!readOnly) { - IconButton(onClick = { onAction(EditorAction.DeleteItem(item.id)) }) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(R.string.editor_remove_item), - ) - } - } - } -} - -/** - * The always-present row at the bottom for adding an item. - * - * It clears but keeps focus after a submit, so a list can be typed straight - * through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That - * is the same speed the capture sheet's one-item-per-line field buys, carried into - * the editor so refining a list never feels slower than making one. - * - * [autoFocus] is set when the toolbar was just asked for a checklist, which is the - * one moment this row is certainly what someone is reaching for. Keyed on `Unit` - * rather than on the flag: the row only enters the composition when the checklist - * opens, so once is exactly right, and re-requesting later would yank the caret out - * of whatever was being typed. - */ -@Composable -private fun AddItemRow( - autoFocus: Boolean, - onAdd: (String) -> Unit, -) { - var text by remember { mutableStateOf("") } - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (autoFocus) focus.requestFocus() - } - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.padding(horizontal = 12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - PlainTextField( - value = text, - onValueChange = { text = it }, - modifier = Modifier.weight(1f).focusRequester(focus), - hint = R.string.editor_add_item, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions(onDone = { - onAdd(text) - text = "" - }), - ) - } -} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index 3ab0513..0f49629 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -79,7 +79,6 @@ fun EditorTopBar( note: Note, readOnly: Boolean, tint: NoteTint, - showAddChecklist: Boolean, onClose: () -> Unit, onStartChecklist: () -> Unit, onPicker: (Picker) -> Unit, @@ -120,18 +119,15 @@ fun EditorTopBar( contentDescription = stringResource(R.string.editor_reminder), ) } - // REVEALS the checklist; it does not write one. This used to add an - // empty item so the section would have something to render, which - // left a blank row above the add-row — two empty fields, and the - // caret in the lower one. Hidden once the checklist is showing, since - // its own "+" row does the rest better. - if (showAddChecklist) { - IconButton(onClick = onStartChecklist) { - Icon( - Icons.AutoMirrored.Filled.List, - contentDescription = stringResource(R.string.editor_add_checklist), - ) - } + // Inserts `- [ ] ` at the caret. Always available, and never hidden: + // a checklist is text now (M304), so there is no section to be + // already-showing and no reason a second list cannot start further + // down the same note. + IconButton(onClick = onStartChecklist) { + Icon( + Icons.AutoMirrored.Filled.List, + contentDescription = stringResource(R.string.editor_add_checklist), + ) } } OverflowMenu( 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 ae962f0..d18fed5 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 @@ -15,10 +15,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextDecoration @@ -27,12 +27,14 @@ import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.ChecklistItem import com.fabledsword.thoughtsync.core.Note +import com.fabledsword.thoughtsync.core.checklistLines import com.fabledsword.thoughtsync.core.NoteLabel @Composable fun NoteCard( note: Note, onOpen: () -> Unit, + onToggleItem: (Int, Boolean) -> Unit, ) { val dark = isSystemInDarkTheme() val tint = noteTint(note.color) @@ -53,21 +55,12 @@ fun NoteCard( // nothing above them: the first line of the body IS the note's name, at the // same weight as the rest of it (M13 steps 3 and 4). if (note.body.isNotBlank()) { - Text( - text = note.body, - style = MaterialTheme.typography.bodyMedium, - maxLines = MAX_PREVIEW_LINES, - overflow = TextOverflow.Ellipsis, - ) - } - if (note.items.isNotEmpty()) { - if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp)) - Checklist(items = note.items) + NoteBody(note = note, onToggleItem = onToggleItem) } - // A note with no body and no items still has to occupy the board legibly — - // otherwise it reads as a rendering bug. - if (note.body.isBlank() && note.items.isEmpty()) { + // A note with nothing in it still has to occupy the board legibly — otherwise + // it reads as a rendering bug. + if (note.body.isBlank()) { Text( text = stringResource(R.string.board_empty_note), style = MaterialTheme.typography.bodyMedium, @@ -88,46 +81,96 @@ fun NoteCard( } } +/** + * The note's body, with its checklist drawn where it actually sits. + * + * Rendered line by line rather than as one block of text, because an item is a line + * of the body now (M304) and a card that showed the prose and then the list would put + * every list in the wrong place — and, since the body already contains those lines, + * would show each one twice. + * + * Which lines are items is asked of the core rather than matched here. The grammar is + * already written three times; a fourth in Compose would be a fourth place for a + * checklist to change shape when it syncs. + */ @Composable -private fun Checklist(items: List) { +private fun NoteBody( + note: Note, + onToggleItem: (Int, Boolean) -> Unit, +) { + val lines = remember(note.body) { note.body.split("\n") } + val itemAtLine = + remember(note.body) { + checklistLines(note.body).withIndex().associate { (i, line) -> line.toInt() to i } + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - items.take(MAX_CHECKLIST_ROWS).forEach { item -> - Row(verticalAlignment = Alignment.Top) { - // A glyph rather than a real Checkbox: the card is a PREVIEW, and - // a live control here would invite taps that the board cannot yet - // honour. It becomes interactive with the editor. - Text( - text = if (item.checked) "☑" else "☐", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(end = 6.dp), - ) - Text( - text = item.text, - style = MaterialTheme.typography.bodyMedium, - textDecoration = if (item.checked) TextDecoration.LineThrough else null, - color = - if (item.checked) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line -> + val index = itemAtLine[n] + val item = index?.let { note.items.getOrNull(it) } + when { + item != null -> ChecklistRow(item) { onToggleItem(index, !item.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)) + else -> + Text( + text = line, + style = MaterialTheme.typography.bodyMedium, + maxLines = MAX_WRAPPED_LINES, + overflow = TextOverflow.Ellipsis, + ) } } - val hidden = items.size - MAX_CHECKLIST_ROWS - if (hidden > 0) { + if (lines.size > MAX_PREVIEW_LINES) { Text( - text = pluralStringResource(R.plurals.board_more_items, hidden, hidden), - style = MaterialTheme.typography.labelMedium, + text = "…", + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), ) } } } +/** + * One checklist row on a card, with a box you can actually tick. + * + * A glyph rather than a Material Checkbox: it sits on a line of text and has to share + * that line's metrics, and a real Checkbox brings 48dp of touch target that would + * space a list out like a form. The tap target is the glyph's own padding, which is + * why it carries `clickable` rather than the row — clicking the TEXT should open the + * note, the way clicking anywhere else on the card does. + */ +@Composable +private fun ChecklistRow( + item: ChecklistItem, + onToggle: () -> Unit, +) { + Row(verticalAlignment = Alignment.Top) { + Text( + text = if (item.checked) "☑" else "☐", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .clickable(onClick = onToggle) + .padding(end = 6.dp), + ) + Text( + text = item.text, + style = MaterialTheme.typography.bodyMedium, + textDecoration = if (item.checked) TextDecoration.LineThrough else null, + color = + if (item.checked) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = MAX_WRAPPED_LINES, + overflow = TextOverflow.Ellipsis, + ) + } +} + @Composable private fun LabelChips(labels: List) { val dark = isSystemInDarkTheme() @@ -182,7 +225,9 @@ private fun ReminderChip( } private const val MAX_PREVIEW_LINES = 8 -private const val MAX_CHECKLIST_ROWS = 8 + +/** How far one long line of a card may wrap before it is cut. */ +private const val MAX_WRAPPED_LINES = 2 private const val MAX_LABEL_CHIPS = 3 private val CARD_RADIUS = 12.dp private val CHIP_RADIUS = 6.dp 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 b8d4649..836c349 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 @@ -37,6 +37,7 @@ 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 /** @@ -91,13 +92,6 @@ fun NoteEditorScreen( var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) } var confirmingDelete by remember(sessionKey) { mutableStateOf(false) } - // Whether the checklist is SHOWING, which is not the same as whether the note has - // one. Asking for a checklist used to write an empty item, purely so the section - // would have something to render — which left a blank row with the add-row beneath - // it, both empty, and the caret in the wrong one. Nothing reaches the store now - // until an item has words in it. Saveable so a rotation does not close a list - // someone is halfway through typing. - var checklistOpen by rememberSaveable(sessionKey) { mutableStateOf(false) } // A note in the trash is a record, not a document: editing one would silently // resurrect work that was meant to be thrown away. It renders read-only, with @@ -185,9 +179,8 @@ fun NoteEditorScreen( note = note, readOnly = readOnly, tint = tint, - showAddChecklist = !checklistOpen && note.items.isEmpty(), onClose = leave, - onStartChecklist = { checklistOpen = true }, + onStartChecklist = { body = insertChecklistMarker(body) }, onPicker = { picker = it }, onConfirmDelete = { confirmingDelete = true }, onAction = onAction, @@ -235,25 +228,16 @@ fun NoteEditorScreen( // render bolder than the line beneath it (M13 steps 3 and 4). EditorField( value = body, - onValueChange = { body = it }, + onValueChange = { body = continueChecklist(body, it) }, hint = R.string.editor_body_hint, enabled = !readOnly, minLines = MIN_BODY_LINES, modifier = Modifier.focusRequester(bodyFocus), ) - // Below the body, not instead of it. Shown once the note has - // items, or once the toolbar has been asked for a checklist — in - // which case the add row takes focus, because being asked for a - // list means being about to type one. - if (note.items.isNotEmpty() || checklistOpen) { - ChecklistEditor( - note = note, - readOnly = readOnly, - focusAddRow = checklistOpen && note.items.isEmpty(), - onAction = onAction, - ) - } + // No checklist section. The items ARE lines of the field above + // (M304) — rendering them again down here is what would put every + // list on screen twice. if (note.labels.isNotEmpty()) { EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction) @@ -375,6 +359,63 @@ private fun EditorField( ) } +/** + * 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 + if (new.selection.length != 0) return new + if (new.text.length != old.text.length + 1) return new + if (caret == 0 || new.text[caret - 1] != '\n') return new + + val before = new.text.take(caret - 1) + val lineStart = before.lastIndexOf('\n') + 1 + val marker = checklistContinuation(before.substring(lineStart)) ?: return new + + if (marker.isEmpty()) { + // An empty item: take the marker line away rather than adding another. + val text = new.text.take(lineStart) + new.text.substring(caret) + return TextFieldValue(text, TextRange(lineStart)) + } + val text = new.text.take(caret) + marker + new.text.substring(caret) + return TextFieldValue(text, TextRange(caret + marker.length)) +} + +private const val CHECKLIST_MARKER = "- [ ] " + /** * How long typing has to stop before the note is written. * diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b8dd342..bf89d2f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -13,10 +13,6 @@ Empty note - - +%d more item - +%d more items - diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index a5a9ebd..5a45573 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -499,6 +499,40 @@ impl ThoughtSync { } } +// ── checklist text, as pure functions ─────────────────────────────────────── +// +// Free functions rather than methods, because these touch no database. The editor's +// body field is LOCAL state — it is autosaved on an idle debounce, not written on +// every keystroke — so a checkbox tapped in the editor has to rewrite the text the +// field is holding, not a row the store would hand back a moment later. Routing that +// through the store would overwrite whatever was being typed. +// +// They also keep the grammar out of Kotlin. Three implementations of it is the price +// 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. +#[uniffi::export] +pub fn checklist_lines(body: String) -> Vec { + local::derive::item_lines(&body) +} + +/// 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 { + 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 { + local::derive::continuation(&line) +} + /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// block names, so these stay Rust-side. impl ThoughtSync { diff --git a/core/src/local/derive.rs b/core/src/local/derive.rs index fbd3247..dedff20 100644 --- a/core/src/local/derive.rs +++ b/core/src/local/derive.rs @@ -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 { + 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 { + 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 { + 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