Skip to content

Commit c9285ca

Browse files
committed
Refactor filter drill UI and autocomplete search
Restructure and polish the filter sheet/drill UX and optimize autocomplete searching. Key changes: - Move the inline search input into a new DrillSearchTopBar used for drill pages (tags/groups) and submit handling for custom tags. - Add AnimatedContent transitions for drill enter/exit and animate the top bar content. - Introduce outerSelectionVersion to propagate selection changes made inside drills back to the main list; local versions still handle in-row mutations. - Implement GroupActivePillsRow to show active child pills for Filter.Group and computeGroupActivePills to generate pill actions. - Expose TagPill as internal and unify pill visuals/gestures used across groups and autocomplete. - Improve AutoComplete performance: debounce input, substring fast-path, and fuzzy fallback (with SearchDebounceMs and FuzzyTagThreshold) running off Dispatchers.Default. - Preserve pinned ordering per query to avoid row reordering/jumping when toggling selections. - Reset LazyColumn scroll when query changes for both tag list and group children. - Visual and layout tweaks: use primary/error tones for pills, translucent row backgrounds, spacing/padding adjustments, menu container color, improved textfield colors/cursor, and tab row accent/indicator. Overall these changes improve responsiveness for large tag sets, unify drill UI, and provide a more consistent, less jumpy user experience.
1 parent 6293c22 commit c9285ca

4 files changed

Lines changed: 435 additions & 222 deletions

File tree

app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/compose/AutoCompleteFilterRow.kt

Lines changed: 76 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@ import androidx.compose.material.icons.outlined.Block
2323
import androidx.compose.material.icons.outlined.ChevronRight
2424
import androidx.compose.material.icons.outlined.Close
2525
import androidx.compose.material3.Icon
26+
import androidx.compose.runtime.LaunchedEffect
2627
import androidx.compose.material3.MaterialTheme
2728
import androidx.compose.material3.Surface
2829
import androidx.compose.material3.Text
2930
import androidx.compose.runtime.Composable
3031
import androidx.compose.runtime.getValue
3132
import androidx.compose.runtime.mutableIntStateOf
32-
import androidx.compose.runtime.mutableStateOf
3333
import androidx.compose.runtime.produceState
3434
import androidx.compose.runtime.remember
3535
import androidx.compose.runtime.setValue
@@ -38,29 +38,28 @@ import androidx.compose.ui.Modifier
3838
import androidx.compose.ui.draw.clip
3939
import androidx.compose.ui.graphics.Color
4040
import androidx.compose.ui.graphics.vector.ImageVector
41-
import androidx.compose.ui.platform.LocalFocusManager
4241
import androidx.compose.ui.text.font.FontWeight
4342
import androidx.compose.ui.text.style.TextOverflow
4443
import androidx.compose.ui.unit.dp
4544
import eu.kanade.tachiyomi.source.model.Filter
4645
import kotlinx.coroutines.Dispatchers
46+
import kotlinx.coroutines.delay
4747
import kotlinx.coroutines.withContext
4848
import 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
7069
internal 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
223229
internal 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
282279
private 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.
305314
private 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-
386394
private data class TagRowVisual(
387395
val background: Color,
388396
val contentColor: Color,
@@ -392,17 +400,17 @@ private data class TagRowVisual(
392400
@Composable
393401
private 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

Comments
 (0)