diff --git a/platform/jewel/foundation/api-dump.txt b/platform/jewel/foundation/api-dump.txt index c2b75157cb941..3688475384cf9 100644 --- a/platform/jewel/foundation/api-dump.txt +++ b/platform/jewel/foundation/api-dump.txt @@ -642,6 +642,10 @@ f:org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult$NoMatch - org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult - sf:$stable:I - sf:INSTANCE:org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult$NoMatch +f:org.jetbrains.jewel.foundation.search.SpeedSearchMatcherKt +- sf:doesMatch(org.jetbrains.jewel.foundation.search.SpeedSearchMatcher,java.lang.String):Z +- sf:filter(java.lang.Iterable,org.jetbrains.jewel.foundation.search.SpeedSearchMatcher):java.util.List +- sf:filter(java.lang.Iterable,org.jetbrains.jewel.foundation.search.SpeedSearchMatcher,kotlin.jvm.functions.Function1):java.util.List f:org.jetbrains.jewel.foundation.state.CommonStateBitMask - sf:$stable:I - sf:FIRST_AVAILABLE_OFFSET:I diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt index e78b75fe66e20..1229af1a7bf1a 100644 --- a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt @@ -130,7 +130,10 @@ public fun SelectableLazyColumn( LaunchedEffect(container, state.selectedKeys) { val selectedKeysSnapshot = state.selectedKeys val indices = selectedKeysSnapshot.mapNotNull { key -> container.getKeyIndex(key) } - if (indices != lastEmittedIndices) { + // The `state.lastActiveItemIndex` can also be changed by the SpeedSearch flow. + // If the last active index is not among the selected indices, the SpeedSearch + // has selected a different value and an update must be triggered. + if (indices != lastEmittedIndices || (indices.isNotEmpty() && state.lastActiveItemIndex !in indices)) { lastEmittedIndices = indices // Keep keyboard navigation gate in sync after key→index remaps, including through empty→non‑empty diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/search/SpeedSearchMatcher.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/search/SpeedSearchMatcher.kt index 83ec347e687bf..32afd571bb08c 100644 --- a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/search/SpeedSearchMatcher.kt +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/search/SpeedSearchMatcher.kt @@ -1,7 +1,9 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package org.jetbrains.jewel.foundation.search +import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.GenerateDataFunctions +import org.jetbrains.jewel.foundation.InternalJewelApi import org.jetbrains.jewel.foundation.search.impl.ExactSubstringSpeedSearchMatcher import org.jetbrains.jewel.foundation.search.impl.PatternSpeedSearchMatcher @@ -153,6 +155,101 @@ public enum class MatchingCaseSensitivity { All, } +/** + * A [SpeedSearchMatcher] implementation that never matches any text. + * + * This matcher is used internally as a performance optimization when the search pattern is empty or invalid. Instead of + * creating a full matcher that would never match anything, this singleton provides a consistent [MatchResult.NoMatch] + * response for all inputs. + * + * **Internal API:** This object is automatically used by `SpeedSearchState` when the filter text is empty. Users should + * not instantiate or use this matcher directly. Instead, use [SpeedSearchMatcher.patternMatcher] or + * [SpeedSearchMatcher.exactSubstringMatcher] to create matchers with actual patterns. + * + * @see SpeedSearchMatcher for creating matchers with actual patterns + * @see filter for filtering collections that handle this matcher efficiently + */ +@InternalJewelApi +@ApiStatus.Internal +public object EmptySpeedSearchMatcher : SpeedSearchMatcher { + override fun matches(text: String?): SpeedSearchMatcher.MatchResult = SpeedSearchMatcher.MatchResult.NoMatch +} + +/** + * Checks whether the given text matches the current [SpeedSearchMatcher] pattern. + * + * This is a convenience method that simplifies checking for matches by returning a boolean instead of requiring pattern + * matching on [SpeedSearchMatcher.MatchResult]. + * + * Example: + * ```kotlin + * val matcher = SpeedSearchMatcher.patternMatcher("foo") + * matcher.doesMatch("foobar") // true + * matcher.doesMatch("baz") // false + * ``` + * + * @param text The text to check for matches. If null, returns false. + * @return `true` if the text matches the pattern, `false` otherwise. + * @see SpeedSearchMatcher.matches for the underlying match result with ranges + */ +public fun SpeedSearchMatcher.doesMatch(text: String?): Boolean = + matches(text) != SpeedSearchMatcher.MatchResult.NoMatch + +/** + * Filters an iterable collection based on whether items match the given [SpeedSearchMatcher]. + * + * For each item in the collection, the [stringBuilder] function is used to extract a string representation, which is + * then matched against the [matcher]. Items that match are included in the returned list. + * + * If the [matcher] is [EmptySpeedSearchMatcher], all items are returned without filtering, optimizing the common case + * of an empty search query. + * + * Example: + * ```kotlin + * data class User(val name: String, val email: String) + * val users = listOf( + * User("John Doe", "john@example.com"), + * User("Jane Smith", "jane@example.com") + * ) + * + * val matcher = SpeedSearchMatcher.patternMatcher("john") + * val filtered = users.filter(matcher) { it.name } + * // Returns: [User("John Doe", "john@example.com")] + * ``` + * + * @param T The type of items in the collection. + * @param matcher The [SpeedSearchMatcher] to use for filtering. + * @param stringBuilder A function that extracts a string representation from each item for matching. + * @return A list containing only the items that match the search pattern. + * @see doesMatch for the underlying boolean match check + */ +public fun Iterable.filter(matcher: SpeedSearchMatcher, stringBuilder: (T) -> String): List = + if (matcher is EmptySpeedSearchMatcher) { + toList() + } else { + filter { matcher.doesMatch(stringBuilder(it)) } + } + +/** + * Filters an iterable collection of strings based on the given [SpeedSearchMatcher]. + * + * This is a convenience overload of [filter] for working directly with string collections, eliminating the need to + * provide a string extraction function. + * + * Example: + * ```kotlin + * val frameworks = listOf("React", "Vue.js", "Angular", "Svelte") + * val matcher = SpeedSearchMatcher.patternMatcher("react") + * val filtered = frameworks.filter(matcher) + * // Returns: ["React"] + * ``` + * + * @param matcher The [SpeedSearchMatcher] to use for filtering. + * @return A list containing only the strings that match the search pattern. + * @see filter for filtering collections of other types + */ +public fun Iterable.filter(matcher: SpeedSearchMatcher): List = filter(matcher) { it } + /** * Split the input into words based on case changes, digits, and special characters, and join them with the wildcard * ('*') character. diff --git a/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/search/IterableFilterTest.kt b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/search/IterableFilterTest.kt new file mode 100644 index 0000000000000..884c612bda84b --- /dev/null +++ b/platform/jewel/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/search/IterableFilterTest.kt @@ -0,0 +1,59 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.foundation.search + +import org.junit.Assert.assertEquals +import org.junit.Test + +public class IterableFilterTest { + // Test data classes + private data class User(val name: String, val email: String) + + // Exact substring matcher tests + @Test + public fun `filter strings should return only exact matching items`() { + val items = listOf("apple", "pineapple", "application", "banana") + val matcher = SpeedSearchMatcher.exactSubstringMatcher("apple") + val result = items.filter(matcher) + + assertEquals(2, result.size) + assertEquals(listOf("apple", "pineapple"), result) + } + + @Test + public fun `filter strings should return empty list when no items match`() { + val items = listOf("apple", "banana", "cherry") + val matcher = SpeedSearchMatcher.exactSubstringMatcher("xyz") + val result = items.filter(matcher) + + assertEquals(0, result.size) + } + + @Test + public fun `filter should work with custom types`() { + val users = + listOf( + User("John Doe", "john@example.com"), + User("Jane Smith", "jane@example.com"), + User("Johnny Cash", "johnny@example.com"), + ) + val matcher = SpeedSearchMatcher.exactSubstringMatcher("jane") + val result = users.filter(matcher) { it.name } + + assertEquals(1, result.size) + assertEquals(listOf(users[1]), result) + } + + @Test + public fun `filter return empty list when no items match with custom types`() { + val users = + listOf( + User("John Doe", "john@example.com"), + User("Jane Smith", "jane@example.com"), + User("Johnny Cash", "johnny@example.com"), + ) + val matcher = SpeedSearchMatcher.exactSubstringMatcher("Joana") + val result = users.filter(matcher) { it.name } + + assertEquals(0, result.size) + } +} diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/SpeedSearches.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/SpeedSearches.kt new file mode 100644 index 0000000000000..1d379fe171f27 --- /dev/null +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/SpeedSearches.kt @@ -0,0 +1,322 @@ +@file:Suppress("UnusedImports") // Detekt false positive on the buildTree import + +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.samples.showcase.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +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.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.lazy.rememberSelectableLazyListState +import org.jetbrains.jewel.foundation.lazy.tree.buildTree +import org.jetbrains.jewel.foundation.lazy.tree.rememberTreeState +import org.jetbrains.jewel.foundation.search.filter +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.foundation.util.JewelLogger +import org.jetbrains.jewel.ui.component.GroupHeader +import org.jetbrains.jewel.ui.component.InlineWarningBanner +import org.jetbrains.jewel.ui.component.SimpleListItem +import org.jetbrains.jewel.ui.component.SpeedSearchArea +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.VerticalScrollbar +import org.jetbrains.jewel.ui.component.rememberSpeedSearchState +import org.jetbrains.jewel.ui.component.search.SpeedSearchableLazyColumn +import org.jetbrains.jewel.ui.component.search.SpeedSearchableTree +import org.jetbrains.jewel.ui.component.search.highlightSpeedSearchMatches +import org.jetbrains.jewel.ui.component.search.highlightTextSearch +import org.jetbrains.jewel.ui.theme.colorPalette + +@Composable +@Suppress("Nls") +internal fun SpeedSearches(modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { + InlineWarningBanner( + text = + "One of the samples is using the SpeedSearch feature for filtering the content. " + + "Despite being possible, we strongly recommend using a different component for this behavior." + ) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column( + Modifier.widthIn(max = 200.dp).weight(1f, fill = false).semantics { isTraversalGroup = true }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + GroupHeader(text = "Tree", modifier = Modifier.fillMaxWidth()) + SpeedSearchTreeExample() + } + + Column( + Modifier.widthIn(max = 200.dp).weight(1f, fill = false).semantics { isTraversalGroup = true }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + GroupHeader(text = "List", modifier = Modifier.fillMaxWidth()) + SpeedSearchListWithHighlighting() + } + + Column( + Modifier.widthIn(max = 200.dp).weight(1f, fill = false).semantics { isTraversalGroup = true }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + GroupHeader(text = "Filter", modifier = Modifier.fillMaxWidth()) + SpeedSearchListWithFiltering() + } + } + } +} + +/** + * Demonstrates speed search functionality in a tree structure. + * + * This example showcases how to implement speed search in a hierarchical tree component. Key concepts: + * - **SpeedSearchArea**: Container that provides speed search functionality to its children + * - **SpeedSearchableTree**: A tree component with built-in speed search support + * - **Text highlighting**: Uses [highlightTextSearch] and [highlightSpeedSearchMatches] to apply visual styling to + * matches + * + * The tree displays a mock project structure (src, docs, build) and allows users to quickly navigate by typing search + * queries. Matching nodes are highlighted in real-time. + */ +@Composable +private fun SpeedSearchTreeExample(modifier: Modifier = Modifier) { + val treeState = rememberTreeState() + val tree = remember { + buildTree { + addNode("src", "src") { + addNode("main", "src/main") { + addNode("kotlin", "src/main/kotlin") { + addLeaf("Application.kt", "src/main/kotlin/Application.kt") + addLeaf("Controller.kt", "src/main/kotlin/Controller.kt") + addLeaf("MainViewModel.kt", "src/main/kotlin/MainViewModel.kt") + } + addNode("resources", "src/main/resources") { + addLeaf("application.properties", "src/main/resources/application.properties") + addLeaf("logback.xml", "src/main/resources/logback.xml") + } + } + addNode("test", "src/test") { + addNode("kotlin", "src/test/kotlin") { + addLeaf("ApplicationTest.kt", "src/test/kotlin/ApplicationTest.kt") + addLeaf("ControllerTest.kt", "src/test/kotlin/ControllerTest.kt") + } + } + } + addNode("docs", "docs") { + addLeaf("README.md", "docs/README.md") + addLeaf("CONTRIBUTING.md", "docs/CONTRIBUTING.md") + addNode("api", "docs/api") { + addLeaf("endpoints.md", "docs/api/endpoints.md") + addLeaf("authentication.md", "docs/api/authentication.md") + } + } + addNode("build", "build") { + addNode("classes", "build/classes") { + addLeaf("Application.class", "build/classes/Application.class") + addLeaf("Controller.class", "build/classes/Controller.class") + } + addNode("libs", "build/libs") { addLeaf("application.jar", "build/libs/application.jar") } + } + addLeaf(".gitignore", ".gitignore") + addLeaf("build.gradle.kts", "build.gradle.kts") + addLeaf("settings.gradle.kts", "settings.gradle.kts") + } + } + + val borderColor = + if (JewelTheme.isDark) { + JewelTheme.colorPalette.grayOrNull(3) ?: Color(0xFF393B40) + } else { + JewelTheme.colorPalette.grayOrNull(12) ?: Color(0xFFEBECF0) + } + + SpeedSearchArea(modifier.border(1.dp, borderColor, RoundedCornerShape(2.dp))) { + SpeedSearchableTree( + tree = tree, + treeState = treeState, + modifier = Modifier.size(200.dp, 200.dp).focusable(), + onElementClick = {}, + onElementDoubleClick = {}, + nodeText = { it.data }, + ) { element -> + Box(Modifier.fillMaxWidth()) { + var textLayoutResult by remember { mutableStateOf(null) } + Text( + element.data.highlightTextSearch(), + Modifier.padding(2.dp).highlightSpeedSearchMatches(textLayoutResult), + onTextLayout = { textLayoutResult = it }, + ) + } + } + } +} + +/** + * Demonstrates speed search with text highlighting in a list (non-filtering approach). + * + * This example shows the simplest speed search implementation where all items remain visible and matching text is + * highlighted. This is useful when you want users to see the full list context while searching. + * + * Key concepts: + * - **Non-filtering behavior**: All list items remain visible regardless of search query + * - **Automatic state management**: SpeedSearchArea automatically manages search state + * - **Text highlighting**: Uses [highlightTextSearch] and [highlightSpeedSearchMatches] to apply visual styling to + * matches + * - **Selection tracking**: Integrates with [rememberSelectableLazyListState] for item selection + * + * Use this approach when: + * - Users need to see the full list context + * - The list is small enough to scan visually + * - You want to show "no matches" by lack of highlighting rather than empty list + */ +@Composable +private fun SpeedSearchListWithHighlighting(modifier: Modifier = Modifier) { + val state = rememberSelectableLazyListState() + + SpeedSearchArea(modifier) { + SpeedSearchableLazyColumn(modifier = Modifier.focusable(), state = state) { + items(TEST_LIST, textContent = { item -> item }, key = { item -> item }) { item -> + LaunchedEffect(isSelected) { + if (isSelected) { + JewelLogger.getInstance("SpeedSearches").info("Item $item got selected") + } + } + + var textLayoutResult by remember { mutableStateOf(null) } + + SimpleListItem( + text = item.highlightTextSearch(), + selected = isSelected, + active = isActive, + onTextLayout = { textLayoutResult = it }, + modifier = Modifier.fillMaxWidth(), + textModifier = Modifier.highlightSpeedSearchMatches(textLayoutResult), + ) + } + } + + VerticalScrollbar(state.lazyListState, modifier = Modifier.align(Alignment.CenterEnd)) + } +} + +/** + * Demonstrates speed search with list filtering (items are hidden when they don't match). + * + * This example shows a more advanced speed search implementation where the list is dynamically filtered based on the + * search query. Non-matching items are removed from view, providing a cleaner, more focused search experience. + * + * Key concepts: + * - **Filtering behavior**: Uses [filter] extension to hide non-matching items + * - **Explicit state management**: Uses [rememberSpeedSearchState] to manually control search state + * - **Derived state**: [derivedStateOf] efficiently recomputes filtered list only when search changes + * - **Matcher pattern**: SpeedSearchState provides a [currentMatcher] that implements the matching logic + * - **Combined highlighting**: Filtered items are still highlighted to show which parts matched + * + * Use this approach when: + * - Working with large lists where showing all items is impractical + * - You want a focused view showing only relevant results + * - The search is the primary interaction method (like command palettes or quick pickers) + * - You need to provide feedback when no items match (empty list state) + */ +@Composable +private fun SpeedSearchListWithFiltering(modifier: Modifier = Modifier) { + val state = rememberSelectableLazyListState() + val speedSearchState = rememberSpeedSearchState() + + val listItems by remember { derivedStateOf { TEST_LIST.filter(speedSearchState.currentMatcher) } } + + SpeedSearchArea(speedSearchState, modifier) { + SpeedSearchableLazyColumn(modifier = Modifier.focusable(), state = state) { + items(listItems, textContent = { item -> item }, key = { item -> item }) { item -> + LaunchedEffect(isSelected) { + if (isSelected) { + JewelLogger.getInstance("SpeedSearches").info("Item $item got selected") + } + } + + var textLayoutResult by remember { mutableStateOf(null) } + + SimpleListItem( + text = item.highlightTextSearch(), + selected = isSelected, + active = isActive, + onTextLayout = { textLayoutResult = it }, + modifier = Modifier.fillMaxWidth(), + textModifier = Modifier.highlightSpeedSearchMatches(textLayoutResult), + ) + } + } + + VerticalScrollbar(state.lazyListState, modifier = Modifier.align(Alignment.CenterEnd)) + } +} + +private val TEST_LIST = + listOf( + "Spring Boot", + "Spring Framework", + "Spring Data", + "Spring Security", + "React", + "React Native", + "Redux", + "Next.js", + "Angular", + "AngularJS", + "RxJS", + "Vue.js", + "Vuex", + "Nuxt.js", + "Django", + "Django REST Framework", + "Flask", + "FastAPI", + "Express.js", + "NestJS", + "Koa", + "Ktor", + "Exposed", + "Kotlinx Serialization", + "Jetpack Compose", + "Compose Multiplatform", + "SwiftUI", + "UIKit", + "Combine", + "Flutter", + "Dart", + "GetX", + "Ruby on Rails", + "Sinatra", + "Hanami", + "Laravel", + "Symfony", + "CodeIgniter", + "ASP.NET Core", + "Entity Framework", + "Blazor", + "Quarkus", + "Micronaut", + "Helidon", + "Node.js", + "Deno", + "Bun", + ) diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/views/ComponentsViewModel.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/views/ComponentsViewModel.kt index a904e30cb26af..120d53227b5d7 100644 --- a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/views/ComponentsViewModel.kt +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/views/ComponentsViewModel.kt @@ -22,6 +22,7 @@ import org.jetbrains.jewel.samples.showcase.components.RadioButtons import org.jetbrains.jewel.samples.showcase.components.Scrollbars import org.jetbrains.jewel.samples.showcase.components.SegmentedControls import org.jetbrains.jewel.samples.showcase.components.Sliders +import org.jetbrains.jewel.samples.showcase.components.SpeedSearches import org.jetbrains.jewel.samples.showcase.components.SplitLayouts import org.jetbrains.jewel.samples.showcase.components.Tabs import org.jetbrains.jewel.samples.showcase.components.TextAreas @@ -30,6 +31,7 @@ import org.jetbrains.jewel.samples.showcase.components.Tooltips import org.jetbrains.jewel.samples.showcase.components.TypographyShowcase import org.jetbrains.jewel.ui.component.SplitLayoutState import org.jetbrains.jewel.ui.component.styling.ScrollbarVisibility +import org.jetbrains.jewel.ui.icons.AllIconsKeys public class ComponentsViewModel( alwaysVisibleScrollbarVisibility: ScrollbarVisibility.AlwaysVisible, @@ -103,6 +105,7 @@ public class ComponentsViewModel( content = { TypographyShowcase() }, ), ViewInfo(title = "Brushes", iconKey = ShowcaseIcons.Components.brush, content = { BrushesShowcase() }), + ViewInfo(title = "Speed Search", iconKey = AllIconsKeys.Actions.Find, content = { SpeedSearches() }), ) private var _currentView: ViewInfo by mutableStateOf(views.first()) diff --git a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchAreaFilteringTest.kt b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchAreaFilteringTest.kt new file mode 100644 index 0000000000000..ed5be866ed8e5 --- /dev/null +++ b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchAreaFilteringTest.kt @@ -0,0 +1,507 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.ui.component.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.dp +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jetbrains.jewel.foundation.lazy.rememberSelectableLazyListState +import org.jetbrains.jewel.foundation.search.filter +import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme +import org.jetbrains.jewel.ui.component.DefaultButton +import org.jetbrains.jewel.ui.component.SimpleListItem +import org.jetbrains.jewel.ui.component.SpeedSearchArea +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.interactions.performKeyPress +import org.jetbrains.jewel.ui.component.rememberSpeedSearchState +import org.junit.Rule + +/** + * Tests for [SpeedSearchArea] focusing on the filtering pattern where non-matching items are hidden from view. + * + * This test suite validates the behavior when using [filter] extension with [SpeedSearchState.currentMatcher] to + * dynamically filter list items based on the search query, as demonstrated in the showcase example + * [SpeedSearchListWithFiltering]. + * + * Key differences from highlighting tests: + * - **Filtering**: Items that don't match are removed from the DOM (not rendered) + * - **Highlighting**: All items remain visible, matches are visually highlighted + * + * Testing approach: + * - Uses explicit [rememberSpeedSearchState] for state management + * - Uses [derivedStateOf] with [filter] to create filtered lists + * - Validates items are actually removed from the component tree (not just hidden) + * - Tests the complete user workflow: type → filter → navigate → clear + */ +@Suppress("ImplicitUnitReturnType") +@OptIn(ExperimentalCoroutinesApi::class) +class SpeedSearchAreaFilteringTest { + @get:Rule val rule = createComposeRule() + + private val testDispatcher = UnconfinedTestDispatcher() + + private val ComposeContentTestRule.onLazyColumn + get() = onNodeWithTag("LazyColumn") + + private val ComposeContentTestRule.onSpeedSearchAreaInput + get() = onNodeWithTag("SpeedSearchArea.Input") + + private fun ComposeContentTestRule.onLazyColumnItem(text: String) = + onNode(hasAnyAncestor(hasTestTag("LazyColumn")) and hasText(text)) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `on type, filter list to show only matching items`() = runFilteringComposeTest { + // Initially, all items should be visible + onLazyColumnItem("Spring Boot").assertExists() + onLazyColumnItem("React").assertExists() + onLazyColumnItem("Django").assertExists() + + // Type "Spring" - only items containing "Spring" should be visible + onLazyColumn.performKeyPress("Spring", rule = this) + + // These should be visible (match "Spring") + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("Spring Framework").assertIsDisplayed() + onLazyColumnItem("Spring Data").assertIsDisplayed() + onLazyColumnItem("Spring Security").assertIsDisplayed() + + // These should not exist in the tree at all (filtered out) + onLazyColumnItem("React").assertDoesNotExist() + onLazyColumnItem("Angular").assertDoesNotExist() + onLazyColumnItem("Django").assertDoesNotExist() + } + + @Test + fun `on type more characters, further narrow filtered results`() = runFilteringComposeTest { + // Type "S" - many items match + onLazyColumn.performKeyPress("S", rule = this) + onLazyColumnItem("Spring Boot").assertExists() + onLazyColumnItem("Spring Framework").assertExists() + onLazyColumnItem("Next.js").assertExists() + + // Type "pr" - only "Spring" items should remain + onLazyColumn.performKeyPress("pr", rule = this) + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("Spring Framework").assertIsDisplayed() + onLazyColumnItem("Next.js").assertDoesNotExist() + + // Type "ing B" - only "Spring Boot" should match + onLazyColumn.performKeyPress("ing B", rule = this) + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("Spring Framework").assertDoesNotExist() + onLazyColumnItem("Spring Data").assertDoesNotExist() + } + + @Test + fun `on backspace, expand filtered list`() = runFilteringComposeTest { + // Type "Spring Boot" - only one item matches + onLazyColumn.performKeyPress("Spring Boot", rule = this) + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("Spring Framework").assertDoesNotExist() + + // Delete " Boot" by pressing backspace 5 times + repeat(5) { onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this) } + + // Now "Spring" matches multiple items + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("Spring Framework").assertIsDisplayed() + onLazyColumnItem("Spring Data").assertIsDisplayed() + onLazyColumnItem("Spring Security").assertIsDisplayed() + } + + @Test + fun `on no matches, list should be empty`() = runFilteringComposeTest { + // Type something that doesn't match any item + onLazyColumn.performKeyPress("NonexistentFramework", rule = this) + + // All items should be filtered out + onLazyColumnItem("Spring Boot").assertDoesNotExist() + onLazyColumnItem("React").assertDoesNotExist() + onLazyColumnItem("Django").assertDoesNotExist() + + // Count all list items in the LazyColumn - should be 0 + onAllNodes(hasAnyAncestor(hasTestTag("LazyColumn"))).assertCountEquals(0) + } + + @Test + fun `filtered items should select first match`() = runFilteringComposeTest { + // Type "Vue" - should select first match + onLazyColumn.performKeyPress("Spring", rule = this) + + // "Spring Boot" should be the first match and selected + onLazyColumnItem("Spring Boot").assertIsDisplayed().assertIsSelected() + + // Other matches should exist but not be selected + onLazyColumnItem("Spring Framework").assertIsDisplayed() + onLazyColumnItem("Spring Data").assertIsDisplayed() + onLazyColumnItem("Spring Security").assertIsDisplayed() + } + + @Test + fun `navigation should cycle through filtered items only`() = runFilteringComposeTest { + // Type "Angular" - filters to Angular-related items + onLazyColumn.performKeyPress("Angular", rule = this) + + // First match should be selected + onLazyColumnItem("Angular").assertIsDisplayed().assertIsSelected() + + // Navigate down - should go to next filtered item + onLazyColumn.performKeyPress(Key.DirectionDown, rule = this) + onLazyColumnItem("AngularJS").assertIsDisplayed().assertIsSelected() + + // Navigate up - should go back to Angular + onLazyColumn.performKeyPress(Key.DirectionUp, rule = this) + onLazyColumnItem("Angular").assertIsDisplayed().assertIsSelected() + } + + @Test + fun `partial match filtering should work correctly`() = runFilteringComposeTest { + // Type "Nest" - should match NestJS + onLazyColumn.performKeyPress("Nest", rule = this) + onLazyColumnItem("NestJS").assertIsDisplayed() + + // Should not match Express.js (doesn't contain "Nest") + onLazyColumnItem("Express.js").assertDoesNotExist() + } + + @Test + fun `case insensitive filtering should work`() = runFilteringComposeTest { + // Type lowercase "spring" + onLazyColumn.performKeyPress("spring", rule = this) + + // Should match items with uppercase "Spring" + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("Spring Framework").assertIsDisplayed() + } + + @Test + fun `special characters should work in filtering`() = + runFilteringComposeTest(listEntries = listOf("React.js", "Vue.js", "Next.js", "Nuxt.js", "Express.js")) { + // Type ".js" - all items contain ".js" + onLazyColumn.performKeyPress(".js", rule = this) + + onLazyColumnItem("React.js").assertIsDisplayed() + onLazyColumnItem("Vue.js").assertIsDisplayed() + onLazyColumnItem("Next.js").assertIsDisplayed() + onLazyColumnItem("Nuxt.js").assertIsDisplayed() + onLazyColumnItem("Express.js").assertIsDisplayed() + } + + @Test + fun `adding character after match should update filter immediately`() = runFilteringComposeTest { + // Type "Spring" + onLazyColumn.performKeyPress("Spring", rule = this) + + // 4 Spring items should be visible + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("Spring Framework").assertIsDisplayed() + onLazyColumnItem("Spring Data").assertIsDisplayed() + onLazyColumnItem("Spring Security").assertIsDisplayed() + + // Add " F" to narrow to "Spring F" + onLazyColumn.performKeyPress(" F", rule = this) + + // Only "Spring Framework" should remain + onLazyColumnItem("Spring Framework").assertIsDisplayed() + onLazyColumnItem("Spring Boot").assertDoesNotExist() + onLazyColumnItem("Spring Data").assertDoesNotExist() + onLazyColumnItem("Spring Security").assertDoesNotExist() + } + + @Test + fun `changing search should update filtered list dynamically`() = runFilteringComposeTest { + // Type "Kotlin" + onLazyColumn.performKeyPress("Kotlin", rule = this) + onLazyColumnItem("Kotlinx Serialization").assertIsDisplayed() + onLazyColumnItem("React").assertDoesNotExist() + + // Delete "Kotlin" and type "React" + repeat(6) { onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this) } + onLazyColumn.performKeyPress("React", rule = this) + + // Now React items should be visible, Kotlin items hidden + onLazyColumnItem("React").assertIsDisplayed() + onLazyColumnItem("React Native").assertIsDisplayed() + onLazyColumnItem("Kotlinx Serialization").assertDoesNotExist() + } + + @Test + fun `on press esc, hide the speed search and list all entries`() = runFilteringComposeTest { + // All items should be visible when start + onLazyColumnItem("React").assertExists() + onLazyColumnItem("Redux").assertExists() + + // Type to filter + onLazyColumn.performKeyPress("React", rule = this) + onLazyColumnItem("React").assertIsDisplayed() + onLazyColumnItem("Redux").assertDoesNotExist() + + // Click button to lose focus + onLazyColumn.performKeyPress(Key.Escape, rule = this) + onSpeedSearchAreaInput.assertDoesNotExist() + + // All items should be visible again + onLazyColumnItem("React").assertExists() + onLazyColumnItem("Redux").assertExists() + } + + @Test + fun `empty search should show all items`() = runFilteringComposeTest { + // Type a space (which is valid input) + onLazyColumn.performKeyPress(" ", rule = this) + + // All items should still be visible (blank search shows all) + onLazyColumnItem("Spring Boot").assertExists() + onLazyColumnItem("React").assertExists() + onLazyColumnItem("Django").assertExists() + } + + @Test + fun `filtering large list should perform correctly`() = + runFilteringComposeTest(listEntries = List(500) { "Item $it" }) { + // Type "1" - should match all items containing "1" + onLazyColumn.performKeyPress("1", rule = this) + + // Should match items like "Item 1", "Item 10", "Item 21", etc. + onLazyColumnItem("Item 1").assertIsDisplayed() + onLazyColumnItem("Item 10").assertIsDisplayed() + onLazyColumnItem("Item 11").assertIsDisplayed() + onLazyColumnItem("Item 21").assertIsDisplayed() + + // Should not match items without "1" + onLazyColumnItem("Item 2").assertDoesNotExist() + onLazyColumnItem("Item 3").assertDoesNotExist() + + // Type "23" to narrow down + onLazyColumn.performKeyPress("23", rule = this) + + // Only items containing "123" should remain + onLazyColumnItem("Item 123").assertIsDisplayed() + onLazyColumnItem("Item 1").assertDoesNotExist() + onLazyColumnItem("Item 10").assertDoesNotExist() + } + + @Test + fun `multiple word search should filter correctly`() = runFilteringComposeTest { + // Type "Spring Frame" - should match "Spring Framework" + onLazyColumn.performKeyPress("Spring Frame", rule = this) + + onLazyColumnItem("Spring Framework").assertIsDisplayed() + onLazyColumnItem("Spring Boot").assertDoesNotExist() + onLazyColumnItem("Spring Data").assertDoesNotExist() + } + + @Test + fun `on lose focus, hide input and show all items`() = runFilteringComposeTest { + // Type to filter + onLazyColumn.performKeyPress("Spring", rule = this) + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("React").assertDoesNotExist() + onSpeedSearchAreaInput.assertExists().assertIsDisplayed() + + // Click button to lose focus + onNodeWithTag("Button").performClick() + onSpeedSearchAreaInput.assertDoesNotExist() + + // All items should be visible again (filter cleared) + onLazyColumnItem("Spring Boot").assertExists() + onLazyColumnItem("React").assertExists() + onLazyColumnItem("Django").assertExists() + } + + @Test + fun `on lose focus with dismissOnLoseFocus false, keep input and filtered items visible`() = + runFilteringComposeTest(dismissOnLoseFocus = false) { + // Type to filter + onLazyColumn.performKeyPress("Spring", rule = this) + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("React").assertDoesNotExist() + onSpeedSearchAreaInput.assertExists().assertIsDisplayed() + + // Click button to lose focus + onNodeWithTag("Button").performClick() + + // Input should still be visible + onSpeedSearchAreaInput.assertExists().assertIsDisplayed() + + // Filtered items should still be visible, non-matching items should not exist + onLazyColumnItem("Spring Boot").assertIsDisplayed() + onLazyColumnItem("Spring Framework").assertIsDisplayed() + onLazyColumnItem("React").assertDoesNotExist() + onLazyColumnItem("Django").assertDoesNotExist() + } + + @Test + fun `navigation after filter clear and dismiss should work correctly`() = runFilteringComposeTest { + // Filter by 'RxJS' - will match only one item + onLazyColumn.performKeyPress("RxJS", rule = this) + onLazyColumnItem("RxJS").assertIsDisplayed() + onLazyColumnItem("React").assertDoesNotExist() + onLazyColumnItem("Ruby on Rails").assertDoesNotExist() + + // Delete all filter text (4 characters) + repeat(4) { onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this) } + waitForIdle() + + // Close speed search with Escape - this should restore the full list and keep RxJS selected + onLazyColumn.performKeyPress(Key.Escape, rule = this) + onSpeedSearchAreaInput.assertDoesNotExist() + + // RxJS should still be selected and visible + // My fix ensures lastActiveItemIndex is updated to RxJS's position in the full list (index 10) + onLazyColumnItem("RxJS").assertIsDisplayed().assertIsSelected() + + // Navigate down through the list - should be able to reach all items + // RxJS is at position 10, Compose Multiplatform at 25, Ruby on Rails at 32 + // Need to press down 22 times to go from position 10 to 32 + repeat(22) { onLazyColumn.performKeyPress(Key.DirectionDown, rule = this) } + + // Should now be at "Ruby on Rails" (position 32) + // This verifies the fix: navigation correctly continues from RxJS's true position + onLazyColumnItem("Ruby on Rails").assertIsDisplayed().assertIsSelected() + } + + private fun runFilteringComposeTest( + listEntries: List = TEST_FRAMEWORKS, + dismissOnLoseFocus: Boolean = true, + block: ComposeContentTestRule.() -> Unit, + ) { + rule.setContent { + val focusRequester = remember { FocusRequester() } + val speedSearchState = rememberSpeedSearchState() + val state = rememberSelectableLazyListState() + + // Filter the list based on the current matcher - same pattern as showcase + val filteredItems by remember { derivedStateOf { listEntries.filter(speedSearchState.currentMatcher) } } + + IntUiTheme { + Column { + SpeedSearchArea( + state = speedSearchState, + modifier = Modifier.testTag("SpeedSearchArea"), + dismissOnLoseFocus = dismissOnLoseFocus, + ) { + SpeedSearchableLazyColumn( + modifier = + Modifier.size(200.dp, 400.dp).testTag("LazyColumn").focusRequester(focusRequester), + state = state, + dispatcher = testDispatcher, + // Don't pass dispatcher to avoid circular dependency with filtering + ) { + items(filteredItems, textContent = { it }, key = { it }) { item -> + var textLayoutResult by remember { mutableStateOf(null) } + + SimpleListItem( + text = item.highlightTextSearch(), + selected = isSelected, + active = isActive, + onTextLayout = { textLayoutResult = it }, + modifier = Modifier.fillMaxWidth(), + textModifier = Modifier.highlightSpeedSearchMatches(textLayoutResult), + ) + } + } + } + + DefaultButton(onClick = {}, modifier = Modifier.testTag("Button")) { Text("Press me") } + } + } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + + rule.waitForIdle() + rule.block() + } +} + +private val TEST_FRAMEWORKS = + listOf( + "Spring Boot", + "Spring Framework", + "Spring Data", + "Spring Security", + "React", + "React Native", + "Redux", + "Next.js", + "Angular", + "AngularJS", + "RxJS", + "Vue.js", + "Vuex", + "Nuxt.js", + "Django", + "Django REST Framework", + "Flask", + "FastAPI", + "Express.js", + "NestJS", + "Koa", + "Ktor", + "Exposed", + "Kotlinx Serialization", + "Jetpack Compose", + "Compose Multiplatform", + "SwiftUI", + "UIKit", + "Combine", + "Flutter", + "Dart", + "GetX", + "Ruby on Rails", + "Sinatra", + "Hanami", + "Laravel", + "Symfony", + "CodeIgniter", + "ASP.NET Core", + "Entity Framework", + "Blazor", + "Quarkus", + "Micronaut", + "Helidon", + "Node.js", + "Deno", + "Bun", + ) diff --git a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumnTest.kt b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumnTest.kt index b3185764e3eac..6fb3619205d73 100644 --- a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumnTest.kt +++ b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumnTest.kt @@ -238,8 +238,19 @@ class SpeedSearchableLazyColumnTest { onSpeedSearchAreaInput.assertDoesNotExist() } + @Test + fun `on lose focus with dismissOnLoseFocus false, keep input visible`() = + runComposeTest(dismissOnLoseFocus = false) { + onLazyColumn.performKeyPress("Item 42", rule = this) + onSpeedSearchAreaInput.assertExists().assertIsDisplayed() + + onNodeWithTag("Button").performClick() + onSpeedSearchAreaInput.assertExists().assertIsDisplayed() + } + private fun runComposeTest( listEntries: List = List(500) { "Item ${it + 1}" }, + dismissOnLoseFocus: Boolean = true, block: ComposeContentTestRule.() -> Unit, ) { rule.setContent { @@ -247,7 +258,10 @@ class SpeedSearchableLazyColumnTest { IntUiTheme { Column { - SpeedSearchArea(modifier = Modifier.testTag("SpeedSearchArea")) { + SpeedSearchArea( + modifier = Modifier.testTag("SpeedSearchArea"), + dismissOnLoseFocus = dismissOnLoseFocus, + ) { SpeedSearchableLazyColumn( modifier = Modifier.size(200.dp).testTag("LazyColumn").focusRequester(focusRequester), dispatcher = testDispatcher, diff --git a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableTreeTest.kt b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableTreeTest.kt index ca596a3d1566b..3abe04776fccb 100644 --- a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableTreeTest.kt +++ b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableTreeTest.kt @@ -262,10 +262,21 @@ class SpeedSearchableTreeTest { onSpeedSearchAreaInput.assertDoesNotExist() } + @Test + fun `on lose focus with dismissOnLoseFocus false, keep input visible`() = + runComposeTest(dismissOnLoseFocus = false) { + onLazyTree.performKeyPress("Root 42", rule = this) + onSpeedSearchAreaInput.assertExists().assertIsDisplayed() + + onNodeWithTag("Button").performClick() + onSpeedSearchAreaInput.assertExists().assertIsDisplayed() + } + private fun runComposeTest( level1: Int = 100, level2: Int = 100, level3: Int = 100, + dismissOnLoseFocus: Boolean = true, block: ComposeContentTestRule.() -> Unit, ) { val tree = buildTree { @@ -285,7 +296,10 @@ class SpeedSearchableTreeTest { IntUiTheme { Column { - SpeedSearchArea(modifier = Modifier.testTag("SpeedSearchArea")) { + SpeedSearchArea( + modifier = Modifier.testTag("SpeedSearchArea"), + dismissOnLoseFocus = dismissOnLoseFocus, + ) { SpeedSearchableTree( tree = tree, modifier = Modifier.size(200.dp).testTag("LazyTree").focusRequester(focusRequester), diff --git a/platform/jewel/ui/api-dump-experimental.txt b/platform/jewel/ui/api-dump-experimental.txt index 0529b6ab214f5..3bfdd63515090 100644 --- a/platform/jewel/ui/api-dump-experimental.txt +++ b/platform/jewel/ui/api-dump-experimental.txt @@ -47,8 +47,11 @@ f:org.jetbrains.jewel.ui.component.ListComboBoxKt - a:getStyle():org.jetbrains.jewel.ui.component.styling.SearchMatchStyle f:org.jetbrains.jewel.ui.component.SpeedSearchAreaKt - *sf:ProvideSearchMatchState(org.jetbrains.jewel.ui.component.SpeedSearchState,java.lang.String,org.jetbrains.jewel.ui.component.styling.SearchMatchStyle,kotlin.jvm.functions.Function2,androidx.compose.runtime.Composer,I):V -- *sf:SpeedSearchArea(androidx.compose.ui.Modifier,kotlin.jvm.functions.Function1,org.jetbrains.jewel.ui.component.styling.SpeedSearchStyle,org.jetbrains.jewel.ui.component.styling.TextFieldStyle,androidx.compose.ui.text.TextStyle,org.jetbrains.jewel.ui.component.styling.SearchMatchStyle,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.jvm.functions.Function3,androidx.compose.runtime.Composer,I,I):V +- *bsf:SpeedSearchArea(androidx.compose.ui.Modifier,kotlin.jvm.functions.Function1,org.jetbrains.jewel.ui.component.styling.SpeedSearchStyle,org.jetbrains.jewel.ui.component.styling.TextFieldStyle,androidx.compose.ui.text.TextStyle,org.jetbrains.jewel.ui.component.styling.SearchMatchStyle,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.jvm.functions.Function3,androidx.compose.runtime.Composer,I,I):V +- *sf:SpeedSearchArea(androidx.compose.ui.Modifier,kotlin.jvm.functions.Function1,org.jetbrains.jewel.ui.component.styling.SpeedSearchStyle,org.jetbrains.jewel.ui.component.styling.TextFieldStyle,androidx.compose.ui.text.TextStyle,org.jetbrains.jewel.ui.component.styling.SearchMatchStyle,androidx.compose.foundation.interaction.MutableInteractionSource,Z,kotlin.jvm.functions.Function3,androidx.compose.runtime.Composer,I,I):V +- *sf:SpeedSearchArea(org.jetbrains.jewel.ui.component.SpeedSearchState,androidx.compose.ui.Modifier,org.jetbrains.jewel.ui.component.styling.SpeedSearchStyle,org.jetbrains.jewel.ui.component.styling.TextFieldStyle,androidx.compose.ui.text.TextStyle,org.jetbrains.jewel.ui.component.styling.SearchMatchStyle,androidx.compose.foundation.interaction.MutableInteractionSource,Z,kotlin.jvm.functions.Function3,androidx.compose.runtime.Composer,I,I):V - *sf:getLocalNodeSearchMatchState():androidx.compose.runtime.ProvidableCompositionLocal +- *sf:rememberSpeedSearchState(kotlin.jvm.functions.Function1,androidx.compose.runtime.Composer,I,I):org.jetbrains.jewel.ui.component.SpeedSearchState *:org.jetbrains.jewel.ui.component.SpeedSearchScope - androidx.compose.foundation.layout.BoxScope - a:getInteractionSource():androidx.compose.foundation.interaction.MutableInteractionSource @@ -59,6 +62,7 @@ f:org.jetbrains.jewel.ui.component.SpeedSearchAreaKt - a:attach(kotlinx.coroutines.flow.StateFlow,kotlinx.coroutines.CoroutineDispatcher,kotlin.coroutines.Continuation):java.lang.Object - bs:attach$default(org.jetbrains.jewel.ui.component.SpeedSearchState,kotlinx.coroutines.flow.StateFlow,kotlinx.coroutines.CoroutineDispatcher,kotlin.coroutines.Continuation,I,java.lang.Object):java.lang.Object - a:clearSearch():Z +- a:getCurrentMatcher():org.jetbrains.jewel.foundation.search.SpeedSearchMatcher - a:getHasMatches():Z - a:getMatchingIndexes():java.util.List - a:getPosition():androidx.compose.ui.Alignment$Vertical @@ -67,6 +71,7 @@ f:org.jetbrains.jewel.ui.component.SpeedSearchAreaKt - a:isVisible():Z - a:matchResultForText(java.lang.String):org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult - a:setPosition(androidx.compose.ui.Alignment$Vertical):V +- a:setVisible(Z):V f:org.jetbrains.jewel.ui.component.TabStripKt - *bsf:TabStrip(java.util.List,org.jetbrains.jewel.ui.component.styling.TabStyle,androidx.compose.ui.Modifier,Z,androidx.compose.runtime.Composer,I,I):V f:org.jetbrains.jewel.ui.component.TextAreaKt diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/SpeedSearchArea.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/SpeedSearchArea.kt index 88e9afc5fe7cc..aeea3c5816c17 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/SpeedSearchArea.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/SpeedSearchArea.kt @@ -19,13 +19,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.State import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -53,12 +53,15 @@ import androidx.compose.ui.window.rememberComponentRectPositionProvider import java.awt.event.KeyEvent as AWTKeyEvent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.InternalJewelApi +import org.jetbrains.jewel.foundation.search.EmptySpeedSearchMatcher import org.jetbrains.jewel.foundation.search.SpeedSearchMatcher import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.styling.SearchMatchStyle @@ -72,6 +75,7 @@ import org.jetbrains.skiko.hostOs @Composable @ExperimentalJewelApi @ApiStatus.Experimental +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) public fun SpeedSearchArea( modifier: Modifier = Modifier, matcherBuilder: (String) -> SpeedSearchMatcher = SpeedSearchMatcher::patternMatcher, @@ -82,13 +86,99 @@ public fun SpeedSearchArea( interactionSource: MutableInteractionSource? = null, content: @Composable SpeedSearchScope.() -> Unit, ) { - val intSource = interactionSource ?: remember { MutableInteractionSource() } - val currentMatcherBuilder = rememberUpdatedState(matcherBuilder) + SpeedSearchArea( + modifier = modifier, + styling = styling, + textFieldStyle = textFieldStyle, + textStyle = textStyle, + searchMatchStyle = searchMatchStyle, + interactionSource = interactionSource, + state = rememberSpeedSearchState(matcherBuilder), + content = content, + ) +} - val state = remember { SpeedSearchStateImpl(currentMatcherBuilder) } +/** + * Creates a speed search area that provides keyboard-driven search functionality for its content. + * + * This composable enables users to quickly filter or highlight items by typing characters. The search input appears as + * an overlay when the user starts typing, and matches are computed using the provided [matcherBuilder]. + * + * @param modifier The modifier to be applied to the container. + * @param matcherBuilder A function that creates a [SpeedSearchMatcher] from the search text. Defaults to + * [SpeedSearchMatcher.patternMatcher]. + * @param styling The visual styling for the speed search input overlay. + * @param textFieldStyle The styling for the text field within the search overlay. + * @param textStyle The text style for the search input text. + * @param searchMatchStyle The styling for highlighting matched text in search results. + * @param interactionSource The interaction source for tracking focus state. If null, a new one will be created. + * @param dismissOnLoseFocus Whether to automatically hide the search input when it loses focus. Defaults to true. + * @param content The content to be displayed within the speed search area. Use [SpeedSearchScope] to access search + * state and process key events. + */ +@Composable +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun SpeedSearchArea( + modifier: Modifier = Modifier, + matcherBuilder: (String) -> SpeedSearchMatcher = SpeedSearchMatcher::patternMatcher, + styling: SpeedSearchStyle = JewelTheme.speedSearchStyle, + textFieldStyle: TextFieldStyle = JewelTheme.textFieldStyle, + textStyle: TextStyle = JewelTheme.defaultTextStyle, + searchMatchStyle: SearchMatchStyle = JewelTheme.searchMatchStyle, + interactionSource: MutableInteractionSource? = null, + dismissOnLoseFocus: Boolean = true, + content: @Composable SpeedSearchScope.() -> Unit, +) { + SpeedSearchArea( + modifier = modifier, + styling = styling, + textFieldStyle = textFieldStyle, + textStyle = textStyle, + searchMatchStyle = searchMatchStyle, + interactionSource = interactionSource, + state = rememberSpeedSearchState(matcherBuilder), + dismissOnLoseFocus = dismissOnLoseFocus, + content = content, + ) +} + +/** + * Creates a speed search area with an externally managed [SpeedSearchState]. + * + * This overload allows for more control over the speed search state, enabling features like sharing state between + * components or programmatically controlling the search. + * + * @param state The [SpeedSearchState] that manages the search functionality and provides access to search results and + * matching logic. + * @param modifier The modifier to be applied to the container. + * @param styling The visual styling for the speed search input overlay. + * @param textFieldStyle The styling for the text field within the search overlay. + * @param textStyle The text style for the search input text. + * @param searchMatchStyle The styling for highlighting matched text in search results. + * @param interactionSource The interaction source for tracking focus state. If null, a new one will be created. + * @param dismissOnLoseFocus Whether to automatically hide the search input when it loses focus. Defaults to true. + * @param content The content to be displayed within the speed search area. Use [SpeedSearchScope] to access search + * state and process key events. + */ +@Composable +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun SpeedSearchArea( + state: SpeedSearchState, + modifier: Modifier = Modifier, + styling: SpeedSearchStyle = JewelTheme.speedSearchStyle, + textFieldStyle: TextFieldStyle = JewelTheme.textFieldStyle, + textStyle: TextStyle = JewelTheme.defaultTextStyle, + searchMatchStyle: SearchMatchStyle = JewelTheme.searchMatchStyle, + interactionSource: MutableInteractionSource? = null, + dismissOnLoseFocus: Boolean = true, + content: @Composable SpeedSearchScope.() -> Unit, +) { + val intSource = interactionSource ?: remember { MutableInteractionSource() } val isFocused by intSource.collectIsFocusedAsState() - LaunchedEffect(isFocused) { if (!isFocused) state.hideSearch() } + LaunchedEffect(isFocused) { if (!isFocused && dismissOnLoseFocus) state.hideSearch() } Box(modifier = modifier) { val scope = @@ -111,48 +201,161 @@ public fun SpeedSearchArea( } } +/** + * Scope for the content of a [SpeedSearchArea], providing access to search state and styling. + * + * This scope extends [BoxScope] and provides additional properties and functions specific to speed search + * functionality. + */ @ExperimentalJewelApi @ApiStatus.Experimental public interface SpeedSearchScope : BoxScope { + /** The style to use for highlighting search matches in the content. */ public val searchMatchStyle: SearchMatchStyle + + /** The current state of the speed search, including search text, visibility, and matching results. */ public val speedSearchState: SpeedSearchState + + /** The interaction source for tracking user interactions with the search input. */ public val interactionSource: MutableInteractionSource + /** + * Processes a keyboard event, potentially updating the search state or input. + * + * @param event The keyboard event to process. + * @return `true` if the event was handled and should not be propagated further, `false` otherwise. + */ public fun processKeyEvent(event: KeyEvent): Boolean } +/** + * State holder for speed search functionality, managing search text, visibility, and match results. + * + * This interface provides access to the current search state and methods for controlling the search behavior. Use + * [rememberSpeedSearchState] to create an instance of this state. + */ @ExperimentalJewelApi @ApiStatus.Experimental public interface SpeedSearchState { + /** The vertical position of the search input overlay (Top or Bottom). */ public var position: Alignment.Vertical + + /** The current search text entered by the user. */ public val searchText: String - public val isVisible: Boolean + + /** The current [SpeedSearchMatcher] used to match items against the search text. */ + public val currentMatcher: SpeedSearchMatcher + + /** Whether the search input overlay is currently visible. */ + public var isVisible: Boolean + + /** Whether there are any matches for the current search text. */ public val hasMatches: Boolean + + /** List of indices of items that match the current search text. */ public val matchingIndexes: List + /** + * Internal text field state. Should not be accessed directly by public API consumers. + * + * This property is exposed with [InternalJewelApi] to allow internal Jewel components (such as [SpeedSearchArea] + * and [SpeedSearchScope]) to access the underlying text field for keyboard event handling, cursor management, and + * text manipulation. Public API consumers should use the [searchText] property to read the current search query. + */ + @InternalJewelApi @get:ApiStatus.Internal public val textFieldState: TextFieldState + + /** + * Retrieves the match result for a specific text value. + * + * @param text The text to check for matches against the current search query. + * @return The match result indicating whether and where the text matches. + */ public fun matchResultForText(text: String?): SpeedSearchMatcher.MatchResult + /** + * Clears the search text without hiding the search input. + * + * @return `true` if the search was cleared, `false` if it was already empty or not visible. + */ public fun clearSearch(): Boolean + /** + * Hides the search input and clears the search text. + * + * @return `true` if the search was hidden, `false` if it was already hidden. + */ public fun hideSearch(): Boolean + /** + * Attaches the speed search state to a flow of searchable entries. + * + * This suspending function continuously listens to changes in the entries and updates the matching results + * accordingly. It should be launched in a coroutine scope that matches the lifetime of the component using the + * speed search. + * + * @param entriesFlow A [StateFlow] containing the list of searchable items. + * @param dispatcher The coroutine dispatcher to use for search matching operations. Defaults to + * [Dispatchers.Default]. + */ public suspend fun attach( entriesFlow: StateFlow>, dispatcher: CoroutineDispatcher = Dispatchers.Default, ) } +/** + * Creates and remembers a [SpeedSearchState] instance. + * + * @param matcherBuilder A function that creates a [SpeedSearchMatcher] from the search text. Defaults to + * [SpeedSearchMatcher.patternMatcher]. + * @return A remembered [SpeedSearchState] instance that will be preserved across recompositions. + */ +@Composable +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun rememberSpeedSearchState( + matcherBuilder: (String) -> SpeedSearchMatcher = SpeedSearchMatcher::patternMatcher +): SpeedSearchState { + val currentMatcherBuilder = rememberUpdatedState(matcherBuilder) + return remember { SpeedSearchStateImpl(currentMatcherBuilder) } +} + +/** + * State that provides match result and styling information for an individual node in the search results. + * + * This interface is typically provided through [LocalNodeSearchMatchState] composition local and accessed by child + * composables that need to render search match highlights. + */ @ExperimentalJewelApi @ApiStatus.Experimental public interface NodeSearchMatchState { + /** The match result for this specific node, indicating whether and how it matches the search query. */ public val matchResult: SpeedSearchMatcher.MatchResult + + /** The styling to apply when highlighting search matches in this node. */ public val style: SearchMatchStyle } +/** + * Composition local that provides [NodeSearchMatchState] to child composables. + * + * Use [ProvideSearchMatchState] to set this value for a specific composable tree. + */ @ExperimentalJewelApi @ApiStatus.Experimental public val LocalNodeSearchMatchState: ProvidableCompositionLocal = compositionLocalOf { null } +/** + * Provides search match state to child composables through [LocalNodeSearchMatchState]. + * + * This composable computes the match result for the given [textContent] using the current speed search state and makes + * it available to descendants via composition local. + * + * @param speedSearchState The current speed search state containing the search query. + * @param textContent The text content to check for matches. If null, no match will be found. + * @param style The styling to use when highlighting matches. + * @param content The child composables that can access the match state through [LocalNodeSearchMatchState]. + */ @Composable @ExperimentalJewelApi @ApiStatus.Experimental @@ -241,7 +444,7 @@ private fun SpeedSearchInput( private class SpeedSearchScopeImpl( val delegate: BoxScope, override val searchMatchStyle: SearchMatchStyle, - override val speedSearchState: SpeedSearchStateImpl, + override val speedSearchState: SpeedSearchState, override val interactionSource: MutableInteractionSource, ) : SpeedSearchScope, BoxScope by delegate { /** @see com.intellij.ui.SpeedSearchBase.isNavigationKey function */ @@ -404,14 +607,12 @@ private fun KeyEvent.isReallyTypedEvent(): Boolean { @ApiStatus.Experimental internal class SpeedSearchStateImpl(private val matcherBuilderState: State<(String) -> SpeedSearchMatcher>) : SpeedSearchState { - internal val textFieldState = TextFieldState() + override val textFieldState = TextFieldState() private var allMatches: Map by mutableStateOf(emptyMap()) - override val searchText: String by derivedStateOf { textFieldState.text.toString() } + override var searchText: String by mutableStateOf("") override var position: Alignment.Vertical by mutableStateOf(Alignment.Top) - override var isVisible: Boolean by mutableStateOf(false) - internal set override var hasMatches: Boolean by mutableStateOf(true) private set @@ -419,12 +620,16 @@ internal class SpeedSearchStateImpl(private val matcherBuilderState: State<(Stri override var matchingIndexes: List by mutableStateOf(emptyList()) private set + override var currentMatcher by mutableStateOf(EmptySpeedSearchMatcher) + private set + override fun matchResultForText(text: String?): SpeedSearchMatcher.MatchResult = allMatches[text] ?: SpeedSearchMatcher.MatchResult.NoMatch override fun clearSearch(): Boolean { if (!isVisible) return false textFieldState.edit { delete(0, length) } + searchText = "" return true } @@ -438,48 +643,76 @@ internal class SpeedSearchStateImpl(private val matcherBuilderState: State<(Stri // To prevent this issue, I'm aggregating the states in this method and posting the values // to the relevant properties. override suspend fun attach(entriesFlow: StateFlow>, dispatcher: CoroutineDispatcher) { - val searchTextFlow = snapshotFlow { searchText } + val searchTextFlow = snapshotFlow { textFieldState.text.toString() } val matcherBuilderFlow = snapshotFlow { matcherBuilderState.value } + createMatcherFlow(searchTextFlow, matcherBuilderFlow) + .combineWithEntries(entriesFlow) + .flowOn(dispatcher) + .collect() + } + + private fun createMatcherFlow( + searchTextFlow: Flow, + matcherBuilderFlow: Flow<(String) -> SpeedSearchMatcher>, + ) = combine(searchTextFlow, matcherBuilderFlow) { text, buildMatcher -> - val items = entriesFlow.value + val matcher = + if (text.isBlank()) { + EmptySpeedSearchMatcher + } else { + buildMatcher(text).cached() + } + .also { currentMatcher = it } - if (text.isBlank() || items.isEmpty()) { + text to matcher + } + + private fun Flow>.combineWithEntries(entriesFlow: StateFlow>) = + combine(entriesFlow) { (text, matcher), items -> + if (text.isBlank() || items.isEmpty()) { + // Batch all state updates in a single snapshot to prevent intermediate recompositions. + // This ensures atomicity when resetting the search state and improves performance by + // triggering only one recomposition instead of four separate ones. + Snapshot.withMutableSnapshot { allMatches = emptyMap() matchingIndexes = emptyList() + searchText = "" hasMatches = true - return@combine } + return@combine + } - val matcher = buildMatcher(text) - - // Please note that use the default capacity can have a significant impact on performance for larger - // data sets. After the first "round", we can start creating the array with an "educated guess" to - // prevent tons of array copy in memory - val newMatchingIndexes = ArrayList(matchingIndexes.size.takeIf { it > 0 } ?: 128) - val newMatches = hashMapOf() - var anyMatch = false - - for (index in items.indices) { - val item = items[index] - val matches = matcher.matches(item) - - if (matches is SpeedSearchMatcher.MatchResult.Match) { - newMatchingIndexes.add(index) - newMatches[item] = matches - anyMatch = true - } + // Please note that use the default capacity can have a significant impact on performance for larger + // data sets. After the first "round", we can start creating the array with an "educated guess" to + // prevent tons of array copy in memory + val newMatchingIndexes = ArrayList(matchingIndexes.size.takeIf { it > 0 } ?: 128) + val newMatches = hashMapOf() + var anyMatch = false + + for (index in items.indices) { + val item = items[index] + val matches = matcher.matches(item) + + if (matches is SpeedSearchMatcher.MatchResult.Match) { + newMatchingIndexes.add(index) + newMatches[item] = matches + anyMatch = true } + } - newMatchingIndexes.trimToSize() + newMatchingIndexes.trimToSize() + // Batch all state updates in a single snapshot to prevent intermediate recompositions. + // This ensures atomicity when updating search results and improves performance by + // triggering only one recomposition instead of four separate ones. + Snapshot.withMutableSnapshot { allMatches = newMatches matchingIndexes = newMatchingIndexes hasMatches = anyMatch + searchText = text } - .flowOn(dispatcher) - .collect() - } + } } private fun KeyEvent.toChar(): Char = @@ -493,3 +726,19 @@ private fun KeyEvent.toChar(): Char = * [SpeedSearch.PUNCTUATION_MARKS](https://github.com/JetBrains/intellij-community/blob/master/platform/platform-api/src/com/intellij/ui/speedSearch/SpeedSearch.java) */ private const val PUNCTUATION_MARKS = "*_-+\"'/.#$>: ,;?!@%^&" + +private fun SpeedSearchMatcher.cached() = + object : SpeedSearchMatcher { + private val cache = LRUCache(100) + + override fun matches(text: String?): SpeedSearchMatcher.MatchResult = + if (text.isNullOrBlank()) { + this@cached.matches(text) + } else { + cache.getOrPut(text) { this@cached.matches(text) } + } + } + +private class LRUCache(private val capacity: Int) : LinkedHashMap(capacity, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > capacity +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumn.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumn.kt index a4e7280ee05aa..0cfd24625888a 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumn.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumn.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow @@ -24,10 +25,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onEach import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.foundation.lazy.DefaultMacOsSelectableColumnKeybindings.Companion.isSelectAll @@ -94,7 +94,12 @@ public fun SpeedSearchScope.SpeedSearchableLazyColumn( content = { SpeedSearchableLazyColumnScopeImpl(speedSearchState, this, searchMatchStyle).content() }, ) - SpeedSearchableLazyColumnScrollEffect(state, speedSearchState, currentStateToList.value.second, dispatcher) + SpeedSearchableLazyColumnScrollEffect( + selectableLazyListState = state, + speedSearchState = speedSearchState, + keys = currentStateToList.value.second, + dispatcher = dispatcher, + ) LaunchedEffect(state, dispatcher) { val entriesState = MutableStateFlow(emptyList()) @@ -223,15 +228,29 @@ internal fun SpeedSearchableLazyColumnScrollEffect( dispatcher: CoroutineDispatcher, ) { val currentKeys = rememberUpdatedState(keys) + LaunchedEffect(selectableLazyListState, speedSearchState, dispatcher) { + val currentSelection = snapshotFlow { selectableLazyListState.selectedKeys } + val currentKeysValue = snapshotFlow { currentKeys.value } + val indicesForKeys = + currentSelection + .combine(currentKeysValue) { selectedKeys, keys -> keys.indicesForKeys(selectedKeys) to keys } + .distinctUntilChanged() + snapshotFlow { speedSearchState.matchingIndexes } .distinctUntilChanged() - .filter { it.isNotEmpty() } .flowOn(dispatcher) - .onEach { indexesMatchingSearchText -> - val keyValues = currentKeys.value + .combine(indicesForKeys) { indexesMatchingSearchText, (indexesForSelectedKeys, keyValues) -> + // When search is dismissed or cleared, sync lastActiveItemIndex with selected key position + if (indexesMatchingSearchText.isEmpty()) { + if (indexesForSelectedKeys.isNotEmpty()) { + val selectedIndex = indexesForSelectedKeys.first() + selectableLazyListState.lastActiveItemIndex = selectedIndex + } + return@combine + } + val visibleItemIndexes = selectableLazyListState.visibleItemsRange - val indexesForSelectedKeys = keyValues.indicesForKeys(selectableLazyListState.selectedKeys) val matchingSelectionIndex = indexesForSelectedKeys.firstOrNull { indexesMatchingSearchText.binarySearch(it) >= 0 } @@ -243,7 +262,7 @@ internal fun SpeedSearchableLazyColumnScrollEffect( selectableLazyListState.scrollToItem(matchingSelectionIndex) } - return@onEach + return@combine } // If any of the visible items match the filter, just select the one closest to any of the selected @@ -264,7 +283,7 @@ internal fun SpeedSearchableLazyColumnScrollEffect( if (bestVisibleMatch != null) { selectableLazyListState.selectedKeys = setOfNotNull(keyValues.getOrNull(bestVisibleMatch)) selectableLazyListState.lastActiveItemIndex = bestVisibleMatch - return@onEach + return@combine } // If no items are visible or selected, scroll to the best match after the last visible item