Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -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.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
||||
import com.fabledsword.thoughtsync.core.BodyItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||
import com.fabledsword.thoughtsync.core.checklistLines
|
||||
import com.fabledsword.thoughtsync.core.checklistItems
|
||||
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
@@ -99,21 +99,21 @@ private fun NoteBody(
|
||||
onToggleItem: (Int, Boolean) -> Unit,
|
||||
) {
|
||||
val lines = remember(note.body) { note.body.split("\n") }
|
||||
// Read from the BODY rather than from note.items, which is the same list by a
|
||||
// longer route — and one that can lag the text by a save.
|
||||
val itemAtLine =
|
||||
remember(note.body) {
|
||||
checklistLines(note.body).withIndex().associate { (i, line) -> line.toInt() to i }
|
||||
checklistItems(note.body)
|
||||
.mapIndexed { index, item -> item.line.toInt() to (index to item) }
|
||||
.toMap()
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
|
||||
val index = itemAtLine[n]
|
||||
val item = index?.let { note.items.getOrNull(it) }
|
||||
val found = itemAtLine[n]
|
||||
when {
|
||||
// Both in the condition, not just `item`: Kotlin will not infer that a
|
||||
// non-null item implies a non-null index, and `onToggleItem` needs the
|
||||
// index smart-cast to act on it.
|
||||
index != null && item != null ->
|
||||
ChecklistRow(item) { onToggleItem(index, !item.checked) }
|
||||
found != null ->
|
||||
ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) }
|
||||
// Kept as a gap rather than dropped: it is the paragraph break
|
||||
// somebody typed, and the card reads as a wall without it.
|
||||
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
||||
@@ -147,7 +147,7 @@ private fun NoteBody(
|
||||
*/
|
||||
@Composable
|
||||
private fun ChecklistRow(
|
||||
item: ChecklistItem,
|
||||
item: BodyItem,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -28,16 +27,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.checklistContinuation
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -73,22 +67,26 @@ fun NoteEditorScreen(
|
||||
|
||||
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
||||
// needs a key — but a draft's id changes the moment it is first saved, and
|
||||
// re-keying on that would reset this field to whatever the store just returned,
|
||||
// re-keying on that would reset this state to whatever the store just returned,
|
||||
// throwing away every character typed during the write.
|
||||
//
|
||||
// Saveable, because a new note has nothing to fall back on. Rotating the phone
|
||||
// mid-capture used to be survivable only in the old capture sheet, which used
|
||||
// rememberSaveable for exactly this reason; the editor inherits the requirement
|
||||
// along with the job.
|
||||
// BLOCKS rather than one string, because a checklist item is drawn as a real
|
||||
// checkbox now and a widget cannot live inside a text field. The note is still one
|
||||
// markdown body underneath — see EditorBlocks.kt — and `bodyText` is what is saved.
|
||||
//
|
||||
// TextFieldValue rather than String so the CARET can start at the end of the
|
||||
// text. A String field always begins its selection at offset zero, which would
|
||||
// drop the cursor before the first character — the wrong place for "carry on
|
||||
// writing this note", which is what opening an existing one usually means.
|
||||
var body by
|
||||
rememberSaveable(sessionKey, stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length)))
|
||||
// Saveable, because a new note has nothing to fall back on if the phone rotates
|
||||
// mid-capture. The saver carries the TEXT and re-derives the shape, since a block's
|
||||
// id means nothing across a process death.
|
||||
var blocks by
|
||||
rememberSaveable(sessionKey, stateSaver = blocksSaver) {
|
||||
mutableStateOf(splitBlocks(note.body).focusedAtEnd())
|
||||
}
|
||||
// Which field the caret is wanted in, or null. Held HERE rather than inside
|
||||
// BlockBody because the toolbar's checklist button also asks for one.
|
||||
var focus by remember(sessionKey) { mutableStateOf<Long?>(null) }
|
||||
|
||||
val bodyText = remember(blocks) { joinBlocks(blocks) }
|
||||
|
||||
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
|
||||
|
||||
@@ -102,8 +100,8 @@ fun NoteEditorScreen(
|
||||
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
||||
// revision identical to the one before it.
|
||||
val flush = {
|
||||
if (!readOnly && body.text != note.body) {
|
||||
onAction(EditorAction.SaveText(body.text))
|
||||
if (!readOnly && bodyText != note.body) {
|
||||
onAction(EditorAction.SaveText(bodyText))
|
||||
}
|
||||
}
|
||||
val leave = {
|
||||
@@ -112,15 +110,12 @@ fun NoteEditorScreen(
|
||||
}
|
||||
|
||||
// Opening an existing note means continuing it. Without this the note arrives
|
||||
// unfocused, and carrying on costs a tap into the body and often a second to
|
||||
// drag the caret to the end — the friction the operator reported.
|
||||
// unfocused, and carrying on costs a tap into the last field.
|
||||
//
|
||||
// Not for a trashed note: it renders read-only, and a keyboard over a record
|
||||
// you cannot edit is noise. `note.id` as the key so the request fires again
|
||||
// when the reused editor is pointed at a different note.
|
||||
val bodyFocus = remember { FocusRequester() }
|
||||
// Not for a trashed note: it renders read-only, and a keyboard over a record you
|
||||
// cannot edit is noise.
|
||||
LaunchedEffect(sessionKey) {
|
||||
if (!readOnly) bodyFocus.requestFocus()
|
||||
if (!readOnly) focus = blocks.lastOrNull()?.id
|
||||
}
|
||||
|
||||
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
|
||||
@@ -134,10 +129,10 @@ fun NoteEditorScreen(
|
||||
// For a note that does not exist yet this is also what CREATES it, which is why
|
||||
// every toolbar button works moments after the first keystroke rather than
|
||||
// needing the note to be saved by hand first.
|
||||
LaunchedEffect(body.text, sessionKey) {
|
||||
if (readOnly || body.text == note.body) return@LaunchedEffect
|
||||
LaunchedEffect(bodyText, sessionKey) {
|
||||
if (readOnly || bodyText == note.body) return@LaunchedEffect
|
||||
delay(AUTOSAVE_IDLE_MS)
|
||||
onAction(EditorAction.SaveText(body.text))
|
||||
onAction(EditorAction.SaveText(bodyText))
|
||||
}
|
||||
|
||||
BackHandler(onBack = leave)
|
||||
@@ -179,7 +174,11 @@ fun NoteEditorScreen(
|
||||
readOnly = readOnly,
|
||||
tint = tint,
|
||||
onClose = leave,
|
||||
onStartChecklist = { body = insertChecklistMarker(body) },
|
||||
onStartChecklist = {
|
||||
val (next, id) = blocks.plusTask()
|
||||
blocks = next
|
||||
focus = id
|
||||
},
|
||||
onPicker = { picker = it },
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
onAction = onAction,
|
||||
@@ -222,16 +221,17 @@ fun NoteEditorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// One field. A note is its body; its NAME is that body's first
|
||||
// line, so there is nothing separate to type into and nothing to
|
||||
// render bolder than the line beneath it (M13 steps 3 and 4).
|
||||
EditorField(
|
||||
value = body,
|
||||
onValueChange = { body = continueChecklist(body, it) },
|
||||
hint = R.string.editor_body_hint,
|
||||
enabled = !readOnly,
|
||||
minLines = MIN_BODY_LINES,
|
||||
modifier = Modifier.focusRequester(bodyFocus),
|
||||
// A note is its body; its NAME is that body's first line, so there
|
||||
// is nothing separate to type into and nothing rendered bolder than
|
||||
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
||||
// how the body is DRAWN — checklist items as boxes rather than as
|
||||
// the markup for boxes.
|
||||
BlockBody(
|
||||
blocks = blocks,
|
||||
readOnly = readOnly,
|
||||
focus = focus,
|
||||
onChange = { blocks = it },
|
||||
onFocus = { focus = it },
|
||||
)
|
||||
|
||||
// No checklist section. The items ARE lines of the field above
|
||||
@@ -327,104 +327,6 @@ private fun EditorOverlays(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's body field.
|
||||
*
|
||||
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
|
||||
* the note's colour, and a filled field would draw a second surface over the first
|
||||
* and turn a note into a form.
|
||||
*
|
||||
* One weight throughout. The first line is the note's name, but it is not a
|
||||
* different KIND of text from the line after it, and typing it should not feel like
|
||||
* filling in a header.
|
||||
*/
|
||||
@Composable
|
||||
private fun EditorField(
|
||||
value: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
@StringRes hint: Int,
|
||||
enabled: Boolean,
|
||||
minLines: Int = 1,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PlainTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
hint = hint,
|
||||
enabled = enabled,
|
||||
minLines = minLines,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `- [ ] ` at the caret, on a line of its own.
|
||||
*
|
||||
* The whole of "add a checklist" now. It writes nothing to the store, needs no saved
|
||||
* note, and works on an empty compose box the moment it opens — because an item is
|
||||
* text, and this screen already knows how to hold text.
|
||||
*/
|
||||
private fun insertChecklistMarker(value: TextFieldValue): TextFieldValue {
|
||||
val start = value.selection.start
|
||||
val before = value.text.take(start)
|
||||
// A new line unless the caret is already at the start of one: a marker in the
|
||||
// middle of a sentence is not a list item, it is a typo.
|
||||
val prefix = if (before.isEmpty() || before.endsWith("\n")) "" else "\n"
|
||||
val text = before + prefix + CHECKLIST_MARKER + value.text.substring(start)
|
||||
return TextFieldValue(text, TextRange(start + prefix.length + CHECKLIST_MARKER.length))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter on a task line starts the next item; on an EMPTY one it ends the list.
|
||||
*
|
||||
* Both halves are needed. Without the first, a list costs six characters a row on a
|
||||
* phone keyboard; without the second, there is no way out of one except deleting the
|
||||
* marker by hand.
|
||||
*
|
||||
* The grammar lives in the core ([checklistContinuation]) rather than here. It is
|
||||
* already written three times — Rust, Python, TypeScript — and a fourth copy in
|
||||
* Compose would be a fourth place for a checklist to change shape when it syncs.
|
||||
*
|
||||
* Recognised by SHAPE rather than by a key event: this runs inside onValueChange, and
|
||||
* a plain Enter is exactly one more character than before, and that character is a
|
||||
* newline. Anything else — a paste, a selection replaced, an autocorrect — falls
|
||||
* through untouched.
|
||||
*/
|
||||
private fun continueChecklist(
|
||||
old: TextFieldValue,
|
||||
new: TextFieldValue,
|
||||
): TextFieldValue {
|
||||
val caret = new.selection.start
|
||||
val typedNewline =
|
||||
new.selection.length == 0 &&
|
||||
new.text.length == old.text.length + 1 &&
|
||||
caret > 0 &&
|
||||
new.text[caret - 1] == '\n'
|
||||
// Both guarded on `typedNewline`, because caret - 1 is only a real index once it
|
||||
// is known to be the newline that was just typed.
|
||||
val lineStart = if (typedNewline) new.text.take(caret - 1).lastIndexOf('\n') + 1 else 0
|
||||
val marker =
|
||||
if (typedNewline) checklistContinuation(new.text.substring(lineStart, caret - 1)) else null
|
||||
|
||||
return when {
|
||||
marker == null -> new
|
||||
// An empty item: take the marker line away rather than adding another.
|
||||
marker.isEmpty() ->
|
||||
TextFieldValue(
|
||||
new.text.take(lineStart) + new.text.substring(caret),
|
||||
TextRange(lineStart),
|
||||
)
|
||||
else ->
|
||||
TextFieldValue(
|
||||
new.text.take(caret) + marker + new.text.substring(caret),
|
||||
TextRange(caret + marker.length),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CHECKLIST_MARKER = "- [ ] "
|
||||
|
||||
/**
|
||||
* How long typing has to stop before the note is written.
|
||||
*
|
||||
@@ -440,4 +342,3 @@ private const val AUTOSAVE_IDLE_MS = 1_000L
|
||||
*/
|
||||
private val SHEET_CORNER = 28.dp
|
||||
|
||||
private const val MIN_BODY_LINES = 6
|
||||
|
||||
+16
-6
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
|
||||
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
||||
|
||||
use models::{
|
||||
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
|
||||
RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery,
|
||||
ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
@@ -511,11 +511,21 @@ impl ThoughtSync {
|
||||
// already being paid (Rust, Python, TypeScript); a fourth in Compose would be one
|
||||
// more place for a checklist to change shape when it syncs.
|
||||
|
||||
/// Which body lines carry checklist items, in item order — so a renderer walking the
|
||||
/// body line by line knows which of them to draw a checkbox on.
|
||||
/// One checklist item as the body line that stores it. For an editor that shows a
|
||||
/// checkbox instead of the markup and has to write the markup back.
|
||||
#[uniffi::export]
|
||||
pub fn checklist_lines(body: String) -> Vec<u32> {
|
||||
local::derive::item_lines(&body)
|
||||
pub fn checklist_render(text: String, checked: bool) -> String {
|
||||
local::derive::render_item(&text, checked)
|
||||
}
|
||||
|
||||
/// Every checklist item in a body, with the line each one sits on — so a renderer
|
||||
/// walking the body line by line knows which lines are boxes and what is in them.
|
||||
#[uniffi::export]
|
||||
pub fn checklist_items(body: String) -> Vec<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
|
||||
|
||||
@@ -49,6 +49,33 @@ pub struct Note {
|
||||
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.
|
||||
///
|
||||
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
||||
|
||||
+40
-27
@@ -76,6 +76,13 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
||||
pub struct DerivedItem {
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
/// Which body line it sits on.
|
||||
///
|
||||
/// Carried here rather than offered as a second function, because every renderer
|
||||
/// that walks a body line by line — the Android card, the block editor — needs the
|
||||
/// text, the state AND the position together, and asking for them separately is
|
||||
/// how two calls come to disagree about a body that changed between them.
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
/// One parsed task line, holding enough to put it back exactly as it was found.
|
||||
@@ -143,6 +150,16 @@ fn parse_task_line(line: &str) -> Option<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 {
|
||||
// Always lowercase `x`, whatever was parsed: one canonical output is what makes
|
||||
// a round trip stable, so `- [X]` normalises the first time it is touched and
|
||||
@@ -169,11 +186,12 @@ pub fn strip_marker(line: &str) -> &str {
|
||||
/// Every checklist item in `body`, in the order they appear.
|
||||
pub fn extract_items(body: &str) -> Vec<DerivedItem> {
|
||||
let mut out = Vec::new();
|
||||
for line in body.split('\n') {
|
||||
for (n, line) in body.split('\n').enumerate() {
|
||||
if let Some(t) = parse_task_line(line) {
|
||||
out.push(DerivedItem {
|
||||
text: t.text.to_string(),
|
||||
checked: t.checked,
|
||||
line: n as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -241,22 +259,6 @@ pub fn remove_item(body: &str, index: usize) -> String {
|
||||
map_task_line(body, index, |_| None)
|
||||
}
|
||||
|
||||
/// The body line each checklist item sits on, in item order.
|
||||
///
|
||||
/// For a renderer that walks the body line by line and has to know which of them are
|
||||
/// items — the Android card does exactly that, and this is what saves it from
|
||||
/// carrying a fourth copy of the grammar. Line numbers rather than text offsets, for
|
||||
/// the same encoding reason [toggle_at] gives.
|
||||
pub fn item_lines(body: &str) -> Vec<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
|
||||
/// line, or when `column` falls outside its `[ ]` marker.
|
||||
///
|
||||
@@ -362,10 +364,11 @@ mod tests {
|
||||
|
||||
// ── checklist items ─────────────────────────────────────────────────────
|
||||
|
||||
fn item(text: &str, checked: bool) -> DerivedItem {
|
||||
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
|
||||
DerivedItem {
|
||||
text: text.to_string(),
|
||||
checked,
|
||||
line,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +377,7 @@ mod tests {
|
||||
let body = "shopping\n\n- [ ] milk\n- [x] eggs";
|
||||
assert_eq!(
|
||||
extract_items(body),
|
||||
vec![item("milk", false), item("eggs", true)]
|
||||
vec![item("milk", false, 2), item("eggs", true, 3)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,7 +386,7 @@ mod tests {
|
||||
// The whole reason the body owns the list: a table of rows could only ever
|
||||
// render after the prose.
|
||||
let body = "before\n- [ ] middle\nafter";
|
||||
assert_eq!(extract_items(body), vec![item("middle", false)]);
|
||||
assert_eq!(extract_items(body), vec![item("middle", false, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -407,20 +410,20 @@ mod tests {
|
||||
let body = "* [ ] star\n - [x] indented";
|
||||
assert_eq!(
|
||||
extract_items(body),
|
||||
vec![item("star", false), item("indented", true)]
|
||||
vec![item("star", false, 0), item("indented", true, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_item_is_still_an_item() {
|
||||
// What pressing Enter on a list leaves behind.
|
||||
assert_eq!(extract_items("- [ ]"), vec![item("", false)]);
|
||||
assert_eq!(extract_items("- [ ] "), vec![item("", false)]);
|
||||
assert_eq!(extract_items("- [ ]"), vec![item("", false, 0)]);
|
||||
assert_eq!(extract_items("- [ ] "), vec![item("", false, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_x_parses_and_normalises_on_rewrite() {
|
||||
assert_eq!(extract_items("- [X] done"), vec![item("done", true)]);
|
||||
assert_eq!(extract_items("- [X] done"), vec![item("done", true, 0)]);
|
||||
// Touching it once canonicalises it, and never again.
|
||||
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
|
||||
}
|
||||
@@ -477,9 +480,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_lines_maps_items_to_the_lines_they_sit_on() {
|
||||
assert_eq!(item_lines("a\n- [ ] x\nb\n- [x] y"), vec![1, 3]);
|
||||
assert!(item_lines("no items here").is_empty());
|
||||
fn render_item_is_what_extract_reads_back() {
|
||||
assert_eq!(render_item("milk", false), "- [ ] milk");
|
||||
assert_eq!(render_item("done", true), "- [x] done");
|
||||
// An empty item has no trailing space, so a round trip does not grow it.
|
||||
assert_eq!(render_item("", false), "- [ ]");
|
||||
let line = render_item("milk", true);
|
||||
assert_eq!(extract_items(&line), vec![item("milk", true, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_carry_the_line_they_sit_on() {
|
||||
let found = extract_items("a\n- [ ] x\nb\n- [x] y");
|
||||
assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user