Skip to content

Commit 7dc60c0

Browse files
committed
Smart search result relevance scoring with lenient fuzzy search
1 parent 97a2feb commit 7dc60c0

File tree

2 files changed

+110
-62
lines changed

2 files changed

+110
-62
lines changed

src/main/kotlin/com/lambda/gui/components/QuickSearch.kt

Lines changed: 109 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
6-
76
* the Free Software Foundation, either version 3 of the License, or
87
* (at your option) any later version.
98
*
@@ -32,13 +31,14 @@ import com.lambda.module.Module
3231
import com.lambda.module.ModuleRegistry
3332
import com.lambda.util.KeyCode
3433
import com.lambda.util.StringUtils.capitalize
35-
import com.lambda.util.StringUtils.findSimilarStrings
34+
import com.lambda.util.StringUtils.levenshteinDistance
3635
import imgui.ImGui
3736
import imgui.flag.ImGuiInputTextFlags
3837
import imgui.flag.ImGuiStyleVar
3938
import imgui.flag.ImGuiWindowFlags
4039
import imgui.type.ImString
4140
import net.minecraft.client.gui.screen.ChatScreen
41+
import kotlin.math.max
4242

4343
object QuickSearch {
4444
private val searchInput = ImString(256)
@@ -50,7 +50,6 @@ object QuickSearch {
5050

5151
private const val DOUBLE_SHIFT_WINDOW_MS = 500L
5252
private const val MAX_RESULTS = 50
53-
private const val SIMILARITY_THRESHOLD = 3
5453
private const val WINDOW_FLAGS = ImGuiWindowFlags.AlwaysAutoResize or
5554
ImGuiWindowFlags.NoTitleBar or
5655
ImGuiWindowFlags.NoMove or
@@ -74,22 +73,6 @@ object QuickSearch {
7473
override fun ImGuiBuilder.buildLayout() {
7574
with(ModuleEntry(module)) { buildLayout() }
7675
}
77-
78-
companion object {
79-
fun search(query: String): List<ModuleResult> {
80-
val modules = ModuleRegistry.modules
81-
val direct = modules.filter {
82-
it.name.lowercase().let { name -> name.startsWith(query) || name.contains(query) }
83-
}
84-
85-
if (direct.isNotEmpty()) return direct.map(::ModuleResult)
86-
87-
val names = modules.map { it.name }.toSet()
88-
val similar = findSimilarStrings(query, names, SIMILARITY_THRESHOLD)
89-
return similar.mapNotNull { name -> modules.find { it.name == name } }
90-
.map(::ModuleResult)
91-
}
92-
}
9376
}
9477

9578
private class CommandResult(val command: LambdaCommand) : SearchResult {
@@ -103,23 +86,6 @@ object QuickSearch {
10386
textDisabled(command.description)
10487
}
10588
}
106-
107-
companion object {
108-
fun search(query: String): List<CommandResult> {
109-
val commands = CommandRegistry.commands
110-
val direct = commands.filter {
111-
val name = it.name.lowercase()
112-
name.startsWith(query) || name.contains(query) || it.aliases.any { alias -> alias.lowercase().contains(query) }
113-
}
114-
115-
if (direct.isNotEmpty()) return direct.map(::CommandResult)
116-
117-
val names = commands.map { it.name }.toSet()
118-
val similar = findSimilarStrings(query, names, SIMILARITY_THRESHOLD)
119-
return similar.mapNotNull { name -> commands.find { it.name == name } }
120-
.map(::CommandResult)
121-
}
122-
}
12389
}
12490

12591
private class SettingResult(val setting: AbstractSetting<*>, val configurable: Configurable) : SearchResult {
@@ -128,20 +94,6 @@ object QuickSearch {
12894
override fun ImGuiBuilder.buildLayout() {
12995
with(setting) { buildLayout() }
13096
}
131-
132-
companion object {
133-
fun search(query: String) =
134-
Configuration.configurations.flatMap { config ->
135-
config.configurables.flatMap { configurable ->
136-
val confNameL = configurable.name.lowercase()
137-
configurable.settings.filter { setting ->
138-
setting.visibility() && (setting.name.lowercase().contains(query))
139-
}.map { setting ->
140-
SettingResult(setting, configurable)
141-
}
142-
}
143-
}
144-
}
14597
}
14698

14799
fun open() {
@@ -182,13 +134,13 @@ object QuickSearch {
182134
return@popupModal
183135
}
184136

185-
// val bgClick = (ImGui.isMouseClicked(0) || ImGui.isMouseClicked(1)) &&
186-
// !ImGui.isWindowHovered(ImGuiHoveredFlags.AnyWindow)
187-
// if (bgClick) {
188-
// close()
189-
// ImGui.closeCurrentPopup()
190-
// return@popupModal
191-
// }
137+
// val bgClick = (ImGui.isMouseClicked(0) || ImGui.isMouseClicked(1)) &&
138+
// !ImGui.isWindowHovered(ImGuiHoveredFlags.AnyWindow)
139+
// if (bgClick) {
140+
// close()
141+
// ImGui.closeCurrentPopup()
142+
// return@popupModal
143+
// }
192144

193145
if (shouldFocus) {
194146
ImGui.setKeyboardFocusHere()
@@ -209,7 +161,7 @@ object QuickSearch {
209161
val query = searchInput.get().trim()
210162
if (query.isEmpty()) return@popupModal
211163

212-
val results = performSearch(query)
164+
val results = SearchService.performSearch(query)
213165
if (results.isEmpty()) {
214166
textDisabled("Nothing found.")
215167
return@popupModal
@@ -235,10 +187,106 @@ object QuickSearch {
235187
}
236188
}
237189

190+
private object SearchService {
191+
private data class RankedSearchResult(val result: SearchResult, val score: Int)
192+
193+
private const val MODULE_PRIORITY_BONUS = 300
194+
private const val COMMAND_PRIORITY_BONUS = 200
195+
196+
/**
197+
* Calculates a relevance score for a query against a target string.
198+
* Returns 0 for no match. Higher scores are better.
199+
* The `lenient` flag adjusts the threshold for fuzzy matching.
200+
*/
201+
private fun calculateScore(query: String, target: String, lenient: Boolean = false): Int {
202+
if (query.isEmpty() || target.isEmpty()) return 0
203+
204+
// 1. Strong Matches (Exact, Prefix, Substring)
205+
if (target == query) return 200
206+
if (target.startsWith(query)) {
207+
val completeness = (query.length * 50) / target.length
208+
return 100 + completeness // Score: 101 - 150
209+
}
210+
if (target.contains(query)) {
211+
val completeness = (query.length * 40) / target.length
212+
return 50 + completeness // Score: 51 - 90
213+
}
238214

239-
private fun performSearch(query: String) =
240-
listOf(ModuleResult::search, CommandResult::search, SettingResult::search)
241-
.flatMap { it(query.lowercase()) }.take(MAX_RESULTS)
215+
// 2. Weak Match (Fuzzy)
216+
val distance = query.levenshteinDistance(target)
217+
val strictThreshold = (query.length / 3).coerceAtLeast(1).coerceAtMost(4)
218+
val lenientThreshold = (query.length / 2).coerceAtLeast(2).coerceAtMost(6)
219+
val threshold = if (lenient) lenientThreshold else strictThreshold
220+
221+
return if (distance <= threshold) {
222+
(50 - (distance * 10)).coerceAtLeast(1) // Score: 1-40
223+
} else {
224+
0
225+
}
226+
}
227+
228+
/**
229+
* Performs a search and returns a list of ranked results. This is the internal
230+
* implementation that can be run in strict or lenient mode.
231+
*/
232+
private fun searchInternal(query: String, lenient: Boolean): List<RankedSearchResult> {
233+
val lowerCaseQuery = query.lowercase()
234+
235+
val moduleResults = ModuleRegistry.modules.mapNotNull { module ->
236+
val nameScore = calculateScore(lowerCaseQuery, module.name.lowercase(), lenient)
237+
val tagScore = calculateScore(lowerCaseQuery, module.tag.name.lowercase(), lenient)
238+
val bestScore = max(nameScore, tagScore)
239+
240+
if (bestScore > 0) {
241+
RankedSearchResult(ModuleResult(module), bestScore + MODULE_PRIORITY_BONUS)
242+
} else null
243+
}
244+
245+
val commandResults = CommandRegistry.commands.mapNotNull { command ->
246+
val nameScore = calculateScore(lowerCaseQuery, command.name.lowercase(), lenient)
247+
val aliasScore = command.aliases.maxOfOrNull { calculateScore(lowerCaseQuery, it.lowercase(), lenient) } ?: 0
248+
val bestScore = max(nameScore, aliasScore)
249+
250+
if (bestScore > 0) {
251+
RankedSearchResult(CommandResult(command), bestScore + COMMAND_PRIORITY_BONUS)
252+
} else null
253+
}
254+
255+
val settingResults = Configuration.configurations.flatMap {
256+
it.configurables.flatMap { configurable ->
257+
configurable.settings
258+
.filter { setting -> setting.visibility() }
259+
.mapNotNull { setting ->
260+
val score = calculateScore(lowerCaseQuery, setting.name.lowercase(), lenient)
261+
if (score > 0) RankedSearchResult(SettingResult(setting, configurable), score) else null
262+
}
263+
}
264+
}
265+
266+
return moduleResults + commandResults + settingResults
267+
}
268+
269+
/**
270+
* Main search entry point. It first attempts a strict search. If no results
271+
* are found, it falls back to a more lenient fuzzy search.
272+
*/
273+
fun performSearch(query: String): List<SearchResult> {
274+
// First pass: strict search for high-quality matches.
275+
val strictResults = searchInternal(query, lenient = false)
276+
if (strictResults.isNotEmpty()) {
277+
return strictResults
278+
.sortedByDescending { it.score }
279+
.map { it.result }
280+
.take(MAX_RESULTS)
281+
}
282+
283+
// Second pass: if nothing was found, perform a more generous fuzzy search.
284+
return searchInternal(query, lenient = true)
285+
.sortedByDescending { it.score }
286+
.map { it.result }
287+
.take(MAX_RESULTS)
288+
}
289+
}
242290

243291
private fun buildSettingBreadcrumb(configurableName: String, setting: AbstractSetting<*>): String {
244292
val group = setting.groups

src/main/kotlin/com/lambda/util/StringUtils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ object StringUtils {
6969
* @receiver The string to compare.
7070
* @param rhs The string to compare against.
7171
*/
72-
private fun CharSequence.levenshteinDistance(rhs: CharSequence): Int {
72+
fun CharSequence.levenshteinDistance(rhs: CharSequence): Int {
7373
if (this == rhs) {
7474
return 0
7575
}

0 commit comments

Comments
 (0)