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