@@ -23,13 +23,13 @@ import androidx.compose.material.icons.outlined.Block
2323import androidx.compose.material.icons.outlined.ChevronRight
2424import androidx.compose.material.icons.outlined.Close
2525import androidx.compose.material3.Icon
26+ import androidx.compose.runtime.LaunchedEffect
2627import androidx.compose.material3.MaterialTheme
2728import androidx.compose.material3.Surface
2829import androidx.compose.material3.Text
2930import androidx.compose.runtime.Composable
3031import androidx.compose.runtime.getValue
3132import androidx.compose.runtime.mutableIntStateOf
32- import androidx.compose.runtime.mutableStateOf
3333import androidx.compose.runtime.produceState
3434import androidx.compose.runtime.remember
3535import androidx.compose.runtime.setValue
@@ -38,29 +38,28 @@ import androidx.compose.ui.Modifier
3838import androidx.compose.ui.draw.clip
3939import androidx.compose.ui.graphics.Color
4040import androidx.compose.ui.graphics.vector.ImageVector
41- import androidx.compose.ui.platform.LocalFocusManager
4241import androidx.compose.ui.text.font.FontWeight
4342import androidx.compose.ui.text.style.TextOverflow
4443import androidx.compose.ui.unit.dp
4544import eu.kanade.tachiyomi.source.model.Filter
4645import kotlinx.coroutines.Dispatchers
46+ import kotlinx.coroutines.delay
4747import kotlinx.coroutines.withContext
4848import yokai.util.search.FuzzyMatcher
4949
5050/* *
5151 * AutoComplete handling for the source filter sheet.
5252 *
5353 * - [FilterAutoCompleteRow] — row in the main filter list. Shows include/exclude pills for the
54- * currently-selected tags directly beneath the row so the user can see and edit selections
55- * without re-opening the picker. Tap pill body cycles include⇄exclude (if the source declares
56- * `-` in `validPrefixes`); trailing × removes.
54+ * currently-selected tags directly beneath the row. Tap pill body cycles include⇄exclude
55+ * (if the source declares `-` in `validPrefixes`); trailing × removes.
5756 *
58- * - [AutoCompleteScreen] — full sheet-body picker. Uses the shared [SheetSearchField] for the
59- * search bar, fuzzy ranking via [FuzzyMatcher.score], and pins currently -selected tags to the
60- * top of the list. The pinned ordering is snapshotted once per query change so tapping rows
61- * to include/exclude never reorders the list — the row stays put and only its colour changes .
57+ * - [AutoCompleteScreen] — full sheet-body picker. The search bar lives in the drill top bar
58+ * above this screen, so the query is passed in from the caller. Pinned -selected tags appear
59+ * first; the pinning snapshot is taken once per query change so tapping a tag inside the
60+ * picker never reorders rows .
6261 *
63- * State mutation routed through [FilterMutations] so the in-place contract
62+ * State mutation is routed through [FilterMutations] so the in-place contract
6463 * `BrowseSourceController.showFilters()` snapshots and compares stays intact.
6564 */
6665
@@ -69,10 +68,13 @@ import yokai.util.search.FuzzyMatcher
6968@Composable
7069internal fun FilterAutoCompleteRow (
7170 filter : Filter .AutoComplete ,
71+ outerSelectionVersion : Int ,
7272 onDrill : (Filter .AutoComplete ) -> Unit ,
7373) {
74- var selectionVersion by remember(filter) { mutableIntStateOf(0 ) }
75- val active = remember(filter, selectionVersion) { filter.state.toList() }
74+ var localVersion by remember(filter) { mutableIntStateOf(0 ) }
75+ // outer version bumps when the user drills out of any picker so this row picks up changes
76+ // made inside the picker. local version bumps for in-row pill mutations.
77+ val active = remember(filter, outerSelectionVersion, localVersion) { filter.state.toList() }
7678 Column (modifier = Modifier .fillMaxWidth()) {
7779 FilterPreferenceRow (
7880 title = filter.name,
@@ -90,16 +92,15 @@ internal fun FilterAutoCompleteRow(
9092 SelectedTagPills (
9193 filter = filter,
9294 selectedState = active,
93- onChange = { selectionVersion ++ },
95+ onChange = { localVersion ++ },
9496 )
9597 }
9698 }
9799}
98100
99101// endregion
100102
101- // region Selected-tag pills — main-list row only. Removed from inside the picker so the picker
102- // only shows pinned rows.
103+ // region Selected-tag pills — main-list row only. Picker drops these in favour of pinned rows.
103104
104105@OptIn(ExperimentalLayoutApi ::class )
105106@Composable
@@ -149,8 +150,15 @@ private fun SelectedTagPills(
149150 }
150151}
151152
153+ /* *
154+ * Shared pill component — used both by [SelectedTagPills] (AutoComplete) and by
155+ * `FilterGroupRow`'s active-children summary (Filter.Group). Same look, same gestures.
156+ *
157+ * Background is the full primary / error tone (not the container variants) so the pill reads as
158+ * an unambiguous "active filter" badge against the sheet's surface.
159+ */
152160@Composable
153- private fun TagPill (
161+ internal fun TagPill (
154162 label : String ,
155163 state : AutoCompleteTagState ,
156164 onClick : (() -> Unit )? ,
@@ -161,19 +169,17 @@ private fun TagPill(
161169 val leading: ImageVector
162170 when (state) {
163171 AutoCompleteTagState .Included -> {
164- container = MaterialTheme .colorScheme.primaryContainer
165- content = MaterialTheme .colorScheme.onPrimaryContainer
172+ container = MaterialTheme .colorScheme.primary
173+ content = MaterialTheme .colorScheme.onPrimary
166174 leading = Icons .Outlined .Add
167175 }
168176 AutoCompleteTagState .Excluded -> {
169- container = MaterialTheme .colorScheme.errorContainer
170- content = MaterialTheme .colorScheme.onErrorContainer
177+ container = MaterialTheme .colorScheme.error
178+ content = MaterialTheme .colorScheme.onError
171179 leading = Icons .Outlined .Block
172180 }
173181 AutoCompleteTagState .Off -> return
174182 }
175- // Surface + Row instead of InputChip — InputChip's default shape is squarish (8dp). We want
176- // fully-rounded pill chrome with an explicit container colour, so build it directly.
177183 Surface (
178184 onClick = onClick ? : onRemove,
179185 shape = CircleShape ,
@@ -217,53 +223,35 @@ private fun TagPill(
217223
218224// endregion
219225
220- // region Drill page — shared search bar + pinned-selected tag list.
226+ // region Drill page — pinned-selected tag list. Search bar lives in the drill top bar above .
221227
222228@Composable
223229internal fun AutoCompleteScreen (
224230 filter : Filter .AutoComplete ,
231+ query : String ,
225232 onListScrollChange : ((canScrollUp: Boolean ) -> Unit )? ,
226233) {
227- var query by remember(filter) { mutableStateOf(" " ) }
228234 // selectionVersion bumps on every cycle. The outer state list snapshot rebuilds via this key
229235 // so per-row colour updates ride the recomposition. LazyColumn keys are kept stable (tag
230236 // name only) so rows don't get disposed/recreated — the row stays in place and only repaints.
231237 var selectionVersion by remember(filter) { mutableIntStateOf(0 ) }
232- val focusManager = LocalFocusManager .current
233238
234239 val visibleTags by visibleTagsState(filter, query)
235240 val currentState = remember(filter, selectionVersion) { filter.state.toList() }
236241 // Pinned ordering is snapshotted from filter.state once per `visibleTags` change (which only
237242 // shifts on query change) — tapping a tag to include/exclude does NOT recompute this list.
238- // That is the fix for the jumpy "row teleports as I click it" UX.
239243 val orderedTags = remember(visibleTags, filter) {
240244 val selectedBase = filter.state.map { it.removePrefix(" -" ) }.toSet()
241245 val (pinned, rest) = visibleTags.partition { it in selectedBase }
242246 pinned + rest
243247 }
244248
245- fun submitCustomTag () {
246- val text = query.trim()
247- if (text.isEmpty()) return
248- if (FilterMutations .addAutoCompleteTag(filter, text)) {
249- selectionVersion++
250- query = " "
251- focusManager.clearFocus()
252- }
253- }
254-
255249 Column (modifier = Modifier .fillMaxSize()) {
256- SheetSearchField (
257- query = query,
258- onQueryChange = { query = it },
259- placeholder = filter.hint.ifEmpty { filter.name },
260- onSubmit = ::submitCustomTag,
261- )
262-
263250 AutoCompleteTagList (
264251 tags = orderedTags,
265252 state = currentState,
266253 supportsExclude = " -" in filter.validPrefixes,
254+ query = query,
267255 onCycle = { tag ->
268256 FilterMutations .cycleAutoCompleteTag(filter, tag)
269257 selectionVersion++
@@ -274,22 +262,41 @@ internal fun AutoCompleteScreen(
274262}
275263
276264/* *
277- * Filters [Filter.AutoComplete.values] against [query] off the main thread, then ranks matches by
278- * [FuzzyMatcher.score]. Empty `query` yields the full list (minus skipped tags) so the user sees
279- * the full catalogue on open.
265+ * Filters [Filter.AutoComplete.values] against [query] off the main thread.
266+ *
267+ * Perf characteristics for sources with thousands of tags (e-hentai ~6k):
268+ * - Coroutine debounce of [SearchDebounceMs] — `produceState` cancels the in-flight coroutine
269+ * on every keystroke; the [delay] doesn't fire its result if the user types again first.
270+ * - Substring fast path: `String.contains(ignoreCase=true)` is O(n*q) where q is query length,
271+ * runs in single-digit ms for 6k tags. Matches are sorted by where the query appears (prefix
272+ * hits before mid-string hits) — that's good enough as a relevance signal for tag names.
273+ * - Fuzzy fallback only fires if substring returned zero matches. That's where the slow
274+ * [FuzzyMatcher.score] (FuzzyWuzzy `partialRatio`) runs — only on typo'd queries.
275+ *
276+ * Empty `query` yields the full list (minus skipped tags).
280277 */
281278@Composable
282279private fun visibleTagsState (
283280 filter : Filter .AutoComplete ,
284281 query : String ,
285282) = produceState(initialValue = filter.values, key1 = filter, key2 = query) {
283+ val prefix = filter.validPrefixes.find { p -> query.startsWith(p) }
284+ val stripped = (if (prefix != null ) query.removePrefix(prefix) else query).trim()
285+ if (stripped.isEmpty()) {
286+ value = filter.values.filter { it !in filter.skipAutoFillTags }
287+ return @produceState
288+ }
289+ // Wait a beat so keystrokes don't each spawn a fuzzy pass. Cancellation propagates if the
290+ // user types again before this completes.
291+ delay(SearchDebounceMs )
286292 withContext(Dispatchers .Default ) {
287- val prefix = filter.validPrefixes.find { p -> query.startsWith(p) }
288- val stripped = (if (prefix != null ) query.removePrefix(prefix) else query).trim()
289293 val baseList = filter.values.filter { it !in filter.skipAutoFillTags }
290- value = if (stripped.isEmpty()) {
291- baseList
294+ val substring = baseList.filter { it.contains(stripped, ignoreCase = true ) }
295+ value = if (substring.isNotEmpty()) {
296+ // Sort by where the match starts — prefix matches at index 0 surface first.
297+ substring.sortedBy { it.indexOf(stripped, ignoreCase = true ) }
292298 } else {
299+ // No substring hit — probably a typo. Slow fuzzy fallback over the full list.
293300 baseList.asSequence()
294301 .map { it to FuzzyMatcher .score(stripped, it) }
295302 .filter { it.second >= FuzzyTagThreshold }
@@ -300,6 +307,8 @@ private fun visibleTagsState(
300307 }
301308}
302309
310+ private const val SearchDebounceMs = 150L
311+
303312// Lower than the conventional 70 because tag names are short — a forgiving cutoff catches
304313// substring queries like "elf" → "long-elven-hair" without surfacing noise.
305314private const val FuzzyTagThreshold = 60
@@ -309,18 +318,21 @@ private fun AutoCompleteTagList(
309318 tags : List <String >,
310319 state : List <String >,
311320 supportsExclude : Boolean ,
321+ query : String ,
312322 onCycle : (String ) -> Unit ,
313323 onListScrollChange : ((canScrollUp: Boolean ) -> Unit )? ,
314324) {
315325 if (tags.isEmpty()) return
316326 val listState = rememberLazyListState()
317327 BridgeScrollState (listState, onListScrollChange)
318- LazyColumn (
319- state = listState,
320- modifier = Modifier .fillMaxSize(),
321- contentPadding = androidx.compose.foundation.layout.PaddingValues (vertical = 6 .dp),
322- verticalArrangement = Arrangement .spacedBy(3 .dp),
323- ) {
328+ // Reset scroll position whenever the query changes — otherwise the user is left mid-list
329+ // looking at irrelevant entries after filtering.
330+ LaunchedEffect (query) {
331+ if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 ) {
332+ listState.scrollToItem(0 )
333+ }
334+ }
335+ LazyColumn (state = listState, modifier = Modifier .fillMaxSize()) {
324336 items(items = tags, key = { it }) { tag ->
325337 val tagState = tagStateFor(tag, state, supportsExclude)
326338 AutoCompleteTagRow (
@@ -352,12 +364,10 @@ private fun AutoCompleteTagRow(
352364 Row (
353365 modifier = Modifier
354366 .fillMaxWidth()
355- .padding(horizontal = 10 .dp)
356- .clip(TagRowShape )
357- .background(visual.background)
367+ .heightIn(min = 36 .dp)
358368 .clickable(onClick = onClick)
359- .heightIn(min = 40 .dp )
360- .padding(horizontal = 14 .dp, vertical = 6 .dp),
369+ .background(visual.background )
370+ .padding(horizontal = 16 .dp, vertical = 4 .dp),
361371 verticalAlignment = Alignment .CenterVertically ,
362372 horizontalArrangement = Arrangement .spacedBy(8 .dp),
363373 ) {
@@ -381,8 +391,6 @@ private fun AutoCompleteTagRow(
381391 }
382392}
383393
384- private val TagRowShape = androidx.compose.foundation.shape.RoundedCornerShape (12 .dp)
385-
386394private data class TagRowVisual (
387395 val background : Color ,
388396 val contentColor : Color ,
@@ -392,17 +400,17 @@ private data class TagRowVisual(
392400@Composable
393401private fun tagRowVisual (state : AutoCompleteTagState ): TagRowVisual = when (state) {
394402 AutoCompleteTagState .Included -> TagRowVisual (
395- background = MaterialTheme .colorScheme.primaryContainer ,
396- contentColor = MaterialTheme .colorScheme.onPrimaryContainer ,
403+ background = MaterialTheme .colorScheme.primary.copy(alpha = 0.14f ) ,
404+ contentColor = MaterialTheme .colorScheme.primary ,
397405 icon = Icons .Outlined .Add ,
398406 )
399407 AutoCompleteTagState .Excluded -> TagRowVisual (
400- background = MaterialTheme .colorScheme.errorContainer ,
401- contentColor = MaterialTheme .colorScheme.onErrorContainer ,
408+ background = MaterialTheme .colorScheme.error.copy(alpha = 0.14f ) ,
409+ contentColor = MaterialTheme .colorScheme.error ,
402410 icon = Icons .Outlined .Block ,
403411 )
404412 AutoCompleteTagState .Off -> TagRowVisual (
405- background = MaterialTheme .colorScheme.surfaceContainerLow ,
413+ background = Color . Transparent ,
406414 contentColor = MaterialTheme .colorScheme.onSurface,
407415 icon = null ,
408416 )
0 commit comments