Android editor suggests existing tags while a #tag is typed
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
Android / Core and FFI clippy and tests (push) Failing after 33s
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Failing after 1m29s
CI & Build / Build & push image (push) Skipped

The web editor's `#` suggestions, ported: with the caret in a `#tag`,
a row of chips under the field offers the existing tags that finish it,
prefix matches first and busiest first. A tap puts in the whole name and
a space. The rules are the web's tags.ts, with the same test cases.

Task #6316.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 16:23:08 -04:00
co-authored by Claude Opus 5.5
parent b98a0c315f
commit ba97003ea0
4 changed files with 264 additions and 11 deletions
@@ -1,11 +1,14 @@
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -16,6 +19,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -32,12 +36,14 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
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.inkwell.R
import com.fabledsword.inkwell.core.Label
/**
* The note's body, as fields and checkboxes rather than as markup.
@@ -50,6 +56,8 @@ fun BlockBody(
blocks: List<EditorBlock>,
readOnly: Boolean,
focus: Long?,
/** Every tag there is, offered while a `#tag` is typed (see [TagSuggestions]). */
tags: List<Label>,
onChange: (List<EditorBlock>) -> Unit,
onFocus: (Long?) -> Unit,
modifier: Modifier = Modifier,
@@ -80,6 +88,7 @@ fun BlockBody(
TaskBlock(
block = block,
readOnly = readOnly,
tags = tags,
requester = requester,
onChange = { replace(index, it) },
onEnter = {
@@ -102,6 +111,7 @@ fun BlockBody(
ProseBlock(
block = block,
readOnly = readOnly,
tags = tags,
requester = requester,
onChange = { replace(index, it) },
onBlur = {
@@ -132,20 +142,30 @@ fun BlockBody(
private fun ProseBlock(
block: EditorBlock,
readOnly: Boolean,
tags: List<Label>,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onBlur: () -> Unit,
) {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged { if (!it.isFocused) onBlur() },
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
var focused by remember { mutableStateOf(false) }
Column {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged {
focused = it.isFocused
if (!it.isFocused) onBlur()
},
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
if (focused && !readOnly) {
TagSuggestions(value = block.value, tags = tags, onPick = { onChange(block.copy(value = it)) })
}
}
}
/**
@@ -159,11 +179,13 @@ private fun ProseBlock(
private fun TaskBlock(
block: EditorBlock,
readOnly: Boolean,
tags: List<Label>,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onEnter: () -> Unit,
onDelete: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
// Material sizes every interactive component to a 48dp touch target, and on a
// checklist that IS the row height.
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
@@ -176,7 +198,11 @@ private fun TaskBlock(
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.weight(1f).focusRequester(requester),
modifier =
Modifier
.weight(1f)
.focusRequester(requester)
.onFocusChanged { focused = it.isFocused },
enabled = !readOnly,
singleLine = true,
textStyle =
@@ -198,6 +224,57 @@ private fun TaskBlock(
}
}
}
if (focused && !readOnly) {
// Indented past the box, so the row reads as belonging to this item's text.
TagSuggestions(
value = block.value,
tags = tags,
onPick = { onChange(block.copy(value = it)) },
modifier = Modifier.padding(start = ROW_TOUCH),
)
}
}
/**
* The existing tags that finish the `#tag` the caret is in, as a row of chips under
* the field; nothing when the caret is not in a tag or no tag fits.
*
* So a note is filed under `#groceries` rather than a new `#grocery` beside it. Which
* text counts as a tag being typed, which tags are offered and how one is put in are
* in TagSuggest.kt, where they are tested; this is only the row. A tap swaps the token
* for the whole name and a space, and leaves the caret after it to keep typing.
*
* A row rather than a dropdown, because a popup over the field would sit on the very
* line being typed, and on a phone the keyboard already owns the bottom of the screen.
*/
@Composable
private fun TagSuggestions(
value: TextFieldValue,
tags: List<Label>,
onPick: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
) {
val caret = value.selection.end
val at = if (value.selection.collapsed) tagQueryAt(value.text, caret) else null
val matches =
remember(at?.query, tags) {
at?.let { suggestTags(tags, it.query, { t -> t.name }, { t -> t.count ?: 0L }) }.orEmpty()
}
if (at == null || matches.isEmpty()) return
Row(
modifier = modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
matches.forEach { tag ->
SuggestionChip(
onClick = {
val (text, end) = insertTag(value.text, at, tag.name)
onPick(TextFieldValue(text, TextRange(end)))
},
label = { Text("#${tag.name}") },
)
}
}
}
/**
@@ -243,6 +243,7 @@ fun NoteEditorScreen(
blocks = blocks,
readOnly = readOnly,
focus = focus,
tags = labels,
onChange = { blocks = it },
onFocus = { focus = it },
)
@@ -0,0 +1,96 @@
package com.fabledsword.inkwell.ui
/**
* Suggesting existing tags while a `#tag` is typed, so a note is filed under
* `#groceries` rather than a new `#grocery` beside it.
*
* The same rules as the web's `frontend/src/notes/tags.ts` (`tagQueryAt`,
* `suggestTags`, `insertTag`), and the same body-tag grammar as `line_tags` in
* core/src/local/derive.rs: a `#` at the start or after whitespace, a letter, then
* letters, digits, `_` and `-`.
*/
private val TAG_CHAR = Regex("[\\p{IsAlphabetic}\\p{N}_-]")
private val WHOLE_TAG = Regex("^\\p{IsAlphabetic}[\\p{IsAlphabetic}\\p{N}_-]*$")
private fun Char.isTagChar() = TAG_CHAR.matches(toString())
/**
* The `#tag` the caret is in the middle of typing: where its `#` is, where the token
* ends (it may run on past the caret), and the part typed so far.
*/
data class TagQuery(
val start: Int,
val end: Int,
val query: String,
)
/**
* The tag being typed at [caret], or null when the caret is not in one.
*
* A bare `#` counts, with an empty query: that is the moment a suggestion list is most
* use, because it is the moment someone has not yet decided what to type. Anything
* the grammar would not derive (`a#b`, `#2024`, `##x`) is not a tag here either, so
* the row never offers to finish something the core would ignore.
*/
fun tagQueryAt(
text: String,
caret: Int,
): TagQuery? {
if (caret < 0 || caret > text.length) return null
var i = caret
while (i > 0 && text[i - 1].isTagChar()) i--
if (i == 0 || text[i - 1] != '#') return null
val hash = i - 1
if (hash > 0 && !text[hash - 1].isWhitespace()) return null
val query = text.substring(i, caret)
if (query.isNotEmpty() && !Character.isAlphabetic(query[0].code)) return null
var end = caret
while (end < text.length && text[end].isTagChar()) end++
return TagQuery(start = hash, end = end, query = query)
}
/**
* The existing tags worth offering for [query], best first.
*
* Names that start with what was typed come before names that merely contain it;
* within each, the tag on the most notes first. A tag already typed out in full is
* left out, so there is nothing to offer once `#grocery` is finished. A tag whose
* name the body grammar can't hold (one made in the picker as "to do") is left out
* too: inserting `#to do` would derive `#to`.
*/
fun <T> suggestTags(
tags: List<T>,
query: String,
name: (T) -> String,
count: (T) -> Long,
limit: Int = SUGGESTION_LIMIT,
): List<T> {
val q = foldTag(query)
val prefix = { t: T -> if (foldTag(name(t)).startsWith(q)) 0 else 1 }
return tags
.filter { t ->
val f = foldTag(name(t))
WHOLE_TAG.matches(name(t)) && f != q && f.contains(q)
}.sortedWith(
compareBy<T> { prefix(it) }
.thenByDescending { count(it) }
.thenBy { name(it) },
).take(limit)
}
/**
* [text] with the tag at [at] replaced by `#name`, and where the caret goes after it.
* A space follows unless one is already there, so typing simply continues.
*/
fun insertTag(
text: String,
at: TagQuery,
name: String,
): Pair<String, Int> {
val after = text.substring(at.end)
val insertion = "#$name" + if (after.firstOrNull()?.isWhitespace() == true) "" else " "
return (text.substring(0, at.start) + insertion + after) to (at.start + insertion.length)
}
private const val SUGGESTION_LIMIT = 8
@@ -0,0 +1,79 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
/** The same cases as `frontend/src/notes/tags.test.ts`. */
class TagSuggestTest {
private data class Tag(
val name: String,
val count: Long,
)
private val tags =
listOf(
Tag("grocery", 3),
Tag("groceries", 9),
Tag("hungry", 1),
Tag("to do", 50),
Tag("Café", 2),
)
private fun suggest(query: String) = suggestTags(tags, query, { it.name }, { it.count }).map { it.name }
@Test
fun `the tag being typed is found at the caret, with what has been typed so far`() {
assertEquals(TagQuery(start = 9, end = 13, query = "gro"), tagQueryAt("buy milk #gro", 13))
}
@Test
fun `a bare hash counts at the start or after a space`() {
assertEquals(TagQuery(0, 1, ""), tagQueryAt("#", 1))
assertEquals(TagQuery(5, 6, ""), tagQueryAt("milk #", 6))
}
@Test
fun `the tag runs on past the caret to the end of the word`() {
assertEquals(TagQuery(0, 8, "gr"), tagQueryAt("#grocery list", 3))
}
@Test
fun `it is not a tag where the grammar says it is not`() {
assertNull(tagQueryAt("a#b", 3)) // no space before the #
assertNull(tagQueryAt("#2024", 5)) // starts with a digit
assertNull(tagQueryAt("##x", 3))
assertNull(tagQueryAt("# heading", 9)) // a space ends it
assertNull(tagQueryAt("plain words", 5))
}
@Test
fun `names that start with the query come first, busiest first`() {
assertEquals(listOf("groceries", "grocery", "hungry"), suggest("gr"))
}
@Test
fun `matching ignores case`() {
assertEquals(listOf("Café"), suggest("CAF"))
}
@Test
fun `a tag already typed in full, and names the body can't hold, are left out`() {
assertEquals(emptyList<String>(), suggest("grocery"))
assertFalse("to do" in suggest(""))
}
@Test
fun `a picked tag replaces the whole token and leaves the caret after a space`() {
val text = "buy #gr milk"
val at = tagQueryAt(text, 7)!!
assertEquals("buy #groceries milk" to 14, insertTag(text, at, "groceries"))
}
@Test
fun `a picked tag at the end of the text gets a space after it`() {
val at = tagQueryAt("#gr", 3)!!
assertEquals("#grocery " to 9, insertTag("#gr", at, "grocery"))
}
}