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.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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user