diff --git a/src/main/java/com/lambda/mixin/CrashReportMixin.java b/src/main/java/com/lambda/mixin/CrashReportMixin.java index 48cd6260b..d39338bae 100644 --- a/src/main/java/com/lambda/mixin/CrashReportMixin.java +++ b/src/main/java/com/lambda/mixin/CrashReportMixin.java @@ -54,8 +54,7 @@ void injectConstructor(String message, Throwable cause, CallbackInfo ci) { @WrapMethod(method = "asString(Lnet/minecraft/util/crash/ReportType;Ljava/util/List;)Ljava/lang/String;") String injectString(ReportType type, List extraInfo, Operation original) { var list = new ArrayList<>(extraInfo); - - list.add("If this issue is related to Lambda, check if other users have experienced this too, or create a new issue at https://github.com/lambda-client/lambda/issues.\n\n"); + list.add("If this issue is related to Lambda, check if other users have experienced this too, or create a new issue at " + Lambda.REPO_URL + "/issues.\n\n"); if (MinecraftClient.getInstance() != null) { list.add("Enabled modules:"); diff --git a/src/main/java/com/lambda/mixin/render/ScreenMixin.java b/src/main/java/com/lambda/mixin/render/ScreenMixin.java new file mode 100644 index 000000000..2acb68348 --- /dev/null +++ b/src/main/java/com/lambda/mixin/render/ScreenMixin.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.mixin.render; + +import com.lambda.gui.components.QuickSearch; +import net.minecraft.client.gui.screen.Screen; +import org.lwjgl.glfw.GLFW; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Screen.class) +public class ScreenMixin { + + @Inject(method = "keyPressed", at = @At("HEAD"), cancellable = true) + private void onKeyPressed(int keyCode, int scanCode, int modifiers, CallbackInfoReturnable cir) { + if (keyCode == GLFW.GLFW_KEY_ESCAPE && QuickSearch.INSTANCE.isOpen()) { + QuickSearch.INSTANCE.close(); + cir.setReturnValue(true); + } + } +} diff --git a/src/main/kotlin/com/lambda/Lambda.kt b/src/main/kotlin/com/lambda/Lambda.kt index e258dc4f6..2954fd92e 100644 --- a/src/main/kotlin/com/lambda/Lambda.kt +++ b/src/main/kotlin/com/lambda/Lambda.kt @@ -51,6 +51,7 @@ object Lambda : ClientModInitializer { const val MOD_ID = "lambda" const val SYMBOL = "λ" const val APP_ID = "1221289599427416127" + const val REPO_URL = "https://github.com/lambda-client/lambda" val VERSION: String = FabricLoader.getInstance() .getModContainer("lambda").orElseThrow() .metadata.version.friendlyString diff --git a/src/main/kotlin/com/lambda/config/AbstractSetting.kt b/src/main/kotlin/com/lambda/config/AbstractSetting.kt index 4571546db..1bc909d40 100644 --- a/src/main/kotlin/com/lambda/config/AbstractSetting.kt +++ b/src/main/kotlin/com/lambda/config/AbstractSetting.kt @@ -158,12 +158,12 @@ abstract class AbstractSetting( groups.add(path.toList()) } - fun reset() { - if (value == defaultValue) { + fun reset(silent: Boolean = false) { + if (!silent && value == defaultValue) { ConfigCommand.info(notChangedMessage()) return } - ConfigCommand.info(resetMessage(value, defaultValue)) + if (!silent) ConfigCommand.info(resetMessage(value, defaultValue)) value = defaultValue } diff --git a/src/main/kotlin/com/lambda/gui/DearImGui.kt b/src/main/kotlin/com/lambda/gui/DearImGui.kt index f1f48f944..eac593a3d 100644 --- a/src/main/kotlin/com/lambda/gui/DearImGui.kt +++ b/src/main/kotlin/com/lambda/gui/DearImGui.kt @@ -26,6 +26,8 @@ import com.lambda.module.modules.client.GuiSettings import com.lambda.util.path import com.mojang.blaze3d.opengl.GlStateManager import com.mojang.blaze3d.systems.RenderSystem +import imgui.ImFontConfig +import imgui.ImFontGlyphRangesBuilder import imgui.ImGui import imgui.ImGuiIO import imgui.flag.ImGuiConfigFlags @@ -33,7 +35,6 @@ import imgui.gl3.ImGuiImplGl3 import imgui.glfw.ImGuiImplGlfw import net.minecraft.client.gl.GlBackend import net.minecraft.client.texture.GlTexture -import org.lwjgl.opengl.GL11.glViewport import org.lwjgl.opengl.GL30.GL_FRAMEBUFFER import kotlin.math.abs @@ -41,6 +42,10 @@ object DearImGui : Loadable { val implGlfw = ImGuiImplGlfw() val implGl3 = ImGuiImplGl3() + const val EXTERNAL_LINK = '↗' + const val BREADCRUMB_SEPARATOR = '»' + const val BASE_FONT_SCALE = 13f + val io: ImGuiIO get() = ImGui.getIO() const val DEFAULT_FLAGS = ImGuiConfigFlags.NavEnableKeyboard or // Enable Keyboard Controls ImGuiConfigFlags.NavEnableSetMousePos or // Move the cursor using the keyboard @@ -52,11 +57,20 @@ object DearImGui : Loadable { private var targetScale = 0f private fun updateScale(scale: Float) { - io.fonts.clear() - val baseFontSize = 13f - io.fonts.addFontFromFileTTF("fonts/FiraSans-Regular.ttf".path, baseFontSize * scale) - io.fonts.build() - + val glyphRanges = ImFontGlyphRangesBuilder().apply { + addRanges(io.fonts.glyphRangesDefault) + addRanges(io.fonts.glyphRangesGreek) + addChar(EXTERNAL_LINK) + addChar(BREADCRUMB_SEPARATOR) + }.buildRanges() + val fontConfig = ImFontConfig() + val size = BASE_FONT_SCALE * scale + with(io.fonts) { + clear() + addFontFromFileTTF("fonts/FiraSans-Regular.ttf".path, size, fontConfig, glyphRanges) + addFontFromFileTTF("fonts/MinecraftDefault-Regular.ttf".path, size, fontConfig, glyphRanges) + build() + } implGl3.createFontsTexture() } diff --git a/src/main/kotlin/com/lambda/gui/LambdaScreen.kt b/src/main/kotlin/com/lambda/gui/LambdaScreen.kt index 873ed82d9..0e00e237a 100644 --- a/src/main/kotlin/com/lambda/gui/LambdaScreen.kt +++ b/src/main/kotlin/com/lambda/gui/LambdaScreen.kt @@ -22,8 +22,7 @@ import net.minecraft.client.gui.DrawContext import net.minecraft.client.gui.screen.Screen import net.minecraft.text.Text - -object LambdaScreen : Screen(Text.of("")) { +object LambdaScreen : Screen(Text.of("Lambda")) { override fun shouldPause() = false override fun removed() = ClickGui.disable() override fun render(context: DrawContext?, mouseX: Int, mouseY: Int, deltaTicks: Float) {} diff --git a/src/main/kotlin/com/lambda/gui/MenuBar.kt b/src/main/kotlin/com/lambda/gui/MenuBar.kt new file mode 100644 index 000000000..21888de84 --- /dev/null +++ b/src/main/kotlin/com/lambda/gui/MenuBar.kt @@ -0,0 +1,453 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.gui + +import com.lambda.Lambda +import com.lambda.Lambda.REPO_URL +import com.lambda.Lambda.mc +import com.lambda.command.CommandRegistry +import com.lambda.config.Configuration +import com.lambda.core.Loader +import com.lambda.event.EventFlow +import com.lambda.graphics.texture.TextureOwner.upload +import com.lambda.gui.DearImGui.EXTERNAL_LINK +import com.lambda.gui.components.QuickSearch +import com.lambda.gui.dsl.ImGuiBuilder +import com.lambda.module.ModuleRegistry +import com.lambda.module.tag.ModuleTag +import com.lambda.threading.runSafe +import com.lambda.util.Communication.info +import com.lambda.util.Diagnostics.gatherDiagnostics +import com.lambda.util.FolderRegister +import com.lambda.util.FolderRegister.minecraft +import com.mojang.blaze3d.platform.TextureUtil +import imgui.ImGui +import imgui.ImGui.closeCurrentPopup +import imgui.flag.ImGuiCol +import imgui.flag.ImGuiStyleVar +import imgui.flag.ImGuiWindowFlags +import imgui.type.ImString +import net.fabricmc.loader.api.FabricLoader +import net.minecraft.client.Keyboard +import net.minecraft.util.Util +import net.minecraft.world.GameMode +import java.nio.file.Path +import java.util.Locale + +object MenuBar { + private var aboutRequested = false + val headerLogo = upload("textures/lambda_text_color.png") + val lambdaLogo = upload("textures/lambda.png") + val githubLogo = upload("textures/github_logo.png") + + fun ImGuiBuilder.buildMenuBar() { + mainMenuBar { + lambdaMenu() + menu("HUD") { buildHudMenu() } + menu("Modules") { buildModulesMenu() } + menu("Minecraft") { buildMinecraftMenu() } + menu("Help") { buildHelpMenu() } + buildGitHubReference() + } + + if (aboutRequested) { + ImGui.openPopup("About Lambda") + aboutRequested = false + } + + aboutPopup() + } + + private fun ImGuiBuilder.lambdaMenu() { + ImGui.pushStyleColor(ImGuiCol.Text, 0) + val opened = ImGui.beginMenu("Lam") + + val headerW = itemRectMaxX - itemRectMinX + val headerH = itemRectMaxY - itemRectMinY + + val pad = 2f + val lambdaIconSize = (headerH - pad * 2f).coerceAtLeast(1f) + val iconX = itemRectMinX + (headerW - lambdaIconSize) * 0.5f + val iconY = itemRectMinY + (headerH - lambdaIconSize) * 0.5f + + foregroundDrawList.addImage( + lambdaLogo.id.toLong(), + iconX, iconY, + iconX + lambdaIconSize, iconY + lambdaIconSize + ) + ImGui.popStyleColor() + + if (opened) { + buildLambdaMenu() + ImGui.endMenu() + } + } + + private fun ImGuiBuilder.buildLambdaMenu() { + menuItem("New Profile...", enabled = false) { + // ToDo (New Profile): + // - Open a modal "New Profile" with: + // [Profile Name] text input + // [Template] combo: Empty / Recommended Defaults / Copy from Current + // [Include HUD Layout] checkbox + // - On Create: instantiate and activate the profile, optionally copying values from current. + // - On Cancel: close modal with no changes. + } + menu("Open Folder") { + menuItem("Open Lambda Folder") { + Util.getOperatingSystem().open(FolderRegister.lambda) + } + menuItem("Open Config Folder") { + Util.getOperatingSystem().open(FolderRegister.config) + } + menuItem("Open Packet Logs Folder") { + Util.getOperatingSystem().open(FolderRegister.packetLogs) + } + menuItem("Open Replay Folder") { + Util.getOperatingSystem().open(FolderRegister.replay) + } + menuItem("Open Cache Folder") { + Util.getOperatingSystem().open(FolderRegister.cache) + } + menuItem("Open Capes Folder") { + Util.getOperatingSystem().open(FolderRegister.capes) + } + menuItem("Open Structures Folder") { + Util.getOperatingSystem().open(FolderRegister.structure) + } + menuItem("Open Maps Folder") { + Util.getOperatingSystem().open(FolderRegister.maps) + } + } + separator() + menuItem("Save Configs") { + Configuration.configurations.forEach { it.trySave(true) } + info("Saved ${Configuration.configurations.size} configuration files.") + } + menuItem("Load Configs") { + Configuration.configurations.forEach { it.tryLoad() } + info("Loaded ${Configuration.configurations.size} configuration files.") + } + separator() + menuItem("Import Profile...", enabled = false) { + // ToDo (Import Profile): + // - Show a file picker for profile file(s). + // - Preview dialog: profile name, version, module count, settings count, includes HUD? + // - Provide options: Merge into Current / Replace Current. + // - Apply with progress/rollback on failure; toast result. + } + menuItem("Export Current Profile...", enabled = false) { + // ToDo (Export Profile): + // - File save modal with checkboxes: + // [Include HUD Layout] [Include Keybinds] [Include Backups Metadata] + // - Create the export and toast result. + } + menu("Recent Profiles") { + // ToDo (MRU Profiles): + // - Populate from a most-recently-used (MRU) list persisted in preferences. + // - On click: switch active profile (confirm if unsaved changes). + menuItem("Example Profile", enabled = false) {} + } + separator() + menu("Autosave Settings") { + // ToDo: + // - Toggle autosave, set interval (1..60s), backup rotation count (0..20). + menuItem("Autosave on changes", selected = true, enabled = false) {} + menuItem("Autosave Interval: 10s", enabled = false) {} + menuItem("Rotate Backups: 5", enabled = false) {} + } + menu("Backup & Restore") { + // ToDo: + // - “Create Backup Now” and “Manage/Restore Backups” UIs; list with timestamps/comments. + menuItem("Create Backup Now", enabled = false) {} + menuItem("Restore From Backup...", enabled = false) {} + menuItem("Manage Backups...", enabled = false) {} + } + menuItem("Profiles & Scopes...", enabled = false) { + // ToDo (Profiles & Scopes Window): + // - Active Profile dropdown. + // - Scopes: Global / Per-Server / Per-World with enable overrides. + // - Show overridden-only list, origin badges, and precedence explanation. + } + separator() + menuItem("About...") { + aboutRequested = true + } + separator() + menuItem("Close GUI", "Esc") { LambdaScreen.close() } + menuItem("Exit Client") { mc.scheduleStop() } + } + + private fun ImGuiBuilder.buildHudMenu() { + menuItem("Open Editor", enabled = false) { + // ToDo (HUD Editor Window): + // - Full-screen canvas with grid; left "Elements" list; right "Properties" inspector. + // - Drag & drop, snap grid, lock/unlock, safe margins, anchors, multi-select & alignment tools. + } + menu("Layouts") { + // ToDo: + // - New/Save/Save As/Load/Import/Export layout actions; Toggle "Autosave on change". + menuItem("New...", enabled = false) {} + menuItem("Save", enabled = false) {} + menuItem("Save As...", enabled = false) {} + menuItem("Load...", enabled = false) {} + menuItem("Import...", enabled = false) {} + menuItem("Export...", enabled = false) {} + separator() + menuItem("Autosave on change", selected = true, enabled = false) {} + } + menuItem("Toggle Edit Handles", selected = true, enabled = false) { + // ToDo: + // - Show/hide bounds, anchors, labels while in edit mode. + } + } + + private fun ImGuiBuilder.buildModulesMenu() { + menu("Module Tag") { + ModuleTag.defaults.forEach { tag -> + menuItem(tag.name, selected = ModuleTag.isTagShown(tag)) { + ModuleTag.toggleTag(tag) + } + } + } + separator() + // By Tag → quick enable/disable per module + ModuleTag.defaults.forEach { tag -> + menu(tag.name) { + ModuleRegistry.modules + .filter { it.tag == tag } + .sortedBy { it.name.lowercase() } + .forEach { module -> + menuItem(module.name, selected = module.isEnabled) { + if (module.isEnabled) module.disable() else module.enable() + } + // Optionally, offer a "Settings..." item to focus this module’s details UI. + } + } + } + } + + private fun ImGuiBuilder.buildMinecraftMenu() { + menu("Open Folder") { + menuItem("Open Minecraft Folder") { + Util.getOperatingSystem().open(minecraft) + } + menuItem("Open Saves Folder") { + Util.getOperatingSystem().open(mc.runDirectory.toPath().toAbsolutePath().resolve("saves").toFile()) + } + menuItem("Open Screenshots Folder") { + Util.getOperatingSystem().open(mc.runDirectory.toPath().toAbsolutePath().resolve("screenshots").toFile()) + } + menuItem("Open Resource Packs Folder") { + Util.getOperatingSystem().open(mc.runDirectory.toPath().toAbsolutePath().resolve("resourcepacks").toFile()) + } + menuItem("Open Mods Folder") { + Util.getOperatingSystem().open(mc.runDirectory.toPath().toAbsolutePath().resolve("mods").toFile()) + } + } + separator() + runSafe { + menu("Gamemode", enabled = player.hasPermissionLevel(2)) { + menuItem("Survival", selected = interaction.gameMode == GameMode.SURVIVAL) { + connection.sendCommand("gamemode survival") + } + menuItem("Creative", selected = interaction.gameMode == GameMode.CREATIVE) { + connection.sendCommand("gamemode creative") + } + menuItem("Adventure", selected = interaction.gameMode == GameMode.ADVENTURE) { + connection.sendCommand("gamemode adventure") + } + menuItem("Spectator", selected = interaction.gameMode == GameMode.SPECTATOR) { + connection.sendCommand("gamemode spectator") + } + } + menu("Debug Menu") { + menuItem("Show Advanced Tooltips", "F3+H", mc.options.advancedItemTooltips) { + mc.options.advancedItemTooltips = !mc.options.advancedItemTooltips + mc.options.write() + } + menuItem("Show Chunk Borders", "F3+G", mc.debugRenderer.showChunkBorder) { + mc.debugRenderer.toggleShowChunkBorder() + } + menuItem("Show Octree", selected = mc.debugRenderer.showOctree) { + mc.debugRenderer.toggleShowOctree() + } + menuItem("Show Hitboxes", "F3+B", mc.entityRenderDispatcher.shouldRenderHitboxes()) { + val now = !mc.entityRenderDispatcher.shouldRenderHitboxes() + mc.entityRenderDispatcher.setRenderHitboxes(now) + } + menuItem("Copy Location (as command)", "F3+C") { + val cmd = String.format( + Locale.ROOT, + "/execute in %s run tp @s %.2f %.2f %.2f %.2f %.2f", + world.registryKey.value, + player.x, player.y, player.z, player.yaw, player.pitch + ) + ImGui.setClipboardText(cmd) + info("Copied location command to clipboard.") + } + menuItem("Clear Chat", "F3+D") { + mc.inGameHud?.chatHud?.clear(false) + } + + separator() + + menuItem( + label = "Pause On Lost Focus", + shortcut = "F3+Esc", + selected = mc.options.pauseOnLostFocus + ) { + mc.options.pauseOnLostFocus = !mc.options.pauseOnLostFocus + mc.options.write() + info("Pause on lost focus ${if (mc.options.pauseOnLostFocus) "enabled" else "disabled"}.") + } + + separator() + + menuItem("Reload Resource Packs", "F3+T") { + info("Reloading resource packs...") + mc.reloadResources() + } + + menuItem("Reload Chunks", "F3+A") { + mc.worldRenderer.reload() + } + + separator() + + menuItem("Show Debug Menu", "F3", mc.debugHud.showDebugHud) { + mc.debugHud.toggleDebugHud() + } + menuItem("Rendering Chart", "F3+1", mc.debugHud.renderingChartVisible) { + mc.debugHud.toggleRenderingChart() + } + menuItem("Rendering & Tick Charts", "F3+2", mc.debugHud.renderingAndTickChartsVisible) { + mc.debugHud.toggleRenderingAndTickCharts() + } + menuItem("Packet Size & Ping Charts", "F3+3", mc.debugHud.packetSizeAndPingChartsVisible) { + mc.debugHud.togglePacketSizeAndPingCharts() + } + + separator() + + menuItem("Start/Stop Profiler", "F3+L") { + mc.toggleDebugProfiler { message -> + info(message) + } + } + menuItem("Dump Dynamic Textures", "F3+S") { + val root = mc.runDirectory.toPath().toAbsolutePath() + val output = TextureUtil.getDebugTexturePath(root) + mc.textureManager.dumpDynamicTextures(output) + info("Dumped dynamic textures to: ${root.relativize(output)}") + } + } + } ?: menuItem("Debug (only available ingame)", enabled = false) + } + + private fun ImGuiBuilder.buildHelpMenu() { + menuItem("Quick Search...", "Shift+Shift") { + QuickSearch.open() + } + menuItem("Documentation $EXTERNAL_LINK") { + Util.getOperatingSystem().open("$REPO_URL/wiki") + } + menuItem("Report Issue $EXTERNAL_LINK") { + mc.keyboard.clipboard = gatherDiagnostics() + info("Copied diagnostics to clipboard. Please paste it in a new issue on GitHub and click “Submit new issue”. Thank you!") + Util.getOperatingSystem().open("$REPO_URL/issues") + } + menuItem("Check for Updates $EXTERNAL_LINK") { + // ToDo: + // - Check for a newer version, show availability & changelog, and allow opening release page. + // - Needs UpdateManager + Util.getOperatingSystem().open("$REPO_URL/releases") + } + } + + private fun ImGuiBuilder.aboutPopup() { + popupModal("About Lambda", ImGuiWindowFlags.AlwaysAutoResize or ImGuiWindowFlags.NoTitleBar) { + imageHorizontallyCentered(headerLogo.id.toLong(), 553f, 200f) + group { + text("Version: ${Lambda.VERSION}") + if (Lambda.isDebug) text("Development Environment") + text("Runtime: ${Loader.runtime}") + text("Modules: ${ModuleRegistry.modules.size}") + text("Commands: ${CommandRegistry.commands.size}") + val totalSettings = Configuration.configurations.sumOf { cfg -> + cfg.configurables.sumOf { it.settings.size } + } + text("Settings: $totalSettings") + text("Synchronous listeners: ${EventFlow.syncListeners.size}") + text("Concurrent listeners: ${EventFlow.concurrentListeners.size}") + } + separator() + text("Authors") + FabricLoader.getInstance().getModContainer("lambda") + .orElseThrow { IllegalStateException("Could not find Lambda mod container!") } + .metadata + .authors + .forEach { author -> + if (author.name.isEmpty()) return@forEach + author.name.split(",").forEach { name -> + bulletText(name.trim()) + } + } + text("Thanks to all community members") + + separator() + group { + button("Copy Diagnostics") { + ImGui.setClipboardText(gatherDiagnostics()) + } + sameLine() + button("View License $EXTERNAL_LINK") { + Util.getOperatingSystem().open("$REPO_URL/blob/master/LICENSE.md") + } + sameLine() + button("Close") { + aboutRequested = false + closeCurrentPopup() + } + } + } + } + + private fun ImGuiBuilder.buildGitHubReference() { + val frameH = frameHeight - 2f + val iconSize = (frameH - 6f).coerceAtLeast(14f) + val spacingPx = 8f + + sameLine() + cursorPosX = windowContentRegionMaxX - iconSize - spacingPx + + withStyleVar(ImGuiStyleVar.FramePadding, 2f, 2f) { + withStyleColor(ImGuiCol.Button, 0x00000000) { + withStyleColor(ImGuiCol.ButtonHovered, 0x22FFFFFF) { + withStyleColor(ImGuiCol.ButtonActive, 0x44FFFFFF) { + val clicked = ImGui.imageButton("##github", githubLogo.id.toLong(), iconSize, iconSize) + lambdaTooltip("Open GitHub Repository $EXTERNAL_LINK") + if (clicked) { + Util.getOperatingSystem().open(REPO_URL) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/gui/components/ClickGuiLayout.kt b/src/main/kotlin/com/lambda/gui/components/ClickGuiLayout.kt index 5a8ccc987..82e4c3d9f 100644 --- a/src/main/kotlin/com/lambda/gui/components/ClickGuiLayout.kt +++ b/src/main/kotlin/com/lambda/gui/components/ClickGuiLayout.kt @@ -17,16 +17,15 @@ package com.lambda.gui.components -import com.lambda.config.Configuration import com.lambda.core.Loadable import com.lambda.event.events.GuiEvent import com.lambda.event.listener.SafeListener.Companion.listen +import com.lambda.gui.MenuBar.buildMenuBar +import com.lambda.gui.components.QuickSearch.renderQuickSearch import com.lambda.gui.dsl.ImGuiBuilder.buildLayout import com.lambda.module.ModuleRegistry import com.lambda.module.modules.client.ClickGui -import com.lambda.module.tag.ModuleTag -import com.lambda.threading.runSafe -import com.lambda.util.Communication.info +import com.lambda.module.tag.ModuleTag.Companion.shownTags import imgui.ImGui import imgui.flag.ImGuiWindowFlags.AlwaysAutoResize @@ -36,36 +35,17 @@ object ClickGuiLayout : Loadable { if (!ClickGui.isEnabled) return@listen buildLayout { - ModuleTag.defaults - .forEach { tag -> - window(tag.name, flags = AlwaysAutoResize) { - ModuleRegistry.modules - .filter { it.tag == tag } - .forEach { with(ModuleEntry(it)) { buildLayout() } } - } - } - - mainMenuBar { - menu("File") { - menuItem("Save Configs", "Ctrl+S") { - Configuration.configurations.forEach { config -> - config.trySave(true) - } - runSafe { - info("Saved ${Configuration.configurations.size} configuration files.") - } - } - menuItem("Load Configs", "Ctrl+L") { - Configuration.configurations.forEach { config -> - config.tryLoad() - } - runSafe { - info("Loaded ${Configuration.configurations.size} configuration files.") - } - } + shownTags.forEach { tag -> + window(tag.name, flags = AlwaysAutoResize) { + ModuleRegistry.modules + .filter { it.tag == tag } + .forEach { with(ModuleEntry(it)) { buildLayout() } } } } + buildMenuBar() + renderQuickSearch() + ImGui.showDemoWindow() } } diff --git a/src/main/kotlin/com/lambda/gui/components/HudGuiLayout.kt b/src/main/kotlin/com/lambda/gui/components/HudGuiLayout.kt index 87e1c1102..95e2da94d 100644 --- a/src/main/kotlin/com/lambda/gui/components/HudGuiLayout.kt +++ b/src/main/kotlin/com/lambda/gui/components/HudGuiLayout.kt @@ -29,7 +29,8 @@ object HudGuiLayout : Loadable { const val DEFAULT_HUD_FLAGS = ImGuiWindowFlags.NoDecoration or ImGuiWindowFlags.NoBackground or - ImGuiWindowFlags.AlwaysAutoResize + ImGuiWindowFlags.AlwaysAutoResize or + ImGuiWindowFlags.NoDocking init { listen { diff --git a/src/main/kotlin/com/lambda/gui/components/ModuleEntry.kt b/src/main/kotlin/com/lambda/gui/components/ModuleEntry.kt index 49e686be2..5b154fc79 100644 --- a/src/main/kotlin/com/lambda/gui/components/ModuleEntry.kt +++ b/src/main/kotlin/com/lambda/gui/components/ModuleEntry.kt @@ -22,21 +22,34 @@ import com.lambda.gui.Layout import com.lambda.gui.dsl.ImGuiBuilder import com.lambda.module.Module import com.lambda.util.NamedEnum +import com.lambda.util.KeyCode +import imgui.ImGui import imgui.flag.ImGuiTabBarFlags +import imgui.flag.ImGuiCol class ModuleEntry(val module: Module): Layout { override fun ImGuiBuilder.buildLayout() { - checkbox("##-$module", module::isEnabled) + selectable(module.name, selected = module.isEnabled) { + module.toggle() + } lambdaTooltip(module.description) - sameLine() - treeNode(module.name) { - lambdaTooltip(module.description) + + ImGui.setNextWindowSizeConstraints(0f, 0f, Float.MAX_VALUE, io.displaySize.y * 0.5f) + popupContextItem("##ctx-${module.name}") { + group { + with(module.keybindSetting) { buildLayout() } + sameLine() + smallButton("Reset") { + module.settings.forEach { it.reset(silent = true) } + } + lambdaTooltip("Resets all settings for this module to their default values") + } + separator() group { - val visibleSettings = module.settings.filter { it.visibility() } + val visibleSettings = module.settings.filter { it.visibility() } - module.keybindSetting val (grouped, ungrouped) = visibleSettings.partition { it.groups.isNotEmpty() } ungrouped.forEach { with(it) { buildLayout() } } - renderGroup(grouped, emptyList()) } } diff --git a/src/main/kotlin/com/lambda/gui/components/QuickSearch.kt b/src/main/kotlin/com/lambda/gui/components/QuickSearch.kt new file mode 100644 index 000000000..0789d7830 --- /dev/null +++ b/src/main/kotlin/com/lambda/gui/components/QuickSearch.kt @@ -0,0 +1,315 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.gui.components + +import com.lambda.Lambda.mc +import com.lambda.command.CommandRegistry +import com.lambda.command.LambdaCommand +import com.lambda.config.AbstractSetting +import com.lambda.config.Configurable +import com.lambda.config.Configuration +import com.lambda.event.events.KeyboardEvent +import com.lambda.event.listener.UnsafeListener.Companion.listenUnsafe +import com.lambda.gui.LambdaScreen +import com.lambda.gui.Layout +import com.lambda.gui.dsl.ImGuiBuilder +import com.lambda.module.Module +import com.lambda.module.ModuleRegistry +import com.lambda.util.KeyCode +import com.lambda.util.StringUtils.capitalize +import com.lambda.util.StringUtils.levenshteinDistance +import imgui.ImGui +import imgui.flag.ImGuiInputTextFlags +import imgui.flag.ImGuiStyleVar +import imgui.flag.ImGuiWindowFlags +import imgui.type.ImString +import net.minecraft.client.gui.screen.ChatScreen +import kotlin.math.max + +// ToDo: Add support for searching of menu bar entries +object QuickSearch { + private val searchInput = ImString(256) + var isOpen = false + private set + private var shouldFocus = false + + private var lastShiftPressTime = 0L + private var lastShiftKeyCode = -1 + + private const val DOUBLE_SHIFT_WINDOW_MS = 500L + private const val MAX_RESULTS = 50 + private const val WINDOW_FLAGS = ImGuiWindowFlags.AlwaysAutoResize or + ImGuiWindowFlags.NoTitleBar or + ImGuiWindowFlags.NoMove or + ImGuiWindowFlags.NoResize or + ImGuiWindowFlags.NoScrollbar or + ImGuiWindowFlags.NoScrollWithMouse + + init { + listenUnsafe { event -> + if (mc.currentScreen !is LambdaScreen) return@listenUnsafe + handleKeyPress(event) + } + } + + interface SearchResult : Layout { + val breadcrumb: String + } + + private class ModuleResult(val module: Module) : SearchResult { + override val breadcrumb = "Module" + + override fun ImGuiBuilder.buildLayout() { + with(ModuleEntry(module)) { + buildLayout { + withItemWidth(ImGui.getContentRegionAvailX()) { + buildLayout() + } + } + } + } + } + + private class CommandResult(val command: LambdaCommand) : SearchResult { + override val breadcrumb = "Command" + override fun ImGuiBuilder.buildLayout() { + text(command.name.capitalize()) + sameLine() + smallButton("Insert") { mc.setScreen(ChatScreen("${CommandRegistry.prefix}${command.name} ")) } + if (command.description.isNotBlank()) { + sameLine() + textDisabled(command.description) + } + } + } + + private class SettingResult(val setting: AbstractSetting<*>, val configurable: Configurable) : SearchResult { + override val breadcrumb: String by lazy { buildSettingBreadcrumb(configurable.name, setting) } + + override fun ImGuiBuilder.buildLayout() { + with(setting) { + buildLayout { + withItemWidth(ImGui.getContentRegionAvailX()) { + buildLayout() + } + } + } + } + } + + fun open() { + isOpen = true + shouldFocus = true + searchInput.clear() + } + + fun close() { + isOpen = false + shouldFocus = false + } + + fun toggle() { + if (isOpen) close() else open() + } + + fun ImGuiBuilder.renderQuickSearch() { + if (!isOpen) return + ImGui.openPopup("QuickSearch") + + ImGui.setNextFrameWantCaptureKeyboard(true) + + val maxW = io.displaySize.x * 0.5f + val maxH = io.displaySize.y * 0.5f + + val popupX = (io.displaySize.x - maxW) * 0.5f + val popupY = io.displaySize.y * 0.3f + ImGui.setNextWindowPos(popupX, popupY) + ImGui.setNextWindowSize(maxW, 0f) + ImGui.setNextWindowSizeConstraints(0f, 0f, maxW, maxH) + + popupModal("QuickSearch", WINDOW_FLAGS) { + if (shouldFocus) { + ImGui.setKeyboardFocusHere() + shouldFocus = false + } + + withItemWidth(ImGui.getContentRegionAvailX()) { + withStyleVar(ImGuiStyleVar.FramePadding, style.framePadding.x, style.framePadding.y) { + ImGui.inputTextWithHint( + "##qs-input", + "Type to search modules, settings, and commands...", + searchInput, + ImGuiInputTextFlags.AutoSelectAll + ) + } + } + + val query = searchInput.get().trim() + if (query.isEmpty()) return@popupModal + + val results = SearchService.performSearch(query) + if (results.isEmpty()) { + textDisabled("Nothing found.") + return@popupModal + } + + val rowH = frameHeightWithSpacing + val topArea = cursorPosY + style.windowPadding.y + val listH = (results.size * rowH).coerceAtMost(maxH - topArea).coerceAtLeast(rowH) + + child("qs_rows", 0f, listH, false) { + results.forEachIndexed { idx, result -> + withId(idx) { + with(result) { + if (breadcrumb.isNotBlank()) { + textDisabled(breadcrumb) + sameLine() + } + buildLayout() + } + } + } + } + } + } + + private object SearchService { + private data class RankedSearchResult(val result: SearchResult, val score: Int) + + private const val MODULE_PRIORITY_BONUS = 300 + private const val COMMAND_PRIORITY_BONUS = 200 + + /** + * Calculates a relevance score for a query against a target string. + * Returns 0 for no match. Higher scores are better. + * The `lenient` flag adjusts the threshold for fuzzy matching. + */ + private fun calculateScore(query: String, target: String, lenient: Boolean = false): Int { + if (query.isEmpty() || target.isEmpty()) return 0 + + // 1. Strong Matches (Exact, Prefix, Substring) + if (target == query) return 200 + if (target.startsWith(query)) { + val completeness = (query.length * 50) / target.length + return 100 + completeness // Score: 101 - 150 + } + if (target.contains(query)) { + val completeness = (query.length * 40) / target.length + return 50 + completeness // Score: 51 - 90 + } + + // 2. Weak Match (Fuzzy) + val distance = query.levenshteinDistance(target) + val strictThreshold = (query.length / 3).coerceAtLeast(1).coerceAtMost(4) + val lenientThreshold = (query.length / 2).coerceAtLeast(2).coerceAtMost(6) + val threshold = if (lenient) lenientThreshold else strictThreshold + + return if (distance <= threshold) { + (50 - (distance * 10)).coerceAtLeast(1) // Score: 1-40 + } else { + 0 + } + } + + /** + * Performs a search and returns a list of ranked results. This is the internal + * implementation that can be run in strict or lenient mode. + */ + private fun searchInternal(query: String, lenient: Boolean): List { + val lowerCaseQuery = query.lowercase() + + val moduleResults = ModuleRegistry.modules.mapNotNull { module -> + val nameScore = calculateScore(lowerCaseQuery, module.name.lowercase(), lenient) + val tagScore = calculateScore(lowerCaseQuery, module.tag.name.lowercase(), lenient) + val bestScore = max(nameScore, tagScore) + + if (bestScore > 0) { + RankedSearchResult(ModuleResult(module), bestScore + MODULE_PRIORITY_BONUS) + } else null + } + + val commandResults = CommandRegistry.commands.mapNotNull { command -> + val nameScore = calculateScore(lowerCaseQuery, command.name.lowercase(), lenient) + val aliasScore = command.aliases.maxOfOrNull { calculateScore(lowerCaseQuery, it.lowercase(), lenient) } ?: 0 + val bestScore = max(nameScore, aliasScore) + + if (bestScore > 0) { + RankedSearchResult(CommandResult(command), bestScore + COMMAND_PRIORITY_BONUS) + } else null + } + + val settingResults = Configuration.configurations.flatMap { + it.configurables.flatMap { configurable -> + configurable.settings + .filter { setting -> setting.visibility() } + .mapNotNull { setting -> + val score = calculateScore(lowerCaseQuery, setting.name.lowercase(), lenient) + if (score > 0) RankedSearchResult(SettingResult(setting, configurable), score) else null + } + } + } + + return moduleResults + commandResults + settingResults + } + + /** + * Main search entry point. It first attempts a strict search. If no results + * are found, it falls back to a more lenient fuzzy search. + */ + fun performSearch(query: String): List { + // First pass: strict search for high-quality matches. + val strictResults = searchInternal(query, lenient = false) + if (strictResults.isNotEmpty()) { + return strictResults + .sortedByDescending { it.score } + .map { it.result } + .take(MAX_RESULTS) + } + + // Second pass: if nothing was found, perform a more generous fuzzy search. + return searchInternal(query, lenient = true) + .sortedByDescending { it.score } + .map { it.result } + .take(MAX_RESULTS) + } + } + + private fun buildSettingBreadcrumb(configurableName: String, setting: AbstractSetting<*>): String { + val group = setting.groups + .minByOrNull { it.size } + ?.joinToString(" » ") { it.displayName } + ?: return configurableName + return "$configurableName » $group" + } + + private fun handleKeyPress(event: KeyboardEvent.Press) { + if (!event.isPressed || !(event.keyCode == KeyCode.LEFT_SHIFT.code || event.keyCode == KeyCode.RIGHT_SHIFT.code)) return + + val currentTime = System.currentTimeMillis() + if (lastShiftKeyCode == event.keyCode && + currentTime - lastShiftPressTime <= DOUBLE_SHIFT_WINDOW_MS + ) { + open() + lastShiftPressTime = 0L + lastShiftKeyCode = -1 + } else { + lastShiftPressTime = currentTime + lastShiftKeyCode = event.keyCode + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/gui/dsl/ImGuiBuilder.kt b/src/main/kotlin/com/lambda/gui/dsl/ImGuiBuilder.kt index a00e84185..ffd67d765 100644 --- a/src/main/kotlin/com/lambda/gui/dsl/ImGuiBuilder.kt +++ b/src/main/kotlin/com/lambda/gui/dsl/ImGuiBuilder.kt @@ -36,10 +36,8 @@ package com.lambda.gui.dsl -import com.lambda.context.SafeContext import com.lambda.gui.dsl.ImGuiBuilder.text import com.lambda.module.modules.client.ClickGui -import com.lambda.threading.runSafe import com.lambda.util.math.Vec2d import imgui.* import imgui.ImGui.* @@ -1496,10 +1494,10 @@ object ImGuiBuilder { inline fun popupModal( title: String, value: KMutableProperty0, - flags: Int = ImGuiPopupFlags.None, + windowFlags: Int = ImGuiWindowFlags.None, block: ProcedureBlock, ) { - if (withBool(value) { beginPopupModal(title, it, flags) }) { + if (withBool(value) { beginPopupModal(title, it, windowFlags) }) { block() endPopup() } @@ -1508,10 +1506,10 @@ object ImGuiBuilder { @ImGuiDsl inline fun popupModal( title: String, - flags: Int = ImGuiPopupFlags.None, + windowFlags: Int = ImGuiWindowFlags.None, block: ProcedureBlock, ) { - if (beginPopupModal(title, flags)) { + if (beginPopupModal(title, windowFlags)) { block() endPopup() } @@ -1912,6 +1910,36 @@ object ImGuiBuilder { @ImGuiDsl val foregroundDrawList: ImDrawList get() = getForegroundDrawList() + /** + * Represents the minimum X-coordinate of the current item's rectangle in the UI. + * + * This value is typically used to calculate the dimensions or positioning + * of graphical elements relative to the current UI item. + */ + @ImGuiDsl + val itemRectMinX: Float get() = getItemRectMinX() + + @ImGuiDsl + val itemRectMinY: Float get() = getItemRectMinY() + + @ImGuiDsl + val itemRectMaxX: Float get() = getItemRectMaxX() + + @ImGuiDsl + val itemRectMaxY: Float get() = getItemRectMaxY() + + @ImGuiDsl + val frameHeight: Float get() = getFrameHeight() + + @ImGuiDsl + val frameHeightWithSpacing: Float get() = getFrameHeightWithSpacing() + + @ImGuiDsl + val windowContentRegionMaxX: Float get() = getWindowContentRegionMaxX() + + @ImGuiDsl + val windowContentRegionMaxY: Float get() = getWindowContentRegionMaxY() + /** * Creates a frame with optional border. */ @@ -1942,6 +1970,24 @@ object ImGuiBuilder { } } + @ImGuiDsl + var cursorPosX: Float get() = getCursorPosX(); set(value) { + setCursorPosX(value) + } + + @ImGuiDsl + var cursorPosY: Float get() = getCursorPosY(); set(value) { + setCursorPosY(value) + } + + @ImGuiDsl + fun imageHorizontallyCentered(textureId: Long, width: Float, height: Float) { + val contentW = getContentRegionAvail().x + val offsetX = (contentW - width) * 0.5f + cursorPosX += maxOf(0f, offsetX) + image(textureId, width, height) + } + @ImGuiDsl fun buildLayout(block: ProcedureBlock) { block() diff --git a/src/main/kotlin/com/lambda/module/Module.kt b/src/main/kotlin/com/lambda/module/Module.kt index 2038340c6..9fb8d927d 100644 --- a/src/main/kotlin/com/lambda/module/Module.kt +++ b/src/main/kotlin/com/lambda/module/Module.kt @@ -24,17 +24,23 @@ import com.lambda.config.Configuration import com.lambda.config.configurations.ModuleConfig import com.lambda.context.SafeContext import com.lambda.event.Muteable +import com.lambda.event.events.ClientEvent +import com.lambda.event.events.ConnectionEvent import com.lambda.event.events.KeyboardEvent import com.lambda.event.listener.Listener import com.lambda.event.listener.SafeListener import com.lambda.event.listener.SafeListener.Companion.listen import com.lambda.event.listener.UnsafeListener +import com.lambda.gui.DearImGui +import com.lambda.gui.LambdaScreen +import com.lambda.module.modules.client.ClickGui import com.lambda.module.tag.ModuleTag import com.lambda.sound.LambdaSound import com.lambda.sound.SoundManager.play import com.lambda.util.Communication.info import com.lambda.util.KeyCode import com.lambda.util.Nameable +import imgui.ImGui /** * A [Module] is a feature or tool for the utility mod. @@ -111,10 +117,10 @@ abstract class Module( private val alwaysListening: Boolean = false, enabledByDefault: Boolean = false, defaultKeybind: KeyCode = KeyCode.UNBOUND, + autoDisable: Boolean = false ) : Nameable, Muteable, Configurable(ModuleConfig) { private val isEnabledSetting = setting("Enabled", enabledByDefault) { false } - private val keybindSetting = setting("Keybind", defaultKeybind) - val reset by setting("Reset", { settings.forEach { it.reset() }; this@Module.info("Settings set to default") }, "Reset settings values to default.") + val keybindSetting = setting("Keybind", defaultKeybind) { false } open val isVisible: Boolean = true @@ -132,7 +138,12 @@ abstract class Module( if (!event.isPressed) return@listen if (keybind == KeyCode.UNBOUND) return@listen if (event.translated != keybind) return@listen - if (mc.currentScreen != null) return@listen + if (mc.currentScreen != null) { + if (ClickGui.isEnabled && mc.currentScreen == LambdaScreen && !DearImGui.io.wantTextInput) { + LambdaScreen.close() + } + return@listen + } toggle() } @@ -142,6 +153,10 @@ abstract class Module( onEnableUnsafe { LambdaSound.MODULE_ON.play() } onDisableUnsafe { LambdaSound.MODULE_OFF.play() } + + listen { if (autoDisable) disable() } + listen { if (autoDisable) disable() } + listen { if (autoDisable) disable() } } fun enable() { diff --git a/src/main/kotlin/com/lambda/module/hud/Coordinates.kt b/src/main/kotlin/com/lambda/module/hud/Coordinates.kt index 209831dc4..a20020500 100644 --- a/src/main/kotlin/com/lambda/module/hud/Coordinates.kt +++ b/src/main/kotlin/com/lambda/module/hud/Coordinates.kt @@ -29,24 +29,23 @@ import com.lambda.util.math.netherCoord import com.lambda.util.math.overworldCoord object Coordinates : HudModule( - name = "Coordinates", + name = "Coordinates", description = "Show your coordinates", - tag = ModuleTag.HUD, + tag = ModuleTag.HUD, ) { private val showDimension by setting("Show Dimension", true) private val decimals by setting("Decimals", 2, 0..4, 1) - //override fun getText() = runSafe { "XYZ ${if (showDimension) world.dimensionName else ""} ${positionForDimension()}" } ?: "" - override fun ImGuiBuilder.buildLayout() { runSafe { - val text = "XYZ ${if (showDimension) world.dimensionName else ""}" - - val coord = - if (world.isNether) "${player.pos.asString(decimals)} [${player.overworldCoord.x.string}; ${player.overworldCoord.z.string}]" - else "${player.pos.asString(decimals)} [${player.netherCoord.x.string}; ${player.netherCoord.z.string}]" - - textCopyable("$text $coord") + val pos = player.pos.asString(decimals) + val coord = if (world.isNether) { + "$pos [${player.overworldCoord.x.string}, ${player.overworldCoord.z.string}]" + } else { + "$pos [${player.netherCoord.x.string}, ${player.netherCoord.z.string}]" + } + val dimension = if (showDimension) " ${world.dimensionName}" else "" + textCopyable("$coord$dimension") } } } diff --git a/src/main/kotlin/com/lambda/module/hud/ModuleList.kt b/src/main/kotlin/com/lambda/module/hud/ModuleList.kt index 672888cf2..587087c50 100644 --- a/src/main/kotlin/com/lambda/module/hud/ModuleList.kt +++ b/src/main/kotlin/com/lambda/module/hud/ModuleList.kt @@ -26,8 +26,8 @@ import imgui.flag.ImGuiCol import java.awt.Color object ModuleList : HudModule( - name = "ModuleList", - tag = ModuleTag.HUD, + name = "ModuleList", + tag = ModuleTag.HUD, ) { override val isVisible: Boolean get() = false @@ -39,10 +39,7 @@ object ModuleList : HudModule( enabled.forEach { text(it.name); sameLine() - - val color = - if (it.keybind == KeyCode.UNBOUND) Color.RED - else Color.GREEN + val color = if (it.keybind == KeyCode.UNBOUND) Color.RED else Color.GREEN withStyleColor(ImGuiCol.Text, color) { text(" [${it.keybind.name}]") } } diff --git a/src/main/kotlin/com/lambda/module/hud/TPS.kt b/src/main/kotlin/com/lambda/module/hud/TPS.kt index 965a735a2..a97a94707 100644 --- a/src/main/kotlin/com/lambda/module/hud/TPS.kt +++ b/src/main/kotlin/com/lambda/module/hud/TPS.kt @@ -25,9 +25,9 @@ import com.lambda.util.NamedEnum import com.lambda.util.ServerTPS.averageMSPerTick object TPS : HudModule( - name = "TPS", + name = "TPS", description = "Display the server's tick rate", - tag = ModuleTag.HUD, + tag = ModuleTag.HUD, ) { private val format by setting("Tick format", TickFormat.TPS) diff --git a/src/main/kotlin/com/lambda/module/hud/TaskFlowHUD.kt b/src/main/kotlin/com/lambda/module/hud/TaskFlowHUD.kt index e2756c2e1..48f44b0f6 100644 --- a/src/main/kotlin/com/lambda/module/hud/TaskFlowHUD.kt +++ b/src/main/kotlin/com/lambda/module/hud/TaskFlowHUD.kt @@ -23,8 +23,8 @@ import com.lambda.module.tag.ModuleTag import com.lambda.task.RootTask object TaskFlowHUD : HudModule( - name = "TaskFlowHud", - tag = ModuleTag.HUD, + name = "TaskFlowHud", + tag = ModuleTag.HUD, ) { override fun ImGuiBuilder.buildLayout() { text(RootTask.toString()) diff --git a/src/main/kotlin/com/lambda/module/hud/Watermark.kt b/src/main/kotlin/com/lambda/module/hud/Watermark.kt index 47311be53..90d46b41e 100644 --- a/src/main/kotlin/com/lambda/module/hud/Watermark.kt +++ b/src/main/kotlin/com/lambda/module/hud/Watermark.kt @@ -23,8 +23,8 @@ import com.lambda.module.HudModule import com.lambda.module.tag.ModuleTag object Watermark : HudModule( - name = "Watermark", - tag = ModuleTag.HUD, + name = "Watermark", + tag = ModuleTag.HUD, ) { private val texture = upload("textures/lambda.png") diff --git a/src/main/kotlin/com/lambda/module/modules/client/ClickGui.kt b/src/main/kotlin/com/lambda/module/modules/client/ClickGui.kt index eaf5d6117..f7aba1700 100644 --- a/src/main/kotlin/com/lambda/module/modules/client/ClickGui.kt +++ b/src/main/kotlin/com/lambda/module/modules/client/ClickGui.kt @@ -41,6 +41,7 @@ object ClickGui : Module( description = "ImGui", tag = ModuleTag.CLIENT, defaultKeybind = KeyCode.Y, + autoDisable = true ) { private enum class Group(override val displayName: String) : NamedEnum { General("General"), diff --git a/src/main/kotlin/com/lambda/module/modules/debug/SettingTest.kt b/src/main/kotlin/com/lambda/module/modules/debug/SettingTest.kt index 2f89a7571..4b97b12ca 100644 --- a/src/main/kotlin/com/lambda/module/modules/debug/SettingTest.kt +++ b/src/main/kotlin/com/lambda/module/modules/debug/SettingTest.kt @@ -68,7 +68,7 @@ object SettingTest : Module( private val blockPosSetting by setting("Block Position", BlockPos(0, 0, 0)).group(Group.COMPLEX) private val blockSetting by setting("Block Setting", Blocks.OBSIDIAN).group(Group.COMPLEX) private val colorSetting by setting("Color Setting", Color.GREEN).group(Group.COMPLEX) - private val keybindSetting by setting("Key Bind Setting", KeyCode.T).group(Group.COMPLEX) + private val keybindSettingTest by setting("Key Bind Setting", KeyCode.T).group(Group.COMPLEX) // Complex collections private val blockPosSet by setting("Block Position Set", setOf(BlockPos(0, 0, 0)), setOf(BlockPos(0, 0, 0))).group(Group.COMPLEX) diff --git a/src/main/kotlin/com/lambda/module/modules/network/PacketDelay.kt b/src/main/kotlin/com/lambda/module/modules/network/PacketDelay.kt index 3cf88493f..b5d6f130e 100644 --- a/src/main/kotlin/com/lambda/module/modules/network/PacketDelay.kt +++ b/src/main/kotlin/com/lambda/module/modules/network/PacketDelay.kt @@ -26,6 +26,8 @@ import com.lambda.module.tag.ModuleTag import com.lambda.threading.runConcurrent import com.lambda.threading.runGameScheduled import com.lambda.util.ClientPacket +import com.lambda.util.Describable +import com.lambda.util.NamedEnum import com.lambda.util.PacketUtils.handlePacketSilently import com.lambda.util.PacketUtils.sendPacketSilently import com.lambda.util.ServerPacket @@ -39,11 +41,11 @@ object PacketDelay : Module( description = "Delays packets client-bound & server-bound", tag = ModuleTag.NETWORK, ) { - private val mode by setting("Mode", Mode.STATIC) - private val networkScope by setting("Network Scope", Direction.BOTH) - private val packetScope by setting("Packet Scope", PacketType.ANY) - private val inboundDelay by setting("Inbound Delay", 250L, 0L..5000L, 10L, unit = "ms") { networkScope != Direction.OUTBOUND } - private val outboundDelay by setting("Outbound Delay", 250L, 0L..5000L, 10L, unit = "ms") { networkScope != Direction.INBOUND } + private val mode by setting("Mode", Mode.Static, description = "How the delay is applied: Static queues packets until a flush; Pulse delays each packet individually.") + private val networkScope by setting("Network Scope", Direction.Both, description = "Which direction(s) to affect: inbound (server → you), outbound (you → server), or both.") + private val packetScope by setting("Packet Scope", PacketType.Any, description = "What packets to delay. Choose all packets or a specific packet type.") + private val inboundDelay by setting("Inbound Delay", 250L, 0L..5000L, 10L, unit = "ms", description = "Time to delay packets received from the server before processing.") { networkScope != Direction.Outbound } + private val outboundDelay by setting("Outbound Delay", 250L, 0L..5000L, 10L, unit = "ms", description = "Time to delay packets sent to the server before sending.") { networkScope != Direction.Inbound } private var outboundPool = ConcurrentLinkedDeque() private var inboundPool = ConcurrentLinkedDeque() @@ -52,7 +54,7 @@ object PacketDelay : Module( init { listen { - if (mode != Mode.STATIC) return@listen + if (mode != Mode.Static) return@listen flushPools(System.currentTimeMillis()) } @@ -61,12 +63,12 @@ object PacketDelay : Module( if (!packetScope.filter(event.packet)) return@listen when (mode) { - Mode.STATIC -> { + Mode.Static -> { outboundPool.add(event.packet) event.cancel() } - Mode.PULSE -> { + Mode.Pulse -> { runConcurrent { delay(outboundDelay) runGameScheduled { @@ -82,12 +84,12 @@ object PacketDelay : Module( if (!packetScope.filter(event.packet)) return@listen when (mode) { - Mode.STATIC -> { + Mode.Static -> { inboundPool.add(event.packet) event.cancel() } - Mode.PULSE -> { + Mode.Pulse -> { runConcurrent { delay(inboundDelay) runGameScheduled { @@ -128,10 +130,30 @@ object PacketDelay : Module( } } - enum class Mode { STATIC, PULSE, } - enum class Direction { BOTH, INBOUND, OUTBOUND } - enum class PacketType(val filter: (Packet<*>) -> Boolean) { - ANY({ true }), - KEEP_ALIVE({ it is KeepAliveC2SPacket }) + enum class Mode( + override val displayName: String, + override val description: String, + ) : NamedEnum, Describable { + Static("Static", "Queue packets and release them in bursts based on your delay. Useful for batching traffic."), + Pulse("Pulse", "Apply a per-packet delay before it is sent/processed. Useful for smoothing timing.") } + + enum class Direction( + override val displayName: String, + override val description: String, + ) : NamedEnum, Describable { + Both("Both", "Affects both outbound (client → server) and inbound (server → client) packets."), + Inbound("Inbound", "Affects only packets received from the server."), + Outbound("Outbound", "Affects only packets sent to the server.") + } + + enum class PacketType( + override val displayName: String, + override val description: String, + val filter: (Packet<*>) -> Boolean, + ) : NamedEnum, Describable { + Any("Any", "Delay every packet regardless of type.", { true }), + KeepAlive("Keep-Alive", "Delay only KeepAlive packets (useful for simulating higher ping).", { it is KeepAliveC2SPacket }) + } + } diff --git a/src/main/kotlin/com/lambda/module/modules/network/PacketLogger.kt b/src/main/kotlin/com/lambda/module/modules/network/PacketLogger.kt index af0c0901b..4ca42ad88 100644 --- a/src/main/kotlin/com/lambda/module/modules/network/PacketLogger.kt +++ b/src/main/kotlin/com/lambda/module/modules/network/PacketLogger.kt @@ -49,6 +49,7 @@ object PacketLogger : Module( name = "PacketLogger", description = "Serializes network traffic and persists it for later analysis", tag = ModuleTag.NETWORK, + autoDisable = true ) { private val logToChat by setting("Log To Chat", false, "Log packets to chat") diff --git a/src/main/kotlin/com/lambda/module/modules/player/Freecam.kt b/src/main/kotlin/com/lambda/module/modules/player/Freecam.kt index fe83b8949..82d104d4b 100644 --- a/src/main/kotlin/com/lambda/module/modules/player/Freecam.kt +++ b/src/main/kotlin/com/lambda/module/modules/player/Freecam.kt @@ -52,6 +52,7 @@ object Freecam : Module( name = "Freecam", description = "Move your camera freely", tag = ModuleTag.PLAYER, + autoDisable = true, ) { private val speed by setting("Speed", 0.5, 0.1..1.0, 0.1) private val sprint by setting("Sprint Multiplier", 3.0, 0.1..10.0, 0.1, description = "Set below 1.0 to fly slower on sprint.") @@ -145,9 +146,5 @@ object Freecam : Module( .rayCast(reach, lerpPos) .orMiss // Can't be null (otherwise mc will spam "Null returned as 'hitResult', this shouldn't happen!") } - - listen { - disable() - } } } diff --git a/src/main/kotlin/com/lambda/module/modules/player/Replay.kt b/src/main/kotlin/com/lambda/module/modules/player/Replay.kt index 04ceaba03..53be575f1 100644 --- a/src/main/kotlin/com/lambda/module/modules/player/Replay.kt +++ b/src/main/kotlin/com/lambda/module/modules/player/Replay.kt @@ -87,6 +87,7 @@ object Replay : Module( name = "Replay", description = "Record gameplay actions and replay them like a TAS.", tag = ModuleTag.PLAYER, + autoDisable = true ) { private val record by setting("Record", KeyCode.R) private val play by setting("Play / Stop", KeyCode.C) diff --git a/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt b/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt index 7bd2fbe7a..51e253196 100644 --- a/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt +++ b/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt @@ -34,6 +34,7 @@ import com.lambda.util.Nameable */ data class ModuleTag(override val name: String) : Nameable { // Totally needs to be reworked + // ToDo: Add registry for tags companion object { val COMBAT = ModuleTag("Combat") val MOVEMENT = ModuleTag("Movement") @@ -45,5 +46,17 @@ data class ModuleTag(override val name: String) : Nameable { val HUD = ModuleTag("Hud") val defaults = setOf(COMBAT, MOVEMENT, RENDER, PLAYER, NETWORK, DEBUG, CLIENT, HUD) + + val shownTags = defaults.toMutableSet() + + fun toggleTag(tag: ModuleTag) { + if (shownTags.contains(tag)) { + shownTags.remove(tag) + } else { + shownTags.add(tag) + } + } + + fun isTagShown(tag: ModuleTag) = shownTags.contains(tag) } } diff --git a/src/main/kotlin/com/lambda/util/Diagnostics.kt b/src/main/kotlin/com/lambda/util/Diagnostics.kt new file mode 100644 index 000000000..e773d2bfb --- /dev/null +++ b/src/main/kotlin/com/lambda/util/Diagnostics.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.util + +import com.lambda.module.ModuleRegistry.modules + +object Diagnostics { + // ToDo: Expand this to include more information like version, etc. + fun gatherDiagnostics() = buildString { + modules.filter { it.isEnabled } + .forEach { module -> + append("\t${module.name}") + module.settings + .filter { it.isModified } + .forEach { setting -> + append("\t\t${setting.name} -> ${setting.value}") + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/util/StringUtils.kt b/src/main/kotlin/com/lambda/util/StringUtils.kt index 7b1b4f845..27c8826b7 100644 --- a/src/main/kotlin/com/lambda/util/StringUtils.kt +++ b/src/main/kotlin/com/lambda/util/StringUtils.kt @@ -69,7 +69,7 @@ object StringUtils { * @receiver The string to compare. * @param rhs The string to compare against. */ - private fun CharSequence.levenshteinDistance(rhs: CharSequence): Int { + fun CharSequence.levenshteinDistance(rhs: CharSequence): Int { if (this == rhs) { return 0 } diff --git a/src/main/kotlin/com/lambda/util/WindowIcons.kt b/src/main/kotlin/com/lambda/util/WindowIcons.kt index e1768b532..dbdd296d6 100644 --- a/src/main/kotlin/com/lambda/util/WindowIcons.kt +++ b/src/main/kotlin/com/lambda/util/WindowIcons.kt @@ -18,12 +18,16 @@ package com.lambda.util import com.lambda.Lambda.mc +import net.minecraft.client.util.MacWindowUtil import org.lwjgl.glfw.GLFW import org.lwjgl.glfw.GLFWImage import org.lwjgl.system.MemoryStack import org.lwjgl.system.MemoryUtil import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.nio.ByteBuffer +import javax.imageio.ImageIO object WindowIcons { /** @@ -86,19 +90,15 @@ object WindowIcons { } GLFW.GLFW_PLATFORM_COCOA -> { - // On macOS glfwSetWindowIcon is ignored; set dock icon instead if possible. - try { - val largest = iconPaths - .mapNotNull { runCatching { it.readImage() }.getOrNull() } - .maxByOrNull { it.width * it.height } - ?: return + val largest = iconPaths + .mapNotNull { runCatching { it.readImage() }.getOrNull() } + .maxByOrNull { it.width * it.height } + ?: return - // Try Minecraft's MacWindowUtil if present - val klass = Class.forName("net.minecraft.client.util.MacWindowUtil") - val method = klass.getMethod("setApplicationIconImage", BufferedImage::class.java) - method.invoke(null, largest) - } catch (_: Throwable) { - // Silently ignore if class isn't available; no safe fallback on macOS via GLFW. + MacWindowUtil.setApplicationIconImage { + val baos = ByteArrayOutputStream() + ImageIO.write(largest, "PNG", baos) + ByteArrayInputStream(baos.toByteArray()) } } diff --git a/src/main/resources/assets/lambda/fonts/MinecraftDefault-Regular.ttf b/src/main/resources/assets/lambda/fonts/MinecraftDefault-Regular.ttf new file mode 100644 index 000000000..7f3d0dcf6 Binary files /dev/null and b/src/main/resources/assets/lambda/fonts/MinecraftDefault-Regular.ttf differ diff --git a/src/main/resources/assets/lambda/textures/github_logo.png b/src/main/resources/assets/lambda/textures/github_logo.png new file mode 100644 index 000000000..50b817522 Binary files /dev/null and b/src/main/resources/assets/lambda/textures/github_logo.png differ diff --git a/src/main/resources/assets/lambda/textures/lambda_text_color.png b/src/main/resources/assets/lambda/textures/lambda_text_color.png new file mode 100644 index 000000000..b47f2f4ff Binary files /dev/null and b/src/main/resources/assets/lambda/textures/lambda_text_color.png differ diff --git a/src/main/resources/lambda.accesswidener b/src/main/resources/lambda.accesswidener index 9b6ded8d2..2e9104da7 100644 --- a/src/main/resources/lambda.accesswidener +++ b/src/main/resources/lambda.accesswidener @@ -8,6 +8,7 @@ accessible field net/minecraft/client/MinecraftClient uptimeInTicks J accessible field net/minecraft/client/input/Input movementVector Lnet/minecraft/util/math/Vec2f; accessible field net/minecraft/client/MinecraftClient renderTaskQueue Ljava/util/Queue; accessible field net/minecraft/client/option/KeyBinding boundKey Lnet/minecraft/client/util/InputUtil$Key; +accessible method net/minecraft/client/MinecraftClient getWindowTitle ()Ljava/lang/String; # World accessible field net/minecraft/client/world/ClientWorld entityManager Lnet/minecraft/world/entity/ClientEntityManager; @@ -43,6 +44,7 @@ accessible field net/minecraft/entity/EntityEquipment map Ljava/util/EnumMap; accessible method net/minecraft/entity/LivingEntity getHandSwingDuration ()I accessible method net/minecraft/client/network/ClientPlayerInteractionManager syncSelectedSlot ()V accessible method net/minecraft/util/math/Direction listClosest (Lnet/minecraft/util/math/Direction;Lnet/minecraft/util/math/Direction;Lnet/minecraft/util/math/Direction;)[Lnet/minecraft/util/math/Direction; +accessible field net/minecraft/client/network/ClientPlayerInteractionManager gameMode Lnet/minecraft/world/GameMode; # Camera accessible method net/minecraft/client/render/Camera setPos (DDD)V @@ -90,6 +92,14 @@ accessible method net/minecraft/util/math/Vec3i setX (I)Lnet/minecraft/util/math accessible method net/minecraft/util/math/Vec3i setY (I)Lnet/minecraft/util/math/Vec3i; accessible method net/minecraft/util/math/Vec3i setZ (I)Lnet/minecraft/util/math/Vec3i; +# Debug +accessible field net/minecraft/client/gui/hud/DebugHud showDebugHud Z +accessible field net/minecraft/client/gui/hud/DebugHud renderingChartVisible Z +accessible field net/minecraft/client/gui/hud/DebugHud renderingAndTickChartsVisible Z +accessible field net/minecraft/client/gui/hud/DebugHud packetSizeAndPingChartsVisible Z +accessible field net/minecraft/client/render/debug/DebugRenderer showChunkBorder Z +accessible field net/minecraft/client/render/debug/DebugRenderer showOctree Z + # Other accessible field net/minecraft/structure/StructureTemplate blockInfoLists Ljava/util/List; accessible method net/minecraft/item/BlockItem getPlacementState (Lnet/minecraft/item/ItemPlacementContext;)Lnet/minecraft/block/BlockState; diff --git a/src/main/resources/lambda.mixins.common.json b/src/main/resources/lambda.mixins.common.json index 72a4d2a99..d306b5035 100644 --- a/src/main/resources/lambda.mixins.common.json +++ b/src/main/resources/lambda.mixins.common.json @@ -47,6 +47,7 @@ "render.PlayerListHudMixin", "render.RenderLayersMixin", "render.ScreenHandlerMixin", + "render.ScreenMixin", "render.SplashOverlayMixin", "render.SplashOverlayMixin$LogoTextureMixin", "render.TooltipComponentMixin",