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