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
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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user