diff --git a/build.gradle.kts b/build.gradle.kts index d33ca031d..fbcd98248 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() @@ -260,7 +332,7 @@ publishing { repositories { maven(mavenUrl) { - name = "lambda-reposilite" + name = "lambda-maven" credentials { username = project.findProperty("mavenUsername").toString() 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..de29cf510 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,11 +57,11 @@ 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 kotlin.reflect.KClass import kotlin.reflect.KProperty import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.jvm.javaField +import java.awt.Color as JColor /** * Represents a set of [EntryCore]s that are associated with the [name] of the [Config]. @@ -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/entries/Setting.kt b/src/main/kotlin/com/lambda/config/entries/Setting.kt index e68c0def7..909717f9b 100644 --- a/src/main/kotlin/com/lambda/config/entries/Setting.kt +++ b/src/main/kotlin/com/lambda/config/entries/Setting.kt @@ -17,6 +17,8 @@ package com.lambda.config.entries +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.Lambda.mapper import com.lambda.brigadier.CommandResult.Companion.failure import com.lambda.brigadier.CommandResult.Companion.success @@ -32,7 +34,6 @@ import com.lambda.config.EntryCore import com.lambda.config.EntryLayer import com.lambda.config.MultipleLayerType import com.lambda.context.SafeContext -import com.lambda.gui.dsl.ImGuiBuilder import com.lambda.threading.runSafe import com.lambda.util.CommunicationUtils.info import com.lambda.util.Describable @@ -142,7 +143,9 @@ abstract class Setting( core = originalCore } - abstract fun ImGuiBuilder.buildLayout() + @ExperimentalMaterial3Api + @Composable + abstract fun gui() open fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { required(string("value as JSON")) { value -> diff --git a/src/main/kotlin/com/lambda/config/settings/CharSetting.kt b/src/main/kotlin/com/lambda/config/settings/CharSetting.kt index 971dad7d5..c9516e650 100644 --- a/src/main/kotlin/com/lambda/config/settings/CharSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/CharSetting.kt @@ -17,6 +17,8 @@ package com.lambda.config.settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.brigadier.CommandResult.Companion.failure import com.lambda.brigadier.CommandResult.Companion.success import com.lambda.brigadier.argument.value @@ -26,7 +28,6 @@ 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.gui.dsl.ImGuiBuilder import com.lambda.util.extension.CommandBuilder import net.minecraft.command.CommandRegistryAccess @@ -41,7 +42,9 @@ class CharSetting( defaultValue: Char, visibility: () -> Boolean ) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() {} + @ExperimentalMaterial3Api + @Composable + override fun gui() {} override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { required(word(name)) { parameter -> diff --git a/src/main/kotlin/com/lambda/config/settings/FunctionSetting.kt b/src/main/kotlin/com/lambda/config/settings/FunctionSetting.kt index 5a537789a..2f876ee5e 100644 --- a/src/main/kotlin/com/lambda/config/settings/FunctionSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/FunctionSetting.kt @@ -17,10 +17,24 @@ package com.lambda.config.settings +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.lambda.config.Config import com.lambda.config.entries.Setting import com.lambda.config.entries.SettingEntryLayer -import com.lambda.gui.dsl.ImGuiBuilder +import com.lambda.newui.theme.Radius class FunctionSetting R, R>( name: String, @@ -30,8 +44,28 @@ class FunctionSetting R, R>( layer: SettingEntryLayer, T>, visibility: () -> Boolean ) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() { - button(name) { value() } - lambdaTooltip(description) - } + @ExperimentalMaterial3Api + @Composable + override fun gui() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 1.dp), + contentAlignment = Alignment.Center + ) { + Button( + onClick = { value() }, + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + shape = RoundedCornerShape(Radius.Small), + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = name, + fontSize = 9.sp + ) + } + } + } } diff --git a/src/main/kotlin/com/lambda/config/settings/NumericSetting.kt b/src/main/kotlin/com/lambda/config/settings/NumericSetting.kt index 2a66e80e1..1671ee445 100644 --- a/src/main/kotlin/com/lambda/config/settings/NumericSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/NumericSetting.kt @@ -17,16 +17,40 @@ package com.lambda.config.settings +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.lambda.config.Config import com.lambda.config.ConfigEditor import com.lambda.config.ConfigEditorD5l import com.lambda.config.entries.Setting import com.lambda.config.entries.SettingEntryLayer import com.lambda.gui.dsl.ImGuiBuilder -import com.lambda.imgui.ImGui -import com.lambda.imgui.ImGui.calcTextSize -import com.lambda.imgui.ImGui.dummy -import com.lambda.imgui.flag.ImGuiCol +import com.lambda.newui.state.LambdaState.observe +import com.lambda.newui.theme.Radius import java.text.NumberFormat import java.util.* @@ -59,39 +83,84 @@ abstract class NumericSetting( */ protected abstract fun ImGuiBuilder.buildSlider() - override fun ImGuiBuilder.buildLayout() { - val showReset = isModified - val resetButtonText = "R" - val valueString = this@NumericSetting.toString() + @ExperimentalMaterial3Api + @Composable + override fun gui() { + val stateValue by this.observe() + val valueString = stateValue.toString() - buildSlider() - lambdaTooltip(description) + val min = (range.start as Number).toFloat() + val max = (range.endInclusive as Number).toFloat() + val current = (stateValue as Number).toFloat() + val fraction = ((current - min) / (max - min)).coerceIn(0f, 1f) + val fillColor = MaterialTheme.colorScheme.primary - val itemRectMin = ImGui.getItemRectMin() - val itemRectMax = ImGui.getItemRectMax() - val textHeight = ImGui.getTextLineHeight() - val textY = itemRectMin.y + (itemRectMax.y - itemRectMin.y - textHeight) / 2.0f - val labelWidth = calcTextSize(name).x - val valueWidth = calcTextSize(valueString).x - - val labelEndPosX = itemRectMin.x + style.framePadding.x * 2 + labelWidth - val valueStartPosX = itemRectMax.x - style.framePadding.x * 2 - valueWidth - - windowDrawList.addText(itemRectMin.x + style.framePadding.x * 2, textY, ImGui.getColorU32(ImGuiCol.Text), name) - if (labelEndPosX < valueStartPosX) { - windowDrawList.addText(valueStartPosX, textY, ImGui.getColorU32(ImGuiCol.Text), valueString) - } - - sameLine(0.0f, style.itemSpacing.x) - if (showReset) { - button("$resetButtonText##$name") { - reset() - } - onItemHover { - tooltip { text("Reset to default") } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 1.dp) + .heightIn(min = 16.dp) + .clip(RoundedCornerShape(Radius.Small)) + .background(Color(0xFF333333)) + // Painted rather than a fillMaxHeight child: the bar now grows to fit a + // wrapped label, and fillMaxHeight cannot resolve against a wrap content height. + .drawBehind { + drawRect(color = fillColor, size = Size(size.width * fraction, size.height)) + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + val percent = (change.position.x / size.width).coerceIn(0f, 1f) + val newValue = min + (max - min) * percent + value = when (stateValue) { + is Int -> newValue.toInt() as T + is Long -> newValue.toLong() as T + is Float -> newValue as T + is Double -> newValue.toDouble() as T + else -> newValue as T + } + } + } + .pointerInput(Unit) { + detectTapGestures { offset -> + val percent = (offset.x / size.width).coerceIn(0f, 1f) + val newValue = min + (max - min) * percent + value = when (stateValue) { + is Int -> newValue.toInt() as T + is Long -> newValue.toLong() as T + is Float -> newValue as T + is Double -> newValue.toDouble() as T + else -> newValue as T + } + } + }, + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = name, + color = Color.White, + style = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + Text( + text = valueString, + color = Color.White, + style = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) } - } else { - dummy(calcTextSize(resetButtonText).x + style.framePadding.x * 2.0f, ImGui.getFrameHeight()) } } @@ -113,3 +182,4 @@ abstract class NumericSetting( } } } + diff --git a/src/main/kotlin/com/lambda/config/settings/StringSetting.kt b/src/main/kotlin/com/lambda/config/settings/StringSetting.kt index 559e3ad45..22cb301b3 100644 --- a/src/main/kotlin/com/lambda/config/settings/StringSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/StringSetting.kt @@ -17,6 +17,27 @@ package com.lambda.config.settings +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.ExperimentalMaterial3Api +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.lambda.brigadier.argument.greedyString import com.lambda.brigadier.argument.value import com.lambda.brigadier.execute @@ -26,8 +47,9 @@ import com.lambda.config.ConfigEditor import com.lambda.config.ConfigEditorD5l import com.lambda.config.entries.Setting import com.lambda.config.entries.SettingEntryLayer -import com.lambda.gui.dsl.ImGuiBuilder +import com.lambda.newui.theme.Radius import com.lambda.imgui.flag.ImGuiInputTextFlags +import com.lambda.newui.state.LambdaState.observe import com.lambda.util.extension.CommandBuilder import net.minecraft.command.CommandRegistryAccess @@ -45,13 +67,47 @@ class StringSetting( var flags: Int = ImGuiInputTextFlags.None, ) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() { - if (multiline) { - inputTextMultiline(name, ::value, flags = flags) - } else { - inputText(name, ::value, flags) + @ExperimentalMaterial3Api + @Composable + override fun gui() { + val stateValue by this.observe() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = name, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 1.dp), + style = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 16.dp) + .clip(RoundedCornerShape(Radius.Small)) + .background(Color(0xFF222222)) + .padding(2.dp) + ) { + BasicTextField( + value = stateValue, + onValueChange = { value = it }, + singleLine = !multiline, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 9.sp, + color = Color.White + ), + cursorBrush = SolidColor(Color.White) + ) + } } - lambdaTooltip(description) } override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { @@ -74,4 +130,4 @@ class StringSetting( (entries as Collection).forEach { it.flags = flags } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/lambda/config/settings/collections/BlockCollectionSetting.kt b/src/main/kotlin/com/lambda/config/settings/collections/BlockCollectionSetting.kt index bf9b158f6..a6cb42f76 100644 --- a/src/main/kotlin/com/lambda/config/settings/collections/BlockCollectionSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/collections/BlockCollectionSetting.kt @@ -17,11 +17,11 @@ package com.lambda.config.settings.collections +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.Lambda.typeFactory import com.lambda.config.Config import com.lambda.config.entries.SettingEntryLayer -import com.lambda.config.serializers.BlockSerializer -import com.lambda.gui.dsl.ImGuiBuilder import net.minecraft.block.Block class BlockCollectionSetting( @@ -43,5 +43,7 @@ class BlockCollectionSetting( typeFactory.constructCollectionType(MutableCollection::class.java, Block::class.java), serialize = true, ) { - override fun ImGuiBuilder.buildLayout() = buildDualPane("block") { BlockSerializer.stringify(it) } + @ExperimentalMaterial3Api + @Composable + override fun gui() {} } \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/config/settings/collections/ClassCollectionSetting.kt b/src/main/kotlin/com/lambda/config/settings/collections/ClassCollectionSetting.kt index 22aa143f9..d3c0b5d56 100644 --- a/src/main/kotlin/com/lambda/config/settings/collections/ClassCollectionSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/collections/ClassCollectionSetting.kt @@ -17,11 +17,11 @@ package com.lambda.config.settings.collections +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.Lambda.typeFactory import com.lambda.config.Config import com.lambda.config.entries.SettingEntryLayer -import com.lambda.gui.dsl.ImGuiBuilder -import com.lambda.util.ReflectionUtils.className /** * @see [CollectionSetting] @@ -46,5 +46,7 @@ class ClassCollectionSetting( typeFactory.constructCollectionType(Collection::class.java, Any::class.java), serialize = false, ) { - override fun ImGuiBuilder.buildLayout() = buildDualPane("item") { it.className } + @ExperimentalMaterial3Api + @Composable + override fun gui() {} } \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/config/settings/collections/CollectionSetting.kt b/src/main/kotlin/com/lambda/config/settings/collections/CollectionSetting.kt index ee9601a91..56d24e7f8 100644 --- a/src/main/kotlin/com/lambda/config/settings/collections/CollectionSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/collections/CollectionSetting.kt @@ -17,6 +17,8 @@ package com.lambda.config.settings.collections +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.config.Config import com.lambda.config.ConfigEditor import com.lambda.config.ConfigEditorD5l @@ -74,7 +76,9 @@ open class CollectionSetting( value.size != defaultValue.size || defaultValue.any { !value.contains(it) } } - override fun ImGuiBuilder.buildLayout() = buildDualPane("item") { it.toString() } + @ExperimentalMaterial3Api + @Composable + override fun gui() {} fun ImGuiBuilder.buildDualPane(itemName: String, toString: (R) -> String) { val text = if (value.size == 1) itemName else "${itemName}s" diff --git a/src/main/kotlin/com/lambda/config/settings/collections/FluidCollectionSetting.kt b/src/main/kotlin/com/lambda/config/settings/collections/FluidCollectionSetting.kt index 28dae8d4e..47680deea 100644 --- a/src/main/kotlin/com/lambda/config/settings/collections/FluidCollectionSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/collections/FluidCollectionSetting.kt @@ -17,11 +17,11 @@ package com.lambda.config.settings.collections +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.Lambda.typeFactory import com.lambda.config.Config import com.lambda.config.entries.SettingEntryLayer -import com.lambda.config.serializers.FluidSerializer -import com.lambda.gui.dsl.ImGuiBuilder import net.minecraft.fluid.Fluid class FluidCollectionSetting( @@ -43,5 +43,7 @@ class FluidCollectionSetting( typeFactory.constructCollectionType(MutableCollection::class.java, Fluid::class.java), serialize = true, ) { - override fun ImGuiBuilder.buildLayout() = buildDualPane("block") { FluidSerializer.stringify(it) } + @ExperimentalMaterial3Api + @Composable + override fun gui() {} } \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/config/settings/collections/ItemCollectionSetting.kt b/src/main/kotlin/com/lambda/config/settings/collections/ItemCollectionSetting.kt index 802a70a04..31738fef7 100644 --- a/src/main/kotlin/com/lambda/config/settings/collections/ItemCollectionSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/collections/ItemCollectionSetting.kt @@ -17,11 +17,11 @@ package com.lambda.config.settings.collections +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.Lambda.typeFactory import com.lambda.config.Config import com.lambda.config.entries.SettingEntryLayer -import com.lambda.config.serializers.ItemSerializer -import com.lambda.gui.dsl.ImGuiBuilder import net.minecraft.item.Item class ItemCollectionSetting( @@ -39,5 +39,7 @@ class ItemCollectionSetting( typeFactory.constructCollectionType(Collection::class.java, Item::class.java), serialize = true, ) { - override fun ImGuiBuilder.buildLayout() = buildDualPane("item") { ItemSerializer.stringify(it) } + @ExperimentalMaterial3Api + @Composable + override fun gui() {} } \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/config/settings/collections/MapSetting.kt b/src/main/kotlin/com/lambda/config/settings/collections/MapSetting.kt index 1bf99c44c..b4a01be7b 100644 --- a/src/main/kotlin/com/lambda/config/settings/collections/MapSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/collections/MapSetting.kt @@ -17,10 +17,11 @@ package com.lambda.config.settings.collections +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.config.Config import com.lambda.config.entries.Setting import com.lambda.config.entries.SettingEntryLayer -import com.lambda.gui.dsl.ImGuiBuilder import tools.jackson.databind.JavaType class MapSetting( @@ -32,5 +33,7 @@ class MapSetting( defaultValue: MutableMap, val type: JavaType ) : Setting>(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() {} + @ExperimentalMaterial3Api + @Composable + override fun gui() {} } \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/config/settings/comparable/BooleanSetting.kt b/src/main/kotlin/com/lambda/config/settings/comparable/BooleanSetting.kt index c958fd1fa..1ef6155ae 100644 --- a/src/main/kotlin/com/lambda/config/settings/comparable/BooleanSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/comparable/BooleanSetting.kt @@ -17,6 +17,34 @@ package com.lambda.config.settings.comparable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.lambda.brigadier.argument.boolean import com.lambda.brigadier.argument.value import com.lambda.brigadier.execute @@ -24,7 +52,8 @@ 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.gui.dsl.ImGuiBuilder +import com.lambda.newui.state.LambdaState.observe +import com.lambda.newui.theme.Radius import com.lambda.util.extension.CommandBuilder import net.minecraft.command.CommandRegistryAccess @@ -36,9 +65,60 @@ class BooleanSetting( visibility: () -> Boolean, defaultValue: Boolean ) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() { - checkbox(name, ::value) - lambdaTooltip(description) + + @ExperimentalMaterial3Api + @Composable + override fun gui() { + val stateValue by this.observe() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 16.dp) + .clickable { value = !stateValue } + .padding(horizontal = 4.dp) + ) { + val checkColor = colorScheme.onPrimary + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(Radius.ExtraSmall)) + .background(if (stateValue) colorScheme.primary else Color(0xFF442233)), + contentAlignment = Alignment.Center + ) { + if (stateValue) { + // Drawn rather than a "✔" glyph: at this size the text box overflows the + // 10dp clip and the mark disappears, and the glyph is font dependent. + Canvas(modifier = Modifier.fillMaxSize()) { + val side = size.minDimension + drawPath( + path = Path().apply { + moveTo(side * 0.22f, side * 0.52f) + lineTo(side * 0.42f, side * 0.72f) + lineTo(side * 0.78f, side * 0.28f) + }, + color = checkColor, + style = Stroke( + width = side * 0.16f, + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) + } + } + } + Text( + text = name, + color = colorScheme.onSurface, + style = androidx.compose.ui.text.TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + } } override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { @@ -48,4 +128,4 @@ class BooleanSetting( } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/lambda/config/settings/comparable/EnumSetting.kt b/src/main/kotlin/com/lambda/config/settings/comparable/EnumSetting.kt index 74d2953ba..d77cebce9 100644 --- a/src/main/kotlin/com/lambda/config/settings/comparable/EnumSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/comparable/EnumSetting.kt @@ -17,6 +17,37 @@ package com.lambda.config.settings.comparable +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.lambda.brigadier.CommandResult.Companion.failure import com.lambda.brigadier.CommandResult.Companion.success import com.lambda.brigadier.argument.value @@ -26,11 +57,10 @@ 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.gui.dsl.ImGuiBuilder -import com.lambda.util.Describable +import com.lambda.newui.state.LambdaState.observe +import com.lambda.newui.theme.Radius import com.lambda.util.StringUtils.capitalize import com.lambda.util.extension.CommandBuilder -import com.lambda.util.extension.displayValue import net.minecraft.command.CommandRegistryAccess class EnumSetting>( @@ -41,24 +71,95 @@ class EnumSetting>( visibility: () -> Boolean, defaultValue: T ) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() { - val values = value.enumValues - val currentDisplay = value.displayValue - val currentIndex = value.ordinal + @ExperimentalMaterial3Api + @Composable + override fun gui() { + val stateValue by this.observe() + var expanded by remember { mutableStateOf(false) } - combo("##$name", preview = "$name: $currentDisplay") { - values.forEachIndexed { idx, v -> - val isSelected = idx == currentIndex - - selectable(v.displayValue, isSelected) { - if (!isSelected) value = values[idx % values.size] + Column( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 1.dp) + // Clipped as a whole so the field and its open list read as one control. + .clip(RoundedCornerShape(Radius.Small)) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 16.dp) + .background(Color(0xFF442233)) + .clickable { expanded = !expanded }, + contentAlignment = Alignment.CenterStart + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + Text( + text = "$name: ${stateValue.name.capitalize()}", + color = Color.White, + style = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + + Text( + text = if (expanded) "▲" else "▼", + color = Color.White, + style = TextStyle( + fontSize = 7.sp, lineHeight = 7.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(animationSpec = tween(150)) + fadeIn(animationSpec = tween(150)), + exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(animationSpec = tween(150)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF331122)) + ) { + stateValue.enumValues.forEach { enumValue -> + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 16.dp) + .clickable { + value = enumValue + expanded = false + } + .background(if (stateValue == enumValue) Color(0xFF552233) else Color.Transparent) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = enumValue.name.capitalize(), + color = if (stateValue == enumValue) Color.White else Color.Gray, + style = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + } + } } - - (v as? Describable)?.let { lambdaTooltip(it.description) } } } - - lambdaTooltip(description) } override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { @@ -80,4 +181,4 @@ class EnumSetting>( val > T.enumValues: Array get() = declaringJavaClass.enumConstants } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/lambda/config/settings/complex/BlockPosSetting.kt b/src/main/kotlin/com/lambda/config/settings/complex/BlockPosSetting.kt index 0580c9470..e3818228b 100644 --- a/src/main/kotlin/com/lambda/config/settings/complex/BlockPosSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/complex/BlockPosSetting.kt @@ -17,7 +17,8 @@ package com.lambda.config.settings.complex -import com.lambda.Lambda.mc +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.brigadier.argument.integer import com.lambda.brigadier.argument.value import com.lambda.brigadier.execute @@ -25,11 +26,7 @@ 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.gui.dsl.ImGuiBuilder -import com.lambda.util.BlockUtils.blockPos -import com.lambda.util.CommunicationUtils.info import com.lambda.util.extension.CommandBuilder -import com.lambda.util.world.raycast.RayCastUtils.blockResult import net.minecraft.command.CommandRegistryAccess import net.minecraft.util.math.BlockPos @@ -41,19 +38,9 @@ class BlockPosSetting( visibility: () -> Boolean, defaultValue: BlockPos ) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() { - button("Set##$name") { - mc.crosshairTarget?.blockResult?.blockPos?.let { - value = it - } ?: info("No block under crosshair") - } - lambdaTooltip("Set the coordinates to the block you are currently looking at") - sameLine() - treeNode(name, id = name) { - inputVec3i("##$name", value) { value = it.blockPos } - } - lambdaTooltip(description) - } + @ExperimentalMaterial3Api + @Composable + override fun gui() {} override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { required(integer("X", -30000000, 30000000)) { x -> diff --git a/src/main/kotlin/com/lambda/config/settings/complex/BlockSetting.kt b/src/main/kotlin/com/lambda/config/settings/complex/BlockSetting.kt index c5ea38965..e6c5cfd7e 100644 --- a/src/main/kotlin/com/lambda/config/settings/complex/BlockSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/complex/BlockSetting.kt @@ -17,6 +17,8 @@ package com.lambda.config.settings.complex +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.brigadier.argument.blockState import com.lambda.brigadier.argument.value import com.lambda.brigadier.execute @@ -24,7 +26,6 @@ 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.gui.dsl.ImGuiBuilder import com.lambda.util.extension.CommandBuilder import net.minecraft.block.Block import net.minecraft.command.CommandRegistryAccess @@ -37,7 +38,9 @@ class BlockSetting( visibility: () -> Boolean, defaultValue: Block ) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() {} + @ExperimentalMaterial3Api + @Composable + override fun gui() {} override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { required(blockState(name, registry)) { argument -> diff --git a/src/main/kotlin/com/lambda/config/settings/complex/ColorSetting.kt b/src/main/kotlin/com/lambda/config/settings/complex/ColorSetting.kt index b7a037159..89080d844 100644 --- a/src/main/kotlin/com/lambda/config/settings/complex/ColorSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/complex/ColorSetting.kt @@ -17,6 +17,9 @@ package com.lambda.config.settings.complex +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import com.lambda.brigadier.argument.integer import com.lambda.brigadier.argument.value import com.lambda.brigadier.execute @@ -25,22 +28,35 @@ 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.gui.dsl.ImGuiBuilder +import com.lambda.newui.components.ColorSettingGui +import com.lambda.newui.state.LambdaState.observe import com.lambda.util.extension.CommandBuilder import net.minecraft.command.CommandRegistryAccess -import java.awt.Color +import java.awt.Color as JColor class ColorSetting( name: String, description: String, config: Config, - layer: SettingEntryLayer, + layer: SettingEntryLayer, visibility: () -> Boolean, - defaultValue: Color -) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() { - colorEdit(name, ::value) - lambdaTooltip(description) + defaultValue: JColor +) : Setting(name, description, defaultValue, layer, config, visibility) { + @ExperimentalMaterial3Api + @Composable + override fun gui() { + val stateValue by this.observe() + + ColorSettingGui( + name = name, + red = stateValue.red, + green = stateValue.green, + blue = stateValue.blue, + alpha = stateValue.alpha, + onColorChange = { r, g, b, a -> + value = JColor(r, g, b, a) + } + ) } override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { @@ -50,11 +66,11 @@ class ColorSetting( optional(integer("Alpha", 0, 255)) { alpha -> execute { val alphaValue = alpha?.let { it().value() } ?: 255 - trySetValue(Color(red().value(), green().value(), blue().value(), alphaValue)) + trySetValue(JColor(red().value(), green().value(), blue().value(), alphaValue)) } } } } } } -} \ No newline at end of file +} 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..e8cc54428 --- /dev/null +++ b/src/main/kotlin/com/lambda/config/settings/complex/KColorSetting.kt @@ -0,0 +1,76 @@ +/* + * 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.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.newui.components.ColorSettingGui +import com.lambda.newui.state.LambdaState.observe +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) { + @ExperimentalMaterial3Api + @Composable + override fun gui() { + val stateValue by this.observe() + + ColorSettingGui( + name = name, + red = (stateValue.red * 255).toInt(), + green = (stateValue.green * 255).toInt(), + blue = (stateValue.blue * 255).toInt(), + alpha = (stateValue.alpha * 255).toInt(), + onColorChange = { r, g, b, a -> + value = Color(r / 255f, g / 255f, b / 255f, a / 255f) + } + ) + } + + 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/config/settings/complex/KeybindSetting.kt b/src/main/kotlin/com/lambda/config/settings/complex/KeybindSetting.kt index ed2c94041..f98689218 100644 --- a/src/main/kotlin/com/lambda/config/settings/complex/KeybindSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/complex/KeybindSetting.kt @@ -17,6 +17,8 @@ package com.lambda.config.settings.complex +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.fasterxml.jackson.annotation.JsonIncludeProperties import com.lambda.Lambda.mc import com.lambda.brigadier.CommandResult.Companion.failure @@ -35,20 +37,12 @@ import com.lambda.context.SafeContext import com.lambda.event.Muteable import com.lambda.event.events.ButtonEvent import com.lambda.event.listener.UnsafeListener.Companion.listenUnsafe -import com.lambda.gui.dsl.ImGuiBuilder -import com.lambda.imgui.ImGui.isMouseClicked -import com.lambda.imgui.flag.ImGuiCol -import com.lambda.imgui.flag.ImGuiHoveredFlags -import com.lambda.imgui.flag.ImGuiMouseButton import com.lambda.threading.runSafe -import com.lambda.util.InputUtils import com.lambda.util.KeyCode import com.lambda.util.Mouse import com.lambda.util.StringUtils.capitalize import com.lambda.util.extension.CommandBuilder import net.minecraft.command.CommandRegistryAccess -import org.lwjgl.glfw.GLFW.GLFW_KEY_LEFT_SHIFT -import org.lwjgl.glfw.GLFW.GLFW_KEY_RIGHT_SUPER import org.lwjgl.glfw.GLFW.GLFW_MOD_ALT import org.lwjgl.glfw.GLFW.GLFW_MOD_CAPS_LOCK import org.lwjgl.glfw.GLFW.GLFW_MOD_CONTROL @@ -115,76 +109,9 @@ class KeybindSetting( } } - override fun ImGuiBuilder.buildLayout() { - text(name) - sameLine() - - val bind = value - val preview = - if (listening) "Press any key…" - else bind.name - - withId("##Bind-${this@KeybindSetting.hashCode()}") { - if (listening) { - withStyleColor(ImGuiCol.Button, 0.20f, 0.50f, 1.00f, 1.00f) { - withStyleColor(ImGuiCol.ButtonHovered, 0.25f, 0.60f, 1.00f, 1.00f) { - withStyleColor(ImGuiCol.ButtonActive, 0.20f, 0.50f, 0.95f, 1.00f) { - button(preview) - } - } - } - } else { - button(preview) { listening = true } - } - } - - lambdaTooltip { - if (!listening) description.ifBlank { "Click to set. Esc cancels. Backspace/Delete unbinds." } - else "Listening… Press a key to bind. Esc to cancel. Backspace/Delete to unbind." - } - - if (listening && !isAnyItemHovered && isMouseClicked(ImGuiMouseButton.Left)) { - listening = false - } - - sameLine() - withId("##Unbind-${this@KeybindSetting.hashCode()}") { - smallButton("Unbind") { - value = Bind.EMPTY - listening = false - } - } - onItemHover(ImGuiHoveredFlags.Stationary) { - lambdaTooltip("Clear binding") - } - - if (listening) { - InputUtils.newMouseEvent() - ?.let { - value = Bind(0, it.modifiers, it.button) - listening = false - return - } - - InputUtils.newKeyboardEvent() - ?.let { - val isModKey = it.keyCode in GLFW_KEY_LEFT_SHIFT..GLFW_KEY_RIGHT_SUPER - - // If a mod key is pressed first ignore it unless it was released without any other keys - if ((it.isPressed && !isModKey) || (it.isReleased && isModKey)) { - when (it.translated) { - KeyCode.Escape -> {} - KeyCode.Backspace, KeyCode.Delete -> value = Bind.EMPTY - else -> value = Bind(it.translated.code, it.modifiers, -1) - } - - listening = false - } - - return - } - } - } + @ExperimentalMaterial3Api + @Composable + override fun gui() {} override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { required(word(name)) { nameArg -> diff --git a/src/main/kotlin/com/lambda/config/settings/complex/Vec3dSetting.kt b/src/main/kotlin/com/lambda/config/settings/complex/Vec3dSetting.kt index 0110a0590..caef8d181 100644 --- a/src/main/kotlin/com/lambda/config/settings/complex/Vec3dSetting.kt +++ b/src/main/kotlin/com/lambda/config/settings/complex/Vec3dSetting.kt @@ -17,6 +17,8 @@ package com.lambda.config.settings.complex +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable import com.lambda.brigadier.argument.double import com.lambda.brigadier.argument.value import com.lambda.brigadier.execute @@ -24,7 +26,6 @@ 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.gui.dsl.ImGuiBuilder import com.lambda.util.extension.CommandBuilder import net.minecraft.command.CommandRegistryAccess import net.minecraft.util.math.Vec3d @@ -37,10 +38,9 @@ class Vec3dSetting( visibility: () -> Boolean, defaultValue: Vec3d ) : Setting(name, description, defaultValue, layer, config, visibility) { - override fun ImGuiBuilder.buildLayout() { - inputVec3d(name, ::value as Vec3d) // FixMe: what the fuck - lambdaTooltip(description) - } + @ExperimentalMaterial3Api + @Composable + override fun gui() {} override fun CommandBuilder.buildCommand(registry: CommandRegistryAccess) { required(double("X", -30000000.0, 30000000.0)) { x -> diff --git a/src/main/kotlin/com/lambda/graphics/mc/LambdaRenderPipelines.kt b/src/main/kotlin/com/lambda/graphics/mc/LambdaRenderPipelines.kt index 911bb1d77..4a814f26f 100644 --- a/src/main/kotlin/com/lambda/graphics/mc/LambdaRenderPipelines.kt +++ b/src/main/kotlin/com/lambda/graphics/mc/LambdaRenderPipelines.kt @@ -300,4 +300,23 @@ object LambdaRenderPipelines : Loadable { ) .build() ) + + val GUI_GLOW: RenderPipeline = + RenderPipelines.register( + RenderPipeline.builder(LAMBDA_ESP_SNIPPET) + .withLocation(Identifier.of("lambda", "pipeline/gui_glow")) + .withVertexShader(Identifier.of("lambda", "core/gui_glow")) + .withFragmentShader(Identifier.of("lambda", "core/gui_glow")) + .withUniform("DynamicTransforms", UniformType.UNIFORM_BUFFER) + .withSampler("Sampler0") + .withBlend(BlendFunction.TRANSLUCENT) + .withDepthWrite(false) + .withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST) + .withCull(false) + .withVertexFormat( + VertexFormats.POSITION_TEXTURE, + VertexFormat.DrawMode.QUADS + ) + .build() + ) } 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..38e04cebf 100644 --- a/src/main/kotlin/com/lambda/gui/MenuBar.kt +++ b/src/main/kotlin/com/lambda/gui/MenuBar.kt @@ -33,7 +33,6 @@ import com.lambda.gui.DearImGui.EXTERNAL_LINK import com.lambda.gui.components.ClickGuiLayout import com.lambda.gui.components.HudGuiLayout import com.lambda.gui.components.QuickSearch -import com.lambda.gui.components.SettingsWidget.buildConfigSettingsContext import com.lambda.gui.dsl.ImGuiBuilder import com.lambda.imgui.ImGui import com.lambda.imgui.ImGui.closeCurrentPopup @@ -41,11 +40,9 @@ import com.lambda.imgui.flag.ImGuiCol import com.lambda.imgui.flag.ImGuiStyleVar import com.lambda.imgui.flag.ImGuiWindowFlags import com.lambda.imgui.type.ImBoolean -import com.lambda.interaction.handlers.BaritoneHandler import com.lambda.module.ModuleRegistry import com.lambda.module.ModuleRegistry.moduleNameMap import com.lambda.module.tag.ModuleTag -import com.lambda.network.LambdaAPI import com.lambda.threading.runSafe import com.lambda.util.CommunicationUtils.info import com.lambda.util.Diagnostics.gatherDiagnostics @@ -63,9 +60,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 @@ -142,20 +139,20 @@ object MenuBar { } } separator() - menu("Settings") { - menu("HUD Settings") { - buildConfigSettingsContext(HudGuiLayout) - } - menu("GUI Settings") { - buildConfigSettingsContext(ClickGuiLayout) - } - menu("Lambda API Settings") { - buildConfigSettingsContext(LambdaAPI) - } - menu("Baritone Settings") { - buildConfigSettingsContext(BaritoneHandler) - } - } +// menu("Settings") { +// menu("HUD Settings") { +// buildConfigSettingsContext(HudGuiLayout) +// } +// menu("GUI Settings") { +// buildConfigSettingsContext(ClickGuiLayout) +// } +// menu("Lambda API Settings") { +// buildConfigSettingsContext(LambdaAPI) +// } +// menu("Baritone Settings") { +// buildConfigSettingsContext(BaritoneHandler) +// } +// } separator() menu("Open Folder") { menuItem("Open Lambda Folder") { @@ -254,12 +251,12 @@ object MenuBar { } separator() menu("HUD Settings") { - buildConfigSettingsContext(HudGuiLayout) +// buildConfigSettingsContext(HudGuiLayout) } } private fun ImGuiBuilder.buildGuiMenu() { - buildConfigSettingsContext(ClickGuiLayout) +// buildConfigSettingsContext(ClickGuiLayout) } private fun ImGuiBuilder.buildModulesMenu() { @@ -312,7 +309,7 @@ object MenuBar { ImGui.setNextWindowSizeConstraints(0f, 0f, Float.MAX_VALUE, io.displaySize.y * 0.5f) menu(config.name) { if (config is UserAutomationConfig) { - with(config.linkedModules) { buildLayout() } +// with(config.linkedModules) { buildLayout() } button("Delete") { config.linkedModules.value.forEach { moduleNameMap[it]?.let { module -> @@ -323,7 +320,7 @@ object MenuBar { } separator() } - buildConfigSettingsContext(config) +// buildConfigSettingsContext(config) } } 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/gui/components/HudGuiLayout.kt b/src/main/kotlin/com/lambda/gui/components/HudGuiLayout.kt index 2e72ec915..77486633e 100644 --- a/src/main/kotlin/com/lambda/gui/components/HudGuiLayout.kt +++ b/src/main/kotlin/com/lambda/gui/components/HudGuiLayout.kt @@ -22,7 +22,6 @@ import com.lambda.config.categories.HudCategory import com.lambda.core.Loadable import com.lambda.event.events.GuiEvent import com.lambda.event.listener.SafeListener.Companion.listen -import com.lambda.gui.components.SettingsWidget.buildConfigSettingsContext import com.lambda.gui.dsl.ImGuiBuilder import com.lambda.gui.dsl.ImGuiBuilder.buildLayout import com.lambda.gui.snap.RectF @@ -88,10 +87,10 @@ object HudGuiLayout : Loadable, Config( } separator() menu("HUD Settings") { - buildConfigSettingsContext(this@HudGuiLayout) +// buildConfigSettingsContext(this@HudGuiLayout) } menu("GUI Settings") { - buildConfigSettingsContext(ClickGuiLayout) +// buildConfigSettingsContext(ClickGuiLayout) } } return@buildLayout @@ -179,7 +178,7 @@ object HudGuiLayout : Loadable, Config( SnapHandler.unregisterElement(hud.name) } separator() - buildConfigSettingsContext(hud) +// buildConfigSettingsContext(hud) } if (!isLocked) drawHudCornerArcs(windowDrawList, windowPos.x, windowPos.y, windowSize.x, windowSize.y) @@ -220,10 +219,10 @@ object HudGuiLayout : Loadable, Config( } separator() menu("HUD Settings") { - buildConfigSettingsContext(this@HudGuiLayout) +// buildConfigSettingsContext(this@HudGuiLayout) } menu("GUI Settings") { - buildConfigSettingsContext(ClickGuiLayout) +// buildConfigSettingsContext(ClickGuiLayout) } } } diff --git a/src/main/kotlin/com/lambda/gui/components/ModuleEntry.kt b/src/main/kotlin/com/lambda/gui/components/ModuleEntry.kt index 44f715906..f9a35edc0 100644 --- a/src/main/kotlin/com/lambda/gui/components/ModuleEntry.kt +++ b/src/main/kotlin/com/lambda/gui/components/ModuleEntry.kt @@ -18,7 +18,6 @@ package com.lambda.gui.components import com.lambda.gui.Layout -import com.lambda.gui.components.SettingsWidget.buildConfigSettingsContext import com.lambda.gui.dsl.ImGuiBuilder import com.lambda.imgui.ImGui import com.lambda.module.Module @@ -32,7 +31,7 @@ class ModuleEntry(val module: Module): Layout { ImGui.setNextWindowSizeConstraints(0f, 0f, Float.MAX_VALUE, io.displaySize.y * 0.5f) popupContextItem("##ctx-${module.name}") { - buildConfigSettingsContext(module) +// buildConfigSettingsContext(module) } } } \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/gui/components/SettingsWidget.kt b/src/main/kotlin/com/lambda/gui/components/SettingsWidget.kt index 4b4efeab8..b4806694b 100644 --- a/src/main/kotlin/com/lambda/gui/components/SettingsWidget.kt +++ b/src/main/kotlin/com/lambda/gui/components/SettingsWidget.kt @@ -17,81 +17,17 @@ package com.lambda.gui.components -import com.lambda.config.Config import com.lambda.config.EntryLayer -import com.lambda.config.automation.AutomationConfig -import com.lambda.config.automation.IMutableAutomationConfig -import com.lambda.config.automation.UserAutomationConfig -import com.lambda.config.categories.UserAutomationCategory import com.lambda.config.entries.Setting import com.lambda.config.entries.SettingEntryLayer import com.lambda.gui.dsl.ImGuiBuilder import com.lambda.imgui.ImGui -import com.lambda.imgui.flag.ImGuiPopupFlags import com.lambda.imgui.flag.ImGuiTabBarFlags -import com.lambda.module.HudModule -import com.lambda.module.Module -import com.lambda.module.modules.client.AutoUpdater object SettingsWidget { /** * Builds the settings context popup content for the given config. */ - fun ImGuiBuilder.buildConfigSettingsContext(config: Config) { - group { - if (config is Module && config != AutoUpdater) { - button("Module Settings") { - ImGui.openPopup("##module-settings-popup-${config.name}") - } - ImGui.setNextWindowSizeConstraints(0f, 0f, Float.MAX_VALUE, io.displaySize.y * 0.5f) - popupContextItem("##module-settings-popup-${config.name}", ImGuiPopupFlags.None) { - with(config.keybindSetting) { buildLayout() } - with(config.prioritySetting) { buildLayout() } - with(config.disableOnReleaseSetting) { buildLayout() } - with(config.drawSetting) { buildLayout() } - if (config is HudModule) { - with(config.backgroundColor) { buildLayout() } - } - smallButton("Reset") { - config.resetSettings() - } - } - } - lambdaTooltip("Resets all settings for this module to their default values") - if (config is IMutableAutomationConfig && config.automationConfig !== AutomationConfig.DEFAULT) { - button("Automation Config") { - ImGui.openPopup("##automation-config-popup-${config.name}") - } - if (config.backingAutomationConfig !== config.defaultAutomationConfig) { - sameLine() - text("(${config.backingAutomationConfig.name})") - } - ImGui.setNextWindowSizeConstraints(0f, 0f, Float.MAX_VALUE, io.displaySize.y * 0.5f) - popupContextItem("##automation-config-popup-${config.name}", ImGuiPopupFlags.None) { - combo("##LinkedConfig", preview = "Linked Config: ${config.backingAutomationConfig.name}") { - val addItem: (Config) -> Unit = { item -> - val selected = item === config.backingAutomationConfig - - selectable(item.name, selected) { - if (!selected) { - (config.backingAutomationConfig as? UserAutomationConfig)?.linkedModules?.value?.remove(config.name) - (item as? UserAutomationConfig)?.linkedModules?.value?.add(config.name) - config.automationConfig = item as? AutomationConfig ?: return@selectable - } - } - } - addItem(config.defaultAutomationConfig) - UserAutomationCategory.configs.forEach { addItem(it) } - } - buildConfigSettingsContext(config.automationConfig) - } - } - } - - if (!hasVisibleSettings(config.settingLayers)) return - separator() - drawLayers(config.settingLayers, config.name) - } private fun ImGuiBuilder.drawLayers(root: EntryLayer.Multiple>, idPrefix: String) { var tabsDrawn = false @@ -131,7 +67,7 @@ object SettingsWidget { private fun ImGuiBuilder.drawSetting(setting: Setting<*>) { if (!setting.visibility()) return if (setting.disabled()) ImGui.beginDisabled() - with(setting) { buildLayout() } +// with(setting) { buildLayout() } if (setting.disabled()) ImGui.endDisabled() } 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..d93526892 --- /dev/null +++ b/src/main/kotlin/com/lambda/module/modules/client/Style.kt @@ -0,0 +1,224 @@ +/* + * 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.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.mutableStateOf +import androidx.compose.runtime.referentialEqualityPolicy +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 +import com.lambda.config.Config +import com.lambda.config.ConfigBlock +import com.lambda.config.ConfigEditor.forEachSetting +import com.lambda.config.Group +import com.lambda.config.withEdits +import com.lambda.module.Module +import com.lambda.module.modules.client.Style.LambdaTypography +import com.lambda.module.modules.client.Style.lambdaTheme +import com.lambda.module.tag.ModuleTag +import com.lambda.newui.theme.SystemThemeTracker.isSystemDark +import com.lambda.util.NamedEnum + +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" + + private enum class ThemeMode( + override val displayName: String = "$this" + ) : NamedEnum { + Auto, + Dark, + Light + } + + private enum class TrueThemeMode(val theme: LambdaColorSettings) { + Dark(darkTheme), + Light(lightTheme) + } + + private val themeMode by setting("Theme", ThemeMode.Auto) + + val enableGlow by setting("Enable Glow", true) + val glowRadius by setting("Glow Radius", 35f, 0f..100f, 1f) + val glowIntensity by setting("Glow Intensity", 1.2f, 0f..10f, 0.1f) + val glowColor1 by setting("Glow Color 1", Color(0, 128, 255)) + val glowColor2 by setting("Glow Color 2", Color(255, 50, 153)) + + // Kept as Setting references (not delegated) so Compose can observe() them. + val blur = setting("Enable Blur", true, "Blurs the game behind windows for a frosted-glass look") + val blurRadius = setting("Blur Radius", 12f, 1f..32f, 1f, "Strength of the frosted-glass blur") { blur.value } + + @Group(DARK_THEME_GROUP) private val darkTheme: LambdaColorSettings by configBlock( + LambdaColorSettings( + this, + true, + primary = Color(100, 180, 255), + secondary = Color(225, 130, 225), + surface = Color(18, 0, 8, 80), + surfaceVariant = Color(35, 3, 18, 80), + 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, 0), + border = Color(140, 15, 65), + textShadow = Color(0, 0, 0, 180), + ) + ).withEdits { + forEachSetting { + onValueChange { _, _ -> + val visible = themeMode == ThemeMode.Dark || (themeMode == ThemeMode.Auto && isSystemDark) + if (visible) lambdaTheme.value = darkTheme.toLambdaPalette() + } + } + } + + @Group(LIGHT_THEME_GROUP) private val lightTheme: LambdaColorSettings by configBlock( + LambdaColorSettings( + this, + false, + primary = Color(0, 100, 180), + secondary = Color(160, 40, 155), + surface = Color(250, 244, 246, 80), + surfaceVariant = Color(240, 226, 232, 80), + 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, 0), + border = Color(198, 118, 148), + textShadow = Color(0, 0, 0, 60), + ) + ).withEdits { + forEachSetting { + onValueChange { _, _ -> + val visible = themeMode == ThemeMode.Light || (themeMode == ThemeMode.Auto && !isSystemDark) + if (visible) lambdaTheme.value = lightTheme.toLambdaPalette() + } + } + } + + private var trueThemeMode = getTrueThemeMode() + val lambdaTheme = + mutableStateOf( + trueThemeMode.theme.toLambdaPalette(), + referentialEqualityPolicy() + ) + + 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, + ), + ) + + fun updateLambdaTheme() { + val currentTrueThemeMode = getTrueThemeMode() + if (trueThemeMode == currentTrueThemeMode) return + lambdaTheme.value = currentTrueThemeMode.theme.toLambdaPalette() + trueThemeMode = currentTrueThemeMode + } + + private fun getTrueThemeMode() = + if (themeMode == ThemeMode.Dark || (themeMode == ThemeMode.Auto && isSystemDark)) TrueThemeMode.Dark + else TrueThemeMode.Light +} + +@Composable +fun LambdaTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = lambdaTheme.value, + typography = LambdaTypography, + content = content + ) +} + +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..8baed3d9e 100644 --- a/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt +++ b/src/main/kotlin/com/lambda/module/tag/ModuleTag.kt @@ -17,6 +17,9 @@ package com.lambda.module.tag +import com.lambda.module.tag.ModuleTag.Companion.onShownTagsChanged +import com.lambda.module.tag.ModuleTag.Companion.shownTags +import com.lambda.module.tag.ModuleTag.Companion.toggleTag import com.lambda.util.Nameable /** @@ -49,16 +52,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..2567ac1e6 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ClickGuiContent.kt @@ -0,0 +1,124 @@ +/* + * 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.border +import androidx.compose.foundation.gestures.awaitEachGesture +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.lambda.module.Module +import com.lambda.module.modules.client.LambdaTheme +import com.lambda.module.tag.ModuleTag +import com.lambda.newui.components.CategoryPanel +import com.lambda.newui.state.LambdaState.observeShownTags +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource + +@Composable +fun ClickGuiContent() { + LambdaTheme { + val shownTags by observeShownTags() + + val focusOrder = remember { mutableStateListOf() } + var selectedSettingsModule by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + val zIndexOf = shownTags + .sortedBy { focusOrder.indexOf(it) } + .withIndex() + .associate { (index, tag) -> tag to index.toFloat() } + + Column( + modifier = Modifier.fillMaxSize() + .pointerInput(Unit) { + awaitEachGesture { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.type == PointerEventType.Press) { + if (event.changes.none { it.isConsumed }) { + scope.launch { selectedSettingsModule = null } + } + } + } + } + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Menu bar + Row( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .frostedBackground(colorScheme.background) + .border(BorderStroke(1.dp, colorScheme.outline)) + ) { + Image( + painter = painterResource(Res.drawable.lambda), + contentDescription = "Lambda logo" + ) + } + + // Categories + Row( + horizontalArrangement = Arrangement.spacedBy(3.dp, Alignment.CenterHorizontally), + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .offset(y = 100.dp), + ) { + shownTags.forEach { tag -> + key(tag) { + CategoryPanel( + tag = tag, + zIndex = zIndexOf[tag] ?: 0f, + onFocus = { + focusOrder.remove(tag) + focusOrder.add(tag) + }, + selectedSettingsModule = selectedSettingsModule, + onSettingsModuleChange = { selectedSettingsModule = it } + ) + } + } + } + } + } +} 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..4f0f3336e --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ComposeClickGui.kt @@ -0,0 +1,93 @@ +/* + * 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.LOG +import com.lambda.Lambda.mc +import com.lambda.core.Loadable +import com.lambda.event.events.ClientEvent +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 { + ComposePreloader.start() + return "Loaded ComposeClickGui" + } + + init { + listenUnsafe { + if (!open) return@listenUnsafe + ComposeRenderer.render() + } + + listenUnsafe { warmUp() } + } + + /** + * Builds the scene, runs its first composition and sets up Skia's GL context, all of which + * the first open would otherwise pay for as a visible stutter. + * + * These need the client thread, so they cannot be moved to [ComposePreloader]'s worker - but + * this fires before the first frame is drawn, so the cost lands while the game is still + * loading and nothing is animating yet, rather than freezing a live menu later. + */ + private fun warmUp() { + runCatching { + ComposeRenderer.initialize() + SystemThemeTracker.refresh() + // Renders the scene into the offscreen buffer but skips the composite, so this + // warmed frame never reaches the screen. + ComposeRenderer.render(composite = false) + }.onFailure { LOG.warn("Compose GUI warmup failed; the first open may stutter", it) } + } + + fun toggle() { + if (open) ComposeScreen.close() + else { + val current = mc.currentScreen + if (current is ComposeScreen) return + if (Client.clientSounds) LambdaSound.ModuleOn.play() + + ComposeRenderer.initialize() + SystemThemeTracker.refresh() + + ComposeScreen.parentScreen = current + (current as? OverlayBackgroundScreen)?.onOverlaidByGui() + mc.setScreen(ComposeScreen) + open = true + } + } + + fun close() { + if (Client.clientSounds) LambdaSound.ModuleOff.play() + open = false + // Drop our references to the game snapshot. The GPU texture itself lives until the + // frosted windows re-record on reopen (their cached draw commands still reference it); + // freeing it eagerly would mean closing the scene and losing window positions. + ComposeRenderer.releaseBackdrop() + } +} diff --git a/src/main/kotlin/com/lambda/newui/ComposePreloader.kt b/src/main/kotlin/com/lambda/newui/ComposePreloader.kt new file mode 100644 index 000000000..44692eed7 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ComposePreloader.kt @@ -0,0 +1,111 @@ +/* + * 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.LOG +import org.jetbrains.skia.FilterTileMode +import org.jetbrains.skia.FontMgr +import org.jetbrains.skia.ImageFilter +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Path +import org.jetbrains.skia.impl.Library +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Loads the part of the Compose GUI that does not need the client thread: skiko's native + * library, Skia's font manager, and the classes with slow static initialisers. Runs on a daemon + * worker, so it adds nothing to startup time and never stalls the frame the main menu is + * animating. + * + * Composition and anything touching the GL context are deliberately absent - both are bound to + * the render thread, so [ComposeClickGui] still does those on an idle frame once [finished]. + * + * Racing the client thread here is safe: if it reaches one of these classes first, JVM + * class-initialisation locks make it wait rather than load anything twice. + */ +object ComposePreloader { + /** Set once the worker has stopped, whether or not every step succeeded. */ + val finished = AtomicBoolean(false) + + private val started = AtomicBoolean(false) + + /** + * Entry points whose own static setup is expensive. Loading a class does not resolve + * everything it references, so this is not an attempt to enumerate the whole graph - just + * the initialisers worth paying for early. + */ + private val CLASS_NAMES = listOf( + "androidx.compose.runtime.Recomposer", + "androidx.compose.runtime.SnapshotStateKt", + "androidx.compose.ui.node.LayoutNode", + "androidx.compose.ui.graphics.Paint", + "androidx.compose.ui.graphics.Canvas", + "androidx.compose.ui.scene.CanvasLayersComposeSceneImpl", + "androidx.compose.ui.text.TextStyle", + "androidx.compose.ui.text.font.FontFamily", + "androidx.compose.material3.MaterialTheme", + ) + + /** Starts the worker. Repeat calls are ignored. */ + fun start() { + if (!started.compareAndSet(false, true)) return + + Thread({ + try { + preload() + } finally { + finished.set(true) + } + }, "Lambda Compose Preload").apply { + isDaemon = true + // The client thread's work matters more than finishing this early. + priority = Thread.MIN_PRIORITY + start() + } + } + + private fun preload() { + // Extracting and dlopen-ing the skiko native library is the single slowest step, and + // every Skia class below needs it. Idempotent, so the client thread can also call it. + step("skiko native library") { Library.staticLoad() } + + // Enumerating the system's fonts, which text layout would otherwise do mid-frame. + step("font manager") { FontMgr.default } + + // Constructed rather than named: touching a class object does not run its initialiser, + // whereas actually using one does. + step("skia objects") { + Paint().close() + Path().close() + ImageFilter.makeBlur(1f, 1f, FilterTileMode.CLAMP).close() + } + + val loader = ComposePreloader::class.java.classLoader + CLASS_NAMES.forEach { name -> + step(name) { Class.forName(name, true, loader) } + } + } + + /** + * A failed step only costs the stutter it was meant to avoid, so log it and carry on + * rather than skipping the rest. + */ + private inline fun step(what: String, block: () -> Unit) { + runCatching(block).onFailure { LOG.debug("Compose preload step '$what' failed", it) } + } +} 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..3801dd198 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ComposeRenderer.kt @@ -0,0 +1,540 @@ +/* + * 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.runtime.mutableStateOf +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.module.modules.client.Style +import com.lambda.newui.theme.SystemThemeTracker +import kotlinx.coroutines.Dispatchers +import org.jetbrains.skia.BackendRenderTarget +import org.jetbrains.skia.ColorSpace +import org.jetbrains.skia.ContentChangeMode +import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.FramebufferFormat +import org.jetbrains.skia.Image +import org.jetbrains.skia.Surface +import org.jetbrains.skia.SurfaceColorFormat +import org.jetbrains.skia.SurfaceOrigin +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.textures.GpuTexture +import com.mojang.blaze3d.textures.GpuTextureView +import com.mojang.blaze3d.textures.TextureFormat +import net.minecraft.client.gl.GlBackend +import net.minecraft.client.texture.GlTexture +import java.util.OptionalInt +import java.util.OptionalDouble +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 + + /** + * The scene's size in framebuffer pixels, which is also the coordinate space + * [androidx.compose.ui.layout.LayoutCoordinates.positionInRoot] reports in. Observable so + * layouts that clamp themselves to the screen react to a resize. + * + * `LocalWindowInfo.containerSize` cannot be used for this: no platform window backs a + * [CanvasLayersComposeScene], so it stays [IntSize.Zero]. + */ + val sceneSize = mutableStateOf(IntSize.Zero) + + // Pending input events + private var mouseX = 0f + private var mouseY = 0f + private var buttonsDown = 0 + + private var guiFboTexture: GpuTexture? = null + private var guiFboView: GpuTextureView? = null + private var guiFboWidth = 0 + private var guiFboHeight = 0 + + // Wrap of the Minecraft framebuffer used to snapshot the game for frosted windows. + private var backdropTarget: BackendRenderTarget? = null + private var backdropSurface: Surface? = null + private var backdropFboId = -1 + private var backdropWidth = 0 + private var backdropHeight = 0 + private var liveBackdropFrame: Image? = null + private var retiredBackdropFrame: Image? = null + + fun initialize() { + if (initialized) return + + scene = CanvasLayersComposeScene( + coroutineContext = Dispatchers.Unconfined, + density = Density(3f), + invalidate = {} + ).apply { + setContent { + ClickGuiContent() + } + } + + listen { + destroy() + } + + initialized = true + LOG.info("Compose renderer initialized") + } + + /** + * @param composite whether to draw the finished GUI onto Minecraft's framebuffer. Pass + * false to warm the pipeline up - the scene still renders into the offscreen buffer, + * compiling Skia's shaders and building its glyph atlas, but nothing reaches the screen. + */ + fun render(composite: Boolean = true) { + 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() + Style.updateLambdaTheme() + + 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 + } + + if (guiFboTexture == null || guiFboWidth != width || guiFboHeight != height) { + guiFboView?.close() + guiFboTexture?.close() + + val gpuDevice = RenderSystem.getDevice() + guiFboTexture = gpuDevice.createTexture( + { "Lambda GUI Glow FBO" }, + 15, + TextureFormat.RGBA8, + width, + height, + 1, + 1 + ) + guiFboView = gpuDevice.createTextureView(guiFboTexture) + guiFboWidth = width + guiFboHeight = height + } + + val gpuDevice = RenderSystem.getDevice() + gpuDevice.createCommandEncoder().createRenderPass( + { "Clear GUI FBO" }, + guiFboView, + OptionalInt.of(0x00000000), // Clear with transparent + null, + OptionalDouble.empty() + )?.close() + + val guiFboId = (guiFboTexture as GlTexture).getOrCreateFramebuffer((gpuDevice as GlBackend).bufferManager, null) + + // Resolved before resetGLAll so any framebuffer creation stays out of Skia's blind spot. + val gameFbo = resolveGameFramebuffer(gpuDevice) + + val originalFbId = GL11.glGetInteger(GL30.GL_FRAMEBUFFER_BINDING) + GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, guiFboId) + + // Invalidate Skia's GL state cache only after all of Minecraft's raw GL work above, + // so every Skia operation this frame rebinds whatever state it relies on. + directContext.resetGLAll() + + updateGameBackdrop(directContext, gameFbo) + + if (currentWidth != width || currentHeight != height) { + previousRenderTarget?.close() + surface?.close() + + val currentTarget = + BackendRenderTarget.makeGL( + width, height, + sampleCnt = 0, + stencilBits = 8, + guiFboId, + 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?.density = Density(height / 720f) + scene?.size = IntSize(width, height) + // Set before the scene renders below, so this frame's composition sees it. + sceneSize.value = IntSize(width, height) + } + + val canvas = surface?.canvas ?: return + + scene?.render(canvas.asComposeCanvas(), System.nanoTime()) + + surface?.flushAndSubmit() + directContext.flush() + + GL30.glBindFramebuffer(GL30.GL_FRAMEBUFFER, originalFbId) + + if (!composite) return + + guiFboView?.let { view -> + GuiGlowRenderer.renderGlow( + view, + if (Style.enableGlow) Style.glowRadius else 0f, + if (Style.enableGlow) Style.glowIntensity else 0f, + Style.glowColor1, + Style.glowColor2 + ) + } + } 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 + ) + } + + private data class GameFbo(val id: Int, val width: Int, val height: Int) + + private fun resolveGameFramebuffer(backend: GlBackend): GameFbo? { + if (!Style.blur.value) return null + val framebuffer = mc.framebuffer ?: return null + val colorTexture = framebuffer.colorAttachment as? GlTexture ?: return null + return GameFbo( + colorTexture.getOrCreateFramebuffer(backend.bufferManager, null), + framebuffer.textureWidth, + framebuffer.textureHeight + ) + } + + /** + * Snapshots the game framebuffer into a Skia image for [GameBackdrop]. The wrap of an + * external render target makes [Surface.makeImageSnapshot] copy, so this is a GPU-side + * copy of the frame as rendered so far — exactly what sits behind the GUI windows. + */ + private fun updateGameBackdrop(context: DirectContext, gameFbo: GameFbo?) { + if (gameFbo == null) { + releaseBackdropTargets() + publishBackdropFrame(null) + return + } + + if (backdropSurface == null || + backdropFboId != gameFbo.id || + backdropWidth != gameFbo.width || + backdropHeight != gameFbo.height + ) { + releaseBackdropTargets() + val target = BackendRenderTarget.makeGL( + gameFbo.width, gameFbo.height, + sampleCnt = 0, + stencilBits = 0, + gameFbo.id, + FramebufferFormat.GR_GL_RGBA8 + ) + backdropTarget = target + backdropSurface = Surface.makeFromBackendRenderTarget( + context, + target, + SurfaceOrigin.BOTTOM_LEFT, + SurfaceColorFormat.RGBA_8888, + ColorSpace.sRGB + ) + backdropFboId = gameFbo.id + backdropWidth = gameFbo.width + backdropHeight = gameFbo.height + } + + publishBackdropFrame(backdropSurface?.let { wrap -> + // Skia caches makeImageSnapshot and only re-copies after it observes a write to the + // surface. Minecraft mutates the wrapped framebuffer behind Skia's back, so without + // this the snapshot would stay frozen at the first frame forever. + wrap.notifyContentWillChange(ContentChangeMode.DISCARD) + wrap.makeImageSnapshot() + }) + } + + /** + * Cached draw commands may reference the previous snapshot until the scene re-records + * against the new one during the upcoming render, so it is retired for one frame and + * closed on the frame after. + */ + private fun publishBackdropFrame(image: Image?) { + if (image == null && liveBackdropFrame == null && retiredBackdropFrame == null) return + retiredBackdropFrame?.close() + retiredBackdropFrame = liveBackdropFrame + liveBackdropFrame = image + GameBackdrop.frame.value = image + } + + private fun releaseBackdropTargets() { + backdropSurface?.close() + backdropSurface = null + backdropTarget?.close() + backdropTarget = null + backdropFboId = -1 + backdropWidth = 0 + backdropHeight = 0 + } + + /** + * Frees the game snapshot and its framebuffer wrap. Safe while the GUI is closed: nothing + * replays the cached draw commands until the next render, which republishes first. + */ + fun releaseBackdrop() { + GameBackdrop.frame.value = null + liveBackdropFrame?.close() + liveBackdropFrame = null + retiredBackdropFrame?.close() + retiredBackdropFrame = null + releaseBackdropTargets() + } + + fun destroy() { + scene?.close() + scene = null + releaseBackdrop() + 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 textureBindings: IntArray, + 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 { + val originalActiveTexture = GL11.glGetInteger(GL13.GL_ACTIVE_TEXTURE) + val textureBindings = IntArray(8) { unit -> + GL13.glActiveTexture(GL13.GL_TEXTURE0 + unit) + GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D) + } + GL13.glActiveTexture(originalActiveTexture) + + return GLState( + activeTexture = originalActiveTexture, + program = GL11.glGetInteger(GL20.GL_CURRENT_PROGRAM), + textureBindings = textureBindings, + 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) + state.textureBindings.forEachIndexed { unit, binding -> + GL13.glActiveTexture(GL13.GL_TEXTURE0 + unit) + GL11.glBindTexture(GL11.GL_TEXTURE_2D, binding) + } + 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) + GL11.glColorMask(true, true, true, true) + GL11.glDepthMask(true) + 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..110d8e231 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/ComposeScreen.kt @@ -0,0 +1,94 @@ +/* + * 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.module.modules.client.Style +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 + // Vanilla's fullscreen blur runs before the frosted-window snapshot is taken, so with + // both active the windows would blur an already-blurred image and the frosted effect + // loses its sharp-outside/frosted-inside contrast. Frosted blur wins; darkening stays. + if (Style.blur.value) 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/GameBackdrop.kt b/src/main/kotlin/com/lambda/newui/GameBackdrop.kt new file mode 100644 index 000000000..d406aee3d --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/GameBackdrop.kt @@ -0,0 +1,211 @@ +/* + * 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.background +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.dp +import com.lambda.module.modules.client.Style +import com.lambda.newui.state.LambdaState.observe +import org.jetbrains.skia.BlendMode +import org.jetbrains.skia.FilterTileMode +import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageFilter +import org.jetbrains.skia.Rect +import org.jetbrains.skia.Canvas as SkCanvas +import org.jetbrains.skia.Paint as SkPaint + +object GameBackdrop { + /** + * GPU snapshot of the Minecraft framebuffer, replaced by [ComposeRenderer] right before + * each scene render. Draw scopes read it so every new snapshot re-records their backdrop. + * The renderer owns the lifecycle: an outdated snapshot stays alive for one extra frame + * because cached draw commands may still reference it until the scene re-records. + */ + val frame: MutableState = mutableStateOf(null, neverEqualPolicy()) +} + +/** + * True when a window lies entirely outside the scene, so there is nothing to frost. The scene's + * bounds are only trusted once known; before the first render everything draws. + */ +private fun isOffScreen(origin: Offset, size: Size): Boolean { + val scene = ComposeRenderer.sceneSize.value + return origin.x + size.width <= 0f || + origin.y + size.height <= 0f || + (scene.width > 0 && origin.x >= scene.width) || + (scene.height > 0 && origin.y >= scene.height) +} + +/** + * Blur filters are not free to build, and every frosted window in a frame shares one radius, so + * the filter is built once and handed out until the setting changes. + * + * A superseded filter is dropped rather than closed: draw commands recorded on an earlier frame + * may still reference it, and skiko's cleaner frees it once both they and this cache have let go. + */ +private var cachedBlur: ImageFilter? = null +private var cachedSigma = Float.NaN + +private fun blurFilter(sigma: Float): ImageFilter { + cachedBlur?.let { if (cachedSigma == sigma) return it } + return ImageFilter.makeBlur(sigma, sigma, FilterTileMode.CLAMP).also { + cachedBlur = it + cachedSigma = sigma + } +} + +/** + * Reused across windows so each frosted draw does not allocate one. Skia copies a path into its + * clip stack, and copy-on-write into a recorded picture, so rewinding this for the next window + * cannot disturb a clip already in effect. + */ +private val roundedClipPath = Path() + +/** + * Ceiling on the blur's sigma in framebuffer pixels. The scene's density scales with framebuffer + * height, so without this the same radius setting costs half again as much at 4K as at 1440p. + * Only takes effect past ~2x density at a high radius. + */ +private const val MAX_BLUR_SIGMA = 32f + +/** + * Window background that shows the game *and any windows already drawn beneath this one* + * blurred behind the window (frosted glass), falling back to a plain [background] when blur is + * disabled or no game snapshot is available. + * + * The blur samples the real framebuffer pixels around the window edge, so the glass does not + * darken toward its borders the way blurring only the clipped region would. + */ +fun Modifier.frostedBackground(color: Color, shape: Shape = RectangleShape): Modifier = composed { + val blurEnabled by Style.blur.observe() + if (!blurEnabled) return@composed background(color, shape) + + val blurRadius by Style.blurRadius.observe() + var windowOrigin by remember { mutableStateOf(Offset.Zero) } + + onGloballyPositioned { windowOrigin = it.positionInRoot() } + .drawBehind { + // Collapsed settings panels stay in the tree at full size, parked far off-screen by + // CategoryPanel until they animate open. Culling them here skips the glass work for + // every module in every shown category, which is most of the frosted nodes in a frame. + if (isOffScreen(windowOrigin, size)) return@drawBehind + + val game = GameBackdrop.frame.value + val outline = shape.createOutline(size, layoutDirection, this) + if (game == null) { + drawOutline(outline, color) + return@drawBehind + } + withOutlineClip(outline) { + drawFrostedGlass(game, windowOrigin, blurRadius) + } + drawOutline(outline, color) + } +} + +private inline fun DrawScope.withOutlineClip(outline: Outline, crossinline block: DrawScope.() -> Unit) { + when (outline) { + is Outline.Rectangle -> clipRect( + outline.rect.left, + outline.rect.top, + outline.rect.right, + outline.rect.bottom + ) { block() } + is Outline.Rounded -> clipPath( + roundedClipPath.apply { + rewind() + addRoundRect(outline.roundRect) + } + ) { block() } + is Outline.Generic -> clipPath(outline.path) { block() } + } +} + +/** + * Replaces the clipped region with frosted glass: the windows already drawn beneath this one, + * blurred, over the blurred game, over opaque black. + * + * The lower windows are blurred by a *backdrop* layer, which filters the destination pixels the + * scene has already drawn. Those pixels are only readable at [SkCanvas.saveLayer] time, so the + * layer has to be built downward — the game and the black base go in afterwards with + * [BlendMode.DST_OVER], which slides them underneath the blurred content instead of over it. + * Skia samples the backdrop from the whole device rather than just the layer, so a window sitting + * just outside this one's edge still bleeds in and the glass does not darken at its borders. + * + * The layer is composited back with [BlendMode.SRC] so the finished glass *replaces* the region: + * blending it would mix the lower windows in a second time, and the sharp originals would show + * through the blur. + */ +private fun DrawScope.drawFrostedGlass(game: Image, windowOrigin: Offset, radiusDp: Float) { + drawIntoCanvas { canvas -> + val native = canvas.nativeCanvas + val blur = blurFilter((radiusDp.dp.toPx() / 2f).coerceAtMost(MAX_BLUR_SIGMA)) + SkPaint().use { layerPaint -> + layerPaint.blendMode = BlendMode.SRC + native.saveLayer( + SkCanvas.SaveLayerRec(Rect.makeWH(size.width, size.height), layerPaint, blur) + ) + } + SkPaint().use { paint -> + paint.blendMode = BlendMode.DST_OVER + paint.imageFilter = blur + // Root coordinates equal framebuffer pixels because the scene is sized to the + // framebuffer, so shifting by the window origin lands the fullscreen snapshot on + // the pixels directly behind this window. + native.save() + native.translate(-windowOrigin.x, -windowOrigin.y) + native.drawImage(game, 0f, 0f, paint) + native.restore() + } + // Minecraft's framebuffer carries junk alpha, which the snapshot inherits. An opaque + // black base keeps the snapshot's rgb as-is while forcing alpha to 1, so the glass is + // fully opaque and the unblurred framebuffer underneath cannot ghost back through it. + SkPaint().use { paint -> + paint.blendMode = BlendMode.DST_OVER + paint.color = BLACK + native.drawPaint(paint) + } + native.restore() + } +} + +private const val BLACK = 0xFF000000.toInt() diff --git a/src/main/kotlin/com/lambda/newui/GuiGlowRenderer.kt b/src/main/kotlin/com/lambda/newui/GuiGlowRenderer.kt new file mode 100644 index 000000000..a591d5958 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/GuiGlowRenderer.kt @@ -0,0 +1,107 @@ +/* + * 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.graphics.mc.LambdaRenderPipelines +import com.mojang.blaze3d.buffers.GpuBuffer +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.textures.FilterMode +import com.mojang.blaze3d.textures.GpuTextureView +import com.mojang.blaze3d.vertex.VertexFormat +import org.joml.Matrix4f +import org.joml.Vector3f +import org.joml.Vector4f +import org.lwjgl.system.MemoryUtil +import java.util.* +import androidx.compose.ui.graphics.Color + +object GuiGlowRenderer { + private var fullscreenQuadBuffer: GpuBuffer? = null + + private fun ensureFullscreenQuad() { + if (fullscreenQuadBuffer != null) return + + val vertexSize = 20 + val buffer = MemoryUtil.memAlloc(4 * vertexSize) + try { + buffer.putFloat(-1f).putFloat(1f).putFloat(0f).putFloat(0f).putFloat(1f) + buffer.putFloat(-1f).putFloat(-1f).putFloat(0f).putFloat(0f).putFloat(0f) + buffer.putFloat(1f).putFloat(-1f).putFloat(0f).putFloat(1f).putFloat(0f) + buffer.putFloat(1f).putFloat(1f).putFloat(0f).putFloat(1f).putFloat(1f) + buffer.flip() + + fullscreenQuadBuffer = RenderSystem.getDevice().createBuffer( + { "Lambda Gui Glow Fullscreen Quad" }, + GpuBuffer.USAGE_VERTEX, + buffer + ) + } finally { + MemoryUtil.memFree(buffer) + } + } + + fun renderGlow(textureView: GpuTextureView, radius: Float, intensity: Float, color1: Color, color2: Color) { + val framebuffer = mc.framebuffer ?: return + + ensureFullscreenQuad() + val quadBuffer = fullscreenQuadBuffer ?: return + + val styleMat = Matrix4f() + styleMat.m00(radius) + styleMat.m01(intensity) + styleMat.m10(color1.red) + styleMat.m11(color1.green) + styleMat.m12(color1.blue) + styleMat.m20(color2.red) + styleMat.m21(color2.green) + styleMat.m22(color2.blue) + + val time = (System.currentTimeMillis() % 100000L) / 1000f + + val dynamicTransform = RenderSystem.getDynamicUniforms().write( + Matrix4f(), Vector4f(1f, 1f, 1f, 1f), Vector3f(time, 0f, 0f), styleMat + ) + + RenderSystem.getDevice() + .createCommandEncoder() + .createRenderPass( + { "Lambda Gui Glow Pass" }, + framebuffer.colorAttachmentView, + OptionalInt.empty(), + null, + OptionalDouble.empty() + )?.use { pass -> + pass.setPipeline(LambdaRenderPipelines.GUI_GLOW) + val nearestSampler = RenderSystem.getSamplerCache().get(FilterMode.NEAREST) + pass.bindTexture("Sampler0", textureView, nearestSampler) + pass.setUniform("DynamicTransforms", dynamicTransform) + pass.setVertexBuffer(0, quadBuffer) + + val shapeIndexBuffer = RenderSystem.getSequentialBuffer(VertexFormat.DrawMode.QUADS) + val indexBuffer = shapeIndexBuffer.getIndexBuffer(4) + pass.setIndexBuffer(indexBuffer, shapeIndexBuffer.indexType) + pass.drawIndexed(0, 0, 6, 1) + } + } + + fun cleanup() { + fullscreenQuadBuffer?.close() + fullscreenQuadBuffer = null + } +} 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..de4be2750 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/components/CategoryPanel.kt @@ -0,0 +1,396 @@ +/* + * 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.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize +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.rememberCoroutineScope +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.draw.clipToBounds +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.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.lambda.Lambda.mc +import com.lambda.module.Module +import com.lambda.module.ModuleRegistry +import com.lambda.module.tag.ModuleTag +import com.lambda.newui.ComposeRenderer +import com.lambda.newui.frostedBackground +import com.lambda.newui.state.LambdaState.observe +import com.lambda.newui.theme.Radius +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +private const val GRID_SIZE_DP = 4f + +/** The settings panel never gets narrower than this, even for short labels. */ +private val SETTINGS_MIN_WIDTH = 150.dp + +/** ...nor wider than this, so one long label cannot swallow the screen. */ +private val SETTINGS_MAX_WIDTH = 300.dp + +private fun snapToGrid(value: Float, gridSize: Float) = + (value / gridSize).roundToInt() * gridSize + +@Composable +fun CategoryPanel( + tag: ModuleTag, + zIndex: Float = 0f, + onFocus: () -> Unit = {}, + selectedSettingsModule: Module? = null, + onSettingsModuleChange: (Module?) -> Unit = {} +) { + 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 yOffsets = remember { androidx.compose.runtime.mutableStateMapOf() } + var panelCoordinates by remember { mutableStateOf(null) } + // Kept as plain Floats so dragging the panel actually recomposes the settings panels; + // positionInRoot() on the cached coordinates would silently go stale. + var panelWindowX by remember { mutableStateOf(0f) } + var panelWindowY by remember { mutableStateOf(0f) } + + // Root coordinates equal framebuffer pixels, so these are the screen bounds the settings + // panels have to stay inside. The height must stay finite: it bounds a scrollable + // container, and an infinite maximum height makes verticalScroll throw. + val sceneSize = ComposeRenderer.sceneSize.value + val screenHeightPx = sceneSize.height.takeIf { it > 0 } + ?: mc.window.framebufferHeight.coerceAtLeast(1) + val screenWidth = (sceneSize.width.takeIf { it > 0 } + ?: mc.window.framebufferWidth.coerceAtLeast(1)).toFloat() + val screenHeight = screenHeightPx.toFloat() + + val screenDensity = LocalDensity.current + val maxSettingsHeight = with(screenDensity) { screenHeightPx.toDp() } + // Anchors overlap the category list by 1dp on the joining edge, so the list's own border + // covers the seam instead of leaving a double line. + val rightAnchor = with(screenDensity) { 99.dp.toPx() } + val leftAnchor = with(screenDensity) { 1.dp.toPx() } + + val colors = MaterialTheme.colorScheme + val shape = RoundedCornerShape(Radius.ExtraSmall) + // Square only where the panel butts against the category list, so the seam reads as a join. + val settingsShape = RoundedCornerShape( + topStart = 0.dp, + topEnd = Radius.Large, + bottomEnd = Radius.Large, + bottomStart = Radius.Large + ) + // Mirrored for a panel sitting left of the category list: the seam moves to its top right. + val flippedSettingsShape = RoundedCornerShape( + topStart = Radius.Large, + topEnd = 0.dp, + bottomEnd = Radius.Large, + bottomStart = Radius.Large + ) + // Pulled off the card to fit on screen, so there is no seam left to square off. + val liftedSettingsShape = RoundedCornerShape(Radius.Large) + + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier + .zIndex(zIndex) + .width(100.dp) + .offset { + IntOffset( + snapToGrid(dragOffset.x, GRID_SIZE_DP * density).roundToInt(), + snapToGrid(dragOffset.y, GRID_SIZE_DP * density).roundToInt() + ) + } + ) { + // Settings Panels - Defined first so they render behind the main panel + Box(modifier = Modifier.matchParentSize()) { + modules.forEach { module -> + key(module.name) { + val yOffset = yOffsets[module.name] ?: 0f + val isVisible = selectedSettingsModule == module + val transitionProgress by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = spring(), + label = "SettingsAnimation" + ) + + var contentSize by remember { mutableStateOf(IntSize.Zero) } + + // Growing down from the card would run off the bottom of the screen, so lift + // the panel by the overflow to pin its bottom edge to the screen instead. The + // lift stops at the top of the screen; past that the content scrolls. + val anchorInWindow = panelWindowY + yOffset + val overflow = (anchorInWindow + contentSize.height - screenHeight).coerceAtLeast(0f) + val lift = overflow.coerceAtMost(anchorInWindow.coerceAtLeast(0f)) + val panelTop = yOffset - lift + + // Opening to the right would run off that edge of the screen, so flip to the + // other side of the category list - but only when the panel actually fits + // there, otherwise it would just run off the left edge instead. + val flipped = contentSize.width > 0 && + panelWindowX + rightAnchor + contentSize.width > screenWidth && + panelWindowX + leftAnchor - contentSize.width >= 0f + + val panelShape = when { + // Lifted off the card, so neither side has a seam left to square off. + lift > 0.5f -> liftedSettingsShape + flipped -> flippedSettingsShape + else -> settingsShape + } + + Box( + modifier = Modifier + .zIndex(if (isVisible) 1f else 0f) + .wrapContentSize(unbounded = true, align = Alignment.TopStart) + .offset { + if (transitionProgress == 0f) return@offset IntOffset(-9999, -9999) + // A flipped panel keeps its right edge pinned to the seam, so it + // opens outwards from the list rather than sliding in from the + // far side. Mirrors the width the layout below settles on. + val x = if (flipped) { + leftAnchor - contentSize.width * transitionProgress + } else { + rightAnchor + } + IntOffset(x.roundToInt(), panelTop.roundToInt()) + } + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val panelHeight = panelCoordinates?.size?.height?.toFloat() ?: 9999f + val originalBottom = panelTop + placeable.height + val targetBottom = minOf(originalBottom, panelHeight) + val animatedBottom = targetBottom + (originalBottom - targetBottom) * transitionProgress + + val currentHeight = maxOf(0, (animatedBottom - panelTop).roundToInt()) + val currentWidth = (placeable.width * transitionProgress).roundToInt() + + layout(currentWidth, currentHeight) { + // Reveal from the seam: the right edge when flipped, so the + // clipped-away part is the one furthest from the list. + placeable.place(if (flipped) currentWidth - placeable.width else 0, 0) + } + } + ) { + Box( + modifier = Modifier + // The panel widens to the longest label rather than wrapping it. + // widthIn clamps first, so width() resolves to the content's max + // intrinsic width coerced into that range. + .widthIn(min = SETTINGS_MIN_WIDTH, max = SETTINGS_MAX_WIDTH) + // Never taller than the screen; the content scrolls beyond that. + // Ahead of width() on purpose: wrapContentSize(unbounded) hands + // down an infinite maximum height, and width(IntrinsicSize.Max) + // forwards it into maxIntrinsicWidth(), where the scrollable + // content would reject it. Clamping first keeps both passes finite. + .heightIn(max = maxSettingsHeight) + .width(IntrinsicSize.Max) + .onSizeChanged { contentSize = it } + .clip(panelShape) + .frostedBackground(colors.surfaceVariant, panelShape) + .border(1.dp, colors.outline, panelShape) + .pointerInput(isVisible) { + if (isVisible) { + awaitEachGesture { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.type == PointerEventType.Press) { + event.changes.forEach { it.consume() } + } + } + } + } + } + .padding(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + SettingsTree(module.settingLayers) + ModuleConfigSettings(module) + } + } + } + } + } + } + + // Main Panel + key("MainPanel") { + Column( + modifier = Modifier + .onGloballyPositioned { + panelCoordinates = it + val root = it.positionInRoot() + panelWindowX = root.x + panelWindowY = root.y + } + .width(100.dp) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type == PointerEventType.Press) { + scope.launch { onFocus() } + } + } + } + } + .clip(shape) + .frostedBackground(colors.surface, shape) + .border(1.dp, colors.outline, shape) + ) { + Row( + modifier = Modifier + .background(colors.primaryContainer) + .fillMaxWidth() + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { scope.launch { onFocus() } }, + onDrag = { _, dragAmount -> + dragOffset += dragAmount + } + ) + } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.type == PointerEventType.Press) { + if (event.buttons.isSecondaryPressed) { + expanded = !expanded + if (!expanded) { + onSettingsModuleChange(null) + } + event.changes.forEach { it.consume() } + } + } + } + } + } + .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))) + ) + } + + val expandProgress by animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = spring(), + label = "ExpandAnimation" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val currentHeight = (placeable.height * expandProgress).roundToInt() + layout(placeable.width, currentHeight) { + placeable.place(0, currentHeight - placeable.height) + } + } + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + ) { + modules.forEach { module -> + key(module.name) { + ModuleCard( + module = module, + isSettingsOpen = selectedSettingsModule == module, + onRightClick = { + if (selectedSettingsModule == module) { + onSettingsModuleChange(null) + } else { + onSettingsModuleChange(module) + } + }, + onPositionChange = { cardCoordinates -> + if (panelCoordinates != null && cardCoordinates.isAttached) { + val pos = panelCoordinates!!.localPositionOf(cardCoordinates, Offset.Zero) + yOffsets[module.name] = pos.y + } + } + ) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/newui/components/ColorSettingGui.kt b/src/main/kotlin/com/lambda/newui/components/ColorSettingGui.kt new file mode 100644 index 000000000..297a6ab4a --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/components/ColorSettingGui.kt @@ -0,0 +1,492 @@ +/* + * 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.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +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.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt + +// ── HSV helpers ────────────────────────────────────────────────────────────── + +private data class HSV(val h: Float, val s: Float, val v: Float) + +private fun rgbToHsv(r: Int, g: Int, b: Int): HSV { + val rf = r / 255f; val gf = g / 255f; val bf = b / 255f + val max = maxOf(rf, gf, bf); val min = minOf(rf, gf, bf) + val delta = max - min + val h = when { + delta == 0f -> 0f + max == rf -> 60f * (((gf - bf) / delta) % 6f) + max == gf -> 60f * (((bf - rf) / delta) + 2f) + else -> 60f * (((rf - gf) / delta) + 4f) + }.let { if (it < 0f) it + 360f else it } + val s = if (max == 0f) 0f else delta / max + return HSV(h, s, max) +} + +private fun hsvToRgb(h: Float, s: Float, v: Float): Triple { + val c = v * s; val x = c * (1f - kotlin.math.abs((h / 60f) % 2f - 1f)); val m = v - c + val (r1, g1, b1) = when { + h < 60f -> Triple(c, x, 0f) + h < 120f -> Triple(x, c, 0f) + h < 180f -> Triple(0f, c, x) + h < 240f -> Triple(0f, x, c) + h < 300f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + return Triple( + ((r1 + m) * 255f).roundToInt().coerceIn(0, 255), + ((g1 + m) * 255f).roundToInt().coerceIn(0, 255), + ((b1 + m) * 255f).roundToInt().coerceIn(0, 255) + ) +} + +private fun hueColor(h: Float): Color { + val (r, g, b) = hsvToRgb(h, 1f, 1f) + return Color(r, g, b) +} + +// ── Compact text style used throughout ─────────────────────────────────────── + +private val compactStyle = TextStyle( + fontSize = 8.sp, lineHeight = 8.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) +) + +private val labelStyle = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) +) + +// ── Public entry point (same signature as before) ──────────────────────────── + +@Composable +fun ColorSettingGui( + name: String, + red: Int, + green: Int, + blue: Int, + alpha: Int, + onColorChange: (r: Int, g: Int, b: Int, a: Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val colors = MaterialTheme.colorScheme + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + // ── Header: name + swatch ──────────────────────────────────────────── + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + .clickable { expanded = !expanded } + ) { + Text(text = name, color = colors.onSurface, style = labelStyle) + Box( + modifier = Modifier + .size(14.dp, 10.dp) + .clip(RoundedCornerShape(2.dp)) + .drawBehind { drawCheckerboard() } + .background(Color(red, green, blue, alpha)) + .border(0.5.dp, colors.outline, RoundedCornerShape(2.dp)) + ) + } + + // ── Expanded picker ────────────────────────────────────────────────── + if (expanded) { + val hsv = remember(red, green, blue) { rgbToHsv(red, green, blue) } + var hue by remember(red, green, blue) { mutableStateOf(hsv.h) } + var sat by remember(red, green, blue) { mutableStateOf(hsv.s) } + var value by remember(red, green, blue) { mutableStateOf(hsv.v) } + + fun emitFromHsv(h: Float = hue, s: Float = sat, v: Float = value, a: Int = alpha) { + val (nr, ng, nb) = hsvToRgb(h, s, v) + onColorChange(nr, ng, nb, a) + } + + Column(modifier = Modifier.padding(top = 3.dp, start = 2.dp)) { + // ── SV square ──────────────────────────────────────────────── + SaturationValuePicker( + hue = hue, sat = sat, value = value, + onSatValChange = { s, v -> + sat = s; value = v; emitFromHsv(s = s, v = v) + } + ) + + Spacer(Modifier.height(3.dp)) + + // ── Hue bar ────────────────────────────────────────────────── + HueBar(hue = hue, onHueChange = { h -> + hue = h; emitFromHsv(h = h) + }) + + Spacer(Modifier.height(2.dp)) + + // ── Alpha bar ──────────────────────────────────────────────── + AlphaBar(red = red, green = green, blue = blue, alpha = alpha, + onAlphaChange = { a -> emitFromHsv(a = a) } + ) + + Spacer(Modifier.height(4.dp)) + + // ── RGBA inputs ────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + ChannelInput("R", red, colors.onSurface, colors.surfaceContainerHigh, Modifier.weight(1f)) { + onColorChange(it, green, blue, alpha) + } + ChannelInput("G", green, colors.onSurface, colors.surfaceContainerHigh, Modifier.weight(1f)) { + onColorChange(red, it, blue, alpha) + } + ChannelInput("B", blue, colors.onSurface, colors.surfaceContainerHigh, Modifier.weight(1f)) { + onColorChange(red, green, it, alpha) + } + ChannelInput("A", alpha, colors.onSurface, colors.surfaceContainerHigh, Modifier.weight(1f)) { + onColorChange(red, green, blue, it) + } + } + + Spacer(Modifier.height(2.dp)) + + // ── Hex input ──────────────────────────────────────────────── + HexInput( + red = red, green = green, blue = blue, alpha = alpha, + textColor = colors.onSurface, bgColor = colors.surfaceContainerHigh, + onColorChange = onColorChange + ) + + Spacer(Modifier.height(2.dp)) + } + } + } +} + +// ── SV picker (saturation on X, value/brightness on Y) ─────────────────────── + +@Composable +private fun SaturationValuePicker( + hue: Float, sat: Float, value: Float, + onSatValChange: (s: Float, v: Float) -> Unit +) { + val baseColor = hueColor(hue) + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1.4f) + .clip(RoundedCornerShape(3.dp)) + .drawBehind { + // Base hue fill + drawRect(baseColor) + // White → transparent (left to right = saturation) + drawRect(Brush.horizontalGradient(listOf(Color.White, Color.Transparent))) + // Transparent → black (top to bottom = brightness) + drawRect(Brush.verticalGradient(listOf(Color.Transparent, Color.Black))) + } + .pointerInput(Unit) { + detectTapGestures { offset -> + val s = (offset.x / size.width).coerceIn(0f, 1f) + val v = 1f - (offset.y / size.height).coerceIn(0f, 1f) + onSatValChange(s, v) + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + val s = (change.position.x / size.width).coerceIn(0f, 1f) + val v = 1f - (change.position.y / size.height).coerceIn(0f, 1f) + onSatValChange(s, v) + } + } + ) { + // Crosshair indicator + val indicatorSize = 6.dp + val parentW = maxWidth + val parentH = maxHeight + Box( + modifier = Modifier + .offset { + IntOffset( + (sat * (parentW.toPx() - indicatorSize.toPx())).roundToInt(), + ((1f - value) * (parentH.toPx() - indicatorSize.toPx())).roundToInt() + ) + } + .size(indicatorSize) + .border(1.5.dp, Color.White, CircleShape) + .border(0.5.dp, Color.Black, CircleShape) + ) + } +} + +// ── Hue bar (rainbow gradient) ─────────────────────────────────────────────── + +@Composable +private fun HueBar(hue: Float, onHueChange: (Float) -> Unit) { + val rainbowColors = remember { + (0..6).map { hueColor(it * 60f) } + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(RoundedCornerShape(2.dp)) + .drawBehind { + drawRect(Brush.horizontalGradient(rainbowColors)) + } + .pointerInput(Unit) { + detectTapGestures { offset -> + onHueChange((offset.x / size.width).coerceIn(0f, 1f) * 360f) + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + onHueChange((change.position.x / size.width).coerceIn(0f, 1f) * 360f) + } + } + ) { + // Position indicator + val fraction = hue / 360f + val parentW = maxWidth + Box( + modifier = Modifier + .offset { + IntOffset( + (fraction * (parentW.toPx() - 4.dp.toPx())).roundToInt(), 0 + ) + } + .width(4.dp) + .fillMaxHeight() + .background(Color.White, RoundedCornerShape(1.dp)) + .border(0.5.dp, Color.Black, RoundedCornerShape(1.dp)) + ) + } +} + +// ── Alpha bar (checkerboard + gradient) ────────────────────────────────────── + +@Composable +private fun AlphaBar( + red: Int, green: Int, blue: Int, alpha: Int, + onAlphaChange: (Int) -> Unit +) { + val solidColor = Color(red, green, blue) + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(RoundedCornerShape(2.dp)) + .drawBehind { + drawCheckerboard() + drawRect(Brush.horizontalGradient(listOf(Color.Transparent, solidColor))) + } + .pointerInput(Unit) { + detectTapGestures { offset -> + onAlphaChange(((offset.x / size.width).coerceIn(0f, 1f) * 255f).roundToInt()) + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + onAlphaChange(((change.position.x / size.width).coerceIn(0f, 1f) * 255f).roundToInt()) + } + } + ) { + val fraction = alpha / 255f + val parentW = maxWidth + Box( + modifier = Modifier + .offset { + IntOffset( + (fraction * (parentW.toPx() - 4.dp.toPx())).roundToInt(), 0 + ) + } + .width(4.dp) + .fillMaxHeight() + .background(Color.White, RoundedCornerShape(1.dp)) + .border(0.5.dp, Color.Black, RoundedCornerShape(1.dp)) + ) + } +} + +// ── RGBA channel text input ────────────────────────────────────────────────── + +@Composable +private fun ChannelInput( + label: String, channelValue: Int, + textColor: Color, bgColor: Color, + modifier: Modifier = Modifier, + onValueChange: (Int) -> Unit +) { + val shape = RoundedCornerShape(2.dp) + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = label, color = textColor.copy(alpha = 0.6f), style = compactStyle) + BasicTextField( + value = channelValue.toString(), + onValueChange = { text -> + val parsed = text.filter { it.isDigit() }.take(3).toIntOrNull() + if (parsed != null) onValueChange(parsed.coerceIn(0, 255)) + else if (text.isEmpty()) onValueChange(0) + }, + textStyle = compactStyle.copy(color = textColor, textAlign = TextAlign.Center), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .height(14.dp) + .clip(shape) + .background(bgColor) + .padding(horizontal = 2.dp, vertical = 2.dp) + ) + } +} + +// ── Hex text input ─────────────────────────────────────────────────────────── + +@Composable +private fun HexInput( + red: Int, green: Int, blue: Int, alpha: Int, + textColor: Color, bgColor: Color, + onColorChange: (r: Int, g: Int, b: Int, a: Int) -> Unit +) { + val hexString = buildString { + append('#') + append("%02X".format(red)) + append("%02X".format(green)) + append("%02X".format(blue)) + if (alpha < 255) append("%02X".format(alpha)) + } + + val shape = RoundedCornerShape(2.dp) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Hex", color = textColor.copy(alpha = 0.6f), style = compactStyle, + modifier = Modifier.padding(end = 4.dp)) + BasicTextField( + value = hexString, + onValueChange = { text -> + parseHexColor(text)?.let { (r, g, b, a) -> onColorChange(r, g, b, a) } + }, + textStyle = compactStyle.copy(color = textColor), + singleLine = true, + modifier = Modifier + .weight(1f) + .height(14.dp) + .clip(shape) + .background(bgColor) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + } +} + +private fun parseHexColor(text: String): List? { + val hex = text.removePrefix("#") + return when (hex.length) { + 6 -> { + val r = hex.substring(0, 2).toIntOrNull(16) ?: return null + val g = hex.substring(2, 4).toIntOrNull(16) ?: return null + val b = hex.substring(4, 6).toIntOrNull(16) ?: return null + listOf(r, g, b, 255) + } + 8 -> { + val r = hex.substring(0, 2).toIntOrNull(16) ?: return null + val g = hex.substring(2, 4).toIntOrNull(16) ?: return null + val b = hex.substring(4, 6).toIntOrNull(16) ?: return null + val a = hex.substring(6, 8).toIntOrNull(16) ?: return null + listOf(r, g, b, a) + } + else -> null + } +} + +// ── Checkerboard (transparency indicator) ──────────────────────────────────── + +private fun DrawScope.drawCheckerboard() { + val cellSize = 4.dp.toPx() + val cols = (size.width / cellSize).toInt() + 1 + val rows = (size.height / cellSize).toInt() + 1 + for (row in 0 until rows) { + for (col in 0 until cols) { + val color = if ((row + col) % 2 == 0) Color(0xFFCCCCCC) else Color(0xFF999999) + drawRect( + color = color, + topLeft = Offset(col * cellSize, row * cellSize), + size = Size(cellSize, cellSize) + ) + } + } +} 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..cdc5aff46 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/components/ModuleCard.kt @@ -0,0 +1,115 @@ +/* + * 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.border +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.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +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.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +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 +import kotlinx.coroutines.launch + +@Composable +fun ModuleCard( + module: Module, + isSettingsOpen: Boolean, + onRightClick: () -> Unit, + onPositionChange: (LayoutCoordinates) -> Unit +) { + val enabled by module.observeEnabled() + val colors = MaterialTheme.colorScheme + + val backgroundColor by animateColorAsState( + targetValue = if (enabled) colors.secondaryContainer else Color.Transparent, + animationSpec = spring(), + label = "moduleCardBg" + ) + val textColor = if (enabled) colors.onSecondaryContainer else colors.onSurfaceVariant + + // The open settings panel is marked with an outline so the card keeps showing + // its own enabled/disabled colouring. + val outlineColor by animateColorAsState( + targetValue = if (isSettingsOpen) colors.primary else Color.Transparent, + animationSpec = spring(), + label = "moduleCardOutline" + ) + + val currentOnRightClick by rememberUpdatedState(onRightClick) + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier + .onGloballyPositioned { onPositionChange(it) } + .fillMaxWidth() + .background(backgroundColor) + // The card spans the panel's full width, so an un-inset outline would put its + // side edges underneath the panel's own border. Inset the outline, not the fill. + .padding(horizontal = 1.dp) + .border(1.dp, outlineColor) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.type == PointerEventType.Press) { + if (event.buttons.isSecondaryPressed) { + scope.launch { currentOnRightClick() } + event.changes.forEach { it.consume() } + } + } + } + } + } + .clickable { module.toggle() } + .padding(horizontal = 4.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))) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/lambda/newui/components/ModuleConfigSettings.kt b/src/main/kotlin/com/lambda/newui/components/ModuleConfigSettings.kt new file mode 100644 index 000000000..15abf6009 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/components/ModuleConfigSettings.kt @@ -0,0 +1,221 @@ +/* + * 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.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme.colorScheme +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.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lambda.config.automation.AutomationConfig +import com.lambda.config.automation.UserAutomationConfig +import com.lambda.config.categories.UserAutomationCategory +import com.lambda.module.HudModule +import com.lambda.module.Module +import com.lambda.module.modules.client.AutoUpdater +import com.lambda.newui.theme.Radius + +/** + * Renders the "Module Settings" and "Automation Config" sections that used to sit + * as buttons at the top of the old ImGui settings popup. They are appended below + * the module's own settings instead. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModuleConfigSettings(module: Module) { + if (module == AutoUpdater) return + + if (module.automationConfig !== AutomationConfig.DEFAULT) { + var linked by remember(module) { mutableStateOf(module.backingAutomationConfig) } + + SettingsGroup( + name = "Automation Config", + suffix = if (linked !== module.defaultAutomationConfig) "(${linked.name})" else null + ) { + AutomationConfigSelector(module, linked) { selected -> + (module.backingAutomationConfig as? UserAutomationConfig)?.linkedModules?.value?.remove(module.name) + (selected as? UserAutomationConfig)?.linkedModules?.value?.add(module.name) + module.automationConfig = selected + linked = module.backingAutomationConfig + } + + // The linked config's cores are aliased into the module's own automation config, + // so the tree is rebuilt on switch to pick up the swapped values. + key(linked) { + SettingsTree(module.automationConfig.settingLayers) + } + } + } + + SettingsGroup("Module Settings") { + module.keybindSetting.gui() + module.prioritySetting.gui() + module.disableOnReleaseSetting.gui() + module.drawSetting.gui() + if (module is HudModule) module.backgroundColor.gui() + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 1.dp), + contentAlignment = Alignment.Center + ) { + Button( + onClick = { module.resetSettings() }, + modifier = Modifier + .fillMaxWidth() + .height(20.dp), + shape = RoundedCornerShape(Radius.Small), + contentPadding = PaddingValues(0.dp) + ) { + Text(text = "Reset", fontSize = 9.sp) + } + } + } +} + +/** + * Dropdown listing the module's own automation config plus every user defined one. + */ +@Composable +private fun AutomationConfigSelector( + module: Module, + linked: AutomationConfig, + onSelect: (AutomationConfig) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val options = listOf(module.defaultAutomationConfig) + + UserAutomationCategory.configs.filterIsInstance() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 1.dp) + // Clipped as a whole so the field and its open list read as one control. + .clip(RoundedCornerShape(Radius.Small)) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 16.dp) + .background(colorScheme.surfaceContainerHigh) + .clickable { expanded = !expanded }, + contentAlignment = Alignment.CenterStart + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + Text( + text = "Linked Config: ${linked.name}", + color = colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false), + style = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + Text( + text = if (expanded) "▲" else "▼", + color = colorScheme.onSurface, + style = TextStyle( + fontSize = 7.sp, lineHeight = 7.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(animationSpec = tween(150)) + fadeIn(animationSpec = tween(150)), + exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(animationSpec = tween(150)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(colorScheme.surfaceContainer) + ) { + options.forEach { option -> + val selected = option === linked + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 16.dp) + .clickable { + expanded = false + if (!selected) onSelect(option) + } + .background(if (selected) colorScheme.primaryContainer else Color.Transparent) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = option.name, + color = if (selected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant, + style = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + } + } + } + } + } +} diff --git a/src/main/kotlin/com/lambda/newui/components/SettingsTree.kt b/src/main/kotlin/com/lambda/newui/components/SettingsTree.kt new file mode 100644 index 000000000..9f91432b1 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/components/SettingsTree.kt @@ -0,0 +1,240 @@ +/* + * 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.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lambda.config.EntryLayer +import com.lambda.config.MultipleLayerType +import com.lambda.config.entries.Setting +import com.lambda.newui.theme.Radius + +/** + * Collapsible, labeled container used for setting groups and for the + * module/automation config sections appended below a module's settings. + */ +@Composable +fun SettingsGroup( + name: String, + suffix: String? = null, + content: @Composable () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + val rotation by animateFloatAsState( + targetValue = if (expanded) 90f else 0f, + animationSpec = tween(150) + ) + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 16.dp) + .clickable { expanded = !expanded } + .padding(horizontal = 4.dp) + ) { + Text( + text = "▶", + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(end = 4.dp).rotate(rotation), + style = TextStyle( + fontSize = 7.sp, lineHeight = 7.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + Text( + text = name, + color = MaterialTheme.colorScheme.onSurface, + style = TextStyle( + fontSize = 9.sp, lineHeight = 9.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + if (suffix != null) { + Text( + text = suffix, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 4.dp), + style = TextStyle( + fontSize = 8.sp, lineHeight = 8.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(animationSpec = tween(150)) + fadeIn(animationSpec = tween(150)), + exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(animationSpec = tween(150)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp) + ) { + content() + } + } + } +} + +/** + * Whether this layer holds at least one currently visible setting, at any depth. + * Layers that hold none are skipped entirely so the panel has no empty headers. + */ +fun EntryLayer.Multiple>.hasVisibleSettings(): Boolean = + layers.any { child -> + when (child) { + is EntryLayer.Single -> child.entry.visibility() + is EntryLayer.Multiple -> child.hasVisibleSettings() + else -> false + } + } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SettingsTree(layer: EntryLayer>) { + when (layer) { + is EntryLayer.Single -> { + val setting = layer.entry + AnimatedVisibility(visible = setting.visibility()) { + setting.gui() + } + } + is EntryLayer.Multiple -> { + val hasTabs = layer.layers.any { it is EntryLayer.Multiple && it.multipleType == MultipleLayerType.Tab } + + if (hasTabs) { + val tabs = layer.layers.filterIsInstance>>() + .filter { it.multipleType == MultipleLayerType.Tab && it.hasVisibleSettings() } + if (tabs.isEmpty()) return + var selectedTabIndex by remember { mutableStateOf(0) } + + val colors = MaterialTheme.colorScheme + val barShape = RoundedCornerShape(Radius.Small) + + Column { + // A TabRow splits its width evenly, which squeezes longer names down to a + // single wrapped character in the narrow panel. The tabs are laid out as one + // continuous segmented bar instead: labels keep their own width and wrap onto + // more rows when needed, and the single filled cell reads as "pick one". + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 1.dp) + .clip(barShape) + .background(colors.surfaceContainer) + .border(1.dp, colors.outlineVariant, barShape) + ) { + tabs.forEachIndexed { index, tabLayer -> + val selected = selectedTabIndex == index + Box( + modifier = Modifier + .background(if (selected) colors.primary else Color.Transparent) + .clickable { selectedTabIndex = index } + .padding(horizontal = 5.dp, vertical = 4.dp) + ) { + Text( + text = tabLayer.name, + color = if (selected) colors.onPrimary else colors.onSurfaceVariant, + maxLines = 1, + softWrap = false, + style = TextStyle( + fontSize = 8.sp, lineHeight = 8.sp, lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both + ) + ) + ) + } + } + } + + // The selection can fall out of range when a tab stops having visible settings. + val selectedTab = tabs.getOrNull(selectedTabIndex) ?: tabs.first() + Column { + selectedTab.layers.forEach { child -> + SettingsTree(child) + } + } + } + } else if (layer.multipleType == MultipleLayerType.Group) { + if (!layer.hasVisibleSettings()) return + SettingsGroup(layer.name) { + layer.layers.forEach { child -> + SettingsTree(child) + } + } + } else { + // Root or standard multiple + Column(modifier = Modifier.fillMaxWidth()) { + layer.layers.forEach { child -> + SettingsTree(child) + } + } + } + } + } +} + 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..2086400c9 --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/state/LambdaState.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.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 + +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 } + } + + @Suppress("UNCHECKED_CAST") + fun Setting.observe(): State = + settingStates.computeIfAbsent(this) { _ -> + val state = mutableStateOf(value) + onValueChangeUnsafe { _, to -> state.value = to } + state + } as State + + fun Module.observeEnabled(): State = + enabledStates.computeIfAbsent(this) { module -> + val state = mutableStateOf(module.isEnabled) + module.onToggleUnsafe { to -> state.value = to } + state + } + + fun observeShownTags(): State> = shownTagsState +} diff --git a/src/main/kotlin/com/lambda/newui/theme/Radius.kt b/src/main/kotlin/com/lambda/newui/theme/Radius.kt new file mode 100644 index 000000000..7a659060d --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/theme/Radius.kt @@ -0,0 +1,51 @@ +/* + * 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.ui.unit.dp + +/** + * Corner radii, on the same four steps and the same tiers as [Spacing]: a component's + * radius comes from the tier it belongs to, so its corners stay in proportion to the + * room it is given. + */ +object Radius { + /** + * Internal interactive elements. + * Checkboxes, toggle switches, colour swatches, small module status tags. + */ + val ExtraSmall = 2.dp + + /** + * Standard actionable elements, and the baseline for anything unlisted. + * Sliders, dropdown fields, text inputs, tab bars, configuration buttons. + */ + val Small = 4.dp + + /** + * Structural components. + * Module panels, draggable menus, HUD widgets, setting popups. + */ + val Medium = 8.dp + + /** + * Major layout windows. + * The GUI wrapper that hosts every panel. + */ + val Large = 12.dp +} 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..92038954d --- /dev/null +++ b/src/main/kotlin/com/lambda/newui/theme/SystemThemeTracker.kt @@ -0,0 +1,49 @@ +/* + * 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 com.lambda.util.Timer +import org.jetbrains.skiko.SystemTheme +import org.jetbrains.skiko.currentSystemTheme +import kotlin.time.Duration.Companion.milliseconds + +object SystemThemeTracker { + private const val POLL_INTERVAL = 500 + + private var dark = queryIsDark() + private val queryTimer = Timer() + + val isSystemDark get() = dark + + fun refresh() { + queryTimer.reset() + dark = queryIsDark() + } + + fun poll() { + if (!queryTimer.timePassed(POLL_INTERVAL.milliseconds)) return + queryTimer.reset() + dark = queryIsDark() + } + + private fun queryIsDark(): Boolean = + when (currentSystemTheme) { + SystemTheme.LIGHT -> false + 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 diff --git a/src/main/resources/assets/lambda/shaders/core/gui_glow.fsh b/src/main/resources/assets/lambda/shaders/core/gui_glow.fsh new file mode 100644 index 000000000..88b7e38e7 --- /dev/null +++ b/src/main/resources/assets/lambda/shaders/core/gui_glow.fsh @@ -0,0 +1,88 @@ +#version 330 +#moj_import + +uniform sampler2D Sampler0; + +in vec2 v_TexCoord; +out vec4 fragColor; + +void main() { + float u_GlowRadius = TextureMat[0][0]; // in pixels + float u_GlowIntensity = TextureMat[0][1]; + vec4 u_GlowColor = ColorModulator; + + vec2 texelSize = 1.0 / textureSize(Sampler0, 0); + vec4 center = texture(Sampler0, v_TexCoord); + + // Draw the original UI if there's any data + if (center.a > 0.0) { + fragColor = center; + return; + } + + int maxRadiusInt = int(ceil(u_GlowRadius)); + if (maxRadiusInt <= 0) { + discard; + } + + float minDist = u_GlowRadius + 1.0; + int step = max(1, maxRadiusInt / 12); + int bestDx = 0, bestDy = 0; + + // Coarse search + for (int dy = -maxRadiusInt; dy <= maxRadiusInt; dy += step) { + for (int dx = -maxRadiusInt; dx <= maxRadiusInt; dx += step) { + float dist = length(vec2(float(dx), float(dy))); + if (dist > u_GlowRadius || dist >= minDist) continue; + + vec2 sampleCoord = v_TexCoord + vec2(float(dx), float(dy)) * texelSize; + if (texture(Sampler0, sampleCoord).a > 0.0) { + minDist = dist; + bestDx = dx; + bestDy = dy; + } + } + } + + // Fine search around the best coarse result + if (step > 1 && minDist <= u_GlowRadius + 1.0) { + int rMin = step; + for (int dy = bestDy - rMin; dy <= bestDy + rMin; dy++) { + for (int dx = bestDx - rMin; dx <= bestDx + rMin; dx++) { + float dist = length(vec2(float(dx), float(dy))); + if (dist > u_GlowRadius || dist >= minDist) continue; + + vec2 sampleCoord = v_TexCoord + vec2(float(dx), float(dy)) * texelSize; + if (texture(Sampler0, sampleCoord).a > 0.0) { + minDist = dist; + } + } + } + } + + if (minDist <= u_GlowRadius) { + float glowFactor = 1.0 - (minDist / u_GlowRadius); + + // Apply an exponential curve for a very smooth, long tail falloff + glowFactor = pow(glowFactor, 2.5); + + // Create an animated gradient between Blue and Pink + vec3 colorBlue = vec3(0.0, 0.5, 1.0); + vec3 colorPink = vec3(1.0, 0.2, 0.6); + + // Animate based on time (ModelOffset.x) and screen coordinates + float time = ModelOffset.x; + float gradientWave = sin(time * 2.0 + v_TexCoord.x * 5.0 + v_TexCoord.y * 3.0); + float mixFactor = (gradientWave + 1.0) * 0.5; + + vec3 gradientColor = mix(colorBlue, colorPink, mixFactor); + + // Clamp alpha to exactly 0.0-1.0 to prevent OpenGL blend mode math corruption + float alpha = clamp(glowFactor * u_GlowIntensity, 0.0, 1.0); + + // Pre-multiply RGB by alpha to prevent sRGB desaturation + fragColor = vec4(gradientColor * alpha, alpha); + } else { + discard; + } +} diff --git a/src/main/resources/assets/lambda/shaders/core/gui_glow.vsh b/src/main/resources/assets/lambda/shaders/core/gui_glow.vsh new file mode 100644 index 000000000..b1b02bf57 --- /dev/null +++ b/src/main/resources/assets/lambda/shaders/core/gui_glow.vsh @@ -0,0 +1,11 @@ +#version 330 + +in vec3 Position; +in vec2 UV0; + +out vec2 v_TexCoord; + +void main() { + gl_Position = vec4(Position.xy, 0.0, 1.0); + v_TexCoord = UV0; +}