diff --git a/src/main/kotlin/org/cobalt/Cobalt.kt b/src/main/kotlin/org/cobalt/Cobalt.kt index f0fb288..0761659 100644 --- a/src/main/kotlin/org/cobalt/Cobalt.kt +++ b/src/main/kotlin/org/cobalt/Cobalt.kt @@ -3,6 +3,8 @@ package org.cobalt import net.fabricmc.api.ClientModInitializer import org.cobalt.api.command.CommandManager import org.cobalt.api.event.EventBus +import org.cobalt.api.hud.HudModuleManager +import org.cobalt.api.hud.modules.WatermarkModule import org.cobalt.api.module.ModuleManager import org.cobalt.api.notification.NotificationManager import org.cobalt.api.rotation.RotationExecutor @@ -16,6 +18,8 @@ object Cobalt : ClientModInitializer { override fun onInitializeClient() { + ModuleManager.addModules(listOf(WatermarkModule(), WatermarkModule())) + AddonLoader.getAddons().map { it.second }.forEach { it.onLoad() ModuleManager.addModules(it.getModules()) @@ -26,7 +30,7 @@ object Cobalt : ClientModInitializer { listOf( TickScheduler, MainCommand, NotificationManager, - RotationExecutor, + RotationExecutor, HudModuleManager, ).forEach { EventBus.register(it) } Config.loadModulesConfig() EventBus.register(this) diff --git a/src/main/kotlin/org/cobalt/api/addon/Addon.kt b/src/main/kotlin/org/cobalt/api/addon/Addon.kt index 30fc313..a25d9f1 100644 --- a/src/main/kotlin/org/cobalt/api/addon/Addon.kt +++ b/src/main/kotlin/org/cobalt/api/addon/Addon.kt @@ -2,11 +2,20 @@ package org.cobalt.api.addon import org.cobalt.api.module.Module +/** + * Base class for Cobalt addons. Implement this to provide modules, HUD elements, and commands. + * + * Define your addon entry in `cobalt.addon.json` and return your modules from [getModules]. + */ abstract class Addon { + /** Called when the addon is loaded during game startup. */ abstract fun onLoad() + + /** Called when the addon is unloaded. */ abstract fun onUnload() + /** Returns the list of [Module]s this addon provides. Override to register your modules. */ open fun getModules(): List = emptyList() diff --git a/src/main/kotlin/org/cobalt/api/hud/HudAnchor.kt b/src/main/kotlin/org/cobalt/api/hud/HudAnchor.kt new file mode 100644 index 0000000..47319d3 --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/hud/HudAnchor.kt @@ -0,0 +1,46 @@ +package org.cobalt.api.hud + +/** + * Screen anchor point for positioning a [HudElement]. + * + * Offsets push the element **inward** from the anchor edge: + * - LEFT anchors: `offsetX` moves right from the left edge + * - RIGHT anchors: `offsetX` moves left from the right edge + * - TOP anchors: `offsetY` moves down from the top edge + * - BOTTOM anchors: `offsetY` moves up from the bottom edge + * - CENTER anchors: offsets adjust from the screen center + */ +enum class HudAnchor { + TOP_LEFT, + TOP_CENTER, + TOP_RIGHT, + CENTER_LEFT, + CENTER, + CENTER_RIGHT, + BOTTOM_LEFT, + BOTTOM_CENTER, + BOTTOM_RIGHT; + + fun computeScreenPosition( + offsetX: Float, + offsetY: Float, + moduleWidth: Float, + moduleHeight: Float, + screenWidth: Float, + screenHeight: Float, + ): Pair { + val x = when (this) { + TOP_LEFT, CENTER_LEFT, BOTTOM_LEFT -> offsetX + TOP_CENTER, CENTER, BOTTOM_CENTER -> screenWidth / 2f - moduleWidth / 2f + offsetX + TOP_RIGHT, CENTER_RIGHT, BOTTOM_RIGHT -> screenWidth - moduleWidth - offsetX + } + + val y = when (this) { + TOP_LEFT, TOP_CENTER, TOP_RIGHT -> offsetY + CENTER_LEFT, CENTER, CENTER_RIGHT -> screenHeight / 2f - moduleHeight / 2f + offsetY + BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT -> screenHeight - moduleHeight - offsetY + } + + return Pair(x, y) + } +} diff --git a/src/main/kotlin/org/cobalt/api/hud/HudElement.kt b/src/main/kotlin/org/cobalt/api/hud/HudElement.kt new file mode 100644 index 0000000..8818016 --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/hud/HudElement.kt @@ -0,0 +1,98 @@ +package org.cobalt.api.hud + +import org.cobalt.api.module.setting.Setting +import org.cobalt.api.module.setting.SettingsContainer + +/** + * A HUD overlay element rendered on the in-game screen. + * + * Created via the [hudElement][org.cobalt.api.hud.hudElement] DSL inside a [Module][org.cobalt.api.module.Module]. + * Each element is independently draggable, scalable, and toggleable through the HUD editor. + * Position, scale, enabled state, and settings are automatically persisted. + * + * @property id Unique identifier used for serialization. Must be stable across versions. + * @property name Display name shown in the HUD editor and settings popup. + * @property description Optional description shown in the UI. + */ +abstract class HudElement( + val id: String, + val name: String, + val description: String = "", +) : SettingsContainer { + + /** Whether this element is rendered. Toggled by the user in the HUD editor. */ + var enabled: Boolean = true + + /** Screen anchor point. Determines which edge/corner offsets are relative to. */ + var anchor: HudAnchor = HudAnchor.TOP_LEFT + + /** Horizontal offset from the [anchor] edge, in pixels. */ + var offsetX: Float = 10f + + /** Vertical offset from the [anchor] edge, in pixels. */ + var offsetY: Float = 10f + + /** Render scale factor, clamped to 0.5-3.0 on load. */ + var scale: Float = 1.0f + + protected open val defaultAnchor: HudAnchor = HudAnchor.TOP_LEFT + protected open val defaultOffsetX: Float = 10f + protected open val defaultOffsetY: Float = 10f + protected open val defaultScale: Float = 1.0f + + private val settingsList = mutableListOf>() + + override fun addSetting(vararg settings: Setting<*>) { + settingsList.addAll(listOf(*settings)) + } + + override fun getSettings(): List> { + return settingsList + } + + /** Returns the unscaled width of this element in pixels. */ + abstract fun getBaseWidth(): Float + + /** Returns the unscaled height of this element in pixels. */ + abstract fun getBaseHeight(): Float + + /** + * Called every frame when this element is [enabled]. + * Draw using [NVGRenderer][org.cobalt.api.util.ui.NVGRenderer] — coordinates are pre-translated, + * so draw relative to (0, 0). + */ + abstract fun render(screenX: Float, screenY: Float, scale: Float) + + fun getScaledWidth(): Float = getBaseWidth() * scale + fun getScaledHeight(): Float = getBaseHeight() * scale + + fun getScreenPosition(screenWidth: Float, screenHeight: Float): Pair = + anchor.computeScreenPosition( + offsetX, offsetY, + getScaledWidth(), getScaledHeight(), + screenWidth, screenHeight + ) + + /** Resets position, anchor, and scale to the defaults set in the DSL builder. */ + fun resetPosition() { + anchor = defaultAnchor + offsetX = defaultOffsetX + offsetY = defaultOffsetY + scale = defaultScale + } + + /** Resets all settings to their default values. */ + fun resetSettings() { + for (setting in getSettings()) { + @Suppress("UNCHECKED_CAST") + val typedSetting = setting as Setting + typedSetting.value = typedSetting.defaultValue + } + } + + fun containsPoint(px: Float, py: Float, screenWidth: Float, screenHeight: Float): Boolean { + val (sx, sy) = getScreenPosition(screenWidth, screenHeight) + return px >= sx && px <= sx + getScaledWidth() && + py >= sy && py <= sy + getScaledHeight() + } +} diff --git a/src/main/kotlin/org/cobalt/api/hud/HudModuleDSL.kt b/src/main/kotlin/org/cobalt/api/hud/HudModuleDSL.kt new file mode 100644 index 0000000..f2cbefd --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/hud/HudModuleDSL.kt @@ -0,0 +1,141 @@ +package org.cobalt.api.hud + +import org.cobalt.api.module.Module +import org.cobalt.api.module.setting.Setting +import org.cobalt.api.module.setting.SettingsContainer + +/** + * Creates and registers a [HudElement] on this module using a DSL builder. + * + * Usage: + * ``` + * class MyModule : Module("My Module") { + * val hud = hudElement("my-hud", "My HUD", "Shows something") { + * anchor = HudAnchor.TOP_RIGHT + * offsetX = 10f + * offsetY = 10f + * + * val showDecimals = setting(CheckboxSetting("Decimals", "", false)) + * + * width { 80f } + * height { 20f } + * render { screenX, screenY, scale -> /* use showDecimals.value */ } + * } + * } + * ``` + * + * @param id Stable unique identifier used for serialization. + * @param name Display name shown in the HUD editor. + * @param description Optional description for the settings popup. + * @param init Builder block — configure position, settings, size, and rendering. + * @return The constructed [HudElement] instance. + */ +fun Module.hudElement( + id: String, + name: String, + description: String = "", + init: HudElementBuilder.() -> Unit +): HudElement { + val builder = HudElementBuilder(id, name, description) + builder.init() + val element = builder.build() + addHudElement(element) + return element +} + +/** + * Builder for configuring a [HudElement] inside the [hudElement] DSL block. + * + * Register settings with [setting] and read their values via `.value`. + * Do **not** use `by` delegation for settings inside this builder — it won't compile + * because Kotlin local delegates require a different type signature. + */ +class HudElementBuilder( + private val id: String, + private val name: String, + private val description: String = "" +) : SettingsContainer { + + private var widthProvider: () -> Float = { 100f } + private var heightProvider: () -> Float = { 20f } + + /** Screen anchor point. Determines which edge/corner offsets are relative to. */ + var anchor: HudAnchor = HudAnchor.TOP_LEFT + + /** Horizontal offset from the [anchor] edge, in pixels. */ + var offsetX: Float = 10f + + /** Vertical offset from the [anchor] edge, in pixels. */ + var offsetY: Float = 10f + + /** Default render scale (clamped to 0.5-3.0 on load). */ + var scale: Float = 1.0f + private var renderLambda: ((Float, Float, Float) -> Unit)? = null + + private val settingsList = mutableListOf>() + + override fun addSetting(vararg settings: Setting<*>) { + settingsList.addAll(listOf(*settings)) + } + + override fun getSettings(): List> { + return settingsList + } + + /** Sets the dynamic width provider. Called every frame — can return values based on setting state. */ + fun width(provider: () -> Float) { + widthProvider = provider + } + + /** Sets the dynamic height provider. Called every frame — can return values based on setting state. */ + fun height(provider: () -> Float) { + heightProvider = provider + } + + /** + * Registers a setting on this HUD element and returns it. + * Access the current value via `.value`: + * ``` + * val speed = setting(SliderSetting("Speed", "Movement speed", 1.0, 0.1, 5.0)) + * render { _, _, _ -> /* use speed.value */ } + * ``` + */ + fun > setting(setting: S): S { + addSetting(setting) + return setting + } + + /** Sets the render callback, called every frame when this element is enabled. */ + fun render(block: (screenX: Float, screenY: Float, scale: Float) -> Unit) { + renderLambda = block + } + + fun build(): HudElement { + val capturedRender = renderLambda ?: { _, _, _ -> } + val capturedWidth = widthProvider + val capturedHeight = heightProvider + val capturedSettings = settingsList.toList() + val capturedAnchor = anchor + val capturedOffsetX = offsetX + val capturedOffsetY = offsetY + val capturedScale = scale + + return object : HudElement(id, name, description) { + override val defaultAnchor = capturedAnchor + override val defaultOffsetX = capturedOffsetX + override val defaultOffsetY = capturedOffsetY + override val defaultScale = capturedScale + + init { + capturedSettings.forEach { addSetting(it) } + resetPosition() + } + + override fun getBaseWidth(): Float = capturedWidth() + override fun getBaseHeight(): Float = capturedHeight() + override fun render(screenX: Float, screenY: Float, scale: Float) { + capturedRender(screenX, screenY, scale) + } + } + } +} diff --git a/src/main/kotlin/org/cobalt/api/hud/HudModuleManager.kt b/src/main/kotlin/org/cobalt/api/hud/HudModuleManager.kt new file mode 100644 index 0000000..b48f38c --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/hud/HudModuleManager.kt @@ -0,0 +1,46 @@ +package org.cobalt.api.hud + +import net.minecraft.client.Minecraft +import org.cobalt.api.event.annotation.SubscribeEvent +import org.cobalt.api.event.impl.render.NvgEvent +import org.cobalt.api.module.ModuleManager +import org.cobalt.api.util.ui.NVGRenderer + +object HudModuleManager { + + private val mc: Minecraft = Minecraft.getInstance() + + @Volatile + var isEditorOpen: Boolean = false + + fun getElements(): List = + ModuleManager.getModules().flatMap { it.getHudElements() } + + fun resetAllPositions() { + getElements().forEach { it.resetPosition() } + } + + @Suppress("unused") + @SubscribeEvent + fun onRender(event: NvgEvent) { + if (mc.screen != null && !isEditorOpen) return + + val window = mc.window + val screenWidth = window.screenWidth.toFloat() + val screenHeight = window.screenHeight.toFloat() + + NVGRenderer.beginFrame(screenWidth, screenHeight) + + getElements().filter { it.enabled }.forEach { element -> + val (screenX, screenY) = element.getScreenPosition(screenWidth, screenHeight) + + NVGRenderer.push() + NVGRenderer.translate(screenX, screenY) + NVGRenderer.scale(element.scale, element.scale) + element.render(0f, 0f, element.scale) + NVGRenderer.pop() + } + + NVGRenderer.endFrame() + } +} diff --git a/src/main/kotlin/org/cobalt/api/hud/modules/WatermarkModule.kt b/src/main/kotlin/org/cobalt/api/hud/modules/WatermarkModule.kt new file mode 100644 index 0000000..f25a31f --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/hud/modules/WatermarkModule.kt @@ -0,0 +1,51 @@ +package org.cobalt.api.hud.modules + +import org.cobalt.api.hud.HudAnchor +import org.cobalt.api.hud.hudElement +import org.cobalt.api.module.Module +import org.cobalt.api.module.setting.impl.TextSetting +import org.cobalt.api.module.setting.impl.ColorSetting +import org.cobalt.api.module.setting.impl.CheckboxSetting +import org.cobalt.api.ui.theme.ThemeManager +import org.cobalt.api.util.ui.NVGRenderer + +class WatermarkModule : Module("Watermark") { + + private val textSize = 18f + + val watermark = hudElement("watermark", "Watermark", "Displays Cobalt branding") { + anchor = HudAnchor.TOP_LEFT + offsetX = 10f + offsetY = 10f + + val text = setting(TextSetting("Text", "Display text", "Cobalt")) + val color = setting(ColorSetting("Color", "Text color", ThemeManager.currentTheme.accent)) + val shadow = setting(CheckboxSetting("Shadow", "Show text shadow", false)) + val background = setting(CheckboxSetting("Background", "Show background box", false)) + + width { NVGRenderer.textWidth(text.value, textSize) + (if (background.value) 16f else 0f) } + height { textSize + (if (background.value) 12f else 4f) } + + render { screenX, screenY, _ -> + val padX = if (background.value) 8f else 0f + val padY = if (background.value) 6f else 0f + + if (background.value) { + NVGRenderer.rect( + screenX, screenY, + NVGRenderer.textWidth(text.value, textSize) + 16f, + textSize + 12f, + ThemeManager.currentTheme.panel, 6f + ) + } + + val textX = screenX + padX + val textY = screenY + padY + if (shadow.value) { + NVGRenderer.textShadow(text.value, textX, textY, textSize, color.value) + } else { + NVGRenderer.text(text.value, textX, textY, textSize, color.value) + } + } + } +} diff --git a/src/main/kotlin/org/cobalt/api/module/Module.kt b/src/main/kotlin/org/cobalt/api/module/Module.kt index e70f2a4..4504c5c 100644 --- a/src/main/kotlin/org/cobalt/api/module/Module.kt +++ b/src/main/kotlin/org/cobalt/api/module/Module.kt @@ -1,17 +1,39 @@ package org.cobalt.api.module +import org.cobalt.api.hud.HudElement import org.cobalt.api.module.setting.Setting +import org.cobalt.api.module.setting.SettingsContainer -abstract class Module(val name: String) { +/** + * Base class for all modules. Extend this to create addon functionality. + * + * Modules can contain settings (via [SettingsContainer]) and HUD elements + * (via the [hudElement][org.cobalt.api.hud.hudElement] DSL). Return your modules + * from [Addon.getModules][org.cobalt.api.addon.Addon.getModules]. + * + * @property name Display name shown in the UI. + */ +abstract class Module(val name: String) : SettingsContainer { private val settingsList = mutableListOf>() + private val hudElementsList = mutableListOf() - fun addSetting(vararg settings: Setting<*>) { + override fun addSetting(vararg settings: Setting<*>) { settingsList.addAll(listOf(*settings)) } - fun getSettings(): List> { + override fun getSettings(): List> { return settingsList } + /** Registers a HUD element on this module. Called automatically by the [hudElement] DSL. */ + fun addHudElement(element: HudElement) { + hudElementsList.add(element) + } + + /** Returns all HUD elements registered on this module. */ + fun getHudElements(): List { + return hudElementsList + } + } diff --git a/src/main/kotlin/org/cobalt/api/module/setting/Setting.kt b/src/main/kotlin/org/cobalt/api/module/setting/Setting.kt index 9ecd9ec..6a237fe 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/Setting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/Setting.kt @@ -4,24 +4,44 @@ import com.google.gson.JsonElement import kotlin.properties.PropertyDelegateProvider import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty -import org.cobalt.api.module.Module +/** + * Base class for all module and HUD element settings. + * + * Settings are automatically serialized to/from the config file. + * Inside a [Module][org.cobalt.api.module.Module], use `by` delegation: + * ``` + * val speed by SliderSetting("Speed", "Movement speed", 1.0, 0.0, 5.0) + * ``` + * Inside a [HudElementBuilder][org.cobalt.api.hud.HudElementBuilder], use `setting()` + `.value`: + * ``` + * val speed = setting(SliderSetting("Speed", "Movement speed", 1.0, 0.0, 5.0)) + * // access via speed.value + * ``` + * + * @property name Display name shown in the settings UI. + * @property description Tooltip/description shown in the settings UI. + * @property value Current value. Updated by the UI and persisted automatically. + */ abstract class Setting( val name: String, val description: String, - var value: T, -) : ReadWriteProperty, PropertyDelegateProvider> { + open var value: T, +) : ReadWriteProperty, PropertyDelegateProvider> { - override operator fun provideDelegate(thisRef: Module, property: KProperty<*>): ReadWriteProperty { + open val defaultValue: T + get() = value + + override operator fun provideDelegate(thisRef: SettingsContainer, property: KProperty<*>): ReadWriteProperty { thisRef.addSetting(this) return this } - override operator fun getValue(thisRef: Module, property: KProperty<*>): T { + override operator fun getValue(thisRef: SettingsContainer, property: KProperty<*>): T { return value } - override operator fun setValue(thisRef: Module, property: KProperty<*>, value: T) { + override operator fun setValue(thisRef: SettingsContainer, property: KProperty<*>, value: T) { this.value = value } diff --git a/src/main/kotlin/org/cobalt/api/module/setting/SettingsContainer.kt b/src/main/kotlin/org/cobalt/api/module/setting/SettingsContainer.kt new file mode 100644 index 0000000..c579ced --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/module/setting/SettingsContainer.kt @@ -0,0 +1,15 @@ +package org.cobalt.api.module.setting + +/** + * Interface for objects that hold [Setting]s (modules and HUD elements). + * Settings registered here appear in the UI and are automatically persisted. + */ +interface SettingsContainer { + + /** Registers one or more settings on this container. */ + fun addSetting(vararg settings: Setting<*>) + + /** Returns all registered settings. */ + fun getSettings(): List> + +} diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/CheckboxSetting.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/CheckboxSetting.kt index edde773..c8d00a1 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/impl/CheckboxSetting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/CheckboxSetting.kt @@ -4,12 +4,15 @@ import com.google.gson.JsonElement import com.google.gson.JsonPrimitive import org.cobalt.api.module.setting.Setting +/** Boolean toggle setting. Renders as a checkbox in the UI. */ class CheckboxSetting( name: String, description: String, defaultValue: Boolean, ) : Setting(name, description, defaultValue) { + override val defaultValue: Boolean = defaultValue + override fun read(element: JsonElement) { this.value = element.asBoolean } diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/ColorMode.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/ColorMode.kt new file mode 100644 index 0000000..910021a --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/ColorMode.kt @@ -0,0 +1,76 @@ +package org.cobalt.api.module.setting.impl + +/** + * Sealed class representing different color modes for the color picker system. + * + * Supports: + * - Static ARGB color + * - Per-instance rainbow with adjustable parameters + * - Globally synced rainbow + * - Theme property reference + * - Theme property with HSB tweaks + */ +sealed class ColorMode { + + /** + * Static ARGB color. + * @param argb The ARGB integer value (e.g. 0xFFFF0000.toInt() for red) + */ + data class Static(val argb: Int) : ColorMode() + + /** + * Per-instance rainbow with adjustable saturation, brightness, and opacity. + * Phase is computed from ColorSetting's instanceStartTime field. + * + * @param speed Rainbow speed multiplier (default 1.0, higher = faster) + * @param saturation Saturation 0..1 (default 1.0) + * @param brightness Brightness 0..1 (default 1.0) + * @param opacity Opacity 0..1 (default 1.0) + */ + data class Rainbow( + val speed: Float = 1f, + val saturation: Float = 1f, + val brightness: Float = 1f, + val opacity: Float = 1f + ) : ColorMode() + + /** + * Globally synced rainbow - all instances with this mode share the same phase. + * Phase is computed from RainbowPhaseProvider. + * + * @param speed Rainbow speed multiplier (default 1.0, higher = faster) + * @param saturation Saturation 0..1 (default 1.0) + * @param brightness Brightness 0..1 (default 1.0) + * @param opacity Opacity 0..1 (default 1.0) + */ + data class SyncedRainbow( + val speed: Float = 1f, + val saturation: Float = 1f, + val brightness: Float = 1f, + val opacity: Float = 1f + ) : ColorMode() + + /** + * Reference to a theme property by name (e.g. "accent", "background"). + * @param propertyName The theme property name (see ThemeColorResolver for valid names) + */ + data class ThemeColor(val propertyName: String) : ColorMode() + + /** + * Reference to a theme property with HSB tweaks applied. + * + * @param propertyName The base theme property name + * @param hueOffset Hue offset in degrees -180..180 (default 0) + * @param saturationMultiplier Saturation multiplier 0..2 (default 1.0, <1 = desaturate, >1 = saturate) + * @param brightnessMultiplier Brightness multiplier 0..2 (default 1.0, <1 = darken, >1 = brighten) + * @param opacityMultiplier Opacity multiplier 0..1 (default 1.0) + */ + data class TweakedTheme( + val propertyName: String, + val hueOffset: Float = 0f, + val saturationMultiplier: Float = 1f, + val brightnessMultiplier: Float = 1f, + val opacityMultiplier: Float = 1f + ) : ColorMode() + +} diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/ColorSetting.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/ColorSetting.kt index 5392548..ad1e047 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/impl/ColorSetting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/ColorSetting.kt @@ -1,21 +1,203 @@ package org.cobalt.api.module.setting.impl import com.google.gson.JsonElement +import com.google.gson.JsonObject import com.google.gson.JsonPrimitive import org.cobalt.api.module.setting.Setting +import org.cobalt.api.ui.theme.ThemeManager +import java.awt.Color +/** ARGB color picker setting. Value is an ARGB integer (e.g. `0xFFFF0000.toInt()` for red). */ class ColorSetting( name: String, description: String, defaultValue: Int, ) : Setting(name, description, defaultValue) { + override val defaultValue: Int = defaultValue + + /** Current color mode (static, rainbow, theme, etc.). */ + var mode: ColorMode = ColorMode.Static(defaultValue) + + /** Instance creation timestamp for per-instance rainbow phase offset. */ + private val instanceStartTime = System.currentTimeMillis() + + /** Dynamically resolved color based on current mode. */ + override var value: Int + get() = resolveColor() + set(newValue) { + mode = ColorMode.Static(newValue) + super.value = newValue + } + + /** + * Resolves the current color based on the active mode. + * Called on every read of `value` to support dynamic modes (rainbow, theme). + */ + private fun resolveColor(): Int { + return when (val m = mode) { + is ColorMode.Static -> m.argb + + is ColorMode.Rainbow -> { + // Per-instance rainbow: use instanceStartTime for phase offset + val elapsed = (System.currentTimeMillis() - instanceStartTime) / 1000.0 + val hue = ((elapsed * m.speed) % 1.0 + 1.0).toFloat() % 1f + val rgb = Color.HSBtoRGB(hue, m.saturation, m.brightness) + val alpha = (m.opacity * 255).toInt().coerceIn(0, 255) + (alpha shl 24) or (rgb and 0x00FFFFFF) + } + + is ColorMode.SyncedRainbow -> { + val theme = ThemeManager.currentTheme + val hue = ThemeManager.getRainbowHue() + val sat = theme.rainbowSaturation + val bri = theme.rainbowBrightness + val rgb = Color.HSBtoRGB(hue, sat, bri) + val alpha = (m.opacity * 255).toInt().coerceIn(0, 255) + (alpha shl 24) or (rgb and 0x00FFFFFF) + } + + is ColorMode.ThemeColor -> { + ThemeColorResolver.resolve(m.propertyName) + } + + is ColorMode.TweakedTheme -> { + val baseColor = ThemeColorResolver.resolve(m.propertyName) + tweakColor(baseColor, m.hueOffset, m.saturationMultiplier, m.brightnessMultiplier, m.opacityMultiplier) + } + } + } + + /** + * Applies HSB/opacity adjustments to a base ARGB color. + * Used by TweakedTheme mode. + */ + private fun tweakColor( + argb: Int, + hueShift: Float, + saturationMult: Float, + brightnessMult: Float, + opacityMult: Float + ): Int { + val color = Color(argb, true) + val hsb = Color.RGBtoHSB(color.red, color.green, color.blue, null) + + val newHue = (hsb[0] + hueShift + 1f) % 1f + val newSat = (hsb[1] * saturationMult).coerceIn(0f, 1f) + val newBri = (hsb[2] * brightnessMult).coerceIn(0f, 1f) + val newAlpha = ((color.alpha / 255f) * opacityMult).coerceIn(0f, 1f) + + val rgb = Color.HSBtoRGB(newHue, newSat, newBri) + val alpha = (newAlpha * 255).toInt().coerceIn(0, 255) + return (alpha shl 24) or (rgb and 0x00FFFFFF) + } + override fun read(element: JsonElement) { - this.value = element.asInt + if (element.isJsonPrimitive) { + // Legacy format: plain ARGB int → Static mode + val argb = element.asInt + mode = ColorMode.Static(argb) + super.value = argb + } else if (element.isJsonObject) { + // New format: JSON object with mode discriminator + val obj = element.asJsonObject + val modeType = obj.get("mode")?.asString ?: "static" + + mode = when (modeType) { + "static" -> { + val argb = obj.get("argb")?.asInt ?: super.value + super.value = argb + ColorMode.Static(argb) + } + + "rainbow" -> { + ColorMode.Rainbow( + speed = obj.get("speed")?.asFloat ?: 1f, + saturation = obj.get("saturation")?.asFloat ?: 1f, + brightness = obj.get("brightness")?.asFloat ?: 1f, + opacity = obj.get("opacity")?.asFloat ?: 1f + ) + } + + "synced_rainbow" -> { + ColorMode.SyncedRainbow( + speed = obj.get("speed")?.asFloat ?: 1f, + saturation = obj.get("saturation")?.asFloat ?: 1f, + brightness = obj.get("brightness")?.asFloat ?: 1f, + opacity = obj.get("opacity")?.asFloat ?: 1f + ) + } + + "theme" -> { + ColorMode.ThemeColor( + propertyName = obj.get("propertyName")?.asString ?: "accent" + ) + } + + "tweaked_theme" -> { + ColorMode.TweakedTheme( + propertyName = obj.get("propertyName")?.asString ?: "accent", + hueOffset = obj.get("hueOffset")?.asFloat ?: 0f, + saturationMultiplier = obj.get("saturationMultiplier")?.asFloat ?: 1f, + brightnessMultiplier = obj.get("brightnessMultiplier")?.asFloat ?: 1f, + opacityMultiplier = obj.get("opacityMultiplier")?.asFloat ?: 1f + ) + } + + else -> { + // Unknown mode: fallback to static + val argb = super.value + ColorMode.Static(argb) + } + } + } } override fun write(): JsonElement { - return JsonPrimitive(value) + return when (val m = mode) { + is ColorMode.Static -> { + // Backward compatible: write as plain int + JsonPrimitive(m.argb) + } + + is ColorMode.Rainbow -> { + JsonObject().apply { + addProperty("mode", "rainbow") + addProperty("speed", m.speed) + addProperty("saturation", m.saturation) + addProperty("brightness", m.brightness) + addProperty("opacity", m.opacity) + } + } + + is ColorMode.SyncedRainbow -> { + JsonObject().apply { + addProperty("mode", "synced_rainbow") + addProperty("speed", m.speed) + addProperty("saturation", m.saturation) + addProperty("brightness", m.brightness) + addProperty("opacity", m.opacity) + } + } + + is ColorMode.ThemeColor -> { + JsonObject().apply { + addProperty("mode", "theme") + addProperty("propertyName", m.propertyName) + } + } + + is ColorMode.TweakedTheme -> { + JsonObject().apply { + addProperty("mode", "tweaked_theme") + addProperty("propertyName", m.propertyName) + addProperty("hueOffset", m.hueOffset) + addProperty("saturationMultiplier", m.saturationMultiplier) + addProperty("brightnessMultiplier", m.brightnessMultiplier) + addProperty("opacityMultiplier", m.opacityMultiplier) + } + } + } } } diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/InfoSetting.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/InfoSetting.kt index 1438b50..651066a 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/impl/InfoSetting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/InfoSetting.kt @@ -10,6 +10,8 @@ internal class InfoSetting( val type: InfoType = InfoType.INFO, ) : Setting(name ?: "", "Info", "") { + override val defaultValue: String = "" + override fun read(element: JsonElement) { // It exists just to show text in the UI, so there is nothing to read. } diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/KeyBindSetting.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/KeyBindSetting.kt index 166a07c..f99e658 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/impl/KeyBindSetting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/KeyBindSetting.kt @@ -6,12 +6,18 @@ import org.cobalt.api.module.setting.Setting import org.cobalt.api.util.helper.KeyBind import org.lwjgl.glfw.GLFW +/** + * Key binding setting. Value is a [KeyBind] — use `value.isPressed()` to check for key presses. + * Default to `KeyBind(-1)` for unbound. + */ class KeyBindSetting( name: String, description: String, defaultValue: KeyBind, ) : Setting(name, description, defaultValue) { + override val defaultValue: KeyBind = defaultValue + val keyName: String get() = when (value.keyCode) { -1 -> "None" diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/ModeSetting.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/ModeSetting.kt index abfb625..0d2de09 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/impl/ModeSetting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/ModeSetting.kt @@ -4,6 +4,12 @@ import com.google.gson.JsonElement import com.google.gson.JsonPrimitive import org.cobalt.api.module.setting.Setting +/** + * Dropdown/cycle setting that selects from a list of named options. + * Value is the selected index. Access the label via `options[value]`. + * + * @property options The available option labels. + */ class ModeSetting( name: String, description: String, @@ -11,6 +17,8 @@ class ModeSetting( val options: Array, ) : Setting(name, description, defaultValue) { + override val defaultValue: Int = defaultValue + override fun read(element: JsonElement) { this.value = element.asInt } diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/RainbowPhaseProvider.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/RainbowPhaseProvider.kt new file mode 100644 index 0000000..4ddd61f --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/RainbowPhaseProvider.kt @@ -0,0 +1,23 @@ +package org.cobalt.api.module.setting.impl + +import org.cobalt.api.ui.theme.ThemeManager + +/** + * Singleton provider for globally synced rainbow phase computation. + * + * All ColorSettings using SyncedRainbow mode share the same phase from this provider. + * Phase computation is delegated to ThemeManager for theme-level synchronization. + */ +object RainbowPhaseProvider { + + /** + * Get the current hue value (0..1) for globally synced rainbow. + * + * @param speed Speed multiplier (default 1.0, higher = faster rotation) + * @return Hue value in range 0..1 (wraps at 1.0) + */ + fun getHue(speed: Float = 1f): Float { + return ThemeManager.getRainbowHue(speed) + } + +} diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/RangeSetting.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/RangeSetting.kt index 68c8f17..29652dc 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/impl/RangeSetting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/RangeSetting.kt @@ -7,13 +7,21 @@ import java.math.BigDecimal import java.math.RoundingMode import org.cobalt.api.module.setting.Setting +/** + * Dual-handle range slider setting. Value is a `Pair` (start, end). + * + * @property min Minimum allowed value for both handles. + * @property max Maximum allowed value for both handles. + */ class RangeSetting( name: String, description: String, - private val defaultValue: Pair, + default: Pair, val min: Double, val max: Double, -) : Setting>(name, description, defaultValue) { +) : Setting>(name, description, default) { + + override val defaultValue: Pair = default override fun read(element: JsonElement) { if (element.isJsonObject) { diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/SliderSetting.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/SliderSetting.kt index 6164927..02b82ef 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/impl/SliderSetting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/SliderSetting.kt @@ -4,6 +4,12 @@ import com.google.gson.JsonElement import com.google.gson.JsonPrimitive import org.cobalt.api.module.setting.Setting +/** + * Numeric slider setting with min/max bounds. + * + * @property min Minimum allowed value. + * @property max Maximum allowed value. + */ class SliderSetting( name: String, description: String, @@ -12,6 +18,8 @@ class SliderSetting( val max: Double, ) : Setting(name, description, defaultValue) { + override val defaultValue: Double = defaultValue + override fun read(element: JsonElement) { this.value = element.asDouble.coerceIn(min, max) } diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/TextSetting.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/TextSetting.kt index 4f41b5f..f3303b7 100644 --- a/src/main/kotlin/org/cobalt/api/module/setting/impl/TextSetting.kt +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/TextSetting.kt @@ -4,12 +4,15 @@ import com.google.gson.JsonElement import com.google.gson.JsonPrimitive import org.cobalt.api.module.setting.Setting +/** String input setting. Renders as a text field in the UI. */ class TextSetting( name: String, description: String, defaultValue: String, ) : Setting(name, description, defaultValue) { + override val defaultValue: String = defaultValue + override fun read(element: JsonElement) { this.value = element.asString } diff --git a/src/main/kotlin/org/cobalt/api/module/setting/impl/ThemeColorResolver.kt b/src/main/kotlin/org/cobalt/api/module/setting/impl/ThemeColorResolver.kt new file mode 100644 index 0000000..1f9ebd8 --- /dev/null +++ b/src/main/kotlin/org/cobalt/api/module/setting/impl/ThemeColorResolver.kt @@ -0,0 +1,198 @@ +package org.cobalt.api.module.setting.impl + +import org.cobalt.api.ui.theme.Theme +import org.cobalt.api.ui.theme.ThemeManager + +/** + * Explicit property name resolver for theme colors. + * + * Maps theme property names (e.g. "accent", "background") to getter lambdas. + * Does NOT use kotlin-reflect to avoid reflection dependency. + * + * Properties are organized into logical groups for UI display. + */ +object ThemeColorResolver { + + /** + * Logical grouping of theme properties for UI organization. + * Keys are group names, values are lists of property names. + */ + val groups: Map> = linkedMapOf( + "Base" to listOf( + "background", + "panel", + "inset", + "overlay" + ), + "Text" to listOf( + "text", + "textPrimary", + "textSecondary", + "textDisabled", + "textPlaceholder", + "textOnAccent", + "selectionText", + "searchPlaceholderText" + ), + "Accent" to listOf( + "accent", + "accentPrimary", + "accentSecondary", + "selection", + "selectedOverlay" + ), + "Controls" to listOf( + "controlBg", + "controlBorder", + "inputBg", + "inputBorder" + ), + "Status" to listOf( + "success", + "warning", + "error", + "info" + ), + "Scrollbar" to listOf( + "scrollbarThumb", + "scrollbarTrack" + ), + "Slider" to listOf( + "sliderTrack", + "sliderFill", + "sliderThumb" + ), + "Tooltip" to listOf( + "tooltipBackground", + "tooltipBorder", + "tooltipText" + ), + "Notification" to listOf( + "notificationBackground", + "notificationBorder", + "notificationText", + "notificationTextSecondary" + ), + "Status BG" to listOf( + "infoBackground", + "infoBorder", + "infoIcon", + "warningBackground", + "warningBorder", + "warningIcon", + "successBackground", + "successBorder", + "successIcon", + "errorBackground", + "errorBorder", + "errorIcon" + ), + "Other" to listOf( + "moduleDivider", + "white", + "black", + "transparent" + ) + ) + + /** + * Explicit map of property name to getter lambda. + * All 54 theme color properties are mapped here. + */ + private val resolvers: Map Int> = mapOf( + // Base colors (4) + "background" to { it.background }, + "panel" to { it.panel }, + "inset" to { it.inset }, + "overlay" to { it.overlay }, + + // Text colors (8) + "text" to { it.text }, + "textPrimary" to { it.textPrimary }, + "textSecondary" to { it.textSecondary }, + "textDisabled" to { it.textDisabled }, + "textPlaceholder" to { it.textPlaceholder }, + "textOnAccent" to { it.textOnAccent }, + "selectionText" to { it.selectionText }, + "searchPlaceholderText" to { it.searchPlaceholderText }, + + // Accent colors (5) + "accent" to { it.accent }, + "accentPrimary" to { it.accentPrimary }, + "accentSecondary" to { it.accentSecondary }, + "selection" to { it.selection }, + "selectedOverlay" to { it.selectedOverlay }, + + // Control colors (4) + "controlBg" to { it.controlBg }, + "controlBorder" to { it.controlBorder }, + "inputBg" to { it.inputBg }, + "inputBorder" to { it.inputBorder }, + + // Status colors (4) + "success" to { it.success }, + "warning" to { it.warning }, + "error" to { it.error }, + "info" to { it.info }, + + // Scrollbar colors (2) + "scrollbarThumb" to { it.scrollbarThumb }, + "scrollbarTrack" to { it.scrollbarTrack }, + + // Slider colors (3) + "sliderTrack" to { it.sliderTrack }, + "sliderFill" to { it.sliderFill }, + "sliderThumb" to { it.sliderThumb }, + + // Tooltip colors (3) + "tooltipBackground" to { it.tooltipBackground }, + "tooltipBorder" to { it.tooltipBorder }, + "tooltipText" to { it.tooltipText }, + + // Notification colors (4) + "notificationBackground" to { it.notificationBackground }, + "notificationBorder" to { it.notificationBorder }, + "notificationText" to { it.notificationText }, + "notificationTextSecondary" to { it.notificationTextSecondary }, + + // Status background colors (12) + "infoBackground" to { it.infoBackground }, + "infoBorder" to { it.infoBorder }, + "infoIcon" to { it.infoIcon }, + "warningBackground" to { it.warningBackground }, + "warningBorder" to { it.warningBorder }, + "warningIcon" to { it.warningIcon }, + "successBackground" to { it.successBackground }, + "successBorder" to { it.successBorder }, + "successIcon" to { it.successIcon }, + "errorBackground" to { it.errorBackground }, + "errorBorder" to { it.errorBorder }, + "errorIcon" to { it.errorIcon }, + + // Other colors (4) + "moduleDivider" to { it.moduleDivider }, + "white" to { it.white }, + "black" to { it.black }, + "transparent" to { it.transparent } + ) + + /** + * Resolve a theme property name to its current ARGB color value. + * + * @param propertyName The property name (e.g. "accent", "background") + * @return The ARGB integer value from the current theme + * @throws IllegalStateException if propertyName is unknown (fallback to "accent") + */ + fun resolve(propertyName: String): Int { + val theme = ThemeManager.currentTheme + val resolver = resolvers[propertyName] ?: resolvers["accent"]!! + return resolver(theme) + } + + /** + * Get all valid property names. + * @return Set of all 54 valid property names + */ + fun getPropertyNames(): Set = resolvers.keys + +} diff --git a/src/main/kotlin/org/cobalt/api/ui/theme/Theme.kt b/src/main/kotlin/org/cobalt/api/ui/theme/Theme.kt index f18df7d..2c26343 100644 --- a/src/main/kotlin/org/cobalt/api/ui/theme/Theme.kt +++ b/src/main/kotlin/org/cobalt/api/ui/theme/Theme.kt @@ -4,6 +4,12 @@ interface Theme { val name: String + // Rainbow settings (globally synced) + val rainbowEnabled: Boolean + val rainbowSpeed: Float + val rainbowSaturation: Float + val rainbowBrightness: Float + val background: Int val panel: Int val inset: Int diff --git a/src/main/kotlin/org/cobalt/api/ui/theme/ThemeManager.kt b/src/main/kotlin/org/cobalt/api/ui/theme/ThemeManager.kt index 5f2f007..79f8dd9 100644 --- a/src/main/kotlin/org/cobalt/api/ui/theme/ThemeManager.kt +++ b/src/main/kotlin/org/cobalt/api/ui/theme/ThemeManager.kt @@ -2,6 +2,7 @@ package org.cobalt.api.ui.theme import org.cobalt.api.ui.theme.impl.DarkTheme import org.cobalt.api.ui.theme.impl.LightTheme +import java.awt.Color object ThemeManager { @@ -9,6 +10,8 @@ object ThemeManager { var currentTheme: Theme = DarkTheme() private set + private val rainbowStartTime = System.currentTimeMillis() + init { registerTheme(DarkTheme()) registerTheme(LightTheme()) @@ -29,12 +32,10 @@ object ThemeManager { } fun unregisterTheme(theme: Theme): Boolean { - // Prevent deleting built-in themes if (theme.name == "Dark" || theme.name == "Light") return false val removed = themes.removeIf { it.name == theme.name } - // If deleted theme was current, switch to DarkTheme if (removed && currentTheme.name == theme.name) { currentTheme = themes.first { it.name == "Dark" } } @@ -42,4 +43,25 @@ object ThemeManager { return removed } + fun getRainbowHue(speedMultiplier: Float = 1f): Float { + val themeSpeed = currentTheme.rainbowSpeed + val effectiveSpeed = themeSpeed * speedMultiplier + val elapsed = (System.currentTimeMillis() - rainbowStartTime) / 1000.0 + return ((elapsed * effectiveSpeed) % 1.0 + 1.0).toFloat() % 1f + } + + fun getRainbowColor( + speedMultiplier: Float = 1f, + saturationOverride: Float? = null, + brightnessOverride: Float? = null, + opacity: Float = 1f + ): Int { + val hue = getRainbowHue(speedMultiplier) + val sat = saturationOverride ?: currentTheme.rainbowSaturation + val bri = brightnessOverride ?: currentTheme.rainbowBrightness + val rgb = Color.HSBtoRGB(hue, sat, bri) + val alpha = (opacity * 255).toInt().coerceIn(0, 255) + return (alpha shl 24) or (rgb and 0x00FFFFFF) + } + } diff --git a/src/main/kotlin/org/cobalt/api/ui/theme/impl/CustomTheme.kt b/src/main/kotlin/org/cobalt/api/ui/theme/impl/CustomTheme.kt index fac6381..a3eea68 100644 --- a/src/main/kotlin/org/cobalt/api/ui/theme/impl/CustomTheme.kt +++ b/src/main/kotlin/org/cobalt/api/ui/theme/impl/CustomTheme.kt @@ -5,6 +5,10 @@ import org.cobalt.api.ui.theme.Theme data class CustomTheme( override var name: String = "Custom", + override var rainbowEnabled: Boolean = false, + override var rainbowSpeed: Float = 1f, + override var rainbowSaturation: Float = 1f, + override var rainbowBrightness: Float = 1f, override var background: Int = Color(18, 18, 18).rgb, override var panel: Int = Color(24, 24, 24).rgb, override var inset: Int = Color(30, 30, 30).rgb, diff --git a/src/main/kotlin/org/cobalt/api/ui/theme/impl/DarkTheme.kt b/src/main/kotlin/org/cobalt/api/ui/theme/impl/DarkTheme.kt index 2e3c662..0563493 100644 --- a/src/main/kotlin/org/cobalt/api/ui/theme/impl/DarkTheme.kt +++ b/src/main/kotlin/org/cobalt/api/ui/theme/impl/DarkTheme.kt @@ -7,6 +7,11 @@ class DarkTheme : Theme { override val name = "Dark" + override val rainbowEnabled = false + override val rainbowSpeed = 1f + override val rainbowSaturation = 1f + override val rainbowBrightness = 1f + override val background = Color(18, 18, 18).rgb override val panel = Color(24, 24, 24).rgb override val inset = Color(30, 30, 30).rgb diff --git a/src/main/kotlin/org/cobalt/api/ui/theme/impl/LightTheme.kt b/src/main/kotlin/org/cobalt/api/ui/theme/impl/LightTheme.kt index 6b4bece..8e7f419 100644 --- a/src/main/kotlin/org/cobalt/api/ui/theme/impl/LightTheme.kt +++ b/src/main/kotlin/org/cobalt/api/ui/theme/impl/LightTheme.kt @@ -7,6 +7,11 @@ class LightTheme : Theme { override val name = "Light" + override val rainbowEnabled = false + override val rainbowSpeed = 1f + override val rainbowSaturation = 1f + override val rainbowBrightness = 1f + override val background = Color(240, 240, 240).rgb override val panel = Color(255, 255, 255).rgb override val inset = Color(232, 232, 232).rgb diff --git a/src/main/kotlin/org/cobalt/internal/helper/Config.kt b/src/main/kotlin/org/cobalt/internal/helper/Config.kt index 7519277..d792298 100644 --- a/src/main/kotlin/org/cobalt/internal/helper/Config.kt +++ b/src/main/kotlin/org/cobalt/internal/helper/Config.kt @@ -6,6 +6,9 @@ import com.google.gson.JsonObject import com.google.gson.JsonParser import java.io.File import net.minecraft.client.Minecraft +import org.cobalt.api.hud.HudAnchor +import org.cobalt.api.module.Module +import org.cobalt.api.module.ModuleManager import org.cobalt.api.ui.theme.ThemeManager import org.cobalt.api.ui.theme.impl.CustomTheme import org.cobalt.internal.loader.AddonLoader @@ -13,11 +16,33 @@ import org.cobalt.internal.ui.theme.ThemeSerializer internal object Config { + private const val BUILTIN_ADDON_ID = "cobalt" + private val mc: Minecraft = Minecraft.getInstance() private val gson = GsonBuilder().setPrettyPrinting().create() private val modulesFile = File(mc.gameDirectory, "config/cobalt/addons.json") private val themesFile = File(mc.gameDirectory, "config/cobalt/themes.json") + private fun buildGroupedModules(): Map> { + val addonModules = mutableSetOf() + val grouped = mutableMapOf>() + + AddonLoader.getAddons().forEach { (metadata, addon) -> + val modules = addon.getModules() + addonModules.addAll(modules) + if (modules.isNotEmpty()) { + grouped[metadata.id] = modules.toMutableList() + } + } + + val builtinModules = ModuleManager.getModules().filter { it !in addonModules } + if (builtinModules.isNotEmpty()) { + grouped[BUILTIN_ADDON_ID] = builtinModules.toMutableList() + } + + return grouped + } + fun loadModulesConfig() { loadThemesConfig() if (!modulesFile.exists()) { @@ -29,26 +54,50 @@ internal object Config { val text = modulesFile.bufferedReader().use { it.readText() } if (text.isEmpty()) return - val addonsMap = AddonLoader.getAddons().associate { it.first.id to it.second } + val grouped = buildGroupedModules() runCatching { JsonParser.parseString(text).asJsonArray }.getOrNull()?.forEach { element -> val addonObj = element.asJsonObject val addonId = addonObj.get("addon").asString - val addon = addonsMap[addonId] ?: return@forEach + val modules = grouped[addonId] ?: return@forEach - val modulesMap = addon.getModules().associateBy { it.name } - val settingsMap = modulesMap.values.flatMap { it.getSettings() }.associateBy { it.name } + val modulesMap = modules.associateBy { it.name } addonObj.getAsJsonArray("modules")?.forEach { moduleElement -> val moduleObj = moduleElement.asJsonObject val moduleName = moduleObj.get("name").asString - modulesMap[moduleName] ?: return@forEach + val module = modulesMap[moduleName] ?: return@forEach + val settingsMap = module.getSettings().associateBy { it.name } moduleObj.getAsJsonObject("settings")?.entrySet()?.forEach { (key, value) -> settingsMap[key]?.read(value) } + + val hudElementsMap = module.getHudElements().associateBy { it.id } + moduleObj.getAsJsonArray("hudElements")?.forEach { hudEl -> + val hudObj = hudEl.asJsonObject + val hudId = hudObj.get("id")?.asString ?: return@forEach + val hudElement = hudElementsMap[hudId] ?: return@forEach + + hudElement.enabled = hudObj.get("enabled")?.asBoolean ?: true + hudElement.anchor = hudObj.get("anchor")?.asString?.let { + runCatching { HudAnchor.valueOf(it) }.getOrNull() + } ?: HudAnchor.TOP_LEFT + hudElement.offsetX = hudObj.get("offsetX")?.asFloat ?: 10f + hudElement.offsetY = hudObj.get("offsetY")?.asFloat ?: 10f + hudElement.scale = hudObj.get("scale")?.asFloat?.coerceIn(0.5f, 3.0f) ?: 1.0f + + val hudSettingsObj = hudObj.getAsJsonObject("settings") + if (hudSettingsObj != null) { + hudElement.getSettings().forEach { setting -> + hudSettingsObj.get(setting.name)?.let { jsonEl -> + runCatching { setting.read(jsonEl) } + } + } + } + } } } } @@ -56,12 +105,12 @@ internal object Config { fun saveModulesConfig() { val jsonArray = JsonArray() - AddonLoader.getAddons().forEach { (metadata, addon) -> + buildGroupedModules().forEach { (addonId, modules) -> val addonObject = JsonObject() - addonObject.addProperty("addon", metadata.id) + addonObject.addProperty("addon", addonId) val modulesArray = JsonArray() - addon.getModules().forEach { module -> + modules.forEach { module -> val moduleObject = JsonObject() moduleObject.addProperty("name", module.name) @@ -70,6 +119,26 @@ internal object Config { settingsObject.add(setting.name, setting.write()) } moduleObject.add("settings", settingsObject) + + val hudElementsArray = JsonArray() + module.getHudElements().forEach { hudElement -> + val hudObj = JsonObject() + hudObj.addProperty("id", hudElement.id) + hudObj.addProperty("enabled", hudElement.enabled) + hudObj.addProperty("anchor", hudElement.anchor.name) + hudObj.addProperty("offsetX", hudElement.offsetX) + hudObj.addProperty("offsetY", hudElement.offsetY) + hudObj.addProperty("scale", hudElement.scale) + + val hudSettingsObj = JsonObject() + hudElement.getSettings().forEach { setting -> + hudSettingsObj.add(setting.name, setting.write()) + } + hudObj.add("settings", hudSettingsObj) + hudElementsArray.add(hudObj) + } + moduleObject.add("hudElements", hudElementsArray) + modulesArray.add(moduleObject) } diff --git a/src/main/kotlin/org/cobalt/internal/ui/components/settings/UIColorSetting.kt b/src/main/kotlin/org/cobalt/internal/ui/components/settings/UIColorSetting.kt index d9d1b47..7e8187c 100644 --- a/src/main/kotlin/org/cobalt/internal/ui/components/settings/UIColorSetting.kt +++ b/src/main/kotlin/org/cobalt/internal/ui/components/settings/UIColorSetting.kt @@ -1,14 +1,23 @@ package org.cobalt.internal.ui.components.settings import java.awt.Color +import net.minecraft.client.Minecraft +import net.minecraft.client.input.CharacterEvent +import net.minecraft.client.input.KeyEvent +import org.cobalt.api.module.setting.impl.ColorMode import org.cobalt.api.module.setting.impl.ColorSetting +import org.cobalt.api.module.setting.impl.ThemeColorResolver import org.cobalt.api.ui.theme.ThemeManager import org.cobalt.api.util.ui.NVGRenderer import org.cobalt.api.util.ui.helper.Gradient import org.cobalt.internal.ui.UIComponent +import org.cobalt.internal.ui.animation.ColorAnimation +import org.cobalt.internal.ui.util.ScrollHandler +import org.cobalt.internal.ui.util.TextInputHandler import org.cobalt.internal.ui.util.isHoveringOver import org.cobalt.internal.ui.util.mouseX import org.cobalt.internal.ui.util.mouseY +import org.lwjgl.glfw.GLFW internal class UIColorSetting(private val setting: ColorSetting) : UIComponent( x = 0F, @@ -18,23 +27,74 @@ internal class UIColorSetting(private val setting: ColorSetting) : UIComponent( ) { private var pickerOpen = false - private var hue = 0f - private var saturation = 1f - private var lightness = 0.5f - private var opacity = 1f - private var draggingHue = false + // Mode dropdown state + private var modeDropdownOpen = false + private val modeDropdownHoverAnim = ColorAnimation(150L) + private var modeDropdownWasHovering = false + + // Static mode (HSB picker) state + private var staticHue = 0f + private var staticSaturation = 1f + private var staticBrightness = 0.5f + private var staticOpacity = 1f + private var draggingStaticHue = false + private var draggingStaticOpacity = false + private var draggingStaticColor = false + + // Rainbow/Synced mode slider states + private var draggingSpeed = false + private var draggingSaturation = false + private var draggingBrightness = false private var draggingOpacity = false - private var draggingColor = false - init { - val color = Color(setting.value) - val hsb = Color.RGBtoHSB(color.red, color.green, color.blue, null) + // Theme mode state + private val themeScrollHandler = ScrollHandler() + private var selectedThemeProperty = "accent" + + // Tweaked mode state + private var tweakedPropertyDropdownOpen = false + private val tweakedPropertyScrollHandler = ScrollHandler() + private var draggingHueOffset = false + private var draggingSaturationMult = false + private var draggingBrightnessMult = false + private var draggingOpacityMult = false + + // Hex input state + private val hexInputHandler = TextInputHandler("", 9) + private var hexFocused = false + private var hexDragging = false + private var hexValid = true - hue = hsb[0] - saturation = hsb[1] - lightness = hsb[2] - opacity = color.alpha / 255f + init { + // Initialize static HSB from current mode if Static + when (val mode = setting.mode) { + is ColorMode.Static -> { + val color = Color(mode.argb, true) + val hsb = Color.RGBtoHSB(color.red, color.green, color.blue, null) + staticHue = hsb[0] + staticSaturation = hsb[1] + staticBrightness = hsb[2] + staticOpacity = color.alpha / 255f + hexInputHandler.setText(argbToHex(mode.argb)) + } + is ColorMode.ThemeColor -> { + selectedThemeProperty = mode.propertyName + } + is ColorMode.TweakedTheme -> { + selectedThemeProperty = mode.propertyName + } + else -> { + // Initialize with default values for other modes + val color = Color(setting.value, true) + val hsb = Color.RGBtoHSB(color.red, color.green, color.blue, null) + staticHue = hsb[0] + staticSaturation = hsb[1] + staticBrightness = hsb[2] + staticOpacity = color.alpha / 255f + hexInputHandler.setText(argbToHex(setting.value)) + } + } } override fun render() { @@ -51,81 +111,435 @@ internal class UIColorSetting(private val setting: ColorSetting) : UIComponent( fun drawColorPicker() { if (!pickerOpen) return - val px = x + width - 220F + val px = x + width - 360F val py = y + height - 10F + + val pickerHeight = when (setting.mode) { + is ColorMode.Static -> 450F + is ColorMode.Rainbow, is ColorMode.SyncedRainbow -> 360F + is ColorMode.ThemeColor -> 400F + is ColorMode.TweakedTheme -> 470F + } + + NVGRenderer.rect(px + 2F, py + 2F, 340F, pickerHeight, Color(0, 0, 0, 50).rgb, 10F) + + NVGRenderer.rect(px, py, 340F, pickerHeight, ThemeManager.currentTheme.panel, 10F) + NVGRenderer.hollowRect(px, py, 340F, pickerHeight, 2F, ThemeManager.currentTheme.controlBorder, 10F) + + drawSourceTabs(px, py) + drawEffectToggles(px, py) + + val controlsY = py + 75F + when (setting.mode) { + is ColorMode.Static -> drawStaticPanel(px, controlsY) + is ColorMode.Rainbow -> drawRainbowPanel(px, controlsY, setting.mode as ColorMode.Rainbow) + is ColorMode.SyncedRainbow -> drawSyncedRainbowPanel(px, controlsY, setting.mode as ColorMode.SyncedRainbow) + is ColorMode.ThemeColor -> drawThemePanel(px, controlsY, setting.mode as ColorMode.ThemeColor) + is ColorMode.TweakedTheme -> drawTweakedPanel(px, controlsY, setting.mode as ColorMode.TweakedTheme) + } + + drawPreviewSwatch(px, py + pickerHeight - 70F) + } + + private fun drawSourceTabs(px: Float, py: Float) { val bx = px + 10F val by = py + 10F - val size = 180F - - NVGRenderer.rect(px, py, 200F, 250F, ThemeManager.currentTheme.panel, 8F) - NVGRenderer.hollowRect(px, py, 200F, 250F, 2F, ThemeManager.currentTheme.controlBorder, 8F) - - val hueColor = Color.HSBtoRGB(hue, 1f, 1f) - - NVGRenderer.rect(bx, by, size, size, hueColor, 0F) - NVGRenderer.gradientRect( - bx, - by, - size, - size, - ThemeManager.currentTheme.white, - ThemeManager.currentTheme.transparent, - Gradient.LeftToRight, - 0F - ) - NVGRenderer.gradientRect( - bx, - by, - size, - size, - ThemeManager.currentTheme.transparent, - ThemeManager.currentTheme.black, - Gradient.TopToBottom, - 0F - ) - NVGRenderer.hollowRect(bx, by, size, size, 1F, ThemeManager.currentTheme.controlBorder, 0F) + val totalWidth = 320F + val tabWidth = (totalWidth - 10F) / 2F + val tabHeight = 28F + + // Custom Tab + val isCustom = setting.mode !is ColorMode.ThemeColor && setting.mode !is ColorMode.TweakedTheme + val customColor = if (isCustom) ThemeManager.currentTheme.accent else ThemeManager.currentTheme.controlBg + + NVGRenderer.rect(bx, by, tabWidth, tabHeight, customColor, 5F) + NVGRenderer.hollowRect(bx, by, tabWidth, tabHeight, 1F, ThemeManager.currentTheme.controlBorder, 5F) + + val customText = "Custom" + val customTextWidth = NVGRenderer.textWidth(customText, 13F) + val customTextColor = if (isCustom) ThemeManager.currentTheme.white else ThemeManager.currentTheme.text + NVGRenderer.text(customText, bx + (tabWidth - customTextWidth) / 2F, by + 9F, 13F, customTextColor) + + // Theme Tab + val themeX = bx + tabWidth + 10F + val themeColor = if (!isCustom) ThemeManager.currentTheme.accent else ThemeManager.currentTheme.controlBg + + NVGRenderer.rect(themeX, by, tabWidth, tabHeight, themeColor, 5F) + NVGRenderer.hollowRect(themeX, by, tabWidth, tabHeight, 1F, ThemeManager.currentTheme.controlBorder, 5F) + + val themeText = "Theme" + val themeTextWidth = NVGRenderer.textWidth(themeText, 13F) + val themeTextColor = if (!isCustom) ThemeManager.currentTheme.white else ThemeManager.currentTheme.text + NVGRenderer.text(themeText, themeX + (tabWidth - themeTextWidth) / 2F, by + 9F, 13F, themeTextColor) + } + + private fun drawEffectToggles(px: Float, py: Float) { + val bx = px + 10F + val by = py + 48F + val checkboxSize = 20F + + val isCustom = setting.mode !is ColorMode.ThemeColor && setting.mode !is ColorMode.TweakedTheme - val selectorX = bx + saturation * size - val selectorY = by + (1f - lightness) * size + if (isCustom) { + val isRainbow = setting.mode is ColorMode.Rainbow || setting.mode is ColorMode.SyncedRainbow + drawCheckbox(bx, by, checkboxSize, isRainbow, "Rainbow") - NVGRenderer.circle(selectorX, selectorY, 5F, ThemeManager.currentTheme.white) - NVGRenderer.circle(selectorX, selectorY, 3F, ThemeManager.currentTheme.black) + val syncX = bx + 100F + val isSynced = setting.mode is ColorMode.SyncedRainbow + drawCheckbox(syncX, by, checkboxSize, isSynced, "Sync") + } else { + val isAdjusted = setting.mode is ColorMode.TweakedTheme + drawCheckbox(bx, by, checkboxSize, isAdjusted, "Adjust") + } + } - val hueY = py + size + 20F + private fun drawCheckbox(x: Float, y: Float, size: Float, checked: Boolean, label: String) { + val bgColor = if (checked) ThemeManager.currentTheme.selectedOverlay else ThemeManager.currentTheme.controlBg + val borderColor = if (checked) ThemeManager.currentTheme.accent else ThemeManager.currentTheme.controlBorder - for (i in 0..5) { - val x1 = bx + (size / 6f) * i - val x2 = bx + (size / 6f) * (i + 1) - val color1 = Color.HSBtoRGB(i / 6f, 1f, 1f) - val color2 = Color.HSBtoRGB((i + 1) / 6f, 1f, 1f) + NVGRenderer.rect(x, y, size, size, bgColor, 5F) + NVGRenderer.hollowRect(x, y, size, size, 1F, borderColor, 5F) - NVGRenderer.gradientRect(x1, hueY, x2 - x1, 15F, color1, color2, Gradient.LeftToRight, 0F) + if (checked) { + NVGRenderer.image(checkmarkIcon, x + 2F, y + 2F, size - 4F, size - 4F, 0F, ThemeManager.currentTheme.accent) } - NVGRenderer.hollowRect(bx, hueY, size, 15F, 1F, ThemeManager.currentTheme.controlBorder, 0F) - NVGRenderer.rect(bx + hue * size - 2F, hueY - 2F, 4F, 19F, ThemeManager.currentTheme.white, 1F) + NVGRenderer.text(label, x + size + 5F, y + 5F, 13F, ThemeManager.currentTheme.text) + } + + + private fun drawStaticPanel(px: Float, py: Float) { + val bx = px + 10F + val by = py + 10F + val boxWidth = 320F + val boxHeight = 180F + + val hueColor = Color.HSBtoRGB(staticHue, 1f, 1f) + + NVGRenderer.pushScissor(bx, by, boxWidth, boxHeight) + NVGRenderer.rect(bx, by, boxWidth, boxHeight, hueColor, 6F) + NVGRenderer.gradientRect(bx, by, boxWidth, boxHeight, ThemeManager.currentTheme.white, ThemeManager.currentTheme.transparent, Gradient.LeftToRight, 6F) + NVGRenderer.gradientRect(bx, by, boxWidth, boxHeight, ThemeManager.currentTheme.transparent, ThemeManager.currentTheme.black, Gradient.TopToBottom, 6F) + NVGRenderer.popScissor() + NVGRenderer.hollowRect(bx, by, boxWidth, boxHeight, 1F, ThemeManager.currentTheme.controlBorder, 6F) + + val selectorX = bx + staticSaturation * boxWidth + val selectorY = by + (1f - staticBrightness) * boxHeight + + val currentRgb = Color.HSBtoRGB(staticHue, staticSaturation, staticBrightness) + NVGRenderer.circle(selectorX, selectorY, 7F, ThemeManager.currentTheme.white) + NVGRenderer.circle(selectorX, selectorY, 5F, currentRgb) + + val hueY = py + boxHeight + 20F + val sliderWidth = 320F + + for (i in 0..35) { + val x1 = bx + (sliderWidth / 36f) * i + val x2 = bx + (sliderWidth / 36f) * (i + 1) + val color1 = Color.HSBtoRGB(i / 36f, 1f, 1f) + val color2 = Color.HSBtoRGB((i + 1) / 36f, 1f, 1f) + NVGRenderer.gradientRect(x1, hueY, x2 - x1, 6F, color1, color2, Gradient.LeftToRight,0f) + } + + NVGRenderer.hollowRect(bx, hueY, sliderWidth, 6F, 1F, ThemeManager.currentTheme.controlBorder, 3F) + NVGRenderer.circle(bx + staticHue * sliderWidth, hueY + 3F, 8F, ThemeManager.currentTheme.white) + + val opacityY = hueY + 20F + + NVGRenderer.rect(bx, opacityY, sliderWidth, 6F, ThemeManager.currentTheme.white, 3F) + + val currentColor = Color.HSBtoRGB(staticHue, staticSaturation, staticBrightness) + val opaqueColor = Color(currentColor or (255 shl 24), true).rgb + val transparentColor = Color(currentColor and 0x00FFFFFF, true).rgb + + NVGRenderer.gradientRect(bx, opacityY, sliderWidth, 6F, transparentColor, opaqueColor, Gradient.LeftToRight, 3F) + NVGRenderer.hollowRect(bx, opacityY, sliderWidth, 6F, 1F, ThemeManager.currentTheme.controlBorder, 3F) + NVGRenderer.circle(bx + staticOpacity * sliderWidth, opacityY + 3F, 8F, ThemeManager.currentTheme.white) + + val hexY = opacityY + 20F + NVGRenderer.text("Hex Code", bx, hexY, 13F, ThemeManager.currentTheme.text) + + val inputY = hexY + 20F + val inputX = bx + val inputWidth = 320F + val inputHeight = 30F + + val borderColor = if (hexFocused) { + ThemeManager.currentTheme.accent + } else if (!hexValid) { + ThemeManager.currentTheme.error + } else { + ThemeManager.currentTheme.inputBorder + } + + NVGRenderer.rect(inputX, inputY, inputWidth, inputHeight, ThemeManager.currentTheme.inputBg, 5F) + NVGRenderer.hollowRect(inputX, inputY, inputWidth, inputHeight, 2F, borderColor, 5F) + + val textX = inputX + 10F + val textY = inputY + 9F + + if (hexFocused) hexInputHandler.updateScroll(300F, 13F) + + NVGRenderer.pushScissor(inputX + 10F, inputY, 300F, inputHeight) + + if (hexFocused) { + hexInputHandler.renderSelection(textX, textY, 13F, 13F, ThemeManager.currentTheme.selection) + } + + NVGRenderer.text(hexInputHandler.getText(), textX - hexInputHandler.getTextOffset(), textY, 13F, ThemeManager.currentTheme.text) + + if (hexFocused) { + hexInputHandler.renderCursor(textX, textY, 13F, ThemeManager.currentTheme.text) + } + + NVGRenderer.popScissor() + } + + private fun drawRainbowPanel(px: Float, py: Float, mode: ColorMode.Rainbow) { + val bx = px + 20F + drawRainbowSliders(bx, py, mode.speed, mode.saturation, mode.brightness, mode.opacity) + } + + private fun drawSyncedRainbowPanel(px: Float, py: Float, mode: ColorMode.SyncedRainbow) { + val bx = px + 20F + drawRainbowSliders(bx, py, mode.speed, mode.saturation, mode.brightness, mode.opacity) + } + + private fun drawRainbowSliders(bx: Float, by: Float, speed: Float, saturation: Float, brightness: Float, opacity: Float) { + val labels = listOf("Speed", "Saturation", "Brightness", "Opacity") + val values = listOf(speed, saturation, brightness, opacity) + val sliderWidth = 300F + + values.forEachIndexed { index, value -> + val sliderY = by + index * 50F - val opacityY = hueY + 25F + NVGRenderer.text(labels[index], bx, sliderY + 2F, 13F, ThemeManager.currentTheme.text) - NVGRenderer.rect(bx, opacityY, size, 15F, ThemeManager.currentTheme.white, 0F) + val valueText = String.format("%.2f", value) + val valueWidth = NVGRenderer.textWidth(valueText, 12F) + NVGRenderer.text(valueText, bx + sliderWidth - valueWidth, sliderY + 2F, 12F, ThemeManager.currentTheme.textSecondary) - for (i in 0..17) { - if (i % 2 == 0) { - NVGRenderer.rect(bx + i * 10F, opacityY, 10F, 7.5F, ThemeManager.currentTheme.textSecondary, 0F) - NVGRenderer.rect(bx + i * 10F, opacityY + 7.5F, 10F, 7.5F, ThemeManager.currentTheme.white, 0F) + val trackY = sliderY + 24F + val normalizedValue = when (index) { + 0 -> (value / 2f).coerceIn(0f, 1f) + else -> value + } + val thumbX = bx + normalizedValue * sliderWidth + + NVGRenderer.rect(bx, trackY, sliderWidth, 6F, ThemeManager.currentTheme.sliderTrack, 3F) + NVGRenderer.rect(bx, trackY, thumbX - bx, 6F, ThemeManager.currentTheme.sliderFill, 3F) + NVGRenderer.circle(thumbX, trackY + 3F, 8F, ThemeManager.currentTheme.sliderThumb) + } + } + + private fun drawThemePanel(px: Float, py: Float, mode: ColorMode.ThemeColor) { + val bx = px + 10F + val by = py + 10F + val panelWidth = 320F + val panelHeight = 240F + + NVGRenderer.rect(bx, by, panelWidth, panelHeight, ThemeManager.currentTheme.inset, 5F) + NVGRenderer.hollowRect(bx, by, panelWidth, panelHeight, 1F, ThemeManager.currentTheme.controlBorder, 5F) + + NVGRenderer.pushScissor(bx, by, panelWidth, panelHeight) + + var currentY = by + 10F - themeScrollHandler.getOffset() + var totalHeight = 10F + + ThemeColorResolver.groups.forEach { (groupName, properties) -> + // Group header + NVGRenderer.text(groupName, bx + 10F, currentY, 12F, ThemeManager.currentTheme.textSecondary) + currentY += 22F + totalHeight += 22F + + // Properties + properties.forEach { propertyName -> + val isSelected = propertyName == mode.propertyName + val itemY = currentY + val isHovering = isHoveringOver(bx + 5F, itemY - 2F, panelWidth - 10F, 22F) + + if (isSelected) { + NVGRenderer.rect(bx + 5F, itemY - 2F, panelWidth - 10F, 22F, ThemeManager.currentTheme.selectedOverlay, 4F) + } else if (isHovering) { + NVGRenderer.rect(bx + 5F, itemY - 2F, panelWidth - 10F, 22F, ThemeManager.currentTheme.controlBg, 4F) + } + + // Color preview box + val previewColor = ThemeColorResolver.resolve(propertyName) + NVGRenderer.rect(bx + 15F, itemY, 18F, 18F, previewColor, 3F) + NVGRenderer.hollowRect(bx + 15F, itemY, 18F, 18F, 1F, ThemeManager.currentTheme.controlBorder, 3F) + + // Property name + val textColor = if (isSelected) ThemeManager.currentTheme.accent else ThemeManager.currentTheme.text + NVGRenderer.text(propertyName, bx + 40F, itemY + 4F, 12F, textColor) + + currentY += 22F + totalHeight += 22F + } + + currentY += 8F + totalHeight += 8F + } + + NVGRenderer.popScissor() + + themeScrollHandler.setMaxScroll(totalHeight, panelHeight) + + // Scrollbar + if (themeScrollHandler.isScrollable()) { + val scrollbarX = bx + panelWidth - 7F + val scrollbarY = by + 3F + val scrollbarHeight = panelHeight - 6F + val thumbHeight = (panelHeight / totalHeight) * scrollbarHeight + val thumbY = scrollbarY + (themeScrollHandler.getOffset() / themeScrollHandler.getMaxScroll()) * (scrollbarHeight - thumbHeight) + + NVGRenderer.rect(scrollbarX, thumbY, 4F, thumbHeight, ThemeManager.currentTheme.scrollbarThumb, 2F) + } + } + + private fun drawTweakedPanel(px: Float, py: Float, mode: ColorMode.TweakedTheme) { + val bx = px + 20F + var currentY = py + 10F + + // Property dropdown + NVGRenderer.text("Base Property", bx, currentY, 13F, ThemeManager.currentTheme.text) + currentY += 22F + + val dropdownWidth = 300F + val dropdownHeight = 30F + val isHovering = isHoveringOver(bx, currentY, dropdownWidth, dropdownHeight) + + val bgColor = if (isHovering) ThemeManager.currentTheme.selectedOverlay else ThemeManager.currentTheme.controlBg + NVGRenderer.rect(bx, currentY, dropdownWidth, dropdownHeight, bgColor, 5F) + NVGRenderer.hollowRect(bx, currentY, dropdownWidth, dropdownHeight, 1F, ThemeManager.currentTheme.controlBorder, 5F) + + // Preview color + text + val previewColor = ThemeColorResolver.resolve(mode.propertyName) + NVGRenderer.rect(bx + 8F, currentY + 6F, 18F, 18F, previewColor, 3F) + NVGRenderer.hollowRect(bx + 8F, currentY + 6F, 18F, 18F, 1F, ThemeManager.currentTheme.controlBorder, 3F) + NVGRenderer.text(mode.propertyName, bx + 32F, currentY + 8F, 12F, ThemeManager.currentTheme.text) + + currentY += 40F + + // Tweaking sliders + val labels = listOf("Hue Offset", "Saturation", "Brightness", "Opacity") + val values = listOf( + mode.hueOffset / 180f, // Normalize -180..180 to -1..1 + mode.saturationMultiplier / 2f, // 0..2 to 0..1 + mode.brightnessMultiplier / 2f, + mode.opacityMultiplier + ) + + values.forEachIndexed { index, normalizedValue -> + val sliderY = currentY + index * 50F + val displayValue = when (index) { + 0 -> mode.hueOffset + 1 -> mode.saturationMultiplier + 2 -> mode.brightnessMultiplier + 3 -> mode.opacityMultiplier + else -> 0f + } + + NVGRenderer.text(labels[index], bx, sliderY + 2F, 13F, ThemeManager.currentTheme.text) + + val valueText = String.format("%.2f", displayValue) + val valueWidth = NVGRenderer.textWidth(valueText, 12F) + NVGRenderer.text(valueText, bx + 300F - valueWidth, sliderY + 2F, 12F, ThemeManager.currentTheme.textSecondary) + + val trackY = sliderY + 24F + val sliderWidth = 300F + + val thumbX = if (index == 0) { + bx + (normalizedValue + 1f) / 2f * sliderWidth + } else { + bx + normalizedValue * sliderWidth + } + + NVGRenderer.rect(bx, trackY, sliderWidth, 6F, ThemeManager.currentTheme.sliderTrack, 3F) + if (index == 0) { + val centerX = bx + sliderWidth / 2f + if (thumbX > centerX) { + NVGRenderer.rect(centerX, trackY, thumbX - centerX, 6F, ThemeManager.currentTheme.sliderFill, 3F) + } else { + NVGRenderer.rect(thumbX, trackY, centerX - thumbX, 6F, ThemeManager.currentTheme.sliderFill, 3F) + } } else { - NVGRenderer.rect(bx + i * 10F, opacityY, 10F, 7.5F, ThemeManager.currentTheme.white, 0F) - NVGRenderer.rect(bx + i * 10F, opacityY + 7.5F, 10F, 7.5F, ThemeManager.currentTheme.textSecondary, 0F) + NVGRenderer.rect(bx, trackY, thumbX - bx, 6F, ThemeManager.currentTheme.sliderFill, 3F) + } + NVGRenderer.circle(thumbX, trackY + 3F, 8F, ThemeManager.currentTheme.sliderThumb) + } + } + + private fun drawPreviewSwatch(px: Float, py: Float) { + val panelX = px + 10F + val panelY = py + 8F + val panelWidth = 320F + val panelHeight = 56F + + NVGRenderer.rect(panelX, panelY, panelWidth, panelHeight, ThemeManager.currentTheme.inset, 6F) + + val swatchX = panelX + 8F + val swatchY = panelY + 8F + val swatchSize = 40F + + NVGRenderer.rect(swatchX, swatchY, swatchSize, swatchSize, setting.value, 6F) + NVGRenderer.hollowRect(swatchX, swatchY, swatchSize, swatchSize, 1.5F, ThemeManager.currentTheme.controlBorder, 6F) + + NVGRenderer.text("Preview", swatchX + 50F, swatchY + 14F, 13F, ThemeManager.currentTheme.text) + + val hexText = argbToHex(setting.value) + val hexWidth = NVGRenderer.textWidth(hexText, 12F) + NVGRenderer.text(hexText, panelX + panelWidth - hexWidth - 10F, swatchY + 15F, 12F, ThemeManager.currentTheme.textSecondary) + } + + private fun argbToHex(argb: Int): String { + return String.format("#%08X", argb) + } + + private fun parseHexToARGB(hex: String): Int? { + val stripped = hex.removePrefix("#").uppercase() + return when (stripped.length) { + 3 -> { + val r = stripped[0].toString().repeat(2) + val g = stripped[1].toString().repeat(2) + val b = stripped[2].toString().repeat(2) + (0xFF000000.toInt() or r.toInt(16).shl(16) or g.toInt(16).shl(8) or b.toInt(16)) + } + 6 -> { + (0xFF000000.toInt() or stripped.toInt(16)) + } + 8 -> { + stripped.toLong(16).toInt() } + else -> null } + } + + private fun validateHexInput(hex: String): Boolean { + val stripped = hex.removePrefix("#").uppercase() + return stripped.matches(Regex("^[0-9A-F]{3}$|^[0-9A-F]{6}$|^[0-9A-F]{8}$")) + } - val currentColor = Color.HSBtoRGB(hue, saturation, lightness) - val opaqueColor = Color(currentColor or (255 shl 24), true).rgb - val transparentColor = Color(currentColor and 0x00FFFFFF, true).rgb + private fun commitHexInput() { + parseHexToARGB(hexInputHandler.getText())?.let { argb -> + val color = Color(argb, true) + val hsb = Color.RGBtoHSB(color.red, color.green, color.blue, null) + staticHue = hsb[0] + staticSaturation = hsb[1] + staticBrightness = hsb[2] + staticOpacity = color.alpha / 255f + updateStaticColor() + } + } - NVGRenderer.gradientRect(bx, opacityY, size, 15F, transparentColor, opaqueColor, Gradient.LeftToRight, 0F) - NVGRenderer.hollowRect(bx, opacityY, size, 15F, 1F, ThemeManager.currentTheme.controlBorder, 0F) - NVGRenderer.rect(bx + opacity * size - 2F, opacityY - 2F, 4F, 19F, ThemeManager.currentTheme.white, 1F) + private fun updateHexFromCurrentColor() { + if (!hexFocused) { + val rgb = Color.HSBtoRGB(staticHue, staticSaturation, staticBrightness) + val alpha = (staticOpacity * 255).toInt() + val argb = (alpha shl 24) or (rgb and 0x00FFFFFF) + hexInputHandler.setText(argbToHex(argb)) + hexValid = true + } } override fun mouseClicked(button: Int): Boolean { @@ -139,47 +553,226 @@ internal class UIColorSetting(private val setting: ColorSetting) : UIComponent( return true } - if (!pickerOpen) { - return false - } + if (!pickerOpen) return false - val px = x + width - 220F + val px = x + width - 360F val py = y + height - 10F val bx = px + 10F val by = py + 10F - if (isHoveringOver(bx, by, 180F, 180F)) { - draggingColor = true - updateColorFromBox(bx, by) + if (handleTabClicks(bx, by)) return true + if (handleCheckboxClicks(bx, py)) return true + + val controlsY = py + 75F + return when (setting.mode) { + is ColorMode.Static -> handleStaticPanelClick(px, controlsY) + is ColorMode.Rainbow -> handleRainbowPanelClick(px, controlsY) + is ColorMode.SyncedRainbow -> handleSyncedRainbowPanelClick(px, controlsY) + is ColorMode.ThemeColor -> handleThemePanelClick(px, controlsY) + is ColorMode.TweakedTheme -> handleTweakedPanelClick(px, controlsY) + } + } + + private fun handleTabClicks(bx: Float, by: Float): Boolean { + val totalWidth = 320F + val tabWidth = (totalWidth - 10F) / 2F + val tabHeight = 28F + + if (isHoveringOver(bx, by, tabWidth, tabHeight)) { + if (setting.mode is ColorMode.ThemeColor || setting.mode is ColorMode.TweakedTheme) { + val rgb = Color.HSBtoRGB(staticHue, staticSaturation, staticBrightness) + val alpha = (staticOpacity * 255).toInt() + setting.mode = ColorMode.Static((alpha shl 24) or (rgb and 0x00FFFFFF)) + } return true } - val hueY = py + 200F + if (isHoveringOver(bx + tabWidth + 10F, by, tabWidth, tabHeight)) { + if (setting.mode !is ColorMode.ThemeColor && setting.mode !is ColorMode.TweakedTheme) { + setting.mode = ColorMode.ThemeColor(selectedThemeProperty) + } + return true + } - if (isHoveringOver(bx, hueY, 180F, 15F)) { - draggingHue = true - updateHueFromSlider(bx) + return false + } + + private fun handleCheckboxClicks(bx: Float, py: Float): Boolean { + val checkboxY = py + 48F + val checkboxSize = 20F + val isCustom = setting.mode !is ColorMode.ThemeColor && setting.mode !is ColorMode.TweakedTheme + + return if (isCustom) { + handleCustomCheckboxClicks(bx, checkboxY, checkboxSize) + } else { + handleThemeCheckboxClicks(bx, checkboxY, checkboxSize) + } + } + + private fun handleCustomCheckboxClicks(bx: Float, checkboxY: Float, checkboxSize: Float): Boolean { + if (isHoveringOver(bx, checkboxY, checkboxSize + 60F, checkboxSize)) { + toggleRainbowMode() return true } - val opacityY = hueY + 25F + val syncX = bx + 100F + if (isHoveringOver(syncX, checkboxY, checkboxSize + 40F, checkboxSize)) { + toggleSyncedMode() + return true + } - if (isHoveringOver(bx, opacityY, 180F, 15F)) { - draggingOpacity = true - updateOpacityFromSlider(bx) + return false + } + + private fun toggleRainbowMode() { + val isRainbow = setting.mode is ColorMode.Rainbow || setting.mode is ColorMode.SyncedRainbow + if (isRainbow) { + val rgb = Color.HSBtoRGB(staticHue, staticSaturation, staticBrightness) + val alpha = (staticOpacity * 255).toInt() + setting.mode = ColorMode.Static((alpha shl 24) or (rgb and 0x00FFFFFF)) + } else { + setting.mode = ColorMode.Rainbow() + } + } + + private fun toggleSyncedMode() { + when (val current = setting.mode) { + is ColorMode.SyncedRainbow -> { + setting.mode = ColorMode.Rainbow(current.speed, current.saturation, current.brightness, current.opacity) + } + is ColorMode.Rainbow -> { + setting.mode = ColorMode.SyncedRainbow(current.speed, current.saturation, current.brightness, current.opacity) + } + else -> { + setting.mode = ColorMode.SyncedRainbow() + } + } + } + + private fun handleThemeCheckboxClicks(bx: Float, checkboxY: Float, checkboxSize: Float): Boolean { + if (isHoveringOver(bx, checkboxY, checkboxSize + 60F, checkboxSize)) { + val isAdjusted = setting.mode is ColorMode.TweakedTheme + setting.mode = if (isAdjusted) { + ColorMode.ThemeColor(selectedThemeProperty) + } else { + ColorMode.TweakedTheme(selectedThemeProperty) + } return true } + return false + } - return isHoveringOver(px, py, 200F, 250F).also { - if (!it) pickerOpen = false + private fun handleStaticPanelClick(px: Float, py: Float): Boolean { + val bx = px + 10F + val by = py + 10F + + if (isHoveringOver(bx, by, 320F, 180F)) { + draggingStaticColor = true + updateStaticColorFromBox(bx, by) + return true + } + + val hueY = py + 200F + if (isHoveringOver(bx, hueY, 320F, 6F)) { + draggingStaticHue = true + updateStaticHueFromSlider(bx) + return true + } + + val opacityY = hueY + 20F + if (isHoveringOver(bx, opacityY, 320F, 6F)) { + draggingStaticOpacity = true + updateStaticOpacityFromSlider(bx) + return true + } + + val hexInputY = opacityY + 40F + if (isHoveringOver(bx, hexInputY, 320F, 30F)) { + hexFocused = true + hexDragging = true + hexInputHandler.startSelection(mouseX.toFloat(), bx + 10F, 13F) + return true + } + + if (hexFocused) { + if (hexValid) commitHexInput() + hexFocused = false + return true + } + + return false + } + + private fun handleRainbowPanelClick(px: Float, py: Float): Boolean { + val bx = px + 20F + val sliderWidth = 300F + + for (i in 0..3) { + val sliderY = py + i * 50F + 24F + if (isHoveringOver(bx, sliderY - 5F, sliderWidth, 16F)) { + when (i) { + 0 -> draggingSpeed = true + 1 -> draggingSaturation = true + 2 -> draggingBrightness = true + 3 -> draggingOpacity = true + } + updateRainbowSlider(i, bx, sliderWidth) + return true + } } + + return false } - override fun mouseReleased(button: Int): Boolean { - if (button == 0) { - draggingHue = false - draggingOpacity = false - draggingColor = false + private fun handleSyncedRainbowPanelClick(px: Float, py: Float): Boolean { + return handleRainbowPanelClick(px, py) + } + + private fun handleThemePanelClick(px: Float, py: Float): Boolean { + val bx = px + 10F + val by = py + 10F + val panelWidth = 320F + val panelHeight = 240F + + if (!isHoveringOver(bx, by, panelWidth, panelHeight)) return false + + var currentY = by + 10F - themeScrollHandler.getOffset() + + ThemeColorResolver.groups.forEach { (_, properties) -> + currentY += 22F // Skip group header + + properties.forEach { propertyName -> + val itemY = currentY + if (isHoveringOver(bx + 5F, itemY - 2F, panelWidth - 10F, 22F)) { + selectedThemeProperty = propertyName + setting.mode = ColorMode.ThemeColor(propertyName) + return true + } + currentY += 22F + } + + currentY += 8F + } + + return true + } + + private fun handleTweakedPanelClick(px: Float, py: Float): Boolean { + val bx = px + 20F + val sliderWidth = 300F + + for (i in 0..3) { + val sliderY = py + 102F + i * 50F + if (isHoveringOver(bx, sliderY - 5F, sliderWidth, 16F)) { + when (i) { + 0 -> draggingHueOffset = true + 1 -> draggingSaturationMult = true + 2 -> draggingBrightnessMult = true + 3 -> draggingOpacityMult = true + } + updateTweakedSlider(i, bx, sliderWidth) + return true + } } return false @@ -188,40 +781,254 @@ internal class UIColorSetting(private val setting: ColorSetting) : UIComponent( override fun mouseDragged(button: Int, offsetX: Double, offsetY: Double): Boolean { if (button != 0 || !pickerOpen) return false - val px = x + width - 220F + val px = x + width - 360F val py = y + height - 10F - val bx = px + 10F + val controlsY = py + 75F - when { - draggingColor -> updateColorFromBox(bx, py + 10F) - draggingHue -> updateHueFromSlider(bx) - draggingOpacity -> updateOpacityFromSlider(bx) + when (setting.mode) { + is ColorMode.Static -> { + val bx = px + 10F + when { + draggingStaticColor -> updateStaticColorFromBox(bx, controlsY + 10F) + draggingStaticHue -> updateStaticHueFromSlider(bx) + draggingStaticOpacity -> updateStaticOpacityFromSlider(bx) + hexDragging && hexFocused -> { + hexInputHandler.updateSelection(mouseX.toFloat(), bx + 10F, 13F) + return true + } + else -> return false + } + return true + } + is ColorMode.Rainbow, is ColorMode.SyncedRainbow -> { + val bx = px + 20F + val sliderWidth = 300F + when { + draggingSpeed -> { updateRainbowSlider(0, bx, sliderWidth); return true } + draggingSaturation -> { updateRainbowSlider(1, bx, sliderWidth); return true } + draggingBrightness -> { updateRainbowSlider(2, bx, sliderWidth); return true } + draggingOpacity -> { updateRainbowSlider(3, bx, sliderWidth); return true } + } + } + is ColorMode.TweakedTheme -> { + val bx = px + 20F + val sliderWidth = 300F + when { + draggingHueOffset -> { updateTweakedSlider(0, bx, sliderWidth); return true } + draggingSaturationMult -> { updateTweakedSlider(1, bx, sliderWidth); return true } + draggingBrightnessMult -> { updateTweakedSlider(2, bx, sliderWidth); return true } + draggingOpacityMult -> { updateTweakedSlider(3, bx, sliderWidth); return true } + } + } else -> return false } - return true + return false } - private fun updateColorFromBox(boxX: Float, boxY: Float) { - saturation = ((mouseX.toFloat() - boxX) / 180F).coerceIn(0f, 1f) - lightness = (1f - (mouseY.toFloat() - boxY) / 180F).coerceIn(0f, 1f) - updateColor() + override fun mouseReleased(button: Int): Boolean { + if (button == 0) { + draggingStaticHue = false + draggingStaticOpacity = false + draggingStaticColor = false + draggingSpeed = false + draggingSaturation = false + draggingBrightness = false + draggingOpacity = false + draggingHueOffset = false + draggingSaturationMult = false + draggingBrightnessMult = false + draggingOpacityMult = false + hexDragging = false + } + return false } - private fun updateHueFromSlider(sliderX: Float) { - hue = ((mouseX.toFloat() - sliderX) / 180F).coerceIn(0f, 1f) - updateColor() + override fun mouseScrolled(horizontalAmount: Double, verticalAmount: Double): Boolean { + if (!pickerOpen) return false + + if (setting.mode is ColorMode.ThemeColor) { + val px = x + width - 360F + val py = y + height - 10F + val bx = px + 10F + val by = py + 60F + val panelWidth = 320F + val panelHeight = 240F + + if (isHoveringOver(bx, by, panelWidth, panelHeight)) { + themeScrollHandler.handleScroll(verticalAmount) + return true + } + } + + return false } - private fun updateOpacityFromSlider(sliderX: Float) { - opacity = ((mouseX.toFloat() - sliderX) / 180F).coerceIn(0f, 1f) - updateColor() + private fun updateStaticColorFromBox(boxX: Float, boxY: Float) { + staticSaturation = ((mouseX.toFloat() - boxX) / 320F).coerceIn(0f, 1f) + staticBrightness = (1f - (mouseY.toFloat() - boxY) / 180F).coerceIn(0f, 1f) + updateStaticColor() + } + + private fun updateStaticHueFromSlider(sliderX: Float) { + staticHue = ((mouseX.toFloat() - sliderX) / 320F).coerceIn(0f, 1f) + updateStaticColor() + } + + private fun updateStaticOpacityFromSlider(sliderX: Float) { + staticOpacity = ((mouseX.toFloat() - sliderX) / 320F).coerceIn(0f, 1f) + updateStaticColor() + } + + private fun updateStaticColor() { + val rgb = Color.HSBtoRGB(staticHue, staticSaturation, staticBrightness) + val alpha = (staticOpacity * 255).toInt() + setting.mode = ColorMode.Static((alpha shl 24) or (rgb and 0x00FFFFFF)) + updateHexFromCurrentColor() + } + + private fun updateRainbowSlider(index: Int, bx: Float, sliderWidth: Float) { + val normalized = ((mouseX.toFloat() - bx) / sliderWidth).coerceIn(0f, 1f) + + setting.mode = when (val mode = setting.mode) { + is ColorMode.Rainbow -> updateRainbowModeValues(mode, index, normalized) + is ColorMode.SyncedRainbow -> updateSyncedRainbowModeValues(mode, index, normalized) + else -> mode + } + } + + private fun updateTweakedSlider(index: Int, bx: Float, sliderWidth: Float) { + val normalized = ((mouseX.toFloat() - bx) / sliderWidth).coerceIn(0f, 1f) + + val mode = setting.mode as? ColorMode.TweakedTheme ?: return + + val newMode = when (index) { + 0 -> mode.copy(hueOffset = (normalized - 0.5f) * 360f) // -180 to 180 + 1 -> mode.copy(saturationMultiplier = normalized * 2f) // 0 to 2 + 2 -> mode.copy(brightnessMultiplier = normalized * 2f) // 0 to 2 + 3 -> mode.copy(opacityMultiplier = normalized) // 0 to 1 + else -> mode + } + + setting.mode = newMode + } + + private fun updateRainbowModeValues(mode: ColorMode.Rainbow, index: Int, normalized: Float): ColorMode.Rainbow { + return when (index) { + 0 -> mode.copy(speed = normalized * 2f) + 1 -> mode.copy(saturation = normalized) + 2 -> mode.copy(brightness = normalized) + 3 -> mode.copy(opacity = normalized) + else -> mode + } + } + + private fun updateSyncedRainbowModeValues(mode: ColorMode.SyncedRainbow, index: Int, normalized: Float): ColorMode.SyncedRainbow { + return when (index) { + 0 -> mode.copy(speed = normalized * 2f) + 1 -> mode.copy(saturation = normalized) + 2 -> mode.copy(brightness = normalized) + 3 -> mode.copy(opacity = normalized) + else -> mode + } + } + + override fun charTyped(input: CharacterEvent): Boolean { + if (!hexFocused || !pickerOpen || setting.mode !is ColorMode.Static) return false + + val char = input.codepoint.toChar() + val isHexChar = char in '0'..'9' || char in 'a'..'f' || char in 'A'..'F' || char == '#' + val isPrintable = char.code >= 32 && char != '\u007f' + + if (isHexChar && isPrintable) { + hexInputHandler.insertText(char.toString()) + hexValid = validateHexInput(hexInputHandler.getText()) + return true + } + + return false + } + + override fun keyPressed(input: KeyEvent): Boolean { + if (!hexFocused || !pickerOpen || setting.mode !is ColorMode.Static) return false + + val ctrl = input.modifiers and GLFW.GLFW_MOD_CONTROL != 0 + val shift = input.modifiers and GLFW.GLFW_MOD_SHIFT != 0 + + if (ctrl) { + val handled = handleCtrlKeyCombo(input.key) + if (handled) return true + } + + return handleHexInputKey(input.key, shift) + } + + private fun handleCtrlKeyCombo(key: Int): Boolean { + return when (key) { + GLFW.GLFW_KEY_A -> { + hexInputHandler.selectAll() + true + } + GLFW.GLFW_KEY_C -> { + hexInputHandler.copy()?.let { Minecraft.getInstance().keyboardHandler.clipboard = it } + true + } + GLFW.GLFW_KEY_X -> { + hexInputHandler.cut()?.let { Minecraft.getInstance().keyboardHandler.clipboard = it } + hexValid = validateHexInput(hexInputHandler.getText()) + true + } + GLFW.GLFW_KEY_V -> { + val clipboard = Minecraft.getInstance().keyboardHandler.clipboard + if (clipboard.isNotEmpty()) { + hexInputHandler.insertText(clipboard) + hexValid = validateHexInput(hexInputHandler.getText()) + } + true + } + else -> false + } + } + + private fun handleHexInputKey(key: Int, shift: Boolean): Boolean { + return when (key) { + GLFW.GLFW_KEY_ESCAPE, GLFW.GLFW_KEY_ENTER -> { + if (hexValid) commitHexInput() + hexFocused = false + true + } + GLFW.GLFW_KEY_BACKSPACE -> { + hexInputHandler.backspace() + hexValid = validateHexInput(hexInputHandler.getText()) + true + } + GLFW.GLFW_KEY_DELETE -> { + hexInputHandler.delete() + hexValid = validateHexInput(hexInputHandler.getText()) + true + } + GLFW.GLFW_KEY_LEFT -> { + hexInputHandler.moveCursorLeft(shift) + true + } + GLFW.GLFW_KEY_RIGHT -> { + hexInputHandler.moveCursorRight(shift) + true + } + GLFW.GLFW_KEY_HOME -> { + hexInputHandler.moveCursorToStart(shift) + true + } + GLFW.GLFW_KEY_END -> { + hexInputHandler.moveCursorToEnd(shift) + true + } + else -> false + } } - private fun updateColor() { - val rgb = Color.HSBtoRGB(hue, saturation, lightness) - val alpha = (opacity * 255).toInt() - setting.value = (alpha shl 24) or (rgb and 0x00FFFFFF) + companion object { + private val checkmarkIcon = NVGRenderer.createImage("/assets/cobalt/textures/ui/checkmark.svg") } } diff --git a/src/main/kotlin/org/cobalt/internal/ui/hud/HudSettingsPopup.kt b/src/main/kotlin/org/cobalt/internal/ui/hud/HudSettingsPopup.kt new file mode 100644 index 0000000..b29545e --- /dev/null +++ b/src/main/kotlin/org/cobalt/internal/ui/hud/HudSettingsPopup.kt @@ -0,0 +1,391 @@ +package org.cobalt.internal.ui.hud + +import java.awt.Color +import net.minecraft.client.input.CharacterEvent +import net.minecraft.client.input.KeyEvent +import org.cobalt.api.hud.HudElement +import org.cobalt.api.module.setting.impl.* +import org.cobalt.api.ui.theme.ThemeManager +import org.cobalt.api.util.ui.NVGRenderer +import org.cobalt.internal.ui.UIComponent +import org.cobalt.internal.ui.animation.ColorAnimation +import org.cobalt.internal.ui.components.settings.* +import org.cobalt.internal.ui.util.GridLayout +import org.cobalt.internal.ui.util.ScrollHandler +import org.cobalt.internal.ui.util.isHoveringOver + +internal class HudSettingsPopup { + var visible: Boolean = false + var module: HudElement? = null + + private var panelX: Float = 0f + private var panelY: Float = 0f + private val panelWidth = 520f + private val panelHeight = 420f + + private val headerHeight = 50f + private val controlsHeight = 50f + private val padding = 20f + private val cornerRadius = 10f + + private val toggleAnim = ColorAnimation(150L) + private val buttonAnim = ColorAnimation(150L) + private val closeAnim = ColorAnimation(150L) + + private var settingComponents: List = emptyList() + + private val settingsScroll = ScrollHandler() + private val settingsLayout = GridLayout( + columns = 1, + itemWidth = panelWidth - padding * 2f, + itemHeight = 60f, + gap = 10f + ) + + fun show(module: HudElement, screenWidth: Float, screenHeight: Float) { + this.module = module + panelX = (screenWidth - panelWidth) / 2f + panelY = (screenHeight - panelHeight) / 2f + visible = true + settingsScroll.reset() + + settingComponents = module.getSettings().map { + when (it) { + is CheckboxSetting -> UICheckboxSetting(it) + is ColorSetting -> UIColorSetting(it) + is InfoSetting -> UIInfoSetting(it) + is KeyBindSetting -> UIKeyBindSetting(it) + is ModeSetting -> UIModeSetting(it) + is RangeSetting -> UIRangeSetting(it) + is SliderSetting -> UISliderSetting(it) + else -> UITextSetting(it as TextSetting) + } + } + + val settingWidth = panelWidth - padding * 2f + settingComponents.forEach { component -> + component.width = settingWidth + component.height = 60f + } + } + + fun hide() { + visible = false + module = null + settingComponents = emptyList() + settingsScroll.reset() + } + + fun render() { + if (!visible) return + val target = module ?: return + + NVGRenderer.rect(0f, 0f, 10000f, 10000f, Color(0, 0, 0, 100).rgb) + + NVGRenderer.rect(panelX, panelY, panelWidth, panelHeight, ThemeManager.currentTheme.background, cornerRadius) + NVGRenderer.hollowRect(panelX, panelY, panelWidth, panelHeight, 1f, ThemeManager.currentTheme.controlBorder, cornerRadius) + + renderHeader(target) + + val dividerY = panelY + headerHeight + NVGRenderer.line( + panelX + padding, dividerY, + panelX + panelWidth - padding, dividerY, + 1f, ThemeManager.currentTheme.moduleDivider + ) + + renderControls(target) + + val controlsDividerY = dividerY + controlsHeight + NVGRenderer.line( + panelX + padding, controlsDividerY, + panelX + panelWidth - padding, controlsDividerY, + 1f, ThemeManager.currentTheme.moduleDivider + ) + + renderSettings(controlsDividerY) + } + + private fun renderHeader(target: HudElement) { + NVGRenderer.text( + target.name, + panelX + padding, + panelY + 17f, + 16f, + ThemeManager.currentTheme.accent + ) + + val closeX = panelX + panelWidth - padding - 26f + val closeY = panelY + 12f + val closeSize = 26f + val closeHover = isHoveringOver(closeX, closeY, closeSize, closeSize) + + val closeBg = closeAnim.get( + ThemeManager.currentTheme.controlBg, + ThemeManager.currentTheme.selectedOverlay, + !closeHover + ) + val closeBorder = closeAnim.get( + ThemeManager.currentTheme.controlBorder, + ThemeManager.currentTheme.accent, + !closeHover + ) + + NVGRenderer.rect(closeX, closeY, closeSize, closeSize, closeBg, 6f) + NVGRenderer.hollowRect(closeX, closeY, closeSize, closeSize, 1f, closeBorder, 6f) + + val cx = closeX + closeSize / 2f + val cy = closeY + closeSize / 2f + val half = 5f + NVGRenderer.line(cx - half, cy - half, cx + half, cy + half, 1.5f, ThemeManager.currentTheme.textPrimary) + NVGRenderer.line(cx + half, cy - half, cx - half, cy + half, 1.5f, ThemeManager.currentTheme.textPrimary) + } + + private fun renderControls(target: HudElement) { + val controlsY = panelY + headerHeight + 10f + val buttonHeight = 30f + + val toggleText = if (target.enabled) "Disable" else "Enable" + val toggleWidth = NVGRenderer.textWidth(toggleText, 13f) + 30f + val toggleX = panelX + padding + + val isToggleHover = isHoveringOver(toggleX, controlsY, toggleWidth, buttonHeight) + val toggleBg = toggleAnim.get( + ThemeManager.currentTheme.controlBg, + ThemeManager.currentTheme.selectedOverlay, + !isToggleHover + ) + val toggleBorder = toggleAnim.get( + ThemeManager.currentTheme.controlBorder, + ThemeManager.currentTheme.accent, + !isToggleHover + ) + + NVGRenderer.rect(toggleX, controlsY, toggleWidth, buttonHeight, toggleBg, 8f) + NVGRenderer.hollowRect(toggleX, controlsY, toggleWidth, buttonHeight, 1.5f, toggleBorder, 8f) + NVGRenderer.text(toggleText, toggleX + 15f, controlsY + 8f, 13f, ThemeManager.currentTheme.textPrimary) + + val statusText = if (target.enabled) "Enabled" else "Disabled" + val statusColor = if (target.enabled) ThemeManager.currentTheme.accent else ThemeManager.currentTheme.textSecondary + NVGRenderer.text(statusText, toggleX + toggleWidth + 12f, controlsY + 8f, 12f, statusColor) + + val resetSettingsText = "Reset Settings" + val resetSettingsWidth = NVGRenderer.textWidth(resetSettingsText, 13f) + 30f + val resetSettingsX = panelX + panelWidth - padding - resetSettingsWidth + val resetSettingsHover = isHoveringOver(resetSettingsX, controlsY, resetSettingsWidth, buttonHeight) + val resetSettingsBg = buttonAnim.get( + ThemeManager.currentTheme.controlBg, + ThemeManager.currentTheme.selectedOverlay, + !resetSettingsHover + ) + val resetSettingsBorder = buttonAnim.get( + ThemeManager.currentTheme.controlBorder, + ThemeManager.currentTheme.accent, + !resetSettingsHover + ) + + NVGRenderer.rect(resetSettingsX, controlsY, resetSettingsWidth, buttonHeight, resetSettingsBg, 8f) + NVGRenderer.hollowRect(resetSettingsX, controlsY, resetSettingsWidth, buttonHeight, 1.5f, resetSettingsBorder, 8f) + NVGRenderer.text(resetSettingsText, resetSettingsX + 15f, controlsY + 8f, 13f, ThemeManager.currentTheme.textPrimary) + + val resetText = "Reset Position" + val resetWidth = NVGRenderer.textWidth(resetText, 13f) + 30f + val resetX = resetSettingsX - resetWidth - 10f + val resetHover = isHoveringOver(resetX, controlsY, resetWidth, buttonHeight) + val resetBg = buttonAnim.get( + ThemeManager.currentTheme.controlBg, + ThemeManager.currentTheme.selectedOverlay, + !resetHover + ) + val resetBorder = buttonAnim.get( + ThemeManager.currentTheme.controlBorder, + ThemeManager.currentTheme.accent, + !resetHover + ) + + NVGRenderer.rect(resetX, controlsY, resetWidth, buttonHeight, resetBg, 8f) + NVGRenderer.hollowRect(resetX, controlsY, resetWidth, buttonHeight, 1.5f, resetBorder, 8f) + NVGRenderer.text(resetText, resetX + 15f, controlsY + 8f, 13f, ThemeManager.currentTheme.textPrimary) + } + + private fun renderSettings(startY: Float) { + if (settingComponents.isEmpty()) { + val noText = "No settings available" + NVGRenderer.text( + noText, + panelX + panelWidth / 2f - NVGRenderer.textWidth(noText, 13f) / 2f, + startY + 30f, + 13f, + ThemeManager.currentTheme.textSecondary + ) + return + } + + val settingsAreaY = startY + 10f + val settingsAreaHeight = panelY + panelHeight - settingsAreaY - 10f + val settingWidth = panelWidth - padding * 2f + + settingsScroll.setMaxScroll( + settingsLayout.contentHeight(settingComponents.size) + 10f, + settingsAreaHeight + ) + + NVGRenderer.pushScissor(panelX + padding, settingsAreaY, settingWidth, settingsAreaHeight) + + val scrollOffset = settingsScroll.getOffset() + settingsLayout.layout(panelX + padding, settingsAreaY - scrollOffset, settingComponents) + settingComponents.forEach { component -> + component.width = settingWidth + component.render() + } + + NVGRenderer.popScissor() + + settingComponents.forEach { setting -> + when (setting) { + is UIModeSetting -> setting.renderDropdown() + is UIColorSetting -> setting.drawColorPicker() + } + } + } + + fun mouseClicked(mouseX: Float, mouseY: Float, button: Int): Boolean { + if (!visible) return false + val target = module ?: return false + + if (button == 0 && handleCloseButtonClick(mouseX, mouseY)) { + return true + } + + if (button != 0) return containsPoint(mouseX, mouseY) + + if (handleControlButtonClicks(mouseX, mouseY, target)) return true + + for (component in settingComponents) { + if (component.mouseClicked(button)) return true + } + + return containsPoint(mouseX, mouseY) + } + + private fun handleCloseButtonClick(mouseX: Float, mouseY: Float): Boolean { + val closeX = panelX + panelWidth - padding - 26f + val closeY = panelY + 12f + if (mouseX >= closeX && mouseX <= closeX + 26f && mouseY >= closeY && mouseY <= closeY + 26f) { + hide() + return true + } + return false + } + + private fun handleControlButtonClicks(mouseX: Float, mouseY: Float, target: HudElement): Boolean { + val controlsY = panelY + headerHeight + 10f + val buttonHeight = 30f + + if (handleToggleButtonClick(mouseX, mouseY, controlsY, buttonHeight, target)) return true + if (handleResetSettingsClick(mouseX, mouseY, controlsY, buttonHeight, target)) return true + if (handleResetPositionClick(mouseX, mouseY, controlsY, buttonHeight, target)) return true + + return false + } + + private fun handleToggleButtonClick(mouseX: Float, mouseY: Float, controlsY: Float, buttonHeight: Float, target: HudElement): Boolean { + val toggleText = if (target.enabled) "Disable" else "Enable" + val toggleWidth = NVGRenderer.textWidth(toggleText, 13f) + 30f + val toggleX = panelX + padding + + if (mouseX >= toggleX && mouseX <= toggleX + toggleWidth && + mouseY >= controlsY && mouseY <= controlsY + buttonHeight + ) { + target.enabled = !target.enabled + toggleAnim.start() + return true + } + return false + } + + private fun handleResetSettingsClick(mouseX: Float, mouseY: Float, controlsY: Float, buttonHeight: Float, target: HudElement): Boolean { + val resetSettingsText = "Reset Settings" + val resetSettingsWidth = NVGRenderer.textWidth(resetSettingsText, 13f) + 30f + val resetSettingsX = panelX + panelWidth - padding - resetSettingsWidth + + if (mouseX >= resetSettingsX && mouseX <= resetSettingsX + resetSettingsWidth && + mouseY >= controlsY && mouseY <= controlsY + buttonHeight + ) { + target.resetSettings() + buttonAnim.start() + return true + } + return false + } + + private fun handleResetPositionClick(mouseX: Float, mouseY: Float, controlsY: Float, buttonHeight: Float, target: HudElement): Boolean { + val resetSettingsText = "Reset Settings" + val resetSettingsWidth = NVGRenderer.textWidth(resetSettingsText, 13f) + 30f + val resetSettingsX = panelX + panelWidth - padding - resetSettingsWidth + val resetText = "Reset Position" + val resetWidth = NVGRenderer.textWidth(resetText, 13f) + 30f + val resetX = resetSettingsX - resetWidth - 10f + + if (mouseX >= resetX && mouseX <= resetX + resetWidth && + mouseY >= controlsY && mouseY <= controlsY + buttonHeight + ) { + target.resetPosition() + buttonAnim.start() + return true + } + return false + } + + fun mouseReleased(button: Int): Boolean { + if (!visible) return false + for (component in settingComponents) { + if (component.mouseReleased(button)) return true + } + return false + } + + fun mouseDragged(button: Int, offsetX: Double, offsetY: Double): Boolean { + if (!visible) return false + for (component in settingComponents) { + if (component.mouseDragged(button, offsetX, offsetY)) return true + } + return false + } + + fun mouseScrolled(horizontalAmount: Double, verticalAmount: Double): Boolean { + if (!visible) return false + + for (component in settingComponents) { + if (component.mouseScrolled(horizontalAmount, verticalAmount)) return true + } + + if (isHoveringOver(panelX, panelY, panelWidth, panelHeight)) { + settingsScroll.handleScroll(verticalAmount) + return true + } + + return false + } + + fun keyPressed(input: KeyEvent): Boolean { + if (!visible) return false + for (component in settingComponents) { + if (component.keyPressed(input)) return true + } + return false + } + + fun charTyped(input: CharacterEvent): Boolean { + if (!visible) return false + for (component in settingComponents) { + if (component.charTyped(input)) return true + } + return false + } + + fun containsPoint(px: Float, py: Float): Boolean { + if (!visible) return false + return px >= panelX && px <= panelX + panelWidth && py >= panelY && py <= panelY + panelHeight + } +} diff --git a/src/main/kotlin/org/cobalt/internal/ui/hud/SnapHelper.kt b/src/main/kotlin/org/cobalt/internal/ui/hud/SnapHelper.kt new file mode 100644 index 0000000..cccd6c3 --- /dev/null +++ b/src/main/kotlin/org/cobalt/internal/ui/hud/SnapHelper.kt @@ -0,0 +1,95 @@ +package org.cobalt.internal.ui.hud + +import kotlin.math.abs +import kotlin.math.round + +internal class SnapHelper( + private val gridSize: Float = 10f, + private val snapThreshold: Float = 5f, +) { + data class GuideLine(val isVertical: Boolean, val position: Float) + + var activeGuides: List = emptyList() + private set + + fun clearGuides() { + activeGuides = emptyList() + } + + fun snapToGrid(x: Float, y: Float): Pair { + val snappedX = round(x / gridSize) * gridSize + val snappedY = round(y / gridSize) * gridSize + return snappedX to snappedY + } + + fun findAlignmentGuides( + moduleX: Float, + moduleY: Float, + moduleW: Float, + moduleH: Float, + screenWidth: Float, + screenHeight: Float, + otherModuleBounds: List, + ): Pair { + val left = moduleX + val right = moduleX + moduleW + val centerX = moduleX + moduleW / 2f + val top = moduleY + val bottom = moduleY + moduleH + val centerY = moduleY + moduleH / 2f + + val xTargets = mutableListOf(0f, screenWidth / 2f, screenWidth) + val yTargets = mutableListOf(0f, screenHeight / 2f, screenHeight) + + otherModuleBounds.forEach { bounds -> + xTargets.add(bounds.x) + xTargets.add(bounds.x + bounds.w) + xTargets.add(bounds.x + bounds.w / 2f) + yTargets.add(bounds.y) + yTargets.add(bounds.y + bounds.h) + yTargets.add(bounds.y + bounds.h / 2f) + } + + var snappedX = moduleX + var snappedY = moduleY + var bestXDiff = snapThreshold + 1f + var bestYDiff = snapThreshold + 1f + var xGuide: GuideLine? = null + var yGuide: GuideLine? = null + + fun checkX(target: Float, edge: Float, newX: Float) { + val diff = abs(edge - target) + if (diff <= snapThreshold && diff < bestXDiff) { + bestXDiff = diff + snappedX = newX + xGuide = GuideLine(true, target) + } + } + + fun checkY(target: Float, edge: Float, newY: Float) { + val diff = abs(edge - target) + if (diff <= snapThreshold && diff < bestYDiff) { + bestYDiff = diff + snappedY = newY + yGuide = GuideLine(false, target) + } + } + + xTargets.forEach { target -> + checkX(target, left, target) + checkX(target, centerX, target - moduleW / 2f) + checkX(target, right, target - moduleW) + } + + yTargets.forEach { target -> + checkY(target, top, target) + checkY(target, centerY, target - moduleH / 2f) + checkY(target, bottom, target - moduleH) + } + + activeGuides = listOfNotNull(xGuide, yGuide) + return snappedX to snappedY + } + + data class ModuleBounds(val x: Float, val y: Float, val w: Float, val h: Float) +} diff --git a/src/main/kotlin/org/cobalt/internal/ui/panel/panels/UIHudList.kt b/src/main/kotlin/org/cobalt/internal/ui/panel/panels/UIHudList.kt new file mode 100644 index 0000000..fff1f94 --- /dev/null +++ b/src/main/kotlin/org/cobalt/internal/ui/panel/panels/UIHudList.kt @@ -0,0 +1,266 @@ +package org.cobalt.internal.ui.panel.panels + +import java.awt.Color +import org.cobalt.api.hud.HudElement +import org.cobalt.api.hud.HudModuleManager +import org.cobalt.api.ui.theme.ThemeManager +import org.cobalt.api.util.ui.NVGRenderer +import org.cobalt.internal.ui.UIComponent +import org.cobalt.internal.ui.components.UITopbar +import org.cobalt.internal.ui.panel.UIPanel +import org.cobalt.internal.ui.screen.UIHudEditor +import org.cobalt.internal.ui.util.ScrollHandler +import org.cobalt.internal.ui.util.isHoveringOver +import org.cobalt.internal.ui.components.settings.* +import org.cobalt.api.module.setting.impl.* + +internal class UIHudList : UIPanel( + x = 0F, + y = 0F, + width = 890F, + height = 600F +) { + + private val topBar = UITopbar("HUD Modules") + private var allEntries = HudModuleManager.getElements().map { HudElementEntry(it) } + private var entries = allEntries + + private val scrollHandler = ScrollHandler() + + private val editButton = ActionButton( + label = "Edit HUD", + width = 170F, + height = 40F, + background = { ThemeManager.currentTheme.accent }, + textColor = { ThemeManager.currentTheme.textOnAccent } + ) { + UIHudEditor().openUI() + } + + private val resetButton = ActionButton( + label = "Reset All", + width = 140F, + height = 40F, + background = { ThemeManager.currentTheme.controlBg }, + textColor = { ThemeManager.currentTheme.text } + ) { + HudModuleManager.resetAllPositions() + } + + fun refreshEntries() { + allEntries = HudModuleManager.getElements().map { HudElementEntry(it) } + entries = allEntries + components.removeAll { it is HudElementEntry } + components.addAll(0, allEntries) + } + + init { + components.addAll(allEntries) + components.add(editButton) + components.add(resetButton) + components.add(topBar) + + topBar.searchChanged { searchText -> + entries = if (searchText.isEmpty()) { + allEntries + } else { + val searchLower = searchText.lowercase() + allEntries.filter { + it.module.name.lowercase().contains(searchLower) || + it.module.description.lowercase().contains(searchLower) + } + } + } + } + + override fun render() { + NVGRenderer.rect(x, y, width, height, ThemeManager.currentTheme.background, 10F) + + topBar + .updateBounds(x, y) + .render() + + val buttonsY = y + topBar.height + 15F + val buttonGap = 12F + val totalButtonsWidth = editButton.width + resetButton.width + buttonGap + val buttonsStartX = x + width / 2F - totalButtonsWidth / 2F + + editButton + .updateBounds(buttonsStartX, buttonsY) + .render() + + resetButton + .updateBounds(buttonsStartX + editButton.width + buttonGap, buttonsY) + .render() + + val listStartY = buttonsY + editButton.height + 20F + val visibleHeight = height - (listStartY - y) + val entryGap = 10F + val contentHeight = if (entries.isEmpty()) 0F else { + entries.sumOf { it.height.toDouble() }.toFloat() + (entries.size - 1) * entryGap + } + + scrollHandler.setMaxScroll(contentHeight + 20F, visibleHeight) + NVGRenderer.pushScissor(x, listStartY, width, visibleHeight) + + val scrollOffset = scrollHandler.getOffset() + entries.forEachIndexed { index, entry -> + val entryY = listStartY + 10F + index * (entry.height + entryGap) - scrollOffset + entry.updateBounds(x, entryY) + entry.render() + } + + NVGRenderer.popScissor() + } + + override fun mouseScrolled(horizontalAmount: Double, verticalAmount: Double): Boolean { + if (isHoveringOver(x, y, width, height)) { + scrollHandler.handleScroll(verticalAmount) + return true + } + + return false + } + + private class ActionButton( + private val label: String, + width: Float, + height: Float, + private val background: () -> Int, + private val textColor: () -> Int, + private val onClick: () -> Unit, + ) : UIComponent(0F, 0F, width, height) { + + override fun render() { + val hovering = isHoveringOver(x, y, width, height) + val baseColor = background() + val color = if (hovering) Color(baseColor).darker().rgb else baseColor + + NVGRenderer.rect(x, y, width, height, color, 8F) + NVGRenderer.text( + label, + x + width / 2F - NVGRenderer.textWidth(label, 14F) / 2F, + y + height / 2F - 7F, + 14F, + textColor() + ) + } + + override fun mouseClicked(button: Int): Boolean { + if (button == 0 && isHoveringOver(x, y, width, height)) { + onClick() + return true + } + return false + } + } + + private class HudElementEntry( + val module: HudElement, + ) : UIComponent(0F, 0F, 890F, 60F) { + + private var expanded = false + private val baseHeight = 60F + private val settings = module.getSettings().map { + when (it) { + is CheckboxSetting -> UICheckboxSetting(it) + is ColorSetting -> UIColorSetting(it) + is InfoSetting -> UIInfoSetting(it) + is KeyBindSetting -> UIKeyBindSetting(it) + is ModeSetting -> UIModeSetting(it) + is RangeSetting -> UIRangeSetting(it) + is SliderSetting -> UISliderSetting(it) + else -> UITextSetting(it as TextSetting) + } + } + + private fun computeHeight(): Float { + return if (expanded && settings.isNotEmpty()) { + baseHeight + settings.size * 70F + } else { + baseHeight + } + } + + override fun render() { + height = computeHeight() + val theme = ThemeManager.currentTheme + NVGRenderer.rect(x, y, width, height, theme.controlBg, 8F) + + NVGRenderer.text( + module.name, + x + 20F, + y + 18F, + 14F, + theme.text + ) + + NVGRenderer.text( + module.description, + x + 20F, + y + 36F, + 12F, + theme.textSecondary + ) + + val toggleWidth = 40F + val toggleHeight = 22F + val toggleX = x + width - 20F - toggleWidth + val toggleY = y + height / 2F - toggleHeight / 2F + val toggleColor = if (module.enabled) theme.accent else theme.controlBg + + NVGRenderer.rect(toggleX, toggleY, toggleWidth, toggleHeight, toggleColor, toggleHeight / 2F) + + val knobRadius = 9F + val knobX = if (module.enabled) { + toggleX + toggleWidth - 11F + } else { + toggleX + 11F + } + val knobY = toggleY + toggleHeight / 2F + NVGRenderer.circle(knobX, knobY, knobRadius, theme.textOnAccent) + + NVGRenderer.line(x, y + height, x + width, y + height, 1F, theme.moduleDivider) + + if (expanded && settings.isNotEmpty()) { + var settingY = y + baseHeight + 10F + settings.forEach { setting -> + setting.updateBounds(x + 20F, settingY) + setting.width = width - 40F + setting.height = 60F + setting.render() + settingY += 70F + } + } + } + + override fun mouseClicked(button: Int): Boolean { + if (button == 0) { + val toggleWidth = 40F + val toggleHeight = 22F + val toggleX = x + width - 20F - toggleWidth + val toggleY = y + baseHeight / 2F - toggleHeight / 2F + + if (isHoveringOver(toggleX, toggleY, toggleWidth, toggleHeight)) { + module.enabled = !module.enabled + return true + } + + if (isHoveringOver(x, y, width, baseHeight)) { + expanded = !expanded + return true + } + } + + if (expanded && button == 0) { + for (setting in settings) { + if (setting.mouseClicked(button)) { + return true + } + } + } + + return false + } + } +} diff --git a/src/main/kotlin/org/cobalt/internal/ui/panel/panels/UISidebar.kt b/src/main/kotlin/org/cobalt/internal/ui/panel/panels/UISidebar.kt index 8ffa8a1..0acb38e 100644 --- a/src/main/kotlin/org/cobalt/internal/ui/panel/panels/UISidebar.kt +++ b/src/main/kotlin/org/cobalt/internal/ui/panel/panels/UISidebar.kt @@ -9,6 +9,7 @@ import org.cobalt.internal.ui.components.tooltips.UITooltip import org.cobalt.internal.ui.components.tooltips.impl.UITextTooltip import org.cobalt.internal.ui.panel.UIPanel import org.cobalt.internal.ui.screen.UIConfig +import org.cobalt.internal.ui.screen.UIHudEditor import org.cobalt.internal.ui.util.isHoveringOver internal class UISidebar : UIPanel( @@ -22,6 +23,10 @@ internal class UISidebar : UIPanel( UIConfig.swapBodyPanel(UIAddonList()) } + private val hudButton = UIButton("/assets/cobalt/textures/ui/palette.svg") { + UIHudEditor().openUI() + } + private val steveIcon = NVGRenderer.createImage("/assets/cobalt/textures/steve.png") private val userIcon = try { NVGRenderer.createImage("https://mc-heads.net/avatar/${Minecraft.getInstance().user.profileId}/100/face.png") @@ -36,7 +41,7 @@ internal class UISidebar : UIPanel( init { components.addAll( - listOf(moduleButton) + listOf(moduleButton, hudButton) ) } @@ -49,6 +54,11 @@ internal class UISidebar : UIPanel( .updateBounds(x + (width / 2F) - (moduleButton.width / 2F), y + 75F) .render() + hudButton + .setSelected(isHoveringOver(x + (width / 2F) - (hudButton.width / 2F), y + 75F + 35F, hudButton.width, hudButton.height)) + .updateBounds(x + (width / 2F) - (hudButton.width / 2F), y + 75F + 35F) + .render() + val userIconX = x + (width / 2F) - 16F val userIconY = y + height - 32F - 20F diff --git a/src/main/kotlin/org/cobalt/internal/ui/screen/UIHudEditor.kt b/src/main/kotlin/org/cobalt/internal/ui/screen/UIHudEditor.kt new file mode 100644 index 0000000..36ba13e --- /dev/null +++ b/src/main/kotlin/org/cobalt/internal/ui/screen/UIHudEditor.kt @@ -0,0 +1,377 @@ +package org.cobalt.internal.ui.screen + +import java.awt.Color +import kotlin.math.round +import net.minecraft.client.input.CharacterEvent +import net.minecraft.client.input.KeyEvent +import net.minecraft.client.input.MouseButtonEvent +import org.cobalt.api.event.EventBus +import org.cobalt.api.event.annotation.SubscribeEvent +import org.cobalt.api.event.impl.render.NvgEvent +import org.cobalt.api.hud.HudAnchor +import org.cobalt.api.hud.HudElement +import org.cobalt.api.hud.HudModuleManager +import org.cobalt.api.ui.theme.ThemeManager +import org.cobalt.api.util.ui.NVGRenderer +import org.cobalt.internal.helper.Config +import org.cobalt.internal.ui.UIScreen +import org.cobalt.internal.ui.hud.HudSettingsPopup +import org.cobalt.internal.ui.hud.SnapHelper +import org.cobalt.internal.ui.util.mouseX +import org.cobalt.internal.ui.util.mouseY + +internal class UIHudEditor : UIScreen() { + private var selectedElement: HudElement? = null + private var dragging = false + private var dragOffsetX = 0f + private var dragOffsetY = 0f + private var resizing = false + private var initialMouseX = 0f + private var initialMouseY = 0f + private var initialWidth = 0f + + private val snapHelper = SnapHelper() + private val settingsPopup = HudSettingsPopup() + + init { + EventBus.register(this) + } + + companion object { + private const val RESIZE_HANDLE_SIZE = 8f + } + + @Suppress("unused") + @SubscribeEvent + fun onRender(event: NvgEvent) { + if (mc.screen != this) return + + val window = mc.window + val width = window.screenWidth.toFloat() + val height = window.screenHeight.toFloat() + + NVGRenderer.beginFrame(width, height) + NVGRenderer.rect(0f, 0f, width, height, Color(0, 0, 0, 128).rgb) + + renderGrid(width, height) + renderElementBounds(width, height) + renderGuides(width, height) + settingsPopup.render() + renderInstructions(width, height) + NVGRenderer.endFrame() + } + + private fun renderGrid(width: Float, height: Float) { + val gridSize = 20f + val gridColor = Color(255, 255, 255, 20).rgb + var x = 0f + while (x <= width) { + NVGRenderer.line(x, 0f, x, height, 1f, gridColor) + x += gridSize + } + var y = 0f + while (y <= height) { + NVGRenderer.line(0f, y, width, y, 1f, gridColor) + y += gridSize + } + } + + private fun renderElementBounds(width: Float, height: Float) { + HudModuleManager.getElements().forEach { element -> + val (sx, sy) = element.getScreenPosition(width, height) + val w = element.getScaledWidth() + val h = element.getScaledHeight() + val isSelected = element == selectedElement + val borderColor = if (isSelected) ThemeManager.currentTheme.accent else ThemeManager.currentTheme.controlBorder + val borderThickness = if (isSelected) 2f else 1f + NVGRenderer.hollowRect(sx, sy, w, h, borderThickness, borderColor, 4f) + NVGRenderer.text(element.name, sx, sy + h + 6f, 12f, ThemeManager.currentTheme.textSecondary) + + if (element == selectedElement) { + val handleX = sx + w - RESIZE_HANDLE_SIZE + val handleY = sy + h - RESIZE_HANDLE_SIZE + NVGRenderer.rect(handleX, handleY, RESIZE_HANDLE_SIZE, RESIZE_HANDLE_SIZE, ThemeManager.currentTheme.accent, 2f) + } + } + } + + private fun renderGuides(width: Float, height: Float) { + if (!dragging) return + snapHelper.activeGuides.forEach { guide -> + if (guide.isVertical) { + NVGRenderer.line(guide.position, 0f, guide.position, height, 1.5f, ThemeManager.currentTheme.accent) + } else { + NVGRenderer.line(0f, guide.position, width, guide.position, 1.5f, ThemeManager.currentTheme.accent) + } + } + } + + private fun renderInstructions(width: Float, height: Float) { + val text = "Left-click to select, drag to move | Right-click for settings | Drag corner to resize | ESC to save and exit" + val textWidth = NVGRenderer.textWidth(text, 12f) + val padding = 14f + val boxWidth = textWidth + padding * 2f + val boxHeight = 26f + val x = width / 2f - boxWidth / 2f + val y = height - boxHeight - 20f + NVGRenderer.rect(x, y, boxWidth, boxHeight, Color(0, 0, 0, 140).rgb, 8f) + NVGRenderer.hollowRect(x, y, boxWidth, boxHeight, 1f, ThemeManager.currentTheme.controlBorder, 8f) + NVGRenderer.text(text, x + padding, y + 7f, 12f, ThemeManager.currentTheme.textSecondary) + } + + override fun mouseClicked(click: MouseButtonEvent, doubled: Boolean): Boolean { + val screenWidth = mc.window.screenWidth.toFloat() + val screenHeight = mc.window.screenHeight.toFloat() + val mx = mouseX.toFloat() + val my = mouseY.toFloat() + val button = click.button() + + if (handleSettingsPopupClick(mx, my, button)) return true + + if (button == 1 && handleRightClick(mx, my, screenWidth, screenHeight)) return true + + if (button == 0 && handleLeftClick(mx, my, screenWidth, screenHeight)) return true + + return super.mouseClicked(click, doubled) + } + + private fun handleSettingsPopupClick(mx: Float, my: Float, button: Int): Boolean { + if (!settingsPopup.visible) return false + if (settingsPopup.mouseClicked(mx, my, button)) return true + if (!settingsPopup.containsPoint(mx, my)) settingsPopup.hide() + return false + } + + private fun handleRightClick(mx: Float, my: Float, screenWidth: Float, screenHeight: Float): Boolean { + val target = findElementUnderCursor(mx, my, screenWidth, screenHeight) + if (target != null) { + selectedElement = target + settingsPopup.show(target, screenWidth, screenHeight) + return true + } + return false + } + + private fun handleLeftClick(mx: Float, my: Float, screenWidth: Float, screenHeight: Float): Boolean { + if (tryStartResizing(mx, my, screenWidth, screenHeight)) return true + return tryStartDragging(mx, my, screenWidth, screenHeight) + } + + private fun tryStartResizing(mx: Float, my: Float, screenWidth: Float, screenHeight: Float): Boolean { + val element = selectedElement ?: return false + val (sx, sy) = element.getScreenPosition(screenWidth, screenHeight) + val w = element.getScaledWidth() + val h = element.getScaledHeight() + val handleX = sx + w - RESIZE_HANDLE_SIZE + val handleY = sy + h - RESIZE_HANDLE_SIZE + + if (mx >= handleX && mx <= handleX + RESIZE_HANDLE_SIZE && + my >= handleY && my <= handleY + RESIZE_HANDLE_SIZE) { + resizing = true + initialMouseX = mx + initialMouseY = my + initialWidth = w + return true + } + return false + } + + private fun tryStartDragging(mx: Float, my: Float, screenWidth: Float, screenHeight: Float): Boolean { + val target = findElementUnderCursor(mx, my, screenWidth, screenHeight) + selectedElement = target + if (target != null) { + val (sx, sy) = target.getScreenPosition(screenWidth, screenHeight) + dragOffsetX = mx - sx + dragOffsetY = my - sy + dragging = true + settingsPopup.hide() + return true + } + return false + } + + override fun mouseReleased(click: MouseButtonEvent): Boolean { + if (settingsPopup.mouseReleased(click.button())) return true + + if (click.button() == 0 && dragging) { + dragging = false + selectedElement?.let { element -> + val screenWidth = mc.window.screenWidth.toFloat() + val screenHeight = mc.window.screenHeight.toFloat() + val mx = mouseX.toFloat() + val my = mouseY.toFloat() + val newScreenX = mx - dragOffsetX + val newScreenY = my - dragOffsetY + + val otherBounds = HudModuleManager.getElements() + .filter { it != element } + .map { + val (sx, sy) = it.getScreenPosition(screenWidth, screenHeight) + SnapHelper.ModuleBounds(sx, sy, it.getScaledWidth(), it.getScaledHeight()) + } + + val (alignedX, alignedY) = snapHelper.findAlignmentGuides( + newScreenX, newScreenY, + element.getScaledWidth(), element.getScaledHeight(), + screenWidth, screenHeight, + otherBounds, + ) + + updateElementPosition(element, round(alignedX), round(alignedY), screenWidth, screenHeight) + snapHelper.clearGuides() + } + return true + } + + if (resizing) { + resizing = false + return true + } + + return super.mouseReleased(click) + } + + override fun mouseDragged(click: MouseButtonEvent, offsetX: Double, offsetY: Double): Boolean { + if (settingsPopup.mouseDragged(click.button(), offsetX, offsetY)) return true + + if (click.button() != 0) return super.mouseDragged(click, offsetX, offsetY) + + val element = selectedElement ?: return super.mouseDragged(click, offsetX, offsetY) + val mx = mouseX.toFloat() + + if (resizing) { + val newWidth = initialWidth + (mx - initialMouseX) + val newScale = newWidth / element.getBaseWidth() + element.scale = newScale.coerceIn(0.5f, 3.0f) + return true + } + + if (!dragging) return super.mouseDragged(click, offsetX, offsetY) + + val screenWidth = mc.window.screenWidth.toFloat() + val screenHeight = mc.window.screenHeight.toFloat() + val my = mouseY.toFloat() + val newScreenX = mx - dragOffsetX + val newScreenY = my - dragOffsetY + + val otherBounds = HudModuleManager.getElements() + .filter { it != element } + .map { + val (sx, sy) = it.getScreenPosition(screenWidth, screenHeight) + SnapHelper.ModuleBounds(sx, sy, it.getScaledWidth(), it.getScaledHeight()) + } + + val (alignedX, alignedY) = snapHelper.findAlignmentGuides( + newScreenX, + newScreenY, + element.getScaledWidth(), + element.getScaledHeight(), + screenWidth, + screenHeight, + otherBounds, + ) + + updateElementPosition(element, round(alignedX), round(alignedY), screenWidth, screenHeight) + return true + } + + override fun mouseScrolled( + mouseX: Double, + mouseY: Double, + horizontalAmount: Double, + verticalAmount: Double, + ): Boolean { + if (settingsPopup.mouseScrolled(horizontalAmount, verticalAmount)) return true + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount) + } + + override fun keyPressed(input: KeyEvent): Boolean { + if (settingsPopup.keyPressed(input)) return true + return super.keyPressed(input) + } + + override fun charTyped(input: CharacterEvent): Boolean { + if (settingsPopup.charTyped(input)) return true + return super.charTyped(input) + } + + override fun init() { + HudModuleManager.isEditorOpen = true + super.init() + } + + override fun onClose() { + HudModuleManager.isEditorOpen = false + Config.saveModulesConfig() + EventBus.unregister(this) + super.onClose() + } + + private fun findElementUnderCursor( + mouseX: Float, + mouseY: Float, + screenWidth: Float, + screenHeight: Float, + ): HudElement? { + return HudModuleManager.getElements().lastOrNull { + it.containsPoint(mouseX, mouseY, screenWidth, screenHeight) + } + } + + private fun updateElementPosition( + element: HudElement, + newScreenX: Float, + newScreenY: Float, + screenWidth: Float, + screenHeight: Float, + ) { + val w = element.getScaledWidth() + val h = element.getScaledHeight() + when (element.anchor) { + HudAnchor.TOP_LEFT -> { + element.offsetX = newScreenX + element.offsetY = newScreenY + } + + HudAnchor.TOP_CENTER -> { + element.offsetX = newScreenX - (screenWidth / 2f - w / 2f) + element.offsetY = newScreenY + } + + HudAnchor.TOP_RIGHT -> { + element.offsetX = screenWidth - w - newScreenX + element.offsetY = newScreenY + } + + HudAnchor.CENTER_LEFT -> { + element.offsetX = newScreenX + element.offsetY = newScreenY - (screenHeight / 2f - h / 2f) + } + + HudAnchor.CENTER -> { + element.offsetX = newScreenX - (screenWidth / 2f - w / 2f) + element.offsetY = newScreenY - (screenHeight / 2f - h / 2f) + } + + HudAnchor.CENTER_RIGHT -> { + element.offsetX = screenWidth - w - newScreenX + element.offsetY = newScreenY - (screenHeight / 2f - h / 2f) + } + + HudAnchor.BOTTOM_LEFT -> { + element.offsetX = newScreenX + element.offsetY = screenHeight - h - newScreenY + } + + HudAnchor.BOTTOM_CENTER -> { + element.offsetX = newScreenX - (screenWidth / 2f - w / 2f) + element.offsetY = screenHeight - h - newScreenY + } + + HudAnchor.BOTTOM_RIGHT -> { + element.offsetX = screenWidth - w - newScreenX + element.offsetY = screenHeight - h - newScreenY + } + } + } +} diff --git a/src/main/kotlin/org/cobalt/internal/ui/theme/ThemeSerializer.kt b/src/main/kotlin/org/cobalt/internal/ui/theme/ThemeSerializer.kt index 3fc6413..731877a 100644 --- a/src/main/kotlin/org/cobalt/internal/ui/theme/ThemeSerializer.kt +++ b/src/main/kotlin/org/cobalt/internal/ui/theme/ThemeSerializer.kt @@ -27,6 +27,10 @@ internal object ThemeSerializer { fun toJson(theme: CustomTheme): JsonObject = JsonObject().apply { addProperty("name", theme.name) + addProperty("rainbowEnabled", theme.rainbowEnabled) + addProperty("rainbowSpeed", theme.rainbowSpeed) + addProperty("rainbowSaturation", theme.rainbowSaturation) + addProperty("rainbowBrightness", theme.rainbowBrightness) addProperty("background", theme.background) addProperty("panel", theme.panel) addProperty("inset", theme.inset) @@ -86,6 +90,10 @@ internal object ThemeSerializer { val defaults = CustomTheme() return CustomTheme( name = json.get("name")?.asString ?: defaults.name, + rainbowEnabled = json.get("rainbowEnabled")?.asBoolean ?: defaults.rainbowEnabled, + rainbowSpeed = json.get("rainbowSpeed")?.asFloat ?: defaults.rainbowSpeed, + rainbowSaturation = json.get("rainbowSaturation")?.asFloat ?: defaults.rainbowSaturation, + rainbowBrightness = json.get("rainbowBrightness")?.asFloat ?: defaults.rainbowBrightness, background = json.get("background")?.asInt ?: defaults.background, panel = json.get("panel")?.asInt ?: defaults.panel, inset = json.get("inset")?.asInt ?: defaults.inset,