Skip to content

Commit 8857fc4

Browse files
committed
[JEWEL-1024] Improvements to SpeedSearchArea to support using for filter
- Fixed index emission on SelectableLazyColumn when the selection changes by the speed search - Created 'EmptySpeedSearchMatcher' to easily identify when the filter text is empty - This matches is automatically returned in the SpeedSearchState.matcher when the text is empty - Added 'dismissOnLoseFocus' to SpeedSearchArea to keep the filter when the focus is left from the component - Added 'currentMatcher' to the 'SpeedSearchState', allowing the user use it for filtering purposes - Created convenience function on top of 'SpeedSearchMatcher' to check if the given text matches or not - Created convenience functions to support filtering collections based on the speed search matcher
1 parent 9fa045c commit 8857fc4

File tree

14 files changed

+1315
-53
lines changed

14 files changed

+1315
-53
lines changed

platform/jewel/foundation/api-dump.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,10 @@ f:org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult$NoMatch
642642
- org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult
643643
- sf:$stable:I
644644
- sf:INSTANCE:org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult$NoMatch
645+
f:org.jetbrains.jewel.foundation.search.SpeedSearchMatcherKt
646+
- sf:doesMatch(org.jetbrains.jewel.foundation.search.SpeedSearchMatcher,java.lang.String):Z
647+
- sf:filter(java.lang.Iterable,org.jetbrains.jewel.foundation.search.SpeedSearchMatcher):java.util.List
648+
- sf:filter(java.lang.Iterable,org.jetbrains.jewel.foundation.search.SpeedSearchMatcher,kotlin.jvm.functions.Function1):java.util.List
645649
f:org.jetbrains.jewel.foundation.state.CommonStateBitMask
646650
- sf:$stable:I
647651
- sf:FIRST_AVAILABLE_OFFSET:I

platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ public fun SelectableLazyColumn(
130130
LaunchedEffect(container, state.selectedKeys) {
131131
val selectedKeysSnapshot = state.selectedKeys
132132
val indices = selectedKeysSnapshot.mapNotNull { key -> container.getKeyIndex(key) }
133-
if (indices != lastEmittedIndices) {
133+
// The `state.lastActiveItemIndex` can also be changed by the SpeedSearch flow.
134+
// If the last active index is not among the selected indices, the SpeedSearch
135+
// has selected a different value and an update must be triggered.
136+
if (indices != lastEmittedIndices || state.lastActiveItemIndex !in indices) {
134137
lastEmittedIndices = indices
135138

136139
// Keep keyboard navigation gate in sync after key→index remaps, including through empty→non‑empty

platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/search/SpeedSearchMatcher.kt

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
22
package org.jetbrains.jewel.foundation.search
33

4+
import org.jetbrains.annotations.ApiStatus
45
import org.jetbrains.jewel.foundation.GenerateDataFunctions
6+
import org.jetbrains.jewel.foundation.InternalJewelApi
57
import org.jetbrains.jewel.foundation.search.impl.ExactSubstringSpeedSearchMatcher
68
import org.jetbrains.jewel.foundation.search.impl.PatternSpeedSearchMatcher
79

@@ -153,6 +155,101 @@ public enum class MatchingCaseSensitivity {
153155
All,
154156
}
155157

158+
/**
159+
* A [SpeedSearchMatcher] implementation that never matches any text.
160+
*
161+
* This matcher is used internally as a performance optimization when the search pattern is empty or invalid. Instead of
162+
* creating a full matcher that would never match anything, this singleton provides a consistent [MatchResult.NoMatch]
163+
* response for all inputs.
164+
*
165+
* **Internal API:** This object is automatically used by `SpeedSearchState` when the filter text is empty. Users should
166+
* not instantiate or use this matcher directly. Instead, use [SpeedSearchMatcher.patternMatcher] or
167+
* [SpeedSearchMatcher.exactSubstringMatcher] to create matchers with actual patterns.
168+
*
169+
* @see SpeedSearchMatcher for creating matchers with actual patterns
170+
* @see filter for filtering collections that handle this matcher efficiently
171+
*/
172+
@InternalJewelApi
173+
@ApiStatus.Internal
174+
public object EmptySpeedSearchMatcher : SpeedSearchMatcher {
175+
override fun matches(text: String?): SpeedSearchMatcher.MatchResult = SpeedSearchMatcher.MatchResult.NoMatch
176+
}
177+
178+
/**
179+
* Checks whether the given text matches the current [SpeedSearchMatcher] pattern.
180+
*
181+
* This is a convenience method that simplifies checking for matches by returning a boolean instead of requiring pattern
182+
* matching on [SpeedSearchMatcher.MatchResult].
183+
*
184+
* Example:
185+
* ```kotlin
186+
* val matcher = SpeedSearchMatcher.patternMatcher("foo")
187+
* matcher.doesMatch("foobar") // true
188+
* matcher.doesMatch("baz") // false
189+
* ```
190+
*
191+
* @param text The text to check for matches. If null, returns false.
192+
* @return `true` if the text matches the pattern, `false` otherwise.
193+
* @see SpeedSearchMatcher.matches for the underlying match result with ranges
194+
*/
195+
public fun SpeedSearchMatcher.doesMatch(text: String?): Boolean =
196+
matches(text) != SpeedSearchMatcher.MatchResult.NoMatch
197+
198+
/**
199+
* Filters an iterable collection based on whether items match the given [SpeedSearchMatcher].
200+
*
201+
* For each item in the collection, the [stringBuilder] function is used to extract a string representation, which is
202+
* then matched against the [matcher]. Items that match are included in the returned list.
203+
*
204+
* If the [matcher] is [EmptySpeedSearchMatcher], all items are returned without filtering, optimizing the common case
205+
* of an empty search query.
206+
*
207+
* Example:
208+
* ```kotlin
209+
* data class User(val name: String, val email: String)
210+
* val users = listOf(
211+
* User("John Doe", "[email protected]"),
212+
* User("Jane Smith", "[email protected]")
213+
* )
214+
*
215+
* val matcher = SpeedSearchMatcher.patternMatcher("john")
216+
* val filtered = users.filter(matcher) { it.name }
217+
* // Returns: [User("John Doe", "[email protected]")]
218+
* ```
219+
*
220+
* @param T The type of items in the collection.
221+
* @param matcher The [SpeedSearchMatcher] to use for filtering.
222+
* @param stringBuilder A function that extracts a string representation from each item for matching.
223+
* @return A list containing only the items that match the search pattern.
224+
* @see doesMatch for the underlying boolean match check
225+
*/
226+
public fun <T> Iterable<T>.filter(matcher: SpeedSearchMatcher, stringBuilder: (T) -> String): List<T> =
227+
if (matcher is EmptySpeedSearchMatcher) {
228+
toList()
229+
} else {
230+
filter { matcher.doesMatch(stringBuilder(it)) }
231+
}
232+
233+
/**
234+
* Filters an iterable collection of strings based on the given [SpeedSearchMatcher].
235+
*
236+
* This is a convenience overload of [filter] for working directly with string collections, eliminating the need to
237+
* provide a string extraction function.
238+
*
239+
* Example:
240+
* ```kotlin
241+
* val frameworks = listOf("React", "Vue.js", "Angular", "Svelte")
242+
* val matcher = SpeedSearchMatcher.patternMatcher("react")
243+
* val filtered = frameworks.filter(matcher)
244+
* // Returns: ["React"]
245+
* ```
246+
*
247+
* @param matcher The [SpeedSearchMatcher] to use for filtering.
248+
* @return A list containing only the strings that match the search pattern.
249+
* @see filter for filtering collections of other types
250+
*/
251+
public fun Iterable<String>.filter(matcher: SpeedSearchMatcher): List<String> = filter(matcher) { it }
252+
156253
/**
157254
* Split the input into words based on case changes, digits, and special characters, and join them with the wildcard
158255
* ('*') character.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package org.jetbrains.jewel.foundation.search
3+
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
7+
public class IterableFilterTest {
8+
// Test data classes
9+
private data class User(val name: String, val email: String)
10+
11+
// Exact substring matcher tests
12+
@Test
13+
public fun `filter strings should return only exact matching items`() {
14+
val items = listOf("apple", "pineapple", "application", "banana")
15+
val matcher = SpeedSearchMatcher.exactSubstringMatcher("apple")
16+
val result = items.filter(matcher) { it }
17+
18+
assertEquals(2, result.size)
19+
assertEquals(listOf("apple", "pineapple"), result)
20+
}
21+
22+
@Test
23+
public fun `filter strings should return empty list when no items match`() {
24+
val items = listOf("apple", "banana", "cherry")
25+
val matcher = SpeedSearchMatcher.exactSubstringMatcher("xyz")
26+
val result = items.filter(matcher) { it }
27+
28+
assertEquals(0, result.size)
29+
}
30+
31+
@Test
32+
public fun `filter should work with custom types`() {
33+
val users =
34+
listOf(
35+
User("John Doe", "[email protected]"),
36+
User("Jane Smith", "[email protected]"),
37+
User("Johnny Cash", "[email protected]"),
38+
)
39+
val matcher = SpeedSearchMatcher.exactSubstringMatcher("jane")
40+
val result = users.filter(matcher) { it.name }
41+
42+
assertEquals(1, result.size)
43+
assertEquals(listOf(users[1]), result)
44+
}
45+
46+
@Test
47+
public fun `filter return empty list when no items match with custom types`() {
48+
val users =
49+
listOf(
50+
User("John Doe", "[email protected]"),
51+
User("Jane Smith", "[email protected]"),
52+
User("Johnny Cash", "[email protected]"),
53+
)
54+
val matcher = SpeedSearchMatcher.exactSubstringMatcher("Joana")
55+
val result = users.filter(matcher) { it.name }
56+
57+
assertEquals(0, result.size)
58+
}
59+
}

0 commit comments

Comments
 (0)