feat: tapping a chip offers to change or remove that filter

A chip is drawn text inside one BasicTextField, not a composable of its own,
so tapping it can only move the caret — there is nowhere to hang a ✕ on the
chip itself. So the caret landing on a finished token is what stands in for
"the reader tapped this one", and a row under the field turns that into the
two things anyone wants from a filter they can see: change it, or drop it.

Change cuts the token back to its prefix — `kind:article` becomes `kind:` —
which leaves the field in exactly the state typing `kind:` and stopping would
produce, so the picker opens on the spot and every existing rule about what it
offers and what a pick splices in still holds. Nothing new had to learn how to
edit a token. A chip with no picker (`#tag`, `-term`, `"a phrase"`) is selected
instead, so the next keystroke replaces it.

Remove takes the one space the token leaves with it. That is not cosmetic: the
field's text round-trips through the parser on every keystroke, so a doubled
space compounds every time a filter is dropped, and a seeded query — which
arrives with a trailing space so its chip settles — has to come back to a
genuinely empty box rather than one that looks used.

The editor and the picker can never both be up: a picker only opens on a token
that is not finished, and the editor only on one that is. That is asserted
rather than assumed, along with every offset of a chip resolving to that chip,
since a tap can land on either edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
This commit is contained in:
Claude
2026-09-09 21:49:29 +00:00
parent 2711b36584
commit 490cd4fa7a
6 changed files with 451 additions and 0 deletions
@@ -75,6 +75,40 @@ sealed interface ActivePicker {
) : ActivePicker
}
/**
* A *finished* token the caret is sitting on, with the span it covers.
*
* This is what a tap on a chip produces: the caret lands somewhere in the token, which is the
* signal that the reader means to change or drop that filter rather than write a new one. A
* half-written token is [ActivePicker]'s business instead, and the two never both apply — a
* picker only opens on a token that is not finished yet.
*/
@Immutable
data class EditableToken(
val segment: SearchSegment,
val start: Int,
val end: Int,
) {
/**
* The prefix that reopens this token's picker when the reader asks to change it, or null for
* a token that has no picker and is changed by retyping it (`#tag`, `-term`, `"a phrase"`).
*/
val editPrefix: String?
get() =
when (val seg = segment) {
is SearchSegment.Key -> seg.field?.let { "${it.token}:" }
is SearchSegment.Pointer -> "to:"
is SearchSegment.DateBound -> "${seg.field.token}:"
is SearchSegment.Group -> "group:"
is SearchSegment.Kind -> "kind:"
is SearchSegment.Label -> "label:"
is SearchSegment.Scope -> "${seg.field}:"
is SearchSegment.Language -> "lang:"
is SearchSegment.Domain -> "domain:"
is SearchSegment.Hashtag, is SearchSegment.Exclusion, is SearchSegment.Phrase, is SearchSegment.Text -> null
}
}
/**
* Which half-written token the caret is in, and therefore which picker belongs under the field.
*
@@ -156,6 +190,27 @@ object PartialTokens {
caret: Int,
): PartialToken? = partialAt(text, caret, GROUP_PREFIXES)
/**
* The finished token the caret is on, or null when it is on plain text.
*
* Offsets come from [SearchTokenizer] rather than being re-scanned here, so the span this
* reports is exactly the span the field drew as a chip — tapping a chip and editing it can
* never act on different characters than the ones the reader saw.
*/
fun tokenAt(
text: String,
caret: Int,
): EditableToken? {
val at = caret.coerceIn(0, text.length)
var start = 0
SearchTokenizer.tokenize(text).forEach { seg ->
val end = start + seg.length
if (seg !is SearchSegment.Text && at in start..end) return EditableToken(seg, start, end)
start = end
}
return null
}
/**
* Is this exactly one finished key? An npub only: hex pasted after `from:` stays unfinished,
* so the picker resolves it and writes the npub back in its place.
@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.commons.search.ActivePicker
import com.vitorpamplona.amethyst.commons.search.EditableToken
import com.vitorpamplona.amethyst.commons.search.PartialToken
import com.vitorpamplona.amethyst.commons.search.PartialTokens
import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock
@@ -69,6 +70,21 @@ class SearchFieldState(
*/
val settleCaret: Int? get() = if (focused && value.selection.collapsed) value.selection.start else null
/**
* The finished token the caret is on, which the field offers to change or remove.
*
* Never both this and [activePicker]: a picker is for a token still being written, this is
* for one already written. Suppressed while a picker is up so the two cannot argue over the
* space under the field.
*/
val editableToken: EditableToken?
get() =
if (!focused || !value.selection.collapsed || activePicker != null) {
null
} else {
PartialTokens.tokenAt(value.text, value.selection.start)
}
/** Which picker the caret's position calls for, or null. */
val activePicker: ActivePicker?
get() =
@@ -147,6 +163,48 @@ class SearchFieldState(
alias: String,
) = replace(picker.token, "kind:$alias")
// ---- changing and removing a token that is already written ------------------------------
/**
* Drop [token] and the one space it leaves behind.
*
* The space matters: removing `kind:article` from `a kind:article b` has to leave `a b`, not
* `a b`, or every removal widens the gap a little more and a round trip through the parser
* starts producing different text than it was given.
*/
fun removeToken(token: EditableToken) {
val text = value.text
var start = token.start
var end = token.end.coerceAtMost(text.length)
if (end < text.length && text[end] == ' ') {
end++
} else if (start > 0 && text[start - 1] == ' ') {
start--
}
setText(text.substring(0, start) + text.substring(end), start)
}
/**
* Ask to change [token]: cut it back to its prefix so its picker opens on the spot, or — for
* a token with no picker — select it so the next keystroke replaces it.
*
* Reopening by truncation rather than by a separate "edit mode" is what keeps this honest:
* the field ends up in exactly the state it would be in had the reader typed `kind:` and
* stopped, so every rule about what a picker offers and what a pick splices in still holds.
*/
fun changeToken(token: EditableToken) {
val text = value.text
val end = token.end.coerceAtMost(text.length)
val prefix = token.editPrefix
if (prefix == null) {
value = TextFieldValue(text, TextRange(token.start, end))
resetCalendar()
return
}
val next = text.substring(0, token.start) + prefix + text.substring(end)
setText(next, token.start + prefix.length)
}
// ---- walking the calendar with the keyboard --------------------------------------------
fun stepMonth(
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.search
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The row that opens under the field when the caret lands on a finished chip.
*
* A chip is drawn text inside one `BasicTextField`, not a composable of its own, so it cannot
* carry its own buttons — tapping it can only move the caret. This row is what turns that caret
* position into the two things a reader wants from a filter they can see: change it, or drop it.
*
* [label] is the chip exactly as the field drew it — the resolved name, not the npub — because a
* row offering to remove `from:npub1qq…` when the field says `from:Alice` reads as being about
* something else.
*/
@Composable
fun SearchTokenEditor(
label: String,
onChange: () -> Unit,
onRemove: () -> Unit,
modifier: Modifier = Modifier,
changeLabel: String = "Change",
removeLabel: String = "Remove",
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onChange) {
SymbolIcon(
symbol = MaterialSymbols.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(changeLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 4.dp))
}
TextButton(onClick = onRemove) {
SymbolIcon(
symbol = MaterialSymbols.Close,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
Text(
removeLabel,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
@@ -46,12 +46,16 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.search.ActivePicker
import com.vitorpamplona.amethyst.commons.search.EditableToken
import com.vitorpamplona.amethyst.commons.search.KindCandidate
import com.vitorpamplona.amethyst.commons.search.KindRegistry
import com.vitorpamplona.amethyst.commons.search.rawText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -214,9 +218,51 @@ fun TokenizedSearchField(
null -> Unit
}
// Tapping a chip only moves the caret — a chip is drawn text, not a composable — so the
// caret landing on a finished token is what stands in for "the reader tapped this one",
// and this row is where changing or dropping it lives.
state.editableToken?.let { token ->
SearchPickerSurface(Modifier.padding(top = 4.dp).fillMaxWidth()) {
SearchTokenEditor(
// Drawn exactly as the field draws it, so the row is unmistakably about the
// chip under the caret rather than the raw text hiding behind it.
label = tokenLabel(token, displayName, groupName, scopeName),
onChange = { state.changeToken(token) },
onRemove = { state.removeToken(token) },
)
}
}
}
}
/** One token drawn the way the field draws it, for the editor row's label. */
private fun tokenLabel(
token: EditableToken,
displayName: (String) -> String?,
groupName: (String) -> String?,
scopeName: (String, String) -> String?,
): String =
SearchTokenTransformation(null, EMPTY_STYLES, displayName, groupName, scopeName)
.filter(AnnotatedString(token.segment.rawText))
.text.text
/** The label only needs the text the transformation produces, never its colours. */
private val EMPTY_STYLES =
SearchTokenStyles(
person = SpanStyle(),
pointer = SpanStyle(),
hashtag = SpanStyle(),
date = SpanStyle(),
label = SpanStyle(),
scope = SpanStyle(),
group = SpanStyle(),
kind = SpanStyle(),
extension = SpanStyle(),
exclusion = SpanStyle(),
phrase = SpanStyle(),
)
@Composable
private fun LaunchedQuery(
picker: ActivePicker?,
@@ -172,4 +172,55 @@ class PartialTokensTest {
// Standing in the middle of a finished token is editing it, not writing it.
assertNull(PartialTokens.activePicker("kind:article extra", 8))
}
// ---- tapping a finished chip -----------------------------------------------------------
@Test
fun theCaretOnAFinishedChipNamesThatChip() {
val text = "bitcoin kind:article rest"
val token = PartialTokens.tokenAt(text, 12)
assertTrue(token?.segment is SearchSegment.Kind)
assertEquals(8, token.start)
assertEquals(20, token.end)
assertEquals("kind:article", text.substring(token.start, token.end))
}
@Test
fun theCaretOnPlainTextNamesNoChip() {
assertNull(PartialTokens.tokenAt("bitcoin kind:article rest", 2))
assertNull(PartialTokens.tokenAt("bitcoin kind:article rest", 23))
assertNull(PartialTokens.tokenAt("", 0))
}
@Test
fun everyOffsetOfEveryChipResolvesToThatChip() {
// A tap can land anywhere in a chip, including either edge, and must always name it.
val text = "#bitcoin"
(0..text.length).forEach { at ->
assertTrue(PartialTokens.tokenAt(text, at)?.segment is SearchSegment.Hashtag, "offset $at")
}
}
@Test
fun aChipWithAPickerReopensOnItsPrefix() {
val text = "kind:article"
assertEquals("kind:", PartialTokens.tokenAt(text, 4)?.editPrefix)
assertEquals("from:", PartialTokens.tokenAt("from:$NPUB", 3)?.editPrefix)
assertEquals("since:", PartialTokens.tokenAt("since:2026-01-01", 3)?.editPrefix)
assertEquals("group:", PartialTokens.tokenAt("group:dev", 3)?.editPrefix)
assertEquals("geo:", PartialTokens.tokenAt("geo:9q8yy", 2)?.editPrefix)
}
@Test
fun aChipWithNoPickerHasNoPrefixAndIsChangedByRetyping() {
assertNull(PartialTokens.tokenAt("#bitcoin", 3)?.editPrefix)
assertNull(PartialTokens.tokenAt("-scam", 3)?.editPrefix)
assertNull(PartialTokens.tokenAt("\"a phrase\"", 3)?.editPrefix)
}
@Test
fun aBareKeyIsNotChangedThroughAPrefixItNeverHad() {
// `npub1…` on its own is a search term, not a `from:`; it has no prefix to reopen.
assertNull(PartialTokens.tokenAt(NPUB, 3)?.editPrefix)
}
}
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.search
import com.vitorpamplona.amethyst.commons.search.PartialTokens
import com.vitorpamplona.amethyst.commons.search.QueryParser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Changing and removing a chip the reader tapped.
*
* The text is the query, so both operations are splices — and a splice that leaves the whitespace
* wrong is not cosmetic: the field's text round-trips through the parser on every keystroke, so a
* doubled space compounds every time a filter is dropped.
*/
class SearchFieldStateTest {
private companion object {
const val NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"
}
/** A focused field with the caret placed on [caret], which is what a tap produces. */
private fun fieldAt(
text: String,
caret: Int,
): SearchFieldState =
SearchFieldState(text).also {
it.onFocusChanged(true)
it.setText(text, caret)
}
private fun tokenAt(
state: SearchFieldState,
caret: Int,
) = PartialTokens.tokenAt(state.text, caret)
@Test
fun removingAChipInTheMiddleLeavesOneSpace() {
val state = fieldAt("bitcoin kind:article rest", 12)
state.removeToken(assertNotNull(tokenAt(state, 12)))
assertEquals("bitcoin rest", state.text)
}
@Test
fun removingTheOnlyChipLeavesAnEmptyField() {
val state = fieldAt("kind:article", 4)
state.removeToken(assertNotNull(tokenAt(state, 4)))
assertEquals("", state.text)
}
@Test
fun removingTheLastChipDoesNotStrandTheSpaceBeforeIt() {
val state = fieldAt("bitcoin kind:article", 12)
state.removeToken(assertNotNull(tokenAt(state, 12)))
assertEquals("bitcoin", state.text)
}
@Test
fun removingASeededChipLeavesTheFieldTrulyEmpty() {
// A seed arrives with a trailing space so its chip settles; dropping it must not leave
// that space behind, or the box looks used when it is not.
val state = fieldAt("kind:article ", 4)
state.removeToken(assertNotNull(tokenAt(state, 4)))
assertEquals("", state.text)
}
@Test
fun removingAChipRemovesItsFilterFromTheQuery() {
val state = fieldAt("from:$NPUB kind:article bitcoin", 3)
assertEquals(1, QueryParser.parse(state.text).authors.size)
state.removeToken(assertNotNull(tokenAt(state, 3)))
val after = QueryParser.parse(state.text)
assertTrue(after.authors.isEmpty())
// and leaves everything else exactly as it was
assertEquals(listOf(30023), after.kinds)
assertEquals("bitcoin", after.text)
}
@Test
fun changingAChipCutsItBackToItsPrefixSoThePickerOpens() {
val state = fieldAt("bitcoin kind:article rest", 12)
state.changeToken(assertNotNull(tokenAt(state, 12)))
assertEquals("bitcoin kind: rest", state.text)
// The field is now in exactly the state typing `kind:` and stopping would produce.
assertNotNull(state.activePicker)
assertNull(state.editableToken)
}
@Test
fun changingAChipWithNoPickerSelectsItForRetyping() {
val state = fieldAt("bitcoin #nostr rest", 10)
state.changeToken(assertNotNull(tokenAt(state, 10)))
// Text untouched; the token is selected, so the next keystroke replaces it.
assertEquals("bitcoin #nostr rest", state.text)
assertEquals(8, state.value.selection.start)
assertEquals(14, state.value.selection.end)
}
@Test
fun aCaretOnPlainTextOffersNoEditor() {
val state = fieldAt("bitcoin kind:article", 2)
assertNull(state.editableToken)
}
@Test
fun aChipUnderTheCaretOffersAnEditor() {
val state = fieldAt("bitcoin kind:article", 12)
assertNotNull(state.editableToken)
}
@Test
fun anUnfocusedFieldOffersNoEditor() {
val state = fieldAt("bitcoin kind:article", 12)
state.onFocusChanged(false)
assertNull(state.editableToken)
}
@Test
fun aHalfWrittenTokenBelongsToThePickerNotTheEditor() {
// Both must never be up at once, or they fight over the space under the field.
val state = fieldAt("kind:art", 8)
assertNotNull(state.activePicker)
assertNull(state.editableToken)
}
}