Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -218,6 +218,7 @@ private fun App(
|
||||
onOpenSync = { showingSync = true },
|
||||
onSearch = board::search,
|
||||
onCompose = board::compose,
|
||||
onToggleItem = board::toggleItem,
|
||||
onDismissError = board::dismissError,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ fun BoardScreen(
|
||||
onOpenSync: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -336,7 +337,11 @@ private fun NoteBoard(
|
||||
// rebuilding them — and so a newly captured note slides in instead of
|
||||
// making every card below it flicker.
|
||||
items(items = notes, key = { it.id }) { note ->
|
||||
NoteCard(note = note, onOpen = { onOpenNote(note) })
|
||||
NoteCard(
|
||||
note = note,
|
||||
onOpen = { onOpenNote(note) },
|
||||
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,14 +263,6 @@ class BoardViewModel(
|
||||
}
|
||||
EditorAction.DismissError -> dismissError()
|
||||
is EditorAction.SaveText -> createFromDraft(action.body)
|
||||
// The first checklist item is the one toolbar action that means something
|
||||
// on a note with no text: a note whose whole content is its items is a
|
||||
// note this app already has, named from the first of them. So it may
|
||||
// create a body-less one — anything else needs words first.
|
||||
is EditorAction.AddItem ->
|
||||
createFromDraft(draft.body, allowEmpty = true) { created ->
|
||||
onEditorAction(created, action)
|
||||
}
|
||||
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
|
||||
// a note. With autosave at a second, "typed something" is true by the time
|
||||
// anyone reaches the toolbar; before that there is nothing to attribute.
|
||||
@@ -386,16 +378,6 @@ class BoardViewModel(
|
||||
null
|
||||
}
|
||||
|
||||
is EditorAction.AddItem ->
|
||||
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
|
||||
mutate { it.addItem(id, text) }
|
||||
}
|
||||
is EditorAction.SetItemChecked ->
|
||||
mutate { it.setItemChecked(id, action.itemId, action.checked) }
|
||||
is EditorAction.SetItemText ->
|
||||
mutate { it.setItemText(id, action.itemId, action.text) }
|
||||
is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) }
|
||||
|
||||
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
|
||||
|
||||
is EditorAction.CreateLabel ->
|
||||
@@ -482,6 +464,21 @@ class BoardViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick or untick one item from the BOARD, without opening the note.
|
||||
*
|
||||
* The common gesture on a checklist, and the reason it goes through the store
|
||||
* rather than the pure text helpers the editor uses: nothing here is holding a
|
||||
* half-typed body, so the reloaded note is simply the truth.
|
||||
*
|
||||
* `index` is the item's ordinal, which is what its id is now (M304).
|
||||
*/
|
||||
fun toggleItem(
|
||||
note: Note,
|
||||
index: Int,
|
||||
checked: Boolean,
|
||||
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
|
||||
|
||||
fun dismissError() {
|
||||
state = state.copy(error = null)
|
||||
}
|
||||
|
||||
@@ -43,33 +43,12 @@ sealed interface EditorAction {
|
||||
|
||||
data object DeleteForever : EditorAction
|
||||
|
||||
/**
|
||||
* Add an item, given its text.
|
||||
*
|
||||
* There is deliberately no "add an EMPTY item" action. Starting a checklist used
|
||||
* to be one, and it wrote a blank row to the store the moment the toolbar button
|
||||
* was tapped — so the note grew an empty item that had to be typed into while the
|
||||
* add-row sat below it, also empty and also asking to be typed into. Showing the
|
||||
* checklist is now UI state, and the store hears nothing until there is an item
|
||||
* with words in it.
|
||||
*/
|
||||
data class AddItem(
|
||||
val text: String,
|
||||
) : EditorAction
|
||||
|
||||
data class SetItemChecked(
|
||||
val itemId: String,
|
||||
val checked: Boolean,
|
||||
) : EditorAction
|
||||
|
||||
data class SetItemText(
|
||||
val itemId: String,
|
||||
val text: String,
|
||||
) : EditorAction
|
||||
|
||||
data class DeleteItem(
|
||||
val itemId: String,
|
||||
) : EditorAction
|
||||
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
|
||||
// body, so adding, renaming, ticking or deleting one is editing text — which the
|
||||
// editor already does, through SaveText, with the same autosave and the same
|
||||
// revision window as any other edit. Routing them through the store would have
|
||||
// meant the store handing back a note whose body disagreed with the field the
|
||||
// person was typing in.
|
||||
|
||||
/**
|
||||
* The note's MANUAL labels, replacing whatever was there.
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
/**
|
||||
* The checklist, with real checkboxes this time.
|
||||
*
|
||||
* The card renders glyphs because it is a preview; here every row is live. This is
|
||||
* the other half of the answer to how a list gets typed on a phone: the capture
|
||||
* sheet takes a whole list at once, one item per line, because at capture time the
|
||||
* list is already in your head and a tap per row would be the slow part. The
|
||||
* editor is where a list is REVISED, and revising is item-at-a-time — so this is
|
||||
* where the per-row control lives.
|
||||
*
|
||||
* No empty state: a checklist with no items already shows the add row with its
|
||||
* hint, which says the same thing an empty state would and can be typed into.
|
||||
*/
|
||||
@Composable
|
||||
fun ChecklistEditor(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
focusAddRow: Boolean,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
note.items.forEach { item ->
|
||||
ChecklistRow(item = item, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
if (!readOnly) {
|
||||
AddItemRow(
|
||||
autoFocus = focusAddRow,
|
||||
onAdd = { onAction(EditorAction.AddItem(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row: a live checkbox, editable text, and a remove button.
|
||||
*
|
||||
* The text commits on FOCUS LOSS rather than per keystroke. Every commit is a
|
||||
* store write that reloads the note, so per-keystroke saving would both hammer
|
||||
* SQLite and race the reload against the next character.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChecklistRow(
|
||||
item: ChecklistItem,
|
||||
readOnly: Boolean,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
// Keyed by item id, so a reload after some OTHER row's edit doesn't reset the
|
||||
// text being typed here.
|
||||
var text by remember(item.id) { mutableStateOf(item.text) }
|
||||
val commit = { if (text != item.text) onAction(EditorAction.SetItemText(item.id, text)) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = item.checked,
|
||||
onCheckedChange = { onAction(EditorAction.SetItemChecked(item.id, it)) },
|
||||
enabled = !readOnly,
|
||||
)
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { if (!it.isFocused) commit() },
|
||||
enabled = !readOnly,
|
||||
singleLine = true,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
// Struck through when done, matching the card and the web.
|
||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { commit() }),
|
||||
)
|
||||
if (!readOnly) {
|
||||
IconButton(onClick = { onAction(EditorAction.DeleteItem(item.id)) }) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.editor_remove_item),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-present row at the bottom for adding an item.
|
||||
*
|
||||
* It clears but keeps focus after a submit, so a list can be typed straight
|
||||
* through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That
|
||||
* is the same speed the capture sheet's one-item-per-line field buys, carried into
|
||||
* the editor so refining a list never feels slower than making one.
|
||||
*
|
||||
* [autoFocus] is set when the toolbar was just asked for a checklist, which is the
|
||||
* one moment this row is certainly what someone is reaching for. Keyed on `Unit`
|
||||
* rather than on the flag: the row only enters the composition when the checklist
|
||||
* opens, so once is exactly right, and re-requesting later would yank the caret out
|
||||
* of whatever was being typed.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddItemRow(
|
||||
autoFocus: Boolean,
|
||||
onAdd: (String) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (autoFocus) focus.requestFocus()
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.weight(1f).focusRequester(focus),
|
||||
hint = R.string.editor_add_item,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(onDone = {
|
||||
onAdd(text)
|
||||
text = ""
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,6 @@ fun EditorTopBar(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
tint: NoteTint,
|
||||
showAddChecklist: Boolean,
|
||||
onClose: () -> Unit,
|
||||
onStartChecklist: () -> Unit,
|
||||
onPicker: (Picker) -> Unit,
|
||||
@@ -120,18 +119,15 @@ fun EditorTopBar(
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
)
|
||||
}
|
||||
// REVEALS the checklist; it does not write one. This used to add an
|
||||
// empty item so the section would have something to render, which
|
||||
// left a blank row above the add-row — two empty fields, and the
|
||||
// caret in the lower one. Hidden once the checklist is showing, since
|
||||
// its own "+" row does the rest better.
|
||||
if (showAddChecklist) {
|
||||
IconButton(onClick = onStartChecklist) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||
)
|
||||
}
|
||||
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
||||
// a checklist is text now (M304), so there is no section to be
|
||||
// already-showing and no reason a second list cannot start further
|
||||
// down the same note.
|
||||
IconButton(onClick = onStartChecklist) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||
)
|
||||
}
|
||||
}
|
||||
OverflowMenu(
|
||||
|
||||
@@ -15,10 +15,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
@@ -27,12 +27,14 @@ import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.checklistLines
|
||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
note: Note,
|
||||
onOpen: () -> Unit,
|
||||
onToggleItem: (Int, Boolean) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
@@ -53,21 +55,12 @@ fun NoteCard(
|
||||
// nothing above them: the first line of the body IS the note's name, at the
|
||||
// same weight as the rest of it (M13 steps 3 and 4).
|
||||
if (note.body.isNotBlank()) {
|
||||
Text(
|
||||
text = note.body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = MAX_PREVIEW_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (note.items.isNotEmpty()) {
|
||||
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
|
||||
Checklist(items = note.items)
|
||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||
}
|
||||
|
||||
// A note with no body and no items still has to occupy the board legibly —
|
||||
// otherwise it reads as a rendering bug.
|
||||
if (note.body.isBlank() && note.items.isEmpty()) {
|
||||
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||
// it reads as a rendering bug.
|
||||
if (note.body.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -88,46 +81,96 @@ fun NoteCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's body, with its checklist drawn where it actually sits.
|
||||
*
|
||||
* Rendered line by line rather than as one block of text, because an item is a line
|
||||
* of the body now (M304) and a card that showed the prose and then the list would put
|
||||
* every list in the wrong place — and, since the body already contains those lines,
|
||||
* would show each one twice.
|
||||
*
|
||||
* Which lines are items is asked of the core rather than matched here. The grammar is
|
||||
* already written three times; a fourth in Compose would be a fourth place for a
|
||||
* checklist to change shape when it syncs.
|
||||
*/
|
||||
@Composable
|
||||
private fun Checklist(items: List<ChecklistItem>) {
|
||||
private fun NoteBody(
|
||||
note: Note,
|
||||
onToggleItem: (Int, Boolean) -> Unit,
|
||||
) {
|
||||
val lines = remember(note.body) { note.body.split("\n") }
|
||||
val itemAtLine =
|
||||
remember(note.body) {
|
||||
checklistLines(note.body).withIndex().associate { (i, line) -> line.toInt() to i }
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items.take(MAX_CHECKLIST_ROWS).forEach { item ->
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// A glyph rather than a real Checkbox: the card is a PREVIEW, and
|
||||
// a live control here would invite taps that the board cannot yet
|
||||
// honour. It becomes interactive with the editor.
|
||||
Text(
|
||||
text = if (item.checked) "☑" else "☐",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = item.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||
color =
|
||||
if (item.checked) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
|
||||
val index = itemAtLine[n]
|
||||
val item = index?.let { note.items.getOrNull(it) }
|
||||
when {
|
||||
item != null -> ChecklistRow(item) { onToggleItem(index, !item.checked) }
|
||||
// Kept as a gap rather than dropped: it is the paragraph break
|
||||
// somebody typed, and the card reads as a wall without it.
|
||||
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
||||
else ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = MAX_WRAPPED_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val hidden = items.size - MAX_CHECKLIST_ROWS
|
||||
if (hidden > 0) {
|
||||
if (lines.size > MAX_PREVIEW_LINES) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
text = "…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One checklist row on a card, with a box you can actually tick.
|
||||
*
|
||||
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
|
||||
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
|
||||
* space a list out like a form. The tap target is the glyph's own padding, which is
|
||||
* why it carries `clickable` rather than the row — clicking the TEXT should open the
|
||||
* note, the way clicking anywhere else on the card does.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChecklistRow(
|
||||
item: ChecklistItem,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(
|
||||
text = if (item.checked) "☑" else "☐",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = item.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||
color =
|
||||
if (item.checked) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = MAX_WRAPPED_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelChips(labels: List<NoteLabel>) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -182,7 +225,9 @@ private fun ReminderChip(
|
||||
}
|
||||
|
||||
private const val MAX_PREVIEW_LINES = 8
|
||||
private const val MAX_CHECKLIST_ROWS = 8
|
||||
|
||||
/** How far one long line of a card may wrap before it is cut. */
|
||||
private const val MAX_WRAPPED_LINES = 2
|
||||
private const val MAX_LABEL_CHIPS = 3
|
||||
private val CARD_RADIUS = 12.dp
|
||||
private val CHIP_RADIUS = 6.dp
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.checklistContinuation
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -91,13 +92,6 @@ fun NoteEditorScreen(
|
||||
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
|
||||
|
||||
// Whether the checklist is SHOWING, which is not the same as whether the note has
|
||||
// one. Asking for a checklist used to write an empty item, purely so the section
|
||||
// would have something to render — which left a blank row with the add-row beneath
|
||||
// it, both empty, and the caret in the wrong one. Nothing reaches the store now
|
||||
// until an item has words in it. Saveable so a rotation does not close a list
|
||||
// someone is halfway through typing.
|
||||
var checklistOpen by rememberSaveable(sessionKey) { mutableStateOf(false) }
|
||||
|
||||
// A note in the trash is a record, not a document: editing one would silently
|
||||
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||
@@ -185,9 +179,8 @@ fun NoteEditorScreen(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
tint = tint,
|
||||
showAddChecklist = !checklistOpen && note.items.isEmpty(),
|
||||
onClose = leave,
|
||||
onStartChecklist = { checklistOpen = true },
|
||||
onStartChecklist = { body = insertChecklistMarker(body) },
|
||||
onPicker = { picker = it },
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
onAction = onAction,
|
||||
@@ -235,25 +228,16 @@ fun NoteEditorScreen(
|
||||
// render bolder than the line beneath it (M13 steps 3 and 4).
|
||||
EditorField(
|
||||
value = body,
|
||||
onValueChange = { body = it },
|
||||
onValueChange = { body = continueChecklist(body, it) },
|
||||
hint = R.string.editor_body_hint,
|
||||
enabled = !readOnly,
|
||||
minLines = MIN_BODY_LINES,
|
||||
modifier = Modifier.focusRequester(bodyFocus),
|
||||
)
|
||||
|
||||
// Below the body, not instead of it. Shown once the note has
|
||||
// items, or once the toolbar has been asked for a checklist — in
|
||||
// which case the add row takes focus, because being asked for a
|
||||
// list means being about to type one.
|
||||
if (note.items.isNotEmpty() || checklistOpen) {
|
||||
ChecklistEditor(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
focusAddRow = checklistOpen && note.items.isEmpty(),
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
// No checklist section. The items ARE lines of the field above
|
||||
// (M304) — rendering them again down here is what would put every
|
||||
// list on screen twice.
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
@@ -375,6 +359,63 @@ private fun EditorField(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `- [ ] ` at the caret, on a line of its own.
|
||||
*
|
||||
* The whole of "add a checklist" now. It writes nothing to the store, needs no saved
|
||||
* note, and works on an empty compose box the moment it opens — because an item is
|
||||
* text, and this screen already knows how to hold text.
|
||||
*/
|
||||
private fun insertChecklistMarker(value: TextFieldValue): TextFieldValue {
|
||||
val start = value.selection.start
|
||||
val before = value.text.take(start)
|
||||
// A new line unless the caret is already at the start of one: a marker in the
|
||||
// middle of a sentence is not a list item, it is a typo.
|
||||
val prefix = if (before.isEmpty() || before.endsWith("\n")) "" else "\n"
|
||||
val text = before + prefix + CHECKLIST_MARKER + value.text.substring(start)
|
||||
return TextFieldValue(text, TextRange(start + prefix.length + CHECKLIST_MARKER.length))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter on a task line starts the next item; on an EMPTY one it ends the list.
|
||||
*
|
||||
* Both halves are needed. Without the first, a list costs six characters a row on a
|
||||
* phone keyboard; without the second, there is no way out of one except deleting the
|
||||
* marker by hand.
|
||||
*
|
||||
* The grammar lives in the core ([checklistContinuation]) rather than here. It is
|
||||
* already written three times — Rust, Python, TypeScript — and a fourth copy in
|
||||
* Compose would be a fourth place for a checklist to change shape when it syncs.
|
||||
*
|
||||
* Recognised by SHAPE rather than by a key event: this runs inside onValueChange, and
|
||||
* a plain Enter is exactly one more character than before, and that character is a
|
||||
* newline. Anything else — a paste, a selection replaced, an autocorrect — falls
|
||||
* through untouched.
|
||||
*/
|
||||
private fun continueChecklist(
|
||||
old: TextFieldValue,
|
||||
new: TextFieldValue,
|
||||
): TextFieldValue {
|
||||
val caret = new.selection.start
|
||||
if (new.selection.length != 0) return new
|
||||
if (new.text.length != old.text.length + 1) return new
|
||||
if (caret == 0 || new.text[caret - 1] != '\n') return new
|
||||
|
||||
val before = new.text.take(caret - 1)
|
||||
val lineStart = before.lastIndexOf('\n') + 1
|
||||
val marker = checklistContinuation(before.substring(lineStart)) ?: return new
|
||||
|
||||
if (marker.isEmpty()) {
|
||||
// An empty item: take the marker line away rather than adding another.
|
||||
val text = new.text.take(lineStart) + new.text.substring(caret)
|
||||
return TextFieldValue(text, TextRange(lineStart))
|
||||
}
|
||||
val text = new.text.take(caret) + marker + new.text.substring(caret)
|
||||
return TextFieldValue(text, TextRange(caret + marker.length))
|
||||
}
|
||||
|
||||
private const val CHECKLIST_MARKER = "- [ ] "
|
||||
|
||||
/**
|
||||
* How long typing has to stop before the note is written.
|
||||
*
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
|
||||
<!-- Board -->
|
||||
<string name="board_empty_note">Empty note</string>
|
||||
<plurals name="board_more_items">
|
||||
<item quantity="one">+%d more item</item>
|
||||
<item quantity="other">+%d more items</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Empty states. Each destination says something true of ITSELF; a single
|
||||
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
||||
|
||||
@@ -499,6 +499,40 @@ impl ThoughtSync {
|
||||
}
|
||||
}
|
||||
|
||||
// ── checklist text, as pure functions ───────────────────────────────────────
|
||||
//
|
||||
// Free functions rather than methods, because these touch no database. The editor's
|
||||
// body field is LOCAL state — it is autosaved on an idle debounce, not written on
|
||||
// every keystroke — so a checkbox tapped in the editor has to rewrite the text the
|
||||
// field is holding, not a row the store would hand back a moment later. Routing that
|
||||
// through the store would overwrite whatever was being typed.
|
||||
//
|
||||
// They also keep the grammar out of Kotlin. Three implementations of it is the price
|
||||
// already being paid (Rust, Python, TypeScript); a fourth in Compose would be one
|
||||
// more place for a checklist to change shape when it syncs.
|
||||
|
||||
/// Which body lines carry checklist items, in item order — so a renderer walking the
|
||||
/// body line by line knows which of them to draw a checkbox on.
|
||||
#[uniffi::export]
|
||||
pub fn checklist_lines(body: String) -> Vec<u32> {
|
||||
local::derive::item_lines(&body)
|
||||
}
|
||||
|
||||
/// The body with the item at `line`/`column` ticked or unticked, or null if that is
|
||||
/// not a checkbox. See `derive::toggle_at` for why the address is line + column and
|
||||
/// not a text offset.
|
||||
#[uniffi::export]
|
||||
pub fn checklist_toggle_at(body: String, line: u32, column: u32) -> Option<String> {
|
||||
local::derive::toggle_at(&body, line as usize, column as usize)
|
||||
}
|
||||
|
||||
/// What pressing Enter at the end of `line` should leave behind: null to let Enter be
|
||||
/// Enter, "" to end the list, or the marker to start the next item.
|
||||
#[uniffi::export]
|
||||
pub fn checklist_continuation(line: String) -> Option<String> {
|
||||
local::derive::continuation(&line)
|
||||
}
|
||||
|
||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||
/// block names, so these stay Rust-side.
|
||||
impl ThoughtSync {
|
||||
|
||||
@@ -241,6 +241,69 @@ pub fn remove_item(body: &str, index: usize) -> String {
|
||||
map_task_line(body, index, |_| None)
|
||||
}
|
||||
|
||||
/// The body line each checklist item sits on, in item order.
|
||||
///
|
||||
/// For a renderer that walks the body line by line and has to know which of them are
|
||||
/// items — the Android card does exactly that, and this is what saves it from
|
||||
/// carrying a fourth copy of the grammar. Line numbers rather than text offsets, for
|
||||
/// the same encoding reason [toggle_at] gives.
|
||||
pub fn item_lines(body: &str) -> Vec<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.
|
||||
///
|
||||
/// Addressed by LINE and COLUMN rather than by a text offset, because the two sides of
|
||||
/// the FFI do not count the same way: Compose measures an offset in UTF-16 units and
|
||||
/// Rust in bytes, so the same number means different places in a note with an emoji in
|
||||
/// it. A line number is identical in every encoding. So is a column inside the marker,
|
||||
/// which is ASCII and sits at the start of its line — and that is the only range this
|
||||
/// function looks at.
|
||||
///
|
||||
/// Only the marker toggles, not the whole line: the rest of it is text somebody needs
|
||||
/// to be able to put a caret into.
|
||||
pub fn toggle_at(body: &str, line: usize, column: usize) -> Option<String> {
|
||||
let target = body.split('\n').nth(line)?;
|
||||
let parsed = parse_task_line(target)?;
|
||||
// One column of slack past the `]`, because a checkbox on a phone should forgive
|
||||
// a near miss.
|
||||
let marker_end = target.chars().position(|c| c == ']')? + 1;
|
||||
if column > marker_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut index = 0;
|
||||
for earlier in body.split('\n').take(line) {
|
||||
if parse_task_line(earlier).is_some() {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
Some(set_item_checked(body, index, !parsed.checked))
|
||||
}
|
||||
|
||||
/// What pressing Enter at the end of `line` should leave behind.
|
||||
///
|
||||
/// * `None` — not a task line. Enter does what Enter always does.
|
||||
/// * `Some("")` — an EMPTY item: clear the marker and end the list. Without this half
|
||||
/// a list is impossible to get out of without deleting characters by hand.
|
||||
/// * `Some(marker)` — start the next item. The indent and bullet are carried over
|
||||
/// rather than normalised, because continuing someone's `*` list with a `-` is an
|
||||
/// edit they did not ask for.
|
||||
pub fn continuation(line: &str) -> Option<String> {
|
||||
let parsed = parse_task_line(line)?;
|
||||
if parsed.text.trim().is_empty() {
|
||||
return Some(String::new());
|
||||
}
|
||||
Some(format!("{}{} [ ] ", parsed.indent, parsed.bullet))
|
||||
}
|
||||
|
||||
/// Add an item at the end of the body.
|
||||
///
|
||||
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
|
||||
@@ -413,6 +476,41 @@ mod tests {
|
||||
assert_eq!(strip_marker("just prose"), "just prose");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_lines_maps_items_to_the_lines_they_sit_on() {
|
||||
assert_eq!(item_lines("a\n- [ ] x\nb\n- [x] y"), vec![1, 3]);
|
||||
assert!(item_lines("no items here").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_at_only_fires_inside_the_marker() {
|
||||
let body = "note\n- [ ] milk\n- [x] eggs";
|
||||
// Column 0 is the bullet, 4 is the `]`, 5 the slack past it.
|
||||
assert_eq!(
|
||||
toggle_at(body, 1, 0).as_deref(),
|
||||
Some("note\n- [x] milk\n- [x] eggs")
|
||||
);
|
||||
assert_eq!(
|
||||
toggle_at(body, 2, 5).as_deref(),
|
||||
Some("note\n- [ ] milk\n- [ ] eggs")
|
||||
);
|
||||
// Past the marker is text someone wants to put a caret in.
|
||||
assert!(toggle_at(body, 1, 9).is_none());
|
||||
// Not a task line, and off the end.
|
||||
assert!(toggle_at(body, 0, 0).is_none());
|
||||
assert!(toggle_at(body, 99, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_starts_the_next_item_or_ends_the_list() {
|
||||
assert_eq!(continuation("- [x] milk").as_deref(), Some("- [ ] "));
|
||||
assert_eq!(continuation(" * [ ] milk").as_deref(), Some(" * [ ] "));
|
||||
// An empty item ends the list rather than adding another.
|
||||
assert_eq!(continuation("- [ ]").as_deref(), Some(""));
|
||||
assert_eq!(continuation("- [ ] ").as_deref(), Some(""));
|
||||
assert!(continuation("just prose").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_index_does_nothing() {
|
||||
// The index comes from a UI that may be a moment behind the store. A tap
|
||||
|
||||
Reference in New Issue
Block a user