Skip to content

Commit 44fe4e8

Browse files
committed
QuickSearch
1 parent d105954 commit 44fe4e8

File tree

5 files changed

+278
-30
lines changed

5 files changed

+278
-30
lines changed

src/main/kotlin/com/lambda/gui/DearImGui.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ object DearImGui : Loadable {
4444
val implGl3 = ImGuiImplGl3()
4545

4646
const val EXTERNAL_LINK = ''
47+
const val BREADCRUMB_SEPARATOR = '»'
4748

4849
val io: ImGuiIO get() = ImGui.getIO()
4950
const val DEFAULT_FLAGS = ImGuiConfigFlags.NavEnableKeyboard or // Enable Keyboard Controls
@@ -62,6 +63,7 @@ object DearImGui : Loadable {
6263
addRanges(io.fonts.glyphRangesDefault)
6364
addRanges(io.fonts.glyphRangesGreek)
6465
addChar(EXTERNAL_LINK)
66+
addChar(BREADCRUMB_SEPARATOR)
6567
}.buildRanges()
6668
io.fonts.addFontFromFileTTF("fonts/FiraSans-Regular.ttf".path, baseFontSize * scale, ImFontConfig(), glyphRanges)
6769
io.fonts.build()

src/main/kotlin/com/lambda/gui/MenuBar.kt

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import com.lambda.core.Loader
2626
import com.lambda.event.EventFlow
2727
import com.lambda.graphics.texture.TextureOwner.upload
2828
import com.lambda.gui.DearImGui.EXTERNAL_LINK
29+
import com.lambda.gui.components.QuickSearch
2930
import com.lambda.gui.dsl.ImGuiBuilder
3031
import com.lambda.module.ModuleRegistry
3132
import com.lambda.module.tag.ModuleTag
@@ -51,10 +52,6 @@ object MenuBar {
5152
val lambdaLogo = upload("textures/lambda.png")
5253
val githubLogo = upload("textures/github_logo.png")
5354

54-
// ToDo: On pressing shift (or something else) open a quick search bar popup.
55-
// - Search for modules, hud elements, and commands using levenshtein distance.
56-
private val quickSearch = ImString(64)
57-
5855
fun ImGuiBuilder.buildMenuBar() {
5956
mainMenuBar {
6057
lambdaMenu()
@@ -436,18 +433,8 @@ object MenuBar {
436433
}
437434

438435
private fun ImGuiBuilder.buildHelpMenu() {
439-
menuItem("Quick Search...") {
440-
// ToDo:
441-
// - Search for modules, commands, and HUD widgets.
442-
// - Show matches in a search panel below the GUI.
443-
// - Support regex.
444-
// - Support levenshtein distance.
445-
// - Support multiple search terms.
446-
// - Support search history.
447-
// - Support search filters (by type, enabled/disabled, etc).
448-
// - Support search scopes (all/enabled/disabled).
449-
// - Support search shortcuts (Ctrl+F, Cmd+F, etc).
450-
// - Show match count in the search panel.
436+
menuItem("Quick Search...", "Shift+Shift") {
437+
QuickSearch.open()
451438
}
452439
menuItem("Documentation $EXTERNAL_LINK") {
453440
Util.getOperatingSystem().open("$REPO_URL/wiki")

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.lambda.core.Loadable
2121
import com.lambda.event.events.GuiEvent
2222
import com.lambda.event.listener.SafeListener.Companion.listen
2323
import com.lambda.gui.MenuBar.buildMenuBar
24+
import com.lambda.gui.components.QuickSearch.renderQuickSearch
2425
import com.lambda.gui.dsl.ImGuiBuilder.buildLayout
2526
import com.lambda.module.ModuleRegistry
2627
import com.lambda.module.modules.client.ClickGui
@@ -43,6 +44,7 @@ object ClickGuiLayout : Loadable {
4344
}
4445

4546
buildMenuBar()
47+
renderQuickSearch()
4648

4749
ImGui.showDemoWindow()
4850
}

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

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,8 @@ import imgui.flag.ImGuiCol
2929

3030
class ModuleEntry(val module: Module): Layout {
3131
override fun ImGuiBuilder.buildLayout() {
32-
if (module.isEnabled) {
33-
withStyleColor(ImGuiCol.Header, 0.20f, 0.55f, 0.25f, 0.85f) {
34-
withStyleColor(ImGuiCol.HeaderHovered, 0.25f, 0.65f, 0.30f, 0.90f) {
35-
withStyleColor(ImGuiCol.HeaderActive, 0.20f, 0.55f, 0.25f, 1.00f) {
36-
selectable(module.name, selected = true) {
37-
module.toggle()
38-
}
39-
}
40-
}
41-
}
42-
} else {
43-
selectable(module.name, selected = false) {
44-
module.toggle()
45-
}
32+
selectable(module.name, selected = module.isEnabled) {
33+
module.toggle()
4634
}
4735
lambdaTooltip(module.description)
4836

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
* Copyright 2025 Lambda
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
package com.lambda.gui.components
20+
21+
import com.lambda.Lambda.mc
22+
import com.lambda.command.CommandRegistry
23+
import com.lambda.command.LambdaCommand
24+
import com.lambda.config.AbstractSetting
25+
import com.lambda.config.Configurable
26+
import com.lambda.config.Configuration
27+
import com.lambda.event.events.KeyboardEvent
28+
import com.lambda.event.listener.UnsafeListener.Companion.listenUnsafe
29+
import com.lambda.gui.Layout
30+
import com.lambda.gui.dsl.ImGuiBuilder
31+
import com.lambda.module.Module
32+
import com.lambda.module.ModuleRegistry
33+
import com.lambda.util.KeyCode
34+
import com.lambda.util.StringUtils.capitalize
35+
import com.lambda.util.StringUtils.findSimilarStrings
36+
import imgui.ImGui
37+
import imgui.flag.ImGuiHoveredFlags
38+
import imgui.flag.ImGuiInputTextFlags
39+
import imgui.flag.ImGuiStyleVar
40+
import imgui.flag.ImGuiWindowFlags
41+
import imgui.type.ImString
42+
import net.minecraft.client.gui.screen.ChatScreen
43+
44+
object QuickSearch {
45+
private val searchInput = ImString(256)
46+
private var isOpen = false
47+
private var shouldFocus = false
48+
49+
private var lastShiftPressTime = 0L
50+
private var lastShiftKeyCode = -1
51+
private const val DOUBLE_SHIFT_WINDOW_MS = 500L
52+
53+
private const val MAX_RESULTS = 50
54+
private const val SIMILARITY_THRESHOLD = 3
55+
56+
init {
57+
listenUnsafe<KeyboardEvent.Press> { event ->
58+
handleKeyPress(event)
59+
}
60+
}
61+
62+
interface SearchResult : Layout {
63+
val breadcrumb: String
64+
}
65+
66+
private class ModuleResult(val module: Module) : SearchResult {
67+
override val breadcrumb = "Module"
68+
69+
override fun ImGuiBuilder.buildLayout() {
70+
with(ModuleEntry(module)) { buildLayout() }
71+
}
72+
73+
companion object {
74+
fun search(query: String): List<ModuleResult> {
75+
val modules = ModuleRegistry.modules
76+
val direct = modules.filter {
77+
it.name.lowercase().let { name -> name.startsWith(query) || name.contains(query) }
78+
}
79+
80+
if (direct.isNotEmpty()) return direct.map(::ModuleResult)
81+
82+
val names = modules.map { it.name }.toSet()
83+
val similar = findSimilarStrings(query, names, SIMILARITY_THRESHOLD)
84+
return similar.mapNotNull { name -> modules.find { it.name == name } }
85+
.map(::ModuleResult)
86+
}
87+
}
88+
}
89+
90+
private class CommandResult(val command: LambdaCommand) : SearchResult {
91+
override val breadcrumb = "Command"
92+
override fun ImGuiBuilder.buildLayout() {
93+
text(command.name.capitalize())
94+
sameLine()
95+
smallButton("Insert") { mc.setScreen(ChatScreen("${CommandRegistry.prefix}${command.name} ")) }
96+
if (command.description.isNotBlank()) {
97+
sameLine()
98+
textDisabled(command.description)
99+
}
100+
}
101+
102+
companion object {
103+
fun search(query: String): List<CommandResult> {
104+
val commands = CommandRegistry.commands
105+
val direct = commands.filter {
106+
val name = it.name.lowercase()
107+
name.startsWith(query) || name.contains(query) || it.aliases.any { alias -> alias.lowercase().contains(query) }
108+
}
109+
110+
if (direct.isNotEmpty()) return direct.map(::CommandResult)
111+
112+
val names = commands.map { it.name }.toSet()
113+
val similar = findSimilarStrings(query, names, SIMILARITY_THRESHOLD)
114+
return similar.mapNotNull { name -> commands.find { it.name == name } }
115+
.map(::CommandResult)
116+
}
117+
}
118+
}
119+
120+
private class SettingResult(val setting: AbstractSetting<*>, val configurable: Configurable) : SearchResult {
121+
override val breadcrumb: String by lazy { buildSettingBreadcrumb(configurable.name, setting) }
122+
123+
override fun ImGuiBuilder.buildLayout() {
124+
with(setting) { buildLayout() }
125+
}
126+
127+
companion object {
128+
fun search(query: String) =
129+
Configuration.configurations.flatMap { config ->
130+
config.configurables.flatMap { configurable ->
131+
val confNameL = configurable.name.lowercase()
132+
configurable.settings.filter { setting ->
133+
setting.visibility() && (setting.name.lowercase().contains(query) || confNameL.contains(query))
134+
}.map { setting ->
135+
SettingResult(setting, configurable)
136+
}
137+
}
138+
}.take(15)
139+
}
140+
}
141+
142+
fun open() {
143+
isOpen = true
144+
shouldFocus = true
145+
searchInput.clear()
146+
}
147+
148+
fun close() {
149+
isOpen = false
150+
shouldFocus = false
151+
}
152+
153+
fun toggle() {
154+
if (isOpen) close() else open()
155+
}
156+
157+
fun ImGuiBuilder.renderQuickSearch() {
158+
if (!isOpen) return
159+
ImGui.openPopup("QuickSearch")
160+
161+
val windowFlags = ImGuiWindowFlags.AlwaysAutoResize or
162+
ImGuiWindowFlags.NoTitleBar or
163+
ImGuiWindowFlags.NoMove or
164+
ImGuiWindowFlags.NoResize or
165+
ImGuiWindowFlags.NoScrollbar or
166+
ImGuiWindowFlags.NoScrollWithMouse
167+
168+
ImGui.setNextFrameWantCaptureKeyboard(true)
169+
170+
val display = ImGui.getIO().displaySize
171+
val maxW = display.x * 0.5f
172+
val maxH = display.y * 0.5f
173+
174+
val popupX = (display.x - maxW) * 0.5f
175+
val popupY = display.y * 0.3f
176+
ImGui.setNextWindowPos(popupX, popupY)
177+
ImGui.setNextWindowSize(maxW, 0f)
178+
ImGui.setNextWindowSizeConstraints(0f, 0f, maxW, maxH)
179+
180+
popupModal("QuickSearch", windowFlags) {
181+
// ToDo: Fix close with background click and escape
182+
if (ImGui.isKeyPressed(256)) { // ESC key
183+
close()
184+
ImGui.closeCurrentPopup()
185+
return@popupModal
186+
}
187+
188+
// val bgClick = (ImGui.isMouseClicked(0) || ImGui.isMouseClicked(1)) &&
189+
// !ImGui.isWindowHovered(ImGuiHoveredFlags.AnyWindow)
190+
// if (bgClick) {
191+
// close()
192+
// ImGui.closeCurrentPopup()
193+
// return@popupModal
194+
// }
195+
196+
if (shouldFocus) {
197+
ImGui.setKeyboardFocusHere()
198+
shouldFocus = false
199+
}
200+
201+
withItemWidth(ImGui.getContentRegionAvailX() * 0.98f) {
202+
withStyleVar(ImGuiStyleVar.FramePadding, style.framePadding.x, style.framePadding.y * 1.35f) {
203+
ImGui.inputTextWithHint(
204+
"##qs-input",
205+
"Type to search modules, settings, and commands...",
206+
searchInput,
207+
ImGuiInputTextFlags.AutoSelectAll
208+
)
209+
}
210+
}
211+
212+
val query = searchInput.get().trim()
213+
if (query.isEmpty()) return@popupModal
214+
215+
val results = performSearch(query)
216+
if (results.isEmpty()) {
217+
textDisabled("Nothing found.")
218+
return@popupModal
219+
}
220+
221+
val rowH = frameHeightWithSpacing
222+
val topArea = ImGui.getCursorPosY() + style.windowPadding.y
223+
val listH = (results.size * rowH).coerceAtMost(maxH - topArea).coerceAtLeast(rowH)
224+
225+
child("qs_rows", 0f, listH, false) {
226+
results.forEachIndexed { idx, result ->
227+
withId(idx) {
228+
with(result) {
229+
if (breadcrumb.isNotBlank()) {
230+
textDisabled(breadcrumb)
231+
sameLine()
232+
}
233+
buildLayout()
234+
}
235+
}
236+
}
237+
}
238+
}
239+
}
240+
241+
242+
private fun performSearch(query: String) =
243+
listOf(ModuleResult::search, CommandResult::search, SettingResult::search)
244+
.flatMap { it(query.lowercase()) }.take(MAX_RESULTS)
245+
246+
private fun buildSettingBreadcrumb(configurableName: String, setting: AbstractSetting<*>): String {
247+
val group = setting.groups
248+
.minByOrNull { it.size }
249+
?.joinToString(" » ") { it.displayName }
250+
?: return configurableName
251+
return "$configurableName » $group"
252+
}
253+
254+
private fun handleKeyPress(event: KeyboardEvent.Press) {
255+
if (!event.isPressed || !(event.keyCode == KeyCode.LEFT_SHIFT.code || event.keyCode == KeyCode.RIGHT_SHIFT.code)) return
256+
257+
val currentTime = System.currentTimeMillis()
258+
if (lastShiftKeyCode == event.keyCode &&
259+
currentTime - lastShiftPressTime <= DOUBLE_SHIFT_WINDOW_MS
260+
) {
261+
open()
262+
lastShiftPressTime = 0L
263+
lastShiftKeyCode = -1
264+
} else {
265+
lastShiftPressTime = currentTime
266+
lastShiftKeyCode = event.keyCode
267+
}
268+
}
269+
}

0 commit comments

Comments
 (0)