diff --git a/build.gradle.kts b/build.gradle.kts index d33ca031d..264317c99 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,30 @@ val lwjglVersion: String by project val sodiumVersion: String by project val litematicaVersion: String by project val maLiLibVersion: String by project +val composeVersion: String by project +val skikoVersion: String by project + +// Skiko publishes its natives as one artifact per platform, and none of them are pulled in +// transitively by Compose. Dev runs only need the host's, so resolve that here. +// TODO: production jars need every target bundled, or downloaded at runtime. +val skikoTarget = run { + val osName = System.getProperty("os.name").lowercase() + val archName = System.getProperty("os.arch").lowercase() + + val os = when { + osName.startsWith("windows") -> "windows" + osName.startsWith("mac") || osName.contains("darwin") -> "macos" + osName.contains("linux") -> "linux" + else -> throw GradleException("No skiko natives for host OS '$osName'") + } + val arch = when (archName) { + "x86_64", "amd64" -> "x64" + "aarch64", "arm64" -> "arm64" + else -> throw GradleException("No skiko natives for host architecture '$archName'") + } + + "$os-$arch" +} val libs = file("libs") val targets = listOf("fabric.mod.json") @@ -46,6 +70,8 @@ val replacements = file("gradle.properties").inputStream().use { stream -> plugins { kotlin("jvm") version "2.3.0" + kotlin("plugin.compose") version "2.3.0" + id("org.jetbrains.compose") version "1.7.0" id("org.jetbrains.dokka") version "2.1.0" id("fabric-loom") version "1.16-SNAPSHOT" id("com.gradleup.shadow") version "9.3.0" @@ -63,6 +89,11 @@ configurations.all { if (requested.group == "org.lwjgl") { useVersion(lwjglVersion) } + // The native artifact and the classes Compose pulls in transitively must agree, + // or skiko fails to find its library at runtime + if (requested.group == "org.jetbrains.skiko") { + useVersion(skikoVersion) + } } } @@ -72,6 +103,8 @@ repositories { maven("https://maven.2b2t.vc/releases") // Baritone maven("https://jitpack.io") // KDiscordIPC maven("https://api.modrinth.com/maven") + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") // Compose Multiplatform + google() // AndroidX dependencies (transitive from Compose) mavenCentral() // Allow the use of local libraries @@ -111,6 +144,12 @@ loom { property("org.lwjgl.util.DebugFunctions", "true") property("mixin.debug.export", "true") + // Skia refuses to initialise on macOS without both of these + if (skikoTarget.startsWith("macos")) { + property("skiko.renderApi", "OPENGL") + property("skiko.macos.opengl.enabled", "true") + } + vmArgs("-XX:+HeapDumpOnOutOfMemoryError", "-XX:+CreateCoredumpOnCrash") programArgs("--username", "Steve", "--uuid", "8667ba71b85a4004af54457a9734eed7", "--accessToken", "****") } @@ -187,6 +226,31 @@ dependencies { modCompileOnly("maven.modrinth:malilib:$maLiLibVersion") modCompileOnly("maven.modrinth:litematica:$litematicaVersion") + shadowLib("org.jetbrains.compose.runtime:runtime-desktop:$composeVersion") { + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "org.jetbrains.skiko") + } + shadowLib("org.jetbrains.compose.ui:ui-desktop:$composeVersion") { + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "org.jetbrains.skiko") + } + shadowLib("org.jetbrains.compose.foundation:foundation-desktop:$composeVersion") { + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "org.jetbrains.skiko") + } + shadowLib("org.jetbrains.compose.material3:material3-desktop:$composeVersion") { + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "org.jetbrains.skiko") + } + + shadowLib("org.jetbrains.skiko:skiko-awt-runtime-$skikoTarget:$skikoVersion") + + implementation(compose.components.resources) + // DevLogin modRuntimeOnly("com.ptsmods:devlogin:3.5") @@ -198,6 +262,14 @@ dependencies { setupConfigurations() } +compose.resources { + customDirectory( + "main", + provider { layout.projectDirectory.dir("src/main/resources/assets/lambda/compose/") } + ) + packageOfResClass = "com.lambda.newui" +} + tasks { test { useJUnitPlatform() diff --git a/gradle.properties b/gradle.properties index 71b45c1cf..a597e98a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,7 +20,7 @@ modId=lambda modName=Lambda modVersion=0.1.0 modDescription=Minecraft utility mod for automation -modIcon=assets/lambda/textures/lambda.png +modIcon=assets/lambda/compose/drawable/lambda.png mavenGroup=com.lambda modAuthors=Constructor (Avanatiker), Blade, beanbag44, Emy @@ -38,6 +38,9 @@ ktorVersion=3.3.3 jacksonVersion=3.1.1 mockkVersion=1.14.7 spairVersion=1.90.0 +composeVersion=1.8.0 +# Must match the skiko version Compose depends on transitively +skikoVersion=0.9.4 # Fabric https://fabricmc.net/develop/ fabricLoaderVersion=0.18.3 diff --git a/settings.gradle.kts b/settings.gradle.kts index 1a052703d..fafcae91d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ pluginManagement { repositories { maven("https://maven.fabricmc.net/") maven("https://jitpack.io") + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") mavenCentral() gradlePluginPortal() } diff --git a/src/main/java/com/lambda/mixin/render/ConnectScreenMixin.java b/src/main/java/com/lambda/mixin/render/ConnectScreenMixin.java index 96c877fa1..c9ce6f394 100644 --- a/src/main/java/com/lambda/mixin/render/ConnectScreenMixin.java +++ b/src/main/java/com/lambda/mixin/render/ConnectScreenMixin.java @@ -31,7 +31,6 @@ @Mixin(value = ConnectScreen.class) public class ConnectScreenMixin { - @Inject(method = "connect(Lnet/minecraft/client/MinecraftClient;Lnet/minecraft/client/network/ServerAddress;Lnet/minecraft/client/network/ServerInfo;Lnet/minecraft/client/network/CookieStorage;)V", at= @At("HEAD")) private void connectHead(MinecraftClient client, ServerAddress address, ServerInfo info, CookieStorage cookieStorage, CallbackInfo ci) { AutoDisconnect.INSTANCE.setLastReconnectTarget(new MultiplayerReconnectTarget(address, info, cookieStorage)); diff --git a/src/main/java/com/lambda/mixin/render/SplashOverlayMixin.java b/src/main/java/com/lambda/mixin/render/SplashOverlayMixin.java index eb42468e1..5ed4e3f02 100644 --- a/src/main/java/com/lambda/mixin/render/SplashOverlayMixin.java +++ b/src/main/java/com/lambda/mixin/render/SplashOverlayMixin.java @@ -45,7 +45,7 @@ public class SplashOverlayMixin { @Inject(method = "", at = @At("RETURN")) private void onInit(CallbackInfo ci) { - LOGO = Identifier.of("lambda", "textures/lambda_banner.png"); + LOGO = Identifier.of("lambda", "compose/drawable/lambda_banner.png"); } @WrapOperation(method = "render", at = @At(value = "INVOKE", target = "Ljava/util/function/IntSupplier;getAsInt()I")) @@ -61,7 +61,7 @@ public LogoTextureMixin(Identifier location) { @WrapOperation(method = "loadContents", at = @At(value = "INVOKE", target = "Lnet/minecraft/resource/ResourceFactory;open(Lnet/minecraft/util/Identifier;)Ljava/io/InputStream;")) InputStream wrapLoadTextureData(ResourceFactory instance, Identifier id, Operation original) { - return LambdaResourceKt.getStream("textures/lambda_banner.png"); + return LambdaResourceKt.getStream("compose/drawable/lambda_banner.png"); } } } diff --git a/src/main/kotlin/com/lambda/config/Config.kt b/src/main/kotlin/com/lambda/config/Config.kt index fe1d82ba8..58dd81c3d 100644 --- a/src/main/kotlin/com/lambda/config/Config.kt +++ b/src/main/kotlin/com/lambda/config/Config.kt @@ -17,6 +17,7 @@ package com.lambda.config +import androidx.compose.ui.graphics.Color import com.lambda.Lambda.typeFactory import com.lambda.config.ConfigLoader.configs import com.lambda.config.entries.ConfigEntryDsl @@ -39,6 +40,7 @@ import com.lambda.config.settings.complex.Bind import com.lambda.config.settings.complex.BlockPosSetting import com.lambda.config.settings.complex.BlockSetting import com.lambda.config.settings.complex.ColorSetting +import com.lambda.config.settings.complex.KColorSetting import com.lambda.config.settings.complex.KeybindSetting import com.lambda.config.settings.complex.Vec3dSetting import com.lambda.config.settings.numeric.DoubleSetting @@ -55,7 +57,7 @@ import net.minecraft.item.Item import net.minecraft.registry.Registries import net.minecraft.util.math.BlockPos import net.minecraft.util.math.Vec3d -import java.awt.Color +import java.awt.Color as JColor import kotlin.reflect.KClass import kotlin.reflect.KProperty import kotlin.reflect.full.declaredMemberProperties @@ -347,11 +349,19 @@ abstract class Config( @ConfigEntryDsl fun setting( name: String, - defaultValue: Color, + defaultValue: JColor, description: String = "", visibility: () -> Boolean = { true } ) = setting(name) { layer -> ColorSetting(name, description, this, layer, visibility, defaultValue) } + @ConfigEntryDsl + fun setting( + name: String, + defaultValue: Color, + description: String = "", + visibility: () -> Boolean = { true } + ) = setting(name) { layer -> KColorSetting(name, description, this, layer, visibility, defaultValue) } + @ConfigEntryDsl fun setting( name: String, diff --git a/src/main/kotlin/com/lambda/config/settings/complex/KColorSetting.kt b/src/main/kotlin/com/lambda/config/settings/complex/KColorSetting.kt new file mode 100644 index 000000000..b7e9c14ff --- /dev/null +++ b/src/main/kotlin/com/lambda/config/settings/complex/KColorSetting.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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.config.settings.complex + +import androidx.compose.ui.graphics.Color +import com.lambda.brigadier.argument.integer +import com.lambda.brigadier.argument.value +import com.lambda.brigadier.execute +import com.lambda.brigadier.optional +import com.lambda.brigadier.required +import com.lambda.config.Config +import com.lambda.config.entries.Setting +import com.lambda.config.entries.SettingEntryLayer +import com.lambda.util.extension.CommandBuilder +import net.minecraft.command.CommandRegistryAccess + +class KColorSetting( + name: String, + description: String, + config: Config, + layer: SettingEntryLayer, + visibility: () -> Boolean, + defaultValue: Color +) : Setting(name, description, defaultValue, layer, config, visibility) { + override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { + required(integer("Red", 0, 255)) { red -> + required(integer("Green", 0, 255)) { green -> + required(integer("Blue", 0, 255)) { blue -> + optional(integer("Alpha", 0, 255)) { alpha -> + execute { + val alphaValue = alpha?.let { it().value() } ?: 255 + trySetValue(Color(red().value(), green().value(), blue().value(), alphaValue)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/graphics/text/FontHandler.kt b/src/main/kotlin/com/lambda/graphics/text/FontHandler.kt index edb196076..68022b7b9 100644 --- a/src/main/kotlin/com/lambda/graphics/text/FontHandler.kt +++ b/src/main/kotlin/com/lambda/graphics/text/FontHandler.kt @@ -44,9 +44,9 @@ object FontHandler : Loadable, Config( private val loadedAtlases = ConcurrentHashMap() private val baseFonts = listOf( - FontInfo("Minecraft Default", "fonts/MinecraftDefault-Regular.ttf"), - FontInfo("FiraSans Regular", "fonts/FiraSans-Regular.ttf"), - FontInfo("FiraSans Bold", "fonts/FiraSans-Bold.ttf") + FontInfo("Minecraft Default", "font/MinecraftDefault-Regular.ttf"), + FontInfo("FiraSans Regular", "font/FiraSans-Regular.ttf"), + FontInfo("FiraSans Bold", "font/FiraSans-Bold.ttf") ) private val discoveredFonts = baseFonts.toMutableList() diff --git a/src/main/kotlin/com/lambda/gui/DearImGui.kt b/src/main/kotlin/com/lambda/gui/DearImGui.kt index fba0d5cf1..ed1ae6493 100644 --- a/src/main/kotlin/com/lambda/gui/DearImGui.kt +++ b/src/main/kotlin/com/lambda/gui/DearImGui.kt @@ -67,9 +67,9 @@ object DearImGui : Loadable { val size = BASE_FONT_SCALE * scale with(io.fonts) { clear() - addFontFromMemoryTTF("fonts/FiraSans-Regular.ttf".stream.readAllBytes(), size, fontConfig, glyphRanges) - addFontFromMemoryTTF("fonts/FiraSans-Bold.ttf".stream.readAllBytes(), size, fontConfig, glyphRanges) - addFontFromMemoryTTF("fonts/MinecraftDefault-Regular.ttf".stream.readAllBytes(), size, fontConfig, glyphRanges) + addFontFromMemoryTTF("font/FiraSans-Regular.ttf".stream.readAllBytes(), size, fontConfig, glyphRanges) + addFontFromMemoryTTF("font/FiraSans-Bold.ttf".stream.readAllBytes(), size, fontConfig, glyphRanges) + addFontFromMemoryTTF("font/MinecraftDefault-Regular.ttf".stream.readAllBytes(), size, fontConfig, glyphRanges) build() } implGl3.createFontsTexture() diff --git a/src/main/kotlin/com/lambda/gui/MenuBar.kt b/src/main/kotlin/com/lambda/gui/MenuBar.kt index 2b205382b..56023e868 100644 --- a/src/main/kotlin/com/lambda/gui/MenuBar.kt +++ b/src/main/kotlin/com/lambda/gui/MenuBar.kt @@ -63,9 +63,9 @@ import java.util.* object MenuBar { private var aboutRequested = false var newConfigName = "" - val headerLogo = upload("textures/lambda_text_color.png") - val lambdaLogo = upload("textures/lambda.png") - val githubLogo = upload("textures/github_logo.png") + val headerLogo = upload("drawable/lambda_text_color.png") + val lambdaLogo = upload("drawable/lambda.png") + val githubLogo = upload("drawable/github_logo.png") var height = 0f diff --git a/src/main/kotlin/com/lambda/gui/components/ClickGuiLayout.kt b/src/main/kotlin/com/lambda/gui/components/ClickGuiLayout.kt index 156fb6062..5b02e3577 100644 --- a/src/main/kotlin/com/lambda/gui/components/ClickGuiLayout.kt +++ b/src/main/kotlin/com/lambda/gui/components/ClickGuiLayout.kt @@ -26,7 +26,6 @@ import com.lambda.config.settings.complex.KeybindSetting.Companion.onPressUnsafe import com.lambda.core.Loadable import com.lambda.event.events.GuiEvent import com.lambda.event.listener.UnsafeListener.Companion.listenUnsafe -import com.lambda.gui.DearImGui import com.lambda.gui.LambdaScreen import com.lambda.gui.MenuBar import com.lambda.gui.MenuBar.buildMenuBar @@ -49,6 +48,7 @@ import com.lambda.module.modules.client.Client import com.lambda.module.modules.combat.autodisconnect.AutoDisconnectScreen import com.lambda.module.tag.ModuleTag import com.lambda.module.tag.ModuleTag.Companion.shownTags +import com.lambda.newui.ComposeClickGui import com.lambda.sound.LambdaSound import com.lambda.sound.SoundHandler.play import com.lambda.util.Describable @@ -75,11 +75,11 @@ object ClickGuiLayout : Loadable, Config( var developerMode = false // onPressUnsafe (not onPress) so the GUI can also be toggled from menu screens // (title, multiplayer, world-select) where there is no SafeContext, not just in-game. + // Temporarily delegates to ComposeClickGui instead of the ImGui toggle. val keybind by setting("Keybind", KeyCode.Y, screenCheck = false) .onPressUnsafe { - if (DearImGui.io.wantTextInput) return@onPressUnsafe - if (!open && !canOpenOver(mc.currentScreen)) return@onPressUnsafe - toggle() + if (!ComposeClickGui.open && !canOpenOver(mc.currentScreen)) return@onPressUnsafe + ComposeClickGui.toggle() } /** diff --git a/src/main/kotlin/com/lambda/module/hud/Watermark.kt b/src/main/kotlin/com/lambda/module/hud/Watermark.kt index fa46da254..8ca398524 100644 --- a/src/main/kotlin/com/lambda/module/hud/Watermark.kt +++ b/src/main/kotlin/com/lambda/module/hud/Watermark.kt @@ -29,7 +29,7 @@ object Watermark : HudModule( tag = ModuleTag.HUD, enabledByDefault = true, ) { - private val texture = upload("textures/lambda.png") + private val texture = upload("drawable/lambda.png") private val scale by setting("Scale", 0.15f, 0.01f..1f, 0.01f) override fun ImGuiBuilder.buildLayout() { diff --git a/src/main/kotlin/com/lambda/module/modules/client/Style.kt b/src/main/kotlin/com/lambda/module/modules/client/Style.kt new file mode 100644 index 000000000..41546ca36 --- /dev/null +++ b/src/main/kotlin/com/lambda/module/modules/client/Style.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2026 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.module.modules.client + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import com.lambda.config.Config +import com.lambda.config.ConfigBlock +import com.lambda.config.Group +import com.lambda.module.Module +import com.lambda.module.tag.ModuleTag + +object Style : Module( + name = "Style", + description = "Controls the style of the ui", + tag = ModuleTag.CLIENT +) { + const val DARK_THEME_GROUP = "Dark Theme" + const val LIGHT_THEME_GROUP = "Light Theme" + + @Group(DARK_THEME_GROUP) val darkTheme by configBlock( + LambdaColorSettings( + this, + true, + primary = Color(100, 180, 255), + secondary = Color(225, 130, 225), + surface = Color(18, 0, 8), + surfaceVariant = Color(35, 3, 18), + onSurface = Color(240, 240, 245), + onSurfaceVariant = Color(140, 140, 150), + headerBg = Color(130, 5, 55), + onHeaderBg = Color(240, 240, 245), + moduleEnabled = Color(88, 0, 35, 255), + onModuleEnabled = Color(240, 240, 245), + moduleDisabled = Color(40, 5, 22, 200), + border = Color(140, 15, 65), + textShadow = Color(0, 0, 0, 180), + ) + ) + + @Group(LIGHT_THEME_GROUP) val lightTheme by configBlock( + LambdaColorSettings( + this, + false, + primary = Color(0, 100, 180), + secondary = Color(160, 40, 155), + surface = Color(250, 244, 246), + surfaceVariant = Color(240, 226, 232), + onSurface = Color(26, 10, 16), + onSurfaceVariant = Color(110, 92, 100), + headerBg = Color(196, 42, 94), + onHeaderBg = Color(255, 250, 252), + moduleEnabled = Color(248, 200, 216, 255), + onModuleEnabled = Color(74, 6, 30), + moduleDisabled = Color(236, 228, 231, 200), + border = Color(198, 118, 148), + textShadow = Color(0, 0, 0, 60), + ) + ) +} + +class LambdaColorSettings( + override val c: Config, + private val dark: Boolean, + primary: Color, + secondary: Color, + surface: Color, + surfaceVariant: Color, + onSurface: Color, + onSurfaceVariant: Color, + headerBg: Color, + onHeaderBg: Color, + moduleEnabled: Color, + onModuleEnabled: Color, + moduleDisabled: Color, + border: Color, + textShadow: Color, +) : ConfigBlock { + val primary by c.setting("Primary", primary) + val secondary by c.setting("Secondary", secondary) + val surface by c.setting("Surface", surface) + val surfaceVariant by c.setting("Surface Variant", surfaceVariant) + val onSurface by c.setting("On Surface", onSurface) + val onSurfaceVariant by c.setting("On Surface Variant", onSurfaceVariant) + val headerBg by c.setting("Header Bg", headerBg) + val onHeaderBg by c.setting("On Header Bg", onHeaderBg) + val moduleEnabled by c.setting("Module Enabled", moduleEnabled) + val onModuleEnabled by c.setting("On Module Enabled", onModuleEnabled) + val moduleDisabled by c.setting("Module Disabled", moduleDisabled) + val border by c.setting("Border", border) + val textShadow by c.setting("Text Shadow", textShadow) + + fun toLambdaPalette() = + (if (dark) darkColorScheme() else lightColorScheme()).copy( + primary = primary, + secondary = secondary, + surface = surface, + surfaceVariant = surfaceVariant, + onSurface = onSurface, + onSurfaceVariant = onSurfaceVariant, + outline = border, + primaryContainer = headerBg, + onPrimaryContainer = onHeaderBg, + secondaryContainer = moduleEnabled, + onSecondaryContainer = onModuleEnabled, + surfaceContainer = moduleDisabled, + scrim = textShadow, + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt b/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt index caab346cc..243ccbfdd 100644 --- a/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt +++ b/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt @@ -49,16 +49,38 @@ data class ModuleTag(override val name: String) : Nameable { val defaults = setOf(COMBAT, MOVEMENT, RENDER, PLAYER, WORLD, NETWORK, CHAT, CLIENT, HUD) - val shownTags = defaults.toMutableSet() + private val mutableShownTags = defaults.toMutableSet() + private val shownTagListeners = mutableListOf<(Set) -> Unit>() + + /** + * Read-only view of the tags the ClickGui should display. + * + * Mutate only through [toggleTag] — direct mutation would skip the + * [onShownTagsChanged] notification that retained-state GUIs rely on to + * know when to redraw. + */ + val shownTags: Set get() = mutableShownTags + + /** + * Registers [block] to run whenever [shownTags] changes. + * + * Listeners are never removed, so only register from a permanent owner + * (a [com.lambda.core.Loadable], an object, or a cached-per-key registry). + */ + fun onShownTagsChanged(block: (Set) -> Unit) { + shownTagListeners.add(block) + } fun toggleTag(tag: ModuleTag) { - if (shownTags.contains(tag)) { - shownTags.remove(tag) + if (mutableShownTags.contains(tag)) { + mutableShownTags.remove(tag) } else { - shownTags.add(tag) + mutableShownTags.add(tag) } + val snapshot = mutableShownTags.toSet() + shownTagListeners.forEach { it(snapshot) } } - fun isTagShown(tag: ModuleTag) = shownTags.contains(tag) + fun isTagShown(tag: ModuleTag) = mutableShownTags.contains(tag) } } diff --git a/src/main/kotlin/com/lambda/newui/ClickGuiContent.kt b/src/main/kotlin/com/lambda/newui/ClickGuiContent.kt new file mode 100644 index 000000000..510d2ab2b --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ClickGuiContent.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 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.newui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lambda.module.tag.ModuleTag +import com.lambda.newui.components.CategoryPanel +import com.lambda.newui.state.LambdaState.observeShownTags +import com.lambda.newui.theme.LambdaTheme +import org.jetbrains.compose.resources.painterResource + +/** + * Top-level composable for the Lambda click GUI. + * + * Renders a row of [CategoryPanel]s — one per shown [ModuleTag]. Each panel lists its + * modules as clickable toggle cards. Panels are individually draggable by their header. + * Most recently interacted panel renders on top of others. + * + * The shown-tag set is observed, so toggling a category from the ImGui menu bar adds or + * removes its panel live. + */ +@Composable +fun ClickGuiContent() { + LambdaTheme { + val shownTags by observeShownTags() + + // Focus history, most recently focused last. Derived from rather than mirroring + // the shown set, so it tolerates tags appearing and disappearing: stale entries + // are simply never looked up, and a tag that has never been focused is absent. + val focusOrder = remember { mutableStateListOf() } + + // Never-focused tags yield indexOf == -1 and so sort to the front, i.e. render + // beneath focused ones. sortedBy is stable, so they keep shownTags order among + // themselves instead of collapsing onto a shared z-index. + val zIndexOf = shownTags + .sortedBy { focusOrder.indexOf(it) } + .withIndex() + .associate { (index, tag) -> tag to index.toFloat() } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .background(colorScheme.background) + .border(BorderStroke(1.dp, colorScheme.outline)) + ) { + Image( + painter = painterResource(Res.drawable.lambda), + contentDescription = "Lambda logo" + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(3.dp, Alignment.CenterHorizontally), + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .offset(y = 100.dp), + ) { + // Layout order stays the shown-tag order so panels don't jump around + // horizontally when one is focused; only zIndex reacts to focus. + shownTags.forEach { tag -> + // Keyed so a tag being shown or hidden doesn't shift the panels after it + // onto the wrong remembered drag offset and expanded state. + key(tag) { + CategoryPanel( + tag = tag, + zIndex = zIndexOf[tag] ?: 0f, + onFocus = { + focusOrder.remove(tag) + focusOrder.add(tag) // Move to end = highest z-index + } + ) + } + } + } + } + } +} diff --git a/src/main/kotlin/com/lambda/newui/ComposeClickGui.kt b/src/main/kotlin/com/lambda/newui/ComposeClickGui.kt new file mode 100644 index 000000000..d8f28c539 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ComposeClickGui.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 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.newui + +import com.lambda.Lambda.mc +import com.lambda.core.Loadable +import com.lambda.event.events.GuiEvent +import com.lambda.event.listener.UnsafeListener.Companion.listenUnsafe +import com.lambda.gui.OverlayBackgroundScreen +import com.lambda.module.modules.client.Client +import com.lambda.newui.theme.SystemThemeTracker +import com.lambda.sound.LambdaSound +import com.lambda.sound.SoundHandler.play + +object ComposeClickGui : Loadable { + var open = false + private set + + override fun load(): String { + return "Loaded ComposeClickGui" + } + + init { + listenUnsafe { + if (!open) return@listenUnsafe + ComposeRenderer.render() + } + } + + fun toggle() { + if (open) ComposeScreen.close() + else { + val current = mc.currentScreen + if (current is ComposeScreen) return + if (Client.clientSounds) LambdaSound.ModuleOn.play() + + ComposeRenderer.initialize() + // The GUI can have been closed across an OS theme change, and polling only runs + // while it is open, so re-read before the first frame rather than after it. + SystemThemeTracker.refresh() + + ComposeScreen.parentScreen = current + (current as? OverlayBackgroundScreen)?.onOverlaidByGui() + mc.setScreen(ComposeScreen) + open = true + } + } + + fun close() { + if (Client.clientSounds) LambdaSound.ModuleOff.play() + open = false + } +} diff --git a/src/main/kotlin/com/lambda/newui/ComposeRenderer.kt b/src/main/kotlin/com/lambda/newui/ComposeRenderer.kt new file mode 100644 index 000000000..efbe88f8a --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ComposeRenderer.kt @@ -0,0 +1,333 @@ +/* + * Copyright 2026 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.newui + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.scene.CanvasLayersComposeScene +import androidx.compose.ui.scene.ComposeScene +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import com.lambda.Lambda.LOG +import com.lambda.Lambda.mc +import com.lambda.event.events.ClientEvent +import com.lambda.event.listener.SafeListener.Companion.listen +import com.lambda.newui.theme.SystemThemeTracker +import kotlinx.coroutines.Dispatchers +import org.jetbrains.skia.BackendRenderTarget +import org.jetbrains.skia.ColorSpace +import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.FramebufferFormat +import org.jetbrains.skia.Surface +import org.jetbrains.skia.SurfaceColorFormat +import org.jetbrains.skia.SurfaceOrigin +import org.lwjgl.opengl.GL11 +import org.lwjgl.opengl.GL12 +import org.lwjgl.opengl.GL13 +import org.lwjgl.opengl.GL14 +import org.lwjgl.opengl.GL15 +import org.lwjgl.opengl.GL20 +import org.lwjgl.opengl.GL30 + +@OptIn(InternalComposeUiApi::class) +object ComposeRenderer { + private var directContext: DirectContext? = null + private var previousRenderTarget: BackendRenderTarget? = null + private var surface: Surface? = null + private var scene: ComposeScene? = null + + private var currentWidth = 0 + private var currentHeight = 0 + private var initialized = false + + // Pending input events + private var mouseX = 0f + private var mouseY = 0f + private var buttonsDown = 0 + + fun initialize() { + if (initialized) return + + scene = CanvasLayersComposeScene( + coroutineContext = Dispatchers.Default, + density = Density(3f), + invalidate = {} + ).apply { + setContent { + ClickGuiContent() + } + } + + listen { + destroy() + } + + initialized = true + LOG.info("Compose renderer initialized") + } + + fun render() { + if (!initialized || scene == null) return + + // Throttled internally, and only reached while the GUI is open, so the OS theme is + // never queried when nothing is on screen. + SystemThemeTracker.poll() + + val width = mc.window.framebufferWidth + val height = mc.window.framebufferHeight + if (width <= 0 || height <= 0) return + + val glState = saveGLState() + resetPixelStore() + + try { + val directContext = directContext + ?: DirectContext.makeGL().also { context -> + directContext = context + } + + directContext.resetGLAll() + + if (currentWidth != width || currentHeight != height) { + previousRenderTarget?.close() + surface?.close() + + val fbId = GL11.glGetInteger(GL30.GL_FRAMEBUFFER_BINDING) + val currentTarget = + BackendRenderTarget.makeGL( + width, height, + sampleCnt = 0, + stencilBits = 8, + fbId, + FramebufferFormat.GR_GL_RGBA8 + ).also { target -> + previousRenderTarget = target + } + + surface = Surface.makeFromBackendRenderTarget( + directContext, + currentTarget, + SurfaceOrigin.BOTTOM_LEFT, + SurfaceColorFormat.RGBA_8888, + ColorSpace.sRGB + ) + + currentWidth = width + currentHeight = height + + scene?.size = IntSize(width, height) + } + + val canvas = surface?.canvas ?: return + + scene?.render(canvas.asComposeCanvas(), System.nanoTime()) + + surface?.flushAndSubmit() + directContext.flush() + } catch (e: Exception) { + LOG.error("Error rendering Compose UI", e) + } finally { + restoreGLState(glState) + } + } + + fun sendMouseMove(x: Double, y: Double) { + val scale = mc.window.scaleFactor.toFloat() + mouseX = (x * scale).toFloat() + mouseY = (y * scale).toFloat() + scene?.sendPointerEvent( + eventType = PointerEventType.Move, + position = Offset(mouseX, mouseY), + buttons = PointerButtons(buttonsDown), + button = PointerButton.Primary, + type = PointerType.Mouse + ) + } + + fun sendMousePress(x: Double, y: Double, button: Int) { + val scale = mc.window.scaleFactor.toFloat() + mouseX = (x * scale).toFloat() + mouseY = (y * scale).toFloat() + buttonsDown = buttonsDown or (1 shl button) + val pointerButton = when (button) { + 0 -> PointerButton.Primary + 1 -> PointerButton.Secondary + 2 -> PointerButton.Tertiary + else -> PointerButton.Primary + } + scene?.sendPointerEvent( + eventType = PointerEventType.Press, + position = Offset(mouseX, mouseY), + buttons = PointerButtons(buttonsDown), + button = pointerButton, + type = PointerType.Mouse + ) + } + + fun sendMouseRelease(x: Double, y: Double, button: Int) { + val scale = mc.window.scaleFactor.toFloat() + mouseX = (x * scale).toFloat() + mouseY = (y * scale).toFloat() + buttonsDown = buttonsDown and (1 shl button).inv() + val pointerButton = when (button) { + 0 -> PointerButton.Primary + 1 -> PointerButton.Secondary + 2 -> PointerButton.Tertiary + else -> PointerButton.Primary + } + scene?.sendPointerEvent( + eventType = PointerEventType.Release, + position = Offset(mouseX, mouseY), + buttons = PointerButtons(buttonsDown), + button = pointerButton, + type = PointerType.Mouse + ) + } + + fun sendMouseScroll(x: Double, y: Double, deltaX: Double, deltaY: Double) { + val scale = mc.window.scaleFactor.toFloat() + scene?.sendPointerEvent( + eventType = PointerEventType.Scroll, + position = Offset((x * scale).toFloat(), (y * scale).toFloat()), + scrollDelta = Offset(deltaX.toFloat(), deltaY.toFloat()), + buttons = PointerButtons(buttonsDown), + button = PointerButton.Primary, + type = PointerType.Mouse + ) + } + + fun destroy() { + scene?.close() + scene = null + surface?.close() + surface = null + previousRenderTarget?.close() + previousRenderTarget = null + directContext?.close() + directContext = null + currentWidth = 0 + currentHeight = 0 + initialized = false + } + + private data class GLState( + val activeTexture: Int, + val program: Int, + val texture2D: Int, + val arrayBuffer: Int, + val elementArrayBuffer: Int, + val vertexArray: Int, + val framebuffer: Int, + val blendSrcRgb: Int, + val blendDstRgb: Int, + val blendSrcAlpha: Int, + val blendDstAlpha: Int, + val blendEquationRgb: Int, + val blendEquationAlpha: Int, + val blendEnabled: Boolean, + val cullFaceEnabled: Boolean, + val depthTestEnabled: Boolean, + val stencilTestEnabled: Boolean, + val scissorTestEnabled: Boolean, + val unpackSwapBytes: Int, + val unpackLsbFirst: Int, + val unpackRowLength: Int, + val unpackImageHeight: Int, + val unpackSkipRows: Int, + val unpackSkipPixels: Int, + val unpackSkipImages: Int, + val unpackAlignment: Int, + ) + + private fun saveGLState(): GLState { + return GLState( + activeTexture = GL11.glGetInteger(GL13.GL_ACTIVE_TEXTURE), + program = GL11.glGetInteger(GL20.GL_CURRENT_PROGRAM), + texture2D = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D), + arrayBuffer = GL11.glGetInteger(GL15.GL_ARRAY_BUFFER_BINDING), + elementArrayBuffer = GL11.glGetInteger(GL15.GL_ELEMENT_ARRAY_BUFFER_BINDING), + vertexArray = GL11.glGetInteger(GL30.GL_VERTEX_ARRAY_BINDING), + framebuffer = GL11.glGetInteger(GL30.GL_FRAMEBUFFER_BINDING), + blendSrcRgb = GL11.glGetInteger(GL14.GL_BLEND_SRC_RGB), + blendDstRgb = GL11.glGetInteger(GL14.GL_BLEND_DST_RGB), + blendSrcAlpha = GL11.glGetInteger(GL14.GL_BLEND_SRC_ALPHA), + blendDstAlpha = GL11.glGetInteger(GL14.GL_BLEND_DST_ALPHA), + blendEquationRgb = GL11.glGetInteger(GL20.GL_BLEND_EQUATION_RGB), + blendEquationAlpha = GL11.glGetInteger(GL20.GL_BLEND_EQUATION_ALPHA), + blendEnabled = GL11.glIsEnabled(GL11.GL_BLEND), + cullFaceEnabled = GL11.glIsEnabled(GL11.GL_CULL_FACE), + depthTestEnabled = GL11.glIsEnabled(GL11.GL_DEPTH_TEST), + stencilTestEnabled = GL11.glIsEnabled(GL11.GL_STENCIL_TEST), + scissorTestEnabled = GL11.glIsEnabled(GL11.GL_SCISSOR_TEST), + unpackSwapBytes = GL11.glGetInteger(GL11.GL_UNPACK_SWAP_BYTES), + unpackLsbFirst = GL11.glGetInteger(GL11.GL_UNPACK_LSB_FIRST), + unpackRowLength = GL11.glGetInteger(GL11.GL_UNPACK_ROW_LENGTH), + unpackImageHeight = GL11.glGetInteger(GL12.GL_UNPACK_IMAGE_HEIGHT), + unpackSkipRows = GL11.glGetInteger(GL11.GL_UNPACK_SKIP_ROWS), + unpackSkipPixels = GL11.glGetInteger(GL11.GL_UNPACK_SKIP_PIXELS), + unpackSkipImages = GL11.glGetInteger(GL12.GL_UNPACK_SKIP_IMAGES), + unpackAlignment = GL11.glGetInteger(GL11.GL_UNPACK_ALIGNMENT), + ) + } + + /** + * Minecraft leaves the pixel-store unpack state non-zero after its own texture uploads. + * Skia would inherit it when uploading its glyph atlas, reading each glyph from the wrong + * offset and stride, which renders all text as garbage. Reset to the GL defaults first. + */ + private fun resetPixelStore() { + GL11.glPixelStorei(GL11.GL_UNPACK_SWAP_BYTES, 0) + GL11.glPixelStorei(GL11.GL_UNPACK_LSB_FIRST, 0) + GL11.glPixelStorei(GL11.GL_UNPACK_ROW_LENGTH, 0) + GL11.glPixelStorei(GL12.GL_UNPACK_IMAGE_HEIGHT, 0) + GL11.glPixelStorei(GL11.GL_UNPACK_SKIP_ROWS, 0) + GL11.glPixelStorei(GL11.GL_UNPACK_SKIP_PIXELS, 0) + GL11.glPixelStorei(GL12.GL_UNPACK_SKIP_IMAGES, 0) + GL11.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 4) + } + + private fun restoreGLState(state: GLState) { + GL20.glUseProgram(state.program) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, state.texture2D) + GL13.glActiveTexture(state.activeTexture) + GL30.glBindVertexArray(state.vertexArray) + GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, state.arrayBuffer) + GL15.glBindBuffer(GL15.GL_ELEMENT_ARRAY_BUFFER, state.elementArrayBuffer) + GL20.glBlendEquationSeparate(state.blendEquationRgb, state.blendEquationAlpha) + GL14.glBlendFuncSeparate(state.blendSrcRgb, state.blendDstRgb, state.blendSrcAlpha, state.blendDstAlpha) + if (state.blendEnabled) GL11.glEnable(GL11.GL_BLEND) else GL11.glDisable(GL11.GL_BLEND) + if (state.cullFaceEnabled) GL11.glEnable(GL11.GL_CULL_FACE) else GL11.glDisable(GL11.GL_CULL_FACE) + if (state.depthTestEnabled) GL11.glEnable(GL11.GL_DEPTH_TEST) else GL11.glDisable(GL11.GL_DEPTH_TEST) + if (state.stencilTestEnabled) GL11.glEnable(GL11.GL_STENCIL_TEST) else GL11.glDisable(GL11.GL_STENCIL_TEST) + if (state.scissorTestEnabled) GL11.glEnable(GL11.GL_SCISSOR_TEST) else GL11.glDisable(GL11.GL_SCISSOR_TEST) + GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, state.framebuffer) + GL11.glPixelStorei(GL11.GL_UNPACK_SWAP_BYTES, state.unpackSwapBytes) + GL11.glPixelStorei(GL11.GL_UNPACK_LSB_FIRST, state.unpackLsbFirst) + GL11.glPixelStorei(GL11.GL_UNPACK_ROW_LENGTH, state.unpackRowLength) + GL11.glPixelStorei(GL12.GL_UNPACK_IMAGE_HEIGHT, state.unpackImageHeight) + GL11.glPixelStorei(GL11.GL_UNPACK_SKIP_ROWS, state.unpackSkipRows) + GL11.glPixelStorei(GL11.GL_UNPACK_SKIP_PIXELS, state.unpackSkipPixels) + GL11.glPixelStorei(GL12.GL_UNPACK_SKIP_IMAGES, state.unpackSkipImages) + GL11.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, state.unpackAlignment) + } +} diff --git a/src/main/kotlin/com/lambda/newui/ComposeScreen.kt b/src/main/kotlin/com/lambda/newui/ComposeScreen.kt new file mode 100644 index 000000000..fe4665e7c --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ComposeScreen.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 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.newui + +import com.lambda.gui.components.ClickGuiLayout +import com.lambda.util.text.buildText +import com.lambda.util.text.literal +import net.minecraft.client.gui.Click +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.screen.Screen + +object ComposeScreen : Screen(buildText { literal("Lambda Screen") }) { + var parentScreen: Screen? = null + + override fun shouldPause() = false + + override fun removed() = ComposeClickGui.close() + + override fun render(context: DrawContext?, mouseX: Int, mouseY: Int, deltaTicks: Float) {} + + override fun renderBackground(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) { + if (parentScreen == null) { + // In-game: keep vanilla behavior (blur + darken the rendered world). + super.renderBackground(context, mouseX, mouseY, delta) + return + } + // Off-screen mouse coords keep the parent's widgets from showing a hover state. + parentScreen?.renderBackground(context, -1, -1, delta) + parentScreen?.render(context, -1, -1, delta) + context.drawDeferredElements() + } + + override fun resize(width: Int, height: Int) { + super.resize(width, height) + parentScreen?.resize(width, height) + } + + override fun close() { + val previous = parentScreen + parentScreen = null + client?.setScreen(previous) + } + + override fun applyBlur(context: DrawContext?) { + if (!ClickGuiLayout.backgroundBlur) return + super.applyBlur(context) + } + + override fun renderDarkening(context: DrawContext?) { + if (!ClickGuiLayout.backgroundDarkening) return + super.renderDarkening(context) + } + + // --- Input forwarding to Compose --- + + override fun mouseClicked(click: Click, doubled: Boolean): Boolean { + ComposeRenderer.sendMousePress(click.x(), click.y(), click.button()) + return true + } + + override fun mouseReleased(click: Click): Boolean { + ComposeRenderer.sendMouseRelease(click.x(), click.y(), click.button()) + return true + } + + override fun mouseMoved(mouseX: Double, mouseY: Double) { + ComposeRenderer.sendMouseMove(mouseX, mouseY) + } + + override fun mouseScrolled(mouseX: Double, mouseY: Double, horizontalAmount: Double, verticalAmount: Double): Boolean { + ComposeRenderer.sendMouseScroll(mouseX, mouseY, horizontalAmount, -verticalAmount) + return true + } +} diff --git a/src/main/kotlin/com/lambda/newui/components/CategoryPanel.kt b/src/main/kotlin/com/lambda/newui/components/CategoryPanel.kt new file mode 100644 index 000000000..22057eb21 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/components/CategoryPanel.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2026 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.newui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.lambda.module.ModuleRegistry +import com.lambda.module.tag.ModuleTag +import com.lambda.newui.state.LambdaState.observe +import kotlin.math.roundToInt + +private const val GRID_SIZE_DP = 4f + +private fun snapToGrid(value: Float, gridSize: Float): Float { + return (value / gridSize).roundToInt() * gridSize +} + +@Composable +fun CategoryPanel(tag: ModuleTag, zIndex: Float = 0f, onFocus: () -> Unit = {}) { + // ModuleRegistry.modules is fixed after load, so the tag filter can be cached. + // "Show In ClickGui" is a live setting, so it has to be observed on every module — + // reading it here re-runs this filter whenever any of them changes. + val tagged = remember(tag) { ModuleRegistry.modules.filter { it.tag == tag } } + val modules = tagged.filter { it.showInClickGui.observe().value } + if (modules.isEmpty()) return + + var expanded by remember { mutableStateOf(true) } + var dragOffset by remember { mutableStateOf(Offset.Zero) } + + val colors = MaterialTheme.colorScheme + val shape = RoundedCornerShape(2.dp) + + Column( + modifier = Modifier + .zIndex(zIndex) + .offset { + IntOffset( + snapToGrid(dragOffset.x, GRID_SIZE_DP * density).roundToInt(), + snapToGrid(dragOffset.y, GRID_SIZE_DP * density).roundToInt() + ) + } + .width(100.dp) + .clip(shape) + .background(colors.surface) + .border(1.dp, colors.outline, shape) + ) { + Row( + modifier = Modifier + .background(colors.primaryContainer) + .fillMaxWidth() + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { onFocus() }, + onDrag = { _, dragAmount -> + dragOffset += dragAmount + } + ) + } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.type == PointerEventType.Press) { + onFocus() + if (event.buttons.isSecondaryPressed) { + expanded = !expanded + } + } + } + } + } + .padding(horizontal = 5.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = tag.name, + fontSize = 10.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Bold, + color = colors.onPrimaryContainer, + style = TextStyle(shadow = Shadow(color = colors.scrim, offset = Offset(2f, 2f))) + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + modules.forEach { module -> + // Keyed because the list is now dynamic — `remember` is positional, + // so without a key a module appearing or disappearing would shift + // every card below it onto the wrong retained state. + key(module.name) { + ModuleCard(module) + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/newui/components/ModuleCard.kt b/src/main/kotlin/com/lambda/newui/components/ModuleCard.kt new file mode 100644 index 000000000..5e2d2d5a5 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/components/ModuleCard.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 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.newui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lambda.module.Module +import com.lambda.newui.state.LambdaState.observeEnabled + +/** + * A clickable card representing a single [Module]. + * Displays the module name, toggles on click, and animates the background color + * between its enabled and disabled states. + * + * The enabled flag is observed from the module itself, so the card stays correct when + * the module is toggled by a keybind, a command, or a config load rather than a click. + */ +@Composable +fun ModuleCard(module: Module) { + val enabled by module.observeEnabled() + + val colors = MaterialTheme.colorScheme + + val backgroundColor by animateColorAsState( + targetValue = if (enabled) colors.secondaryContainer else colors.surfaceContainer, + animationSpec = spring(), + label = "moduleCardBg" + ) + + val textColor = if (enabled) colors.onSecondaryContainer else colors.onSurfaceVariant + + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .clickable { module.toggle() } + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { + Text( + text = module.name, + fontSize = 9.sp, + lineHeight = 9.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TextStyle(shadow = Shadow(color = colors.scrim, offset = Offset(2f, 2f))) + ) + } +} diff --git a/src/main/kotlin/com/lambda/newui/state/LambdaState.kt b/src/main/kotlin/com/lambda/newui/state/LambdaState.kt new file mode 100644 index 000000000..9f6d8d8d5 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/state/LambdaState.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2026 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.newui.state + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import com.lambda.config.entries.Setting +import com.lambda.config.entries.Setting.Companion.onValueChangeUnsafe +import com.lambda.module.Module +import com.lambda.module.tag.ModuleTag +import java.util.concurrent.ConcurrentHashMap + +/** + * Bridges Lambda's push-based config system onto Compose's snapshot state. + * + * ImGui re-read every property every frame, so nothing had to be observable. Compose + * only redraws what it is told changed, so anything a composable reads must be backed + * by snapshot state or the UI silently goes stale — a module toggled by keybind, + * command, or config load would leave the GUI showing the old value. + * + * Use [observe] for any [Setting], and [observeEnabled] for a [Module]'s enabled flag + * (which is deliberately not exposed as a [Setting], to keep writes going through + * [Module.toggle] and its events). + * + * ### Why the states are cached rather than per-composition + * + * [Setting.listeners] has no removal API, and `Setting.setValue` iterates it without + * synchronization, so registering per-composition (in a `DisposableEffect`) would both + * leak a listener on every recomposition and risk a `ConcurrentModificationException` + * against a concurrent config write. Instead exactly one listener is registered per + * source, the first time it is observed, and the resulting state is cached forever. + * Settings and modules are process-lifetime singletons, so this holds no memory the + * client was not already holding. + * + * ### Threading + * + * Config writes arrive from arbitrary threads (commands, packet handlers, config load). + * Snapshot state writes are safe from any thread — they take the global snapshot lock — + * and the recomposer picks them up on its own dispatcher, so listeners write directly + * with no marshalling. + */ +object LambdaState { + private val settingStates = ConcurrentHashMap, MutableState<*>>() + private val enabledStates = ConcurrentHashMap>() + + private val shownTagsState: MutableState> = + mutableStateOf(ModuleTag.shownTags.toSet()).also { state -> + ModuleTag.onShownTagsChanged { tags -> state.value = tags } + } + + /** + * Observes this setting's value as snapshot state. Reading the returned state from a + * composable subscribes it to future changes. + */ + @Suppress("UNCHECKED_CAST") + fun Setting.observe(): State = + settingStates.computeIfAbsent(this) { _ -> + val state = mutableStateOf(value) + onValueChangeUnsafe { _, to -> state.value = to } + // The listener is registered after the initial read, so re-sync in case the + // value changed in between. + state.value = value + state + } as State + + /** + * Observes [Module.isEnabled] as snapshot state. + * + * Goes through [Module.onToggleUnsafe] rather than the backing setting so that the + * setting stays private and every write keeps flowing through + * [Module.enable]/[Module.disable] and their events. + */ + fun Module.observeEnabled(): State = + enabledStates.computeIfAbsent(this) { module -> + val state = mutableStateOf(module.isEnabled) + module.onToggleUnsafe { to -> state.value = to } + state.value = module.isEnabled + state + } + + /** Observes [ModuleTag.shownTags] as snapshot state. */ + fun observeShownTags(): State> = shownTagsState +} diff --git a/src/main/kotlin/com/lambda/newui/theme/LambdaTheme.kt b/src/main/kotlin/com/lambda/newui/theme/LambdaTheme.kt new file mode 100644 index 000000000..d097b0239 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/theme/LambdaTheme.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2026 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.newui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * The colors the Compose GUI draws with, named for what they paint rather than for + * Material3's slots. + * + * Lambda's surfaces are not M3 concepts — a category header and a module card have no slot + * of their own — so the palette is declared in Lambda's own terms here and projected onto + * M3 by [toColorScheme]. Components never read this directly; they read + * `MaterialTheme.colorScheme`, which is what lets the whole GUI swap palettes at once. + */ +data class LambdaPalette( + val primary: Color, + val secondary: Color, + val surface: Color, + val surfaceVariant: Color, + val onSurface: Color, + val onSurfaceVariant: Color, + /** Category header strip, and the text drawn on it. */ + val headerBg: Color, + val onHeaderBg: Color, + /** Module card while the module is on, and the text drawn on it. */ + val moduleEnabled: Color, + val onModuleEnabled: Color, + /** Module card while the module is off. Its text uses [onSurfaceVariant]. */ + val moduleDisabled: Color, + val border: Color, + /** Hard offset shadow behind GUI text, in the style of Minecraft's own font. */ + val textShadow: Color, +) + +/** The original crimson-on-near-black palette. Also the fallback when the OS has no preference. */ +val LambdaDarkPalette = LambdaPalette( + primary = Color(100, 180, 255), // Bright blue accent + secondary = Color(225, 130, 225), // Pink/magenta accent + surface = Color(18, 0, 8), // Deep dark background + surfaceVariant = Color(35, 3, 18), // Slightly lighter dark + onSurface = Color(240, 240, 245), // Near-white text + onSurfaceVariant = Color(140, 140, 150), // Dimmed text + headerBg = Color(130, 5, 55), + onHeaderBg = Color(240, 240, 245), + moduleEnabled = Color(88, 0, 35, 255), // Lifted crimson when module is on + onModuleEnabled = Color(240, 240, 245), + moduleDisabled = Color(40, 5, 22, 200), // Muted dark when module is off + border = Color(140, 15, 65), // Pink border — higher contrast + textShadow = Color(0, 0, 0, 180), +) + +/** + * Light counterpart to [LambdaDarkPalette]. + * + * Lightness is inverted but the crimson identity is not: the header stays a saturated + * crimson with near-white text on it, so the GUI still reads as the same thing rather than + * as a generic light theme. The enabled module card becomes a pale crimson wash — a tint + * light enough for dark text, since on a light surface an enabled row has to read as + * *lifted* rather than as *darkened*. + */ +val LambdaLightPalette = LambdaPalette( + primary = Color(0, 100, 180), // Deep blue — the dark palette's accent is too pale here + secondary = Color(160, 40, 155), + surface = Color(250, 244, 246), // Near-white, faintly warmed toward the crimson + surfaceVariant = Color(240, 226, 232), + onSurface = Color(26, 10, 16), // Near-black text + onSurfaceVariant = Color(110, 92, 100), // Dimmed text + headerBg = Color(196, 42, 94), + onHeaderBg = Color(255, 250, 252), + moduleEnabled = Color(248, 200, 216, 255), + onModuleEnabled = Color(74, 6, 30), + moduleDisabled = Color(236, 228, 231, 200), + border = Color(198, 118, 148), + textShadow = Color(0, 0, 0, 60), // Much softer — a hard black shadow muddies dark text +) + +/** + * Projects a [LambdaPalette] onto Material3's semantic slots. + * + * A few slots are chosen for the role they play rather than their name, since Lambda's + * surfaces have no M3 equivalent: `primaryContainer` carries the header strip (M3's + * prominent-container slot), `secondaryContainer` the enabled module card (the slot M3's + * own components use for a selected row), `surfaceContainer` the disabled one (a resting + * container drawn on top of `surface`), and `scrim` the text shadow (M3 has no shadow + * slot, and scrim is the closest thing — an overlay darkening what is behind it). + * + * The unset slots come from M3's own light/dark baselines, which is why [isDark] is passed + * separately rather than inferred: it picks sensible defaults for everything Lambda does + * not paint yet, such as the error colors. + */ +private fun LambdaPalette.toColorScheme(isDark: Boolean): ColorScheme = + (if (isDark) darkColorScheme() else lightColorScheme()).copy( + primary = primary, + secondary = secondary, + surface = surface, + surfaceVariant = surfaceVariant, + onSurface = onSurface, + onSurfaceVariant = onSurfaceVariant, + outline = border, + primaryContainer = headerBg, + onPrimaryContainer = onHeaderBg, + secondaryContainer = moduleEnabled, + onSecondaryContainer = onModuleEnabled, + surfaceContainer = moduleDisabled, + scrim = textShadow, + ) + +/** + * Typography carries no colors: a baked-in color would survive the palette swap and leave + * near-white text on a near-white surface in light mode. Color comes from the color scheme + * at each call site instead. + */ +private val LambdaTypography = Typography( + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 11.sp, + ), +) + +/** + * Lambda Material3 theme wrapper. + * + * Selects [LambdaLightPalette] or [LambdaDarkPalette] from the OS setting tracked by + * [SystemThemeTracker], and recomposes the GUI when that changes. + */ +@Composable +fun LambdaTheme(content: @Composable () -> Unit) { + val isDark by SystemThemeTracker.isDark + + val colorScheme = remember(isDark) { + (if (isDark) LambdaDarkPalette else LambdaLightPalette).toColorScheme(isDark) + } + + MaterialTheme( + colorScheme = colorScheme, + typography = LambdaTypography, + content = content + ) +} diff --git a/src/main/kotlin/com/lambda/newui/theme/SystemThemeTracker.kt b/src/main/kotlin/com/lambda/newui/theme/SystemThemeTracker.kt new file mode 100644 index 000000000..04c2dd660 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/theme/SystemThemeTracker.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 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.newui.theme + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import org.jetbrains.skiko.SystemTheme +import org.jetbrains.skiko.currentSystemTheme + +/** + * Tracks whether the OS is in dark mode, as snapshot state. + * + * ### Why not `isSystemInDarkTheme()` + * + * Compose's own `isSystemInDarkTheme()` reads `LocalSystemTheme`, which is + * `@InternalComposeUiApi` and — on this setup — never provided: the GUI drives a bare + * [androidx.compose.ui.scene.ComposeScene] with no window layer, so the composition local + * falls through to its default. That default does query the OS (via the same skiko call + * used here), but it is a `staticCompositionLocalOf` behind a lazy holder, so the value is + * computed once and then frozen for the life of the process, and reading a static local + * creates no recomposition subscription. It would report the theme at startup and never + * change. This object queries the same source but keeps it in ordinary snapshot state, so + * a change actually recomposes. + * + * ### Cost + * + * `currentSystemTheme` bottoms out in a native JNI call (`SystemThemeHelper`), reading + * `AppleInterfaceStyle` on macOS and the personalization registry key on Windows. There is + * no AWT toolkit involved and no subprocess, so it is cheap enough to poll — but not free, + * hence the throttle in [poll]. + */ +object SystemThemeTracker { + /** + * Minimum gap between OS queries. The GUI only polls while it is open, so this trades a + * worst-case half-second lag on a theme flip against two JNI calls per second. + */ + private const val POLL_INTERVAL_MS = 500L + + private val darkState = mutableStateOf(queryIsDark()) + private var lastQuery = 0L + + /** Reading this from a composable subscribes it to OS theme changes. */ + val isDark: State get() = darkState + + /** Queries the OS immediately. Call when the GUI opens, so it never appears stale. */ + fun refresh() { + lastQuery = System.currentTimeMillis() + darkState.value = queryIsDark() + } + + /** + * Queries the OS at most once per [POLL_INTERVAL_MS]. Safe to call every frame. + * + * Snapshot writes take the global snapshot lock and are safe from any thread, so this + * can run on the render thread without marshalling. + */ + fun poll() { + val now = System.currentTimeMillis() + if (now - lastQuery < POLL_INTERVAL_MS) return + lastQuery = now + darkState.value = queryIsDark() + } + + private fun queryIsDark(): Boolean = when (currentSystemTheme) { + SystemTheme.LIGHT -> false + // DARK, plus UNKNOWN on platforms skiko cannot read a preference from. The GUI has + // always been dark, so that is the less surprising fallback. + else -> true + } +} diff --git a/src/main/kotlin/com/lambda/util/LambdaResource.kt b/src/main/kotlin/com/lambda/util/LambdaResource.kt index 6bfd43650..987251bd4 100644 --- a/src/main/kotlin/com/lambda/util/LambdaResource.kt +++ b/src/main/kotlin/com/lambda/util/LambdaResource.kt @@ -29,15 +29,15 @@ import javax.imageio.ImageIO typealias LambdaResource = String val LambdaResource.stream: InputStream - get() = Lambda::class.java.getResourceAsStream("/assets/lambda/$this") - ?: throw FileNotFoundException("File \"/assets/lambda/$this\" not found") + get() = Lambda::class.java.getResourceAsStream("/assets/lambda/compose/$this") + ?: throw FileNotFoundException("File \"/assets/lambda/compose/$this\" not found") val LambdaResource.text: String - get() = Lambda::class.java.getResourceAsStream("/assets/lambda/$this")?.readAllBytes()?.decodeToString() - ?: throw FileNotFoundException("File \"/assets/lambda/$this\" not found") + get() = Lambda::class.java.getResourceAsStream("/assets/lambda/compose/$this")?.readAllBytes()?.decodeToString() + ?: throw FileNotFoundException("File \"/assets/lambda/compose/$this\" not found") val LambdaResource.url: URL - get() = Lambda::class.java.getResource("/assets/lambda/$this") - ?: throw FileNotFoundException("File \"/assets/lambda/$this\" not found") + get() = Lambda::class.java.getResource("/assets/lambda/compose/$this") + ?: throw FileNotFoundException("File \"/assets/lambda/compose/$this\" not found") fun LambdaResource.readImage(): BufferedImage = ImageIO.read(this.stream) diff --git a/src/main/kotlin/com/lambda/util/WindowUtils.kt b/src/main/kotlin/com/lambda/util/WindowUtils.kt index c8e269a88..9ad83066b 100644 --- a/src/main/kotlin/com/lambda/util/WindowUtils.kt +++ b/src/main/kotlin/com/lambda/util/WindowUtils.kt @@ -41,7 +41,7 @@ object WindowUtils { } fun setLambdaWindowIcon() { - val icons = listOf(16, 24, 32, 48, 64, 128, 256).map { "textures/icon/logo_$it.png" } + val icons = listOf(16, 24, 32, 48, 64, 128, 256).map { "drawable/icon/logo_$it.png" } setWindowIcon(*icons.toTypedArray()) } @@ -53,10 +53,10 @@ object WindowUtils { * Example: * ``` * WindowIcons.setWindowIcon( - * "textures/icon16.png", - * "textures/icon32.png", - * "textures/icon48.png", - * "textures/icon128.png", + * "drawable/icon16.png", + * "drawable/icon32.png", + * "drawable/icon48.png", + * "drawable/icon128.png", * ) * ``` */ diff --git a/src/main/resources/assets/lambda/chika.gif b/src/main/resources/assets/lambda/chika.gif deleted file mode 100644 index 2def218ec..000000000 Binary files a/src/main/resources/assets/lambda/chika.gif and /dev/null differ diff --git a/src/main/resources/assets/lambda/textures/github_logo.png b/src/main/resources/assets/lambda/compose/drawable/github_logo.png similarity index 100% rename from src/main/resources/assets/lambda/textures/github_logo.png rename to src/main/resources/assets/lambda/compose/drawable/github_logo.png diff --git a/src/main/resources/assets/lambda/textures/icon/logo_128.png b/src/main/resources/assets/lambda/compose/drawable/icon/logo_128.png similarity index 100% rename from src/main/resources/assets/lambda/textures/icon/logo_128.png rename to src/main/resources/assets/lambda/compose/drawable/icon/logo_128.png diff --git a/src/main/resources/assets/lambda/textures/icon/logo_16.png b/src/main/resources/assets/lambda/compose/drawable/icon/logo_16.png similarity index 100% rename from src/main/resources/assets/lambda/textures/icon/logo_16.png rename to src/main/resources/assets/lambda/compose/drawable/icon/logo_16.png diff --git a/src/main/resources/assets/lambda/textures/icon/logo_24.png b/src/main/resources/assets/lambda/compose/drawable/icon/logo_24.png similarity index 100% rename from src/main/resources/assets/lambda/textures/icon/logo_24.png rename to src/main/resources/assets/lambda/compose/drawable/icon/logo_24.png diff --git a/src/main/resources/assets/lambda/textures/icon/logo_256.png b/src/main/resources/assets/lambda/compose/drawable/icon/logo_256.png similarity index 100% rename from src/main/resources/assets/lambda/textures/icon/logo_256.png rename to src/main/resources/assets/lambda/compose/drawable/icon/logo_256.png diff --git a/src/main/resources/assets/lambda/textures/icon/logo_32.png b/src/main/resources/assets/lambda/compose/drawable/icon/logo_32.png similarity index 100% rename from src/main/resources/assets/lambda/textures/icon/logo_32.png rename to src/main/resources/assets/lambda/compose/drawable/icon/logo_32.png diff --git a/src/main/resources/assets/lambda/textures/icon/logo_48.png b/src/main/resources/assets/lambda/compose/drawable/icon/logo_48.png similarity index 100% rename from src/main/resources/assets/lambda/textures/icon/logo_48.png rename to src/main/resources/assets/lambda/compose/drawable/icon/logo_48.png diff --git a/src/main/resources/assets/lambda/textures/icon/logo_64.png b/src/main/resources/assets/lambda/compose/drawable/icon/logo_64.png similarity index 100% rename from src/main/resources/assets/lambda/textures/icon/logo_64.png rename to src/main/resources/assets/lambda/compose/drawable/icon/logo_64.png diff --git a/src/main/resources/assets/lambda/textures/lambda.png b/src/main/resources/assets/lambda/compose/drawable/lambda.png similarity index 100% rename from src/main/resources/assets/lambda/textures/lambda.png rename to src/main/resources/assets/lambda/compose/drawable/lambda.png diff --git a/src/main/resources/assets/lambda/textures/lambda_banner.png b/src/main/resources/assets/lambda/compose/drawable/lambda_banner.png similarity index 100% rename from src/main/resources/assets/lambda/textures/lambda_banner.png rename to src/main/resources/assets/lambda/compose/drawable/lambda_banner.png diff --git a/src/main/resources/assets/lambda/textures/lambda_mono.png b/src/main/resources/assets/lambda/compose/drawable/lambda_mono.png similarity index 100% rename from src/main/resources/assets/lambda/textures/lambda_mono.png rename to src/main/resources/assets/lambda/compose/drawable/lambda_mono.png diff --git a/src/main/resources/assets/lambda/textures/lambda_text_color.png b/src/main/resources/assets/lambda/compose/drawable/lambda_text_color.png similarity index 100% rename from src/main/resources/assets/lambda/textures/lambda_text_color.png rename to src/main/resources/assets/lambda/compose/drawable/lambda_text_color.png diff --git a/src/main/resources/assets/lambda/fonts/FiraSans-Bold.ttf b/src/main/resources/assets/lambda/compose/font/FiraSans-Bold.ttf similarity index 100% rename from src/main/resources/assets/lambda/fonts/FiraSans-Bold.ttf rename to src/main/resources/assets/lambda/compose/font/FiraSans-Bold.ttf diff --git a/src/main/resources/assets/lambda/fonts/FiraSans-Regular.ttf b/src/main/resources/assets/lambda/compose/font/FiraSans-Regular.ttf similarity index 100% rename from src/main/resources/assets/lambda/fonts/FiraSans-Regular.ttf rename to src/main/resources/assets/lambda/compose/font/FiraSans-Regular.ttf diff --git a/src/main/resources/assets/lambda/fonts/MinecraftDefault-Regular.ttf b/src/main/resources/assets/lambda/compose/font/MinecraftDefault-Regular.ttf similarity index 100% rename from src/main/resources/assets/lambda/fonts/MinecraftDefault-Regular.ttf rename to src/main/resources/assets/lambda/compose/font/MinecraftDefault-Regular.ttf diff --git a/src/main/resources/assets/lambda/fonts/emojis.zip b/src/main/resources/assets/lambda/fonts/emojis.zip deleted file mode 100644 index 5ca722f52..000000000 Binary files a/src/main/resources/assets/lambda/fonts/emojis.zip and /dev/null differ