mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
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:
+55
@@ -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.
|
||||
|
||||
+58
@@ -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(
|
||||
|
||||
+95
@@ -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
@@ -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?,
|
||||
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+146
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user