diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestConfig_Test.java b/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestConfig_Test.java index 7c6cc1d9b..935c7b03b 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestConfig_Test.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestConfig_Test.java @@ -200,6 +200,25 @@ public class TestConfig_Test extends Config { ) public static String[] singleItem = {"minecraft:ender_pearl"}; + @EffectList( + title = "Effect List", + description = "Search registered status effects and add several of them to an ordered list.", + category = "Lists", + subcategory = "Effect List", + maxEntries = 6 + ) + public static String[] effectList = {"minecraft:speed", "minecraft:night_vision"}; + + @EffectList( + title = "Single Effect", + description = "The same picker limited to one selected effect.", + category = "Lists", + subcategory = "Effect List", + maxEntries = 1, + reorderable = false + ) + public static String[] singleEffect = {"minecraft:strength"}; + @TestTilePicker_Test( title = "Tile Picker", description = "Search a set of colors and pick up to three of them.", diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/MinecraftEffectCatalogService.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/MinecraftEffectCatalogService.kt new file mode 100644 index 000000000..32a336ae4 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/MinecraftEffectCatalogService.kt @@ -0,0 +1,137 @@ +package org.polyfrost.oneconfig.internal.ui.components.effect + +import org.jetbrains.skia.Image +import org.polyfrost.oneconfig.api.event.v1.EventManager +import org.polyfrost.oneconfig.api.event.v1.events.ResourceFinishedLoading +import org.slf4j.LoggerFactory + +//? if > 1.8.9 { +import net.minecraft.client.Minecraft +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.resources.Identifier +import net.minecraft.world.effect.MobEffectCategory +//?} else { +/*import kotlin.math.roundToInt +import net.minecraft.client.resource.language.I18n +import net.minecraft.entity.living.effect.StatusEffect +import net.ornithemc.osl.core.api.util.NamespacedIdentifiers +import net.ornithemc.osl.resource.loader.api.resource.manager.ResourceManager +import org.jetbrains.skia.Bitmap +*///?} + +class MinecraftEffectCatalogService : EffectCatalogService { + private val lock = Any() + + @Volatile + private var catalogCache: List? = null + private val iconCache = HashMap() + + //? if = 1.8.9 + //private var atlasLoaded = false + + init { + EventManager.register(ResourceFinishedLoading::class.java, Runnable(::clearCaches)) + } + + private fun clearCaches() { + synchronized(lock) { + catalogCache = null + iconCache.clear() + //? if = 1.8.9 + //atlasLoaded = false + } + EffectCatalog.invalidate() + } + + override fun effects(): List { + catalogCache?.let { return it } + return synchronized(lock) { + catalogCache ?: buildCatalog().also { catalogCache = it } + } + } + + private fun buildCatalog(): List { + //? if > 1.8.9 { + val registry = BuiltInRegistries.MOB_EFFECT + return registry.mapNotNull { effect -> + val id = registry.getKey(effect) ?: return@mapNotNull null + val category = when (effect.category) { + MobEffectCategory.BENEFICIAL -> EffectCategory.BENEFICIAL + MobEffectCategory.NEUTRAL -> EffectCategory.NEUTRAL + MobEffectCategory.HARMFUL -> EffectCategory.HARMFUL + } + EffectDescriptor(id.toString(), effect.displayName.string, category) + } + //?} else { + /*return StatusEffect.getKeys().mapNotNull { key -> + val effect = StatusEffect.get(key.toString()) ?: return@mapNotNull null + val category = if (effect.isHarmful) EffectCategory.HARMFUL else EffectCategory.BENEFICIAL + EffectDescriptor(key.toString(), I18n.translate(effect.translationKey), category) + } + *///?} + } + + override fun icon(id: String): Image? = synchronized(lock) { + //? if > 1.8.9 { + if (iconCache.containsKey(id)) return iconCache[id] + loadIcon(id).also { iconCache[id] = it } + //?} else { + /*if (!atlasLoaded) { + atlasLoaded = true + try { + loadAtlas() + } catch (e: Exception) { + LOG.warn("Failed to load the status effect icons", e) + } + } + iconCache[id] + *///?} + } + + //? if > 1.8.9 { + private fun loadIcon(id: String): Image? { + val namespace = id.substringBefore(':', "minecraft") + val path = id.substringAfter(':') + val texture = Identifier.tryBuild(namespace, "textures/mob_effect/$path.png") ?: return null + return try { + Minecraft.getInstance().resourceManager.getResource(texture).orElse(null) + ?.open()?.use { Image.makeFromEncoded(it.readAllBytes()) } + } catch (e: Exception) { + LOG.warn("Failed to load the icon for status effect {}", id, e) + null + } + } + //?} else { + /*private fun loadAtlas() { + val texture = NamespacedIdentifiers.from("minecraft", "textures/gui/container/inventory.png") + val atlas = ResourceManager.client().getResource(texture).orElse(null) + ?.open()?.use { Image.makeFromEncoded(it.readBytes()) } + if (atlas == null) { + LOG.warn("Failed to read the status effect icon atlas {}", texture) + return + } + atlas.use { + val scale = atlas.width / 256f + val size = (ICON_SIZE * scale).roundToInt() + Bitmap().use { bitmap -> + bitmap.allocN32Pixels(size, size) + for (key in StatusEffect.getKeys()) { + val effect = StatusEffect.get(key.toString()) ?: continue + if (!effect.hasIcon()) continue + val x = ((effect.iconIndex % 8 * ICON_SIZE) * scale).roundToInt() + val y = ((198 + effect.iconIndex / 8 * ICON_SIZE) * scale).roundToInt() + bitmap.erase(0) + if (atlas.readPixels(bitmap, x, y)) iconCache[key.toString()] = Image.makeFromBitmap(bitmap) + } + } + } + } + *///?} + + private companion object { + private val LOG = LoggerFactory.getLogger("OneConfig/Effect Catalog") + + //? if = 1.8.9 + //private const val ICON_SIZE = 18 + } +} diff --git a/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.internal.ui.components.effect.EffectCatalogService b/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.internal.ui.components.effect.EffectCatalogService new file mode 100644 index 000000000..22ea5f501 --- /dev/null +++ b/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.internal.ui.components.effect.EffectCatalogService @@ -0,0 +1 @@ +org.polyfrost.oneconfig.internal.ui.components.effect.MinecraftEffectCatalogService diff --git a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index dfaa08b25..49a74ad81 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -197,6 +197,11 @@ "oneconfig.picker.search": "Search by name or ID", "oneconfig.picker.empty": "No matches", "oneconfig.picker.unavailable": "Unavailable", + "oneconfig.effectlist.add": "Choose effects", + "oneconfig.effectlist.title": "Select effects", + "oneconfig.effectlist.search": "Search by name or ID", + "oneconfig.effectlist.empty": "No matching effects", + "oneconfig.effectlist.unavailable": "Unavailable effect", "oneconfig.itemlist.add": "Choose items", "oneconfig.itemlist.title": "Select items", "oneconfig.itemlist.search": "Search by name or ID", diff --git a/modules/config-impl/api/config-impl.api b/modules/config-impl/api/config-impl.api index b7e8f654b..3151b3c62 100644 --- a/modules/config-impl/api/config-impl.api +++ b/modules/config-impl/api/config-impl.api @@ -193,6 +193,8 @@ public class org/polyfrost/oneconfig/api/config/v1/KtConfig : org/polyfrost/onec protected final synthetic fun dropdown ([Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; public static synthetic fun dropdown$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;Ljava/lang/Object;[Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/properties/PropertyDelegateProvider; public static synthetic fun dropdown$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;[Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; + protected final synthetic fun effectList (Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZLjava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; + public static synthetic fun effectList$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; protected final synthetic fun file (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; public static synthetic fun file$default (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; protected final synthetic fun fileList (Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZLjava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/KtConfig$Provider; @@ -326,6 +328,12 @@ public final class org/polyfrost/oneconfig/api/config/v1/Visualizer$DropdownVisu public fun visualize (Lorg/polyfrost/oneconfig/api/config/v1/Property;Landroidx/compose/runtime/Composer;I)V } +public final class org/polyfrost/oneconfig/api/config/v1/Visualizer$EffectListVisualizer : org/polyfrost/oneconfig/api/config/v1/Visualizer { + public static final field $stable I + public fun ()V + public fun visualize (Lorg/polyfrost/oneconfig/api/config/v1/Property;Landroidx/compose/runtime/Composer;I)V +} + public final class org/polyfrost/oneconfig/api/config/v1/Visualizer$FileListVisualizer : org/polyfrost/oneconfig/api/config/v1/Visualizer { public static final field $stable I public fun ()V @@ -574,6 +582,17 @@ public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1 public abstract fun titleTranslation ()Z } +public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1/annotations/EffectList : java/lang/annotation/Annotation { + public abstract fun addText ()Ljava/lang/String; + public abstract fun category ()Ljava/lang/String; + public abstract fun description ()Ljava/lang/String; + public abstract fun icon ()Ljava/lang/String; + public abstract fun maxEntries ()I + public abstract fun reorderable ()Z + public abstract fun subcategory ()Ljava/lang/String; + public abstract fun title ()Ljava/lang/String; +} + public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1/annotations/File : java/lang/annotation/Annotation { public abstract fun category ()Ljava/lang/String; public abstract fun categoryKey ()Ljava/lang/String; @@ -974,6 +993,7 @@ public final class org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL { public final fun draggableList ([Ljava/lang/String;[Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$DraggableListProp; public final fun dropdown (I[Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$OptionsProp; public final synthetic fun dropdown (I[Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$Prop; + public final fun effectList ([Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$ListProp; public final fun file (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$FileProp; public static synthetic fun file$default (Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$FileProp; public final fun fileList ([Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL$FileListProp; diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/EffectList.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/EffectList.java new file mode 100644 index 000000000..024c98bf3 --- /dev/null +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/EffectList.java @@ -0,0 +1,70 @@ +/* + * This file is part of OneConfig. + * OneConfig - Next Generation Config Library for Minecraft: Java Edition + * Copyright (C) 2021~2024 Polyfrost. + * + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * OneConfig is licensed under the terms of version 3 of the GNU Lesser + * General Public License as published by the Free Software Foundation, AND + * under the Additional Terms Applicable to OneConfig, as published by Polyfrost, + * either version 1.0 of the Additional Terms, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License. If not, see . You should + * have also received a copy of the Additional Terms Applicable + * to OneConfig, as published by Polyfrost. If not, see + * + */ + +package org.polyfrost.oneconfig.api.config.v1.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.polyfrost.oneconfig.api.config.v1.Visualizer; + +/** + * Renders a searchable selector for status effects (potion effects) + *

+ * The annotated field must be a {@code String[]} or {@code List} containing + * namespaced effect registry IDs for example {@code minecraft:speed} + *

+ * Selected entries are ordered and can be removed and may be reordered + *

+ * Use {@link #maxEntries()} with a value of {@code 1} for a single-effect selector + */ +@Option(display = Visualizer.EffectListVisualizer.class) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@Documented +public @interface EffectList { + String title(); + + String description() default ""; + + String icon() default ""; + + String category() default "General"; + + String subcategory() default "General"; + + /** Maximum number of selected effects and {@code 0} means unlimited */ + int maxEntries() default 0; + + /** Allow the user to drag selected effects into a different order */ + boolean reorderable() default true; + + /** Label of the button that opens the effect selector */ + @TranslatedDefault("oneconfig.effectlist.add") + String addText() default "oneconfig.effectlist.add"; +} diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/collect/impl/OneConfigCollector.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/collect/impl/OneConfigCollector.java index 164fc5e27..78cc27dbc 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/collect/impl/OneConfigCollector.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/collect/impl/OneConfigCollector.java @@ -228,7 +228,8 @@ protected void handleMetadata(Tree tree, @NotNull Property property, @NotNull case ColorList color -> { if (!color.alpha()) property.addMetadata("noAlpha", Unit.INSTANCE); } - case ItemList itemList -> validateItemListField(field, itemList); + case ItemList itemList -> validateIdListField(field, "@ItemList", itemList.maxEntries()); + case EffectList effectList -> validateIdListField(field, "@EffectList", effectList.maxEntries()); default -> {} } @@ -276,20 +277,20 @@ private int rangeFieldSize(Field field, Property property, String annotationN )); } - private void validateItemListField(Field field, ItemList annotation) { + private void validateIdListField(Field field, String annotation, int maxEntries) { Class type = field.getType(); boolean stringArray = type.isArray() && type.getComponentType() == String.class; boolean stringList = List.class.isAssignableFrom(type) && hasStringListElements(field.getGenericType()); if (!stringArray && !stringList) { throw new IllegalArgumentException(String.format( - "@ItemList field '%s' must be a String[] or List, but was %s.", - field.getName(), field.getGenericType().getTypeName() + "%s field '%s' must be a String[] or List, but was %s.", + annotation, field.getName(), field.getGenericType().getTypeName() )); } - if (annotation.maxEntries() < 0) { + if (maxEntries < 0) { throw new IllegalArgumentException(String.format( - "@ItemList field '%s' has a negative maxEntries value (%s).", - field.getName(), annotation.maxEntries() + "%s field '%s' has a negative maxEntries value (%s).", + annotation, field.getName(), maxEntries )); } } diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt index 43de56534..d7c5e81f4 100644 --- a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt @@ -1051,6 +1051,29 @@ open class KtConfig(id: String, title: String, category: Category, icon: String? addListMetadata(maxEntries, reorderable, addText, addTextKey) } + /** Namespaced status effect IDs such as `minecraft:speed`, a [maxEntries] of 1 makes a single selector */ + @JvmSynthetic + protected fun effectList( + name: String, + def: Array = arrayOf(), + nameKey: String? = null, + description: String? = null, + descriptionKey: String? = null, + icon: String? = null, + category: String? = "General", + categoryKey: String? = null, + subcategory: String? = "General", + subcategoryKey: String? = null, + maxEntries: Int = 0, + reorderable: Boolean = true, + addText: String? = null, + addTextKey: String? = "oneconfig.effectlist.add", + ) = Provider(def, name, description, category, subcategory, Array::class.java, Visualizer.EffectListVisualizer()) { + addMetadata(name, nameKey, description, descriptionKey, icon, category, categoryKey, subcategory, subcategoryKey) + require(maxEntries >= 0) { "effectList '$name' has a negative maxEntries ($maxEntries)" } + addListMetadata(maxEntries, reorderable, addText, addTextKey) + } + private fun Node.addListMetadata(maxEntries: Int, reorderable: Boolean, addText: String?, addTextKey: String?) { this.addMetadata("maxEntries", maxEntries) this.addMetadata("reorderable", reorderable) diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Visualizer.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Visualizer.kt index 332bd40d1..daca72a59 100644 --- a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Visualizer.kt +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Visualizer.kt @@ -178,6 +178,16 @@ fun interface Visualizer { @Composable override fun visualize(prop: Property<*>) { Visualizer[ItemListVisualizer::class.java]?.visualize(prop) } } + /** + * A searchable status effect selector backed by a `String[]` or `List` of + * namespaced registry IDs + * + * A `maxEntries` metadata value of one makes it a single selector + */ + class EffectListVisualizer : Visualizer { + @Composable override fun visualize(prop: Property<*>) { Visualizer[EffectListVisualizer::class.java]?.visualize(prop) } + } + class FileVisualizer : Visualizer { @Composable override fun visualize(prop: Property<*>) { Visualizer[FileVisualizer::class.java]?.visualize(prop) } } diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL.kt index e00211a0b..5fa88a45e 100644 --- a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL.kt +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/dsl/ConfigDSL.kt @@ -104,6 +104,9 @@ class ConfigDSL(id: String? = null, title: String? = null, description: String? /** Namespaced item IDs such as `minecraft:diamond`, a `maxEntries` of 1 makes a single selector */ fun itemList(vararg default: String): ListProp> = put(ListProp(arrayOf(*default), Array::class.java), Visualizer.ItemListVisualizer()) + /** Namespaced status effect IDs such as `minecraft:speed`, a `maxEntries` of 1 makes a single selector */ + fun effectList(vararg default: String): ListProp> = put(ListProp(arrayOf(*default), Array::class.java), Visualizer.EffectListVisualizer()) + /** Moves the saved value of [this] from any of [names] when loading older configs, needs [Prop.id] set first */ fun Prop<*>.previousNames(vararg names: String) { val id = requireNotNull(id) { "previousNames needs the property id to be set first" } diff --git a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/EffectListTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/EffectListTest.java new file mode 100644 index 000000000..50d80065e --- /dev/null +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/EffectListTest.java @@ -0,0 +1,108 @@ +/* + * This file is part of OneConfig. + * OneConfig - Next Generation Config Library for Minecraft: Java Edition + * Copyright (C) 2021~2024 Polyfrost. + * + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * OneConfig is licensed under the terms of version 3 of the GNU Lesser + * General Public License as published by the Free Software Foundation, AND + * under the Additional Terms Applicable to OneConfig, as published by Polyfrost, + * either version 1.0 of the Additional Terms, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License. If not, see . You should + * have also received a copy of the Additional Terms Applicable + * to OneConfig, as published by Polyfrost. If not, see + * + */ + +package org.polyfrost.oneconfig.api.config.v1; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.annotations.EffectList; +import org.polyfrost.oneconfig.api.config.v1.collect.impl.OneConfigCollector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EffectListTest { + @Test + void collectsStringArrayAndListMetadata() { + Tree tree = new OneConfigCollector().collect(new ValidEffectListConfig()); + + Property multiple = tree.getProp("multiple"); + assertEquals(Visualizer.EffectListVisualizer.class, multiple.getMetadata("visualizer")); + assertEquals(Integer.valueOf(0), multiple.getMetadata("maxEntries")); + assertEquals("oneconfig.effectlist.add", multiple.getMetadata("addTextKey")); + + Property single = tree.getProp("single"); + assertEquals(Integer.valueOf(1), single.getMetadata("maxEntries")); + assertEquals(false, single.getMetadata("reorderable")); + } + + @Test + void rejectsAListWithTheWrongElementType() { + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> new OneConfigCollector().collect(new InvalidElementConfig()) + ); + assertTrue(hasMessage(exception, "@EffectList field 'invalid' must be a String[] or List")); + } + + @Test + void rejectsNegativeLimits() { + RuntimeException exception = assertThrows( + RuntimeException.class, + () -> new OneConfigCollector().collect(new InvalidLimitConfig()) + ); + assertTrue(hasMessage(exception, "@EffectList field 'invalid' has a negative maxEntries")); + } + + private static boolean hasMessage(Throwable throwable, String text) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current.getMessage() != null && current.getMessage().contains(text)) return true; + } + return false; + } + + private static final class ValidEffectListConfig extends Config { + @EffectList(title = "Effects") + private String[] multiple = {"minecraft:speed"}; + + @EffectList(title = "Effect", maxEntries = 1, reorderable = false) + private List single = new ArrayList<>(List.of("minecraft:night_vision")); + + private ValidEffectListConfig() { + super("effect-list-test.json", "Effect List Test", Category.OTHER); + } + } + + private static final class InvalidElementConfig extends Config { + @EffectList(title = "Invalid") + private List invalid = List.of(1); + + private InvalidElementConfig() { + super("invalid-effect-list-test.json", "Invalid Effect List Test", Category.OTHER); + } + } + + private static final class InvalidLimitConfig extends Config { + @EffectList(title = "Invalid", maxEntries = -1) + private String[] invalid = {}; + + private InvalidLimitConfig() { + super("invalid-effect-limit-test.json", "Invalid Effect Limit Test", Category.OTHER); + } + } +} diff --git a/modules/config-impl/src/test/kotlin/org/polyfrost/oneconfig/api/config/v1/KtApiParityTest.kt b/modules/config-impl/src/test/kotlin/org/polyfrost/oneconfig/api/config/v1/KtApiParityTest.kt index 69ca032a8..bde8f22ca 100644 --- a/modules/config-impl/src/test/kotlin/org/polyfrost/oneconfig/api/config/v1/KtApiParityTest.kt +++ b/modules/config-impl/src/test/kotlin/org/polyfrost/oneconfig/api/config/v1/KtApiParityTest.kt @@ -116,6 +116,8 @@ class KtApiParityTest { dsl.number(5f).apply { min = 0f; max = 1f; step = 5f } dsl.button { error("boom") }.property.getMetadata("runnable")!!.run() assertThrows(IllegalArgumentException::class.java) { dsl.itemList().maxEntries = -1 } + assertThrows(IllegalArgumentException::class.java) { dsl.effectList().maxEntries = -1 } + assertTrue(dsl.effectList("minecraft:speed").property.getMetadata("visualizer") is Visualizer.EffectListVisualizer) val list = dsl.draggableList(arrayOf("a"), "a", "b").apply { checkable = true } assertEquals(true, list["checkable"]) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt index 51510265d..f6a732688 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt @@ -7,6 +7,7 @@ import org.polyfrost.oneconfig.internal.ui.components.settings.ColorListOption import org.polyfrost.oneconfig.internal.ui.components.settings.ColorOption import org.polyfrost.oneconfig.internal.ui.components.settings.DraggableListOption import org.polyfrost.oneconfig.internal.ui.components.settings.DropdownOption +import org.polyfrost.oneconfig.internal.ui.components.settings.EffectListOption import org.polyfrost.oneconfig.internal.ui.components.settings.FileListOption import org.polyfrost.oneconfig.internal.ui.components.settings.FileOption import org.polyfrost.oneconfig.internal.ui.components.settings.InfoOption @@ -59,6 +60,9 @@ internal object BuiltinVisualizers { Visualizer.register(Visualizer.ItemListVisualizer::class.java, Visualizer { prop -> ItemListOption(ItemListOptionData(prop)) }) + Visualizer.register(Visualizer.EffectListVisualizer::class.java, Visualizer { prop -> + EffectListOption(EffectListOptionData(prop)) + }) Visualizer.register(Visualizer.FileVisualizer::class.java, Visualizer { prop -> FileOption(FileOptionData(prop)) }) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt index 5fb222362..6bc105960 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt @@ -10,6 +10,7 @@ import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.api.config.v1.ValueFormatter import org.polyfrost.oneconfig.internal.ui.components.asRenderText +import org.polyfrost.oneconfig.internal.ui.components.effect.normalizeEffectIds import org.polyfrost.oneconfig.internal.ui.components.localizedDescription import org.polyfrost.oneconfig.internal.ui.components.localizedString import org.polyfrost.oneconfig.internal.ui.components.localizedText @@ -211,17 +212,23 @@ class TextListOptionData(prop: Property<*>) : ListOptionData(prop) { } open class TileListOptionData(prop: Property<*>) : ListOptionData(prop) { - fun ids(): List = normalizeIds( + fun ids(): List = normalize( elements().mapNotNull { it as? String } ) fun setIds(values: List) = prop.setIdListElements( - normalizeIds(values) + normalize(values) ) + + protected open fun normalize(ids: Iterable): List = normalizeIds(ids) } class ItemListOptionData(prop: Property<*>) : TileListOptionData(prop) +class EffectListOptionData(prop: Property<*>) : TileListOptionData(prop) { + override fun normalize(ids: Iterable): List = normalizeEffectIds(ids) +} + class FileOptionData(prop: Property<*>) : OptionData(prop) { val placeholder: String? get() = localizedString(prop.getMetadata("placeholderKey"), prop.getMetadata("placeholder")).takeIf { it.isNotBlank() } val directory: Boolean get() = prop.getMetadata("directory") ?: false diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/EffectCatalogService.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/EffectCatalogService.kt new file mode 100644 index 000000000..6a32f7e62 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/EffectCatalogService.kt @@ -0,0 +1,55 @@ +package org.polyfrost.oneconfig.internal.ui.components.effect + +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.snapshots.Snapshot +import java.util.Locale +import java.util.ServiceLoader +import org.jetbrains.skia.Image +import org.polyfrost.oneconfig.internal.ui.components.picker.PickerEntry +import org.polyfrost.oneconfig.internal.ui.components.picker.normalizeIds + +enum class EffectCategory { BENEFICIAL, NEUTRAL, HARMFUL } + +/** A stable status effect registry ID such as `minecraft:speed` and the localized name shown to the user */ +data class EffectDescriptor( + override val id: String, + override val displayName: String, + val category: EffectCategory, +) : PickerEntry + +interface EffectCatalogService { + fun effects(): List + + fun icon(id: String): Image? +} + +object EffectCatalog { + private val service: EffectCatalogService? by lazy { + runCatching { + ServiceLoader.load(EffectCatalogService::class.java, EffectCatalogService::class.java.classLoader) + .iterator() + .let { if (it.hasNext()) it.next() else null } + }.getOrNull() + } + + private val _revision = mutableIntStateOf(0) + + val revision: Int get() = _revision.intValue + + fun platformService(): EffectCatalogService? = service + + fun effects(): List = service?.effects().orEmpty() + + fun icon(id: String): Image? = service?.icon(normalizeEffectId(id)) + + fun invalidate() { + Snapshot.withMutableSnapshot { _revision.intValue++ } + } +} + +fun normalizeEffectId(id: String): String { + val trimmed = id.trim().lowercase(Locale.ROOT) + return if (trimmed.isEmpty() || ':' in trimmed) trimmed else "minecraft:$trimmed" +} + +fun normalizeEffectIds(ids: Iterable): List = normalizeIds(ids.map(::normalizeEffectId)) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/EffectVisuals.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/EffectVisuals.kt new file mode 100644 index 000000000..359d6abd1 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/effect/EffectVisuals.kt @@ -0,0 +1,61 @@ +package org.polyfrost.oneconfig.internal.ui.components.effect + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.components.picker.PickerTileIcon +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme + +/** The catalog deduplicated and sorted by display name so grid positions never jump */ +@Composable +fun rememberEffectCatalog(key: Any? = Unit): List { + val revision = EffectCatalog.revision + return remember(key, revision) { + EffectCatalog.effects() + .distinctBy(EffectDescriptor::id) + .sortedWith { first, second -> + first.displayName.compareTo(second.displayName, ignoreCase = true) + .takeIf { it != 0 } ?: first.id.compareTo(second.id) + } + } +} + +/** The icon for [id] showing [placeholder] if the effect is unknown */ +@Composable +fun EffectIcon( + id: String, + modifier: Modifier = Modifier, + alpha: Float = 1f, + placeholder: String = "eye-off", +) { + val revision = EffectCatalog.revision + val bitmap = remember(id, revision) { EffectCatalog.icon(id)?.toComposeImageBitmap() } + if (bitmap == null) { + Icon( + placeholder, + modifier = modifier.aspectRatio(1f).padding(2.dp), + color = LocalTheme.current.textColorSecondary.copy(alpha = alpha), + ) + return + } + Image(bitmap, contentDescription = null, modifier = modifier.aspectRatio(1f), alpha = alpha, filterQuality = FilterQuality.None) +} + +internal val EffectTileIcon: PickerTileIcon = { effect, modifier, alpha -> + EffectIcon(effect.id, modifier, alpha) +} + +/** Default translation keys where every composable taking one accepts an override */ +object EffectStrings { + const val Title = "oneconfig.effectlist.title" + const val Search = "oneconfig.effectlist.search" + const val Empty = "oneconfig.effectlist.empty" + const val Unavailable = "oneconfig.effectlist.unavailable" +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/EffectListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/EffectListOption.kt new file mode 100644 index 000000000..b639beb75 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/EffectListOption.kt @@ -0,0 +1,20 @@ +package org.polyfrost.oneconfig.internal.ui.components.settings + +import androidx.compose.runtime.Composable +import org.polyfrost.oneconfig.internal.ui.api.settings.EffectListOptionData +import org.polyfrost.oneconfig.internal.ui.components.effect.EffectIcon +import org.polyfrost.oneconfig.internal.ui.components.effect.EffectStrings +import org.polyfrost.oneconfig.internal.ui.components.effect.EffectTileIcon +import org.polyfrost.oneconfig.internal.ui.components.effect.rememberEffectCatalog + +@Composable +fun EffectListOption(data: EffectListOptionData) = TileListOption( + data, + rememberEffectCatalog(data.prop), + EffectTileIcon, + titleKey = EffectStrings.Title, + searchPlaceholderKey = EffectStrings.Search, + emptyKey = EffectStrings.Empty, + unavailableKey = EffectStrings.Unavailable, + rowIcon = { id, _, modifier -> EffectIcon(id, modifier) }, +) diff --git a/modules/internal/src/main/resources/assets/oneconfig/en_default.lang b/modules/internal/src/main/resources/assets/oneconfig/en_default.lang index e3706a907..d74f0fc22 100644 --- a/modules/internal/src/main/resources/assets/oneconfig/en_default.lang +++ b/modules/internal/src/main/resources/assets/oneconfig/en_default.lang @@ -102,6 +102,11 @@ oneconfig.picker.title=Select oneconfig.picker.search=Search by name or ID oneconfig.picker.empty=No matches oneconfig.picker.unavailable=Unavailable +oneconfig.effectlist.add=Choose effects +oneconfig.effectlist.title=Select effects +oneconfig.effectlist.search=Search by name or ID +oneconfig.effectlist.empty=No matching effects +oneconfig.effectlist.unavailable=Unavailable effect oneconfig.itemlist.add=Choose items oneconfig.itemlist.title=Select items oneconfig.itemlist.search=Search by name or ID diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/components/effect/EffectIdTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/components/effect/EffectIdTest.java new file mode 100644 index 000000000..1a8613724 --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/components/effect/EffectIdTest.java @@ -0,0 +1,45 @@ +package org.polyfrost.oneconfig.internal.ui.components.effect; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.Properties; +import org.polyfrost.oneconfig.api.config.v1.Property; +import org.polyfrost.oneconfig.internal.ui.api.settings.EffectListOptionData; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class EffectIdTest { + @Test + void namespacesBareIdsAndKeepsModdedOnes() { + assertEquals("minecraft:speed", EffectCatalogServiceKt.normalizeEffectId(" Speed ")); + assertEquals("minecraft:speed", EffectCatalogServiceKt.normalizeEffectId("minecraft:speed")); + assertEquals("example:rage", EffectCatalogServiceKt.normalizeEffectId("example:rage")); + assertEquals("", EffectCatalogServiceKt.normalizeEffectId(" ")); + } + + @Test + void normalizesBlankDuplicateAndBareIdsWithoutReordering() { + assertEquals( + List.of("minecraft:speed", "example:rage"), + EffectCatalogServiceKt.normalizeEffectIds(List.of("speed", "", "minecraft:speed", "example:rage")) + ); + } + + @Test + void optionDataNamespacesStoredIds() { + Property property = Properties.simple( + "effects", + "Effects", + null, + new String[]{"speed", "minecraft:speed", "example:rage"}, + String[].class + ); + EffectListOptionData data = new EffectListOptionData(property); + + assertEquals(List.of("minecraft:speed", "example:rage"), data.ids()); + + data.setIds(List.of("night_vision", "minecraft:haste")); + assertArrayEquals(new String[]{"minecraft:night_vision", "minecraft:haste"}, property.get()); + } +}