android: the editor draws the checklist instead of the markup for one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android / Kotlin + Rust (APK) (push) Failing after 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m53s
Desktop (Tauri) / Update manifest (push) Successful in 4s

2992. A checklist item is a real Checkbox with its text beside it, so a box can be
ticked while looking at the note — which is what M304 left undone. It changed where
a checklist is STORED and never changed what the editor draws.

The body is split into blocks and joined back on every edit, so the note underneath
is the same markdown string it was this morning. Nothing below the editor can tell
this exists: no migration, no protocol change, no new shape on the wire.

A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
like typing a paragraph, and a separate field under every sentence would break the
caret mid-sentence. Only a checklist item earns a block, because only a checklist
item needs a widget.

Two things that look like detail and are not:

  * A block carries its own TextFieldValue, and an ID that survives insertion.
    Compose keys fields by position unless told otherwise, so adding an item would
    otherwise move every caret below it up a row. Content cannot be that key —
    two empty items are identical and neither is the other.
  * Focus is hoisted to the screen rather than kept inside BlockBody, because the
    toolbar's checklist button also asks for one. Two owners of one cursor is one
    too many.

Return on an item makes the next item and puts the caret in it; on an EMPTY item
the block becomes prose, which is how a list ends and how you get a paragraph after
one — the same rule the plain text field used, now with somewhere to land. It
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 that key.

The core gains `render_item` and `DerivedItem.line`; `item_lines` and
`checklist_lines` are gone, subsumed. Every renderer that walks a body line by line
needs the text, the state and the position TOGETHER — asking for them separately is
how two calls come to disagree about a body that changed between them. The card now
reads its items from the body for the same reason, instead of from note.items,
which is the same list by a longer route and one save behind.

WANTS A DEVICE PASS, and the focus behaviours are what to look at: return making a
row and landing in it, return twice at the end of a list getting you a paragraph,
and rotation restoring the right field. CI can only prove this compiles.
This commit is contained in:
2026-08-24 10:14:53 -04:00
parent 9a3c4ec377
commit b2435d97b6
6 changed files with 442 additions and 184 deletions
@@ -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<EditorBlock> {
val itemAt = checklistItems(body).associateBy { it.line.toInt() }
val out = mutableListOf<EditorBlock>()
val prose = mutableListOf<String>()
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<EditorBlock>): 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<List<EditorBlock>, 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<EditorBlock>,
readOnly: Boolean,
focus: Long?,
onChange: (List<EditorBlock>) -> 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<Long, FocusRequester>() }
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<EditorBlock>,
index: Int,
newId: Long,
): List<EditorBlock> {
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<EditorBlock>.withoutIndex(index: Int): List<EditorBlock> {
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<EditorBlock>.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<EditorBlock>.plusTask(): Pair<List<EditorBlock>, 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<EditorBlock>.focusedAtEnd(): List<EditorBlock> {
if (isEmpty()) return this
val last = last()
return dropLast(1) + last.copy(value = last.value.copy(selection = TextRange(last.value.text.length)))
}
@@ -25,10 +25,10 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R 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.Note
import com.fabledsword.thoughtsync.core.NoteLabel import com.fabledsword.thoughtsync.core.NoteLabel
import com.fabledsword.thoughtsync.core.checklistLines import com.fabledsword.thoughtsync.core.checklistItems
@Composable @Composable
fun NoteCard( fun NoteCard(
@@ -99,21 +99,21 @@ private fun NoteBody(
onToggleItem: (Int, Boolean) -> Unit, onToggleItem: (Int, Boolean) -> Unit,
) { ) {
val lines = remember(note.body) { note.body.split("\n") } 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 = val itemAtLine =
remember(note.body) { 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)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line -> lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
val index = itemAtLine[n] val found = itemAtLine[n]
val item = index?.let { note.items.getOrNull(it) }
when { when {
// Both in the condition, not just `item`: Kotlin will not infer that a found != null ->
// non-null item implies a non-null index, and `onToggleItem` needs the ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) }
// index smart-cast to act on it.
index != null && item != null ->
ChecklistRow(item) { onToggleItem(index, !item.checked) }
// Kept as a gap rather than dropped: it is the paragraph break // Kept as a gap rather than dropped: it is the paragraph break
// somebody typed, and the card reads as a wall without it. // somebody typed, and the card reads as a wall without it.
line.isBlank() -> Spacer(Modifier.height(4.dp)) line.isBlank() -> Spacer(Modifier.height(4.dp))
@@ -147,7 +147,7 @@ private fun NoteBody(
*/ */
@Composable @Composable
private fun ChecklistRow( private fun ChecklistRow(
item: ChecklistItem, item: BodyItem,
onToggle: () -> Unit, onToggle: () -> Unit,
) { ) {
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.Top) {
@@ -1,7 +1,6 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.checklistContinuation
import kotlinx.coroutines.delay 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 // 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 // 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. // throwing away every character typed during the write.
// //
// Saveable, because a new note has nothing to fall back on. Rotating the phone // BLOCKS rather than one string, because a checklist item is drawn as a real
// mid-capture used to be survivable only in the old capture sheet, which used // checkbox now and a widget cannot live inside a text field. The note is still one
// rememberSaveable for exactly this reason; the editor inherits the requirement // markdown body underneath — see EditorBlocks.kt — and `bodyText` is what is saved.
// along with the job.
// //
// TextFieldValue rather than String so the CARET can start at the end of the // Saveable, because a new note has nothing to fall back on if the phone rotates
// text. A String field always begins its selection at offset zero, which would // mid-capture. The saver carries the TEXT and re-derives the shape, since a block's
// drop the cursor before the first character — the wrong place for "carry on // id means nothing across a process death.
// writing this note", which is what opening an existing one usually means. var blocks by
var body by rememberSaveable(sessionKey, stateSaver = blocksSaver) {
rememberSaveable(sessionKey, stateSaver = TextFieldValue.Saver) { mutableStateOf(splitBlocks(note.body).focusedAtEnd())
mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length)))
} }
// 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<Long?>(null) }
val bodyText = remember(blocks) { joinBlocks(blocks) }
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) } var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) } 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 // would bump `updated_at`, mark the note dirty for sync, and snapshot a
// revision identical to the one before it. // revision identical to the one before it.
val flush = { val flush = {
if (!readOnly && body.text != note.body) { if (!readOnly && bodyText != note.body) {
onAction(EditorAction.SaveText(body.text)) onAction(EditorAction.SaveText(bodyText))
} }
} }
val leave = { val leave = {
@@ -112,15 +110,12 @@ fun NoteEditorScreen(
} }
// Opening an existing note means continuing it. Without this the note arrives // 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 // unfocused, and carrying on costs a tap into the last field.
// drag the caret to the end — the friction the operator reported.
// //
// Not for a trashed note: it renders read-only, and a keyboard over a record // Not for a trashed note: it renders read-only, and a keyboard over a record you
// you cannot edit is noise. `note.id` as the key so the request fires again // cannot edit is noise.
// when the reused editor is pointed at a different note.
val bodyFocus = remember { FocusRequester() }
LaunchedEffect(sessionKey) { LaunchedEffect(sessionKey) {
if (!readOnly) bodyFocus.requestFocus() if (!readOnly) focus = blocks.lastOrNull()?.id
} }
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every // 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 // 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 // every toolbar button works moments after the first keystroke rather than
// needing the note to be saved by hand first. // needing the note to be saved by hand first.
LaunchedEffect(body.text, sessionKey) { LaunchedEffect(bodyText, sessionKey) {
if (readOnly || body.text == note.body) return@LaunchedEffect if (readOnly || bodyText == note.body) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS) delay(AUTOSAVE_IDLE_MS)
onAction(EditorAction.SaveText(body.text)) onAction(EditorAction.SaveText(bodyText))
} }
BackHandler(onBack = leave) BackHandler(onBack = leave)
@@ -179,7 +174,11 @@ fun NoteEditorScreen(
readOnly = readOnly, readOnly = readOnly,
tint = tint, tint = tint,
onClose = leave, onClose = leave,
onStartChecklist = { body = insertChecklistMarker(body) }, onStartChecklist = {
val (next, id) = blocks.plusTask()
blocks = next
focus = id
},
onPicker = { picker = it }, onPicker = { picker = it },
onConfirmDelete = { confirmingDelete = true }, onConfirmDelete = { confirmingDelete = true },
onAction = onAction, onAction = onAction,
@@ -222,16 +221,17 @@ fun NoteEditorScreen(
) )
} }
// One field. A note is its body; its NAME is that body's first // A note is its body; its NAME is that body's first line, so there
// line, so there is nothing separate to type into and nothing to // is nothing separate to type into and nothing rendered bolder than
// render bolder than the line beneath it (M13 steps 3 and 4). // the line beneath it (M13 steps 3 and 4). What 2992 changed is only
EditorField( // how the body is DRAWN — checklist items as boxes rather than as
value = body, // the markup for boxes.
onValueChange = { body = continueChecklist(body, it) }, BlockBody(
hint = R.string.editor_body_hint, blocks = blocks,
enabled = !readOnly, readOnly = readOnly,
minLines = MIN_BODY_LINES, focus = focus,
modifier = Modifier.focusRequester(bodyFocus), onChange = { blocks = it },
onFocus = { focus = it },
) )
// No checklist section. The items ARE lines of the field above // 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. * 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 val SHEET_CORNER = 28.dp
private const val MIN_BODY_LINES = 6
+16 -6
View File
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state}; use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{ use models::{
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery,
RevokeOutcome, SyncOutcome, SyncStatus, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
}; };
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
@@ -511,11 +511,21 @@ impl ThoughtSync {
// already being paid (Rust, Python, TypeScript); a fourth in Compose would be one // already being paid (Rust, Python, TypeScript); a fourth in Compose would be one
// more place for a checklist to change shape when it syncs. // 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 /// One checklist item as the body line that stores it. For an editor that shows a
/// body line by line knows which of them to draw a checkbox on. /// checkbox instead of the markup and has to write the markup back.
#[uniffi::export] #[uniffi::export]
pub fn checklist_lines(body: String) -> Vec<u32> { pub fn checklist_render(text: String, checked: bool) -> String {
local::derive::item_lines(&body) 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<BodyItem> {
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 /// The body with the item at `line`/`column` ticked or unticked, or null if that is
+27
View File
@@ -49,6 +49,33 @@ pub struct Note {
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
/// 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<thoughtsync_core::local::derive::DerivedItem> 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. /// 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 /// A mirror rather than a re-export of `client::ClientRelease`, for the same
+40 -27
View File
@@ -76,6 +76,13 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
pub struct DerivedItem { pub struct DerivedItem {
pub text: String, pub text: String,
pub checked: bool, 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. /// 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<TaskLine<'_>> {
}) })
} }
/// 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 { 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 // 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 // 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. /// Every checklist item in `body`, in the order they appear.
pub fn extract_items(body: &str) -> Vec<DerivedItem> { pub fn extract_items(body: &str) -> Vec<DerivedItem> {
let mut out = Vec::new(); 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) { if let Some(t) = parse_task_line(line) {
out.push(DerivedItem { out.push(DerivedItem {
text: t.text.to_string(), text: t.text.to_string(),
checked: t.checked, 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) map_task_line(body, index, |_| None)
} }
/// The body line each checklist item sits on, in item order.
///
/// For a renderer that walks the body line by line and has to know which of them are
/// items — the Android card does exactly that, and this is what saves it from
/// carrying a fourth copy of the grammar. Line numbers rather than text offsets, for
/// the same encoding reason [toggle_at] gives.
pub fn item_lines(body: &str) -> Vec<u32> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
if parse_task_line(line).is_some() {
out.push(n as u32);
}
}
out
}
/// The body with the item on `line` toggled — or None when that line is not a task /// 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. /// line, or when `column` falls outside its `[ ]` marker.
/// ///
@@ -362,10 +364,11 @@ mod tests {
// ── checklist items ───────────────────────────────────────────────────── // ── checklist items ─────────────────────────────────────────────────────
fn item(text: &str, checked: bool) -> DerivedItem { fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
DerivedItem { DerivedItem {
text: text.to_string(), text: text.to_string(),
checked, checked,
line,
} }
} }
@@ -374,7 +377,7 @@ mod tests {
let body = "shopping\n\n- [ ] milk\n- [x] eggs"; let body = "shopping\n\n- [ ] milk\n- [x] eggs";
assert_eq!( assert_eq!(
extract_items(body), 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 // The whole reason the body owns the list: a table of rows could only ever
// render after the prose. // render after the prose.
let body = "before\n- [ ] middle\nafter"; 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] #[test]
@@ -407,20 +410,20 @@ mod tests {
let body = "* [ ] star\n - [x] indented"; let body = "* [ ] star\n - [x] indented";
assert_eq!( assert_eq!(
extract_items(body), extract_items(body),
vec![item("star", false), item("indented", true)] vec![item("star", false, 0), item("indented", true, 1)]
); );
} }
#[test] #[test]
fn an_empty_item_is_still_an_item() { fn an_empty_item_is_still_an_item() {
// What pressing Enter on a list leaves behind. // What pressing Enter on a list leaves behind.
assert_eq!(extract_items("- [ ]"), vec![item("", false)]); assert_eq!(extract_items("- [ ]"), vec![item("", false, 0)]);
assert_eq!(extract_items("- [ ] "), vec![item("", false)]); assert_eq!(extract_items("- [ ] "), vec![item("", false, 0)]);
} }
#[test] #[test]
fn uppercase_x_parses_and_normalises_on_rewrite() { 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. // Touching it once canonicalises it, and never again.
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done"); assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
} }
@@ -477,9 +480,19 @@ mod tests {
} }
#[test] #[test]
fn item_lines_maps_items_to_the_lines_they_sit_on() { fn render_item_is_what_extract_reads_back() {
assert_eq!(item_lines("a\n- [ ] x\nb\n- [x] y"), vec![1, 3]); assert_eq!(render_item("milk", false), "- [ ] milk");
assert!(item_lines("no items here").is_empty()); 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<_>>(), vec![1, 3]);
} }
#[test] #[test]