From 6f146f631e4a24cf975d87f31ad0eba664cd84a4 Mon Sep 17 00:00:00 2001 From: chrrrs Date: Sat, 20 Dec 2025 16:28:47 +0100 Subject: [PATCH 1/2] feat: implement custom config system --- build.gradle.kts | 4 - .../chrr/scribble/fabric/ModMenuCompat.java | 10 +- .../chrr/scribble/fabric/ScribbleFabric.java | 5 - gradle.properties | 1 - .../scribble/neoforge/ScribbleNeoForge.java | 11 +- src/main/java/me/chrr/scribble/Scribble.java | 25 +- .../java/me/chrr/scribble/ScribbleConfig.java | 55 ++++ .../java/me/chrr/scribble/config/Config.java | 37 --- .../chrr/scribble/config/ConfigManager.java | 56 ---- .../config/YACLConfigScreenFactory.java | 103 ------- .../gui/edit/RichMultiLineTextField.java | 4 +- .../chrr/scribble/history/CommandManager.java | 4 +- .../scribble/mixin/BookSignScreenMixin.java | 4 +- .../mixin/ClientPacketListenerMixin.java | 4 +- .../chrr/scribble/mixin/LocalPlayerMixin.java | 4 +- .../screen/ScribbleBookEditScreen.java | 4 +- .../scribble/screen/ScribbleBookScreen.java | 23 +- .../screen/ScribbleBookViewScreen.java | 4 +- .../java/me/chrr/tapestry/config/Binding.java | 31 +++ .../java/me/chrr/tapestry/config/Config.java | 33 +++ .../tapestry/config/ConfigEnvironment.java | 7 + .../me/chrr/tapestry/config/ConfigIo.java | 85 ++++++ .../me/chrr/tapestry/config/Controller.java | 32 +++ .../java/me/chrr/tapestry/config/Option.java | 44 +++ .../chrr/tapestry/config/gui/OptionList.java | 125 +++++++++ .../chrr/tapestry/config/gui/OptionProxy.java | 27 ++ .../config/gui/TapestryConfigScreen.java | 70 +++++ .../gui/widget/BooleanOptionWidget.java | 40 +++ .../config/gui/widget/EnumOptionWidget.java | 36 +++ .../config/gui/widget/OptionWidget.java | 105 ++++++++ .../gui/widget/ReadOnlyOptionWidget.java | 20 ++ .../config/gui/widget/SliderOptionWidget.java | 156 +++++++++++ .../config/reflect/NamingStrategy.java | 51 ++++ .../config/reflect/ReflectedBinding.java | 57 ++++ .../config/reflect/ReflectedConfig.java | 251 ++++++++++++++++++ .../reflect/annotation/DisplayName.java | 22 ++ .../config/reflect/annotation/Header.java} | 10 +- .../config/reflect/annotation/Rebind.java | 24 ++ .../reflect/annotation/SerializeName.java | 22 ++ .../config/reflect/annotation/Slider.java | 32 +++ .../annotation/TranslateDisplayNames.java | 15 ++ .../reflect/annotation/UpgradeRewriter.java | 15 ++ 42 files changed, 1392 insertions(+), 276 deletions(-) create mode 100644 src/main/java/me/chrr/scribble/ScribbleConfig.java delete mode 100644 src/main/java/me/chrr/scribble/config/Config.java delete mode 100644 src/main/java/me/chrr/scribble/config/ConfigManager.java delete mode 100644 src/main/java/me/chrr/scribble/config/YACLConfigScreenFactory.java create mode 100644 src/main/java/me/chrr/tapestry/config/Binding.java create mode 100644 src/main/java/me/chrr/tapestry/config/Config.java create mode 100644 src/main/java/me/chrr/tapestry/config/ConfigEnvironment.java create mode 100644 src/main/java/me/chrr/tapestry/config/ConfigIo.java create mode 100644 src/main/java/me/chrr/tapestry/config/Controller.java create mode 100644 src/main/java/me/chrr/tapestry/config/Option.java create mode 100644 src/main/java/me/chrr/tapestry/config/gui/OptionList.java create mode 100644 src/main/java/me/chrr/tapestry/config/gui/OptionProxy.java create mode 100644 src/main/java/me/chrr/tapestry/config/gui/TapestryConfigScreen.java create mode 100644 src/main/java/me/chrr/tapestry/config/gui/widget/BooleanOptionWidget.java create mode 100644 src/main/java/me/chrr/tapestry/config/gui/widget/EnumOptionWidget.java create mode 100644 src/main/java/me/chrr/tapestry/config/gui/widget/OptionWidget.java create mode 100644 src/main/java/me/chrr/tapestry/config/gui/widget/ReadOnlyOptionWidget.java create mode 100644 src/main/java/me/chrr/tapestry/config/gui/widget/SliderOptionWidget.java create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/NamingStrategy.java create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/ReflectedBinding.java create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/ReflectedConfig.java create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/annotation/DisplayName.java rename src/main/java/me/chrr/{scribble/config/DeprecatedConfigOption.java => tapestry/config/reflect/annotation/Header.java} (59%) create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/annotation/Rebind.java create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/annotation/SerializeName.java create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/annotation/Slider.java create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/annotation/TranslateDisplayNames.java create mode 100644 src/main/java/me/chrr/tapestry/config/reflect/annotation/UpgradeRewriter.java diff --git a/build.gradle.kts b/build.gradle.kts index 5ac27c6..1ba8df6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,8 +21,6 @@ architectury.common(stonecutter.tree.branches.mapNotNull { }) repositories { - maven("https://maven.shedaniel.me/") - maven("https://maven.isxander.dev/releases") maven("https://maven.parchmentmc.org") } @@ -36,8 +34,6 @@ dependencies { }) modImplementation("net.fabricmc:fabric-loader:${prop("fabric", "loaderVersion")}") - - modCompileOnly("dev.isxander:yet-another-config-lib:${prop("yacl", "version")}") } loom { diff --git a/fabric/src/main/java/me/chrr/scribble/fabric/ModMenuCompat.java b/fabric/src/main/java/me/chrr/scribble/fabric/ModMenuCompat.java index 9f6fb99..8908ae0 100644 --- a/fabric/src/main/java/me/chrr/scribble/fabric/ModMenuCompat.java +++ b/fabric/src/main/java/me/chrr/scribble/fabric/ModMenuCompat.java @@ -3,18 +3,12 @@ import com.terraformersmc.modmenu.api.ConfigScreenFactory; import com.terraformersmc.modmenu.api.ModMenuApi; import me.chrr.scribble.Scribble; -import net.fabricmc.loader.api.FabricLoader; import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; @NullMarked public class ModMenuCompat implements ModMenuApi { @Override - public @Nullable ConfigScreenFactory getModConfigScreenFactory() { - if (FabricLoader.getInstance().isModLoaded("yet_another_config_lib_v3")) { - return Scribble::buildConfigScreen; - } - - return null; + public ConfigScreenFactory getModConfigScreenFactory() { + return Scribble::buildConfigScreen; } } \ No newline at end of file diff --git a/fabric/src/main/java/me/chrr/scribble/fabric/ScribbleFabric.java b/fabric/src/main/java/me/chrr/scribble/fabric/ScribbleFabric.java index 9df2051..7bd84fb 100644 --- a/fabric/src/main/java/me/chrr/scribble/fabric/ScribbleFabric.java +++ b/fabric/src/main/java/me/chrr/scribble/fabric/ScribbleFabric.java @@ -14,11 +14,6 @@ public void onInitializeClient() { Scribble.init(this); } - @Override - protected boolean isModLoaded(String modId) { - return FabricLoader.getInstance().isModLoaded(modId); - } - @Override protected String getModVersion() { return FabricLoader.getInstance().getModContainer(Scribble.MOD_ID) diff --git a/gradle.properties b/gradle.properties index dd8c299..d3cba3d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,6 @@ neoforge.version=[versioned] # Dependencies modmenu.version=11.0.2 -yacl.version=3.8.1+1.21.11-fabric # Distribution modrinth.id=yXAvIk0x diff --git a/neoforge/src/main/java/me/chrr/scribble/neoforge/ScribbleNeoForge.java b/neoforge/src/main/java/me/chrr/scribble/neoforge/ScribbleNeoForge.java index a994ac4..66f6ac7 100644 --- a/neoforge/src/main/java/me/chrr/scribble/neoforge/ScribbleNeoForge.java +++ b/neoforge/src/main/java/me/chrr/scribble/neoforge/ScribbleNeoForge.java @@ -17,15 +17,8 @@ public class ScribbleNeoForge extends Scribble.Platform { public ScribbleNeoForge(ModContainer mod) { Scribble.init(this); - if (Scribble.platform().HAS_YACL) { - mod.registerExtensionPoint(IConfigScreenFactory.class, - (container, parent) -> Scribble.buildConfigScreen(parent)); - } - } - - @Override - protected boolean isModLoaded(String modId) { - return ModList.get().isLoaded(modId); + mod.registerExtensionPoint(IConfigScreenFactory.class, + (container, parent) -> Scribble.buildConfigScreen(parent)); } @Override diff --git a/src/main/java/me/chrr/scribble/Scribble.java b/src/main/java/me/chrr/scribble/Scribble.java index 5cb04a6..260ea63 100644 --- a/src/main/java/me/chrr/scribble/Scribble.java +++ b/src/main/java/me/chrr/scribble/Scribble.java @@ -1,9 +1,7 @@ package me.chrr.scribble; import me.chrr.scribble.book.FileChooser; -import me.chrr.scribble.config.Config; -import me.chrr.scribble.config.ConfigManager; -import me.chrr.scribble.config.YACLConfigScreenFactory; +import me.chrr.tapestry.config.gui.TapestryConfigScreen; import net.minecraft.client.gui.screens.Screen; import net.minecraft.resources.Identifier; import org.apache.logging.log4j.LogManager; @@ -13,24 +11,18 @@ import java.io.IOException; import java.nio.file.Path; -import java.util.Optional; +import java.util.Objects; @NullMarked public class Scribble { public static final String MOD_ID = "scribble"; public static final Logger LOGGER = LogManager.getLogger(); - private static final ConfigManager CONFIG_MANAGER = new ConfigManager(); private static @Nullable Platform PLATFORM; public static void init(Platform platform) { PLATFORM = platform; - - try { - CONFIG_MANAGER.load(); - } catch (IOException e) { - LOGGER.error("failed to load config", e); - } + ScribbleConfig.INSTANCE.ensureLoaded(); try { FileChooser.convertLegacyBooks(); @@ -40,15 +32,11 @@ public static void init(Platform platform) { } public static Screen buildConfigScreen(Screen parent) { - return YACLConfigScreenFactory.create(CONFIG_MANAGER, parent); + return new TapestryConfigScreen(ScribbleConfig.INSTANCE, parent); } public static Platform platform() { - return Optional.ofNullable(PLATFORM).orElseThrow(); - } - - public static Config config() { - return CONFIG_MANAGER.getConfig(); + return Objects.requireNonNull(PLATFORM); } public static Identifier id(String path) { @@ -60,9 +48,6 @@ public abstract static class Platform { public final Path BOOK_DIR = getGameDir().resolve("books"); public final String VERSION = getModVersion(); - public final boolean HAS_YACL = isModLoaded("yet_another_config_lib_v3"); - - protected abstract boolean isModLoaded(String modId); protected abstract String getModVersion(); diff --git a/src/main/java/me/chrr/scribble/ScribbleConfig.java b/src/main/java/me/chrr/scribble/ScribbleConfig.java new file mode 100644 index 0000000..9ad110a --- /dev/null +++ b/src/main/java/me/chrr/scribble/ScribbleConfig.java @@ -0,0 +1,55 @@ +package me.chrr.scribble; + +import com.google.gson.JsonObject; +import me.chrr.tapestry.config.Binding; +import me.chrr.tapestry.config.reflect.NamingStrategy; +import me.chrr.tapestry.config.reflect.ReflectedConfig; +import me.chrr.tapestry.config.reflect.annotation.*; +import org.jspecify.annotations.NullMarked; + +import java.util.List; + +@NullMarked +@TranslateDisplayNames(prefix = "config.scribble") +@SerializeName.Strategy(NamingStrategy.SNAKE_CASE) +public class ScribbleConfig extends ReflectedConfig { + public static final ScribbleConfig INSTANCE = load(() -> Scribble.platform().CONFIG_DIR, + ScribbleConfig.class, "scribble.client.json", List.of("scribble.json")); + + + @Header("appearance") + @Rebind.Display("doublePageViewing") + @DisplayName("double_page_viewing") + public int pagesToShow = 1; + public boolean centerBookGui = true; + public ShowActionButtons showActionButtons = ShowActionButtons.WHEN_EDITING; + + @Header("behaviour") + public boolean copyFormattingCodes = true; + @Slider.Int(min = 8, max = 128) + public int editHistorySize = 32; + + @Header("miscellaneous") + public boolean openVanillaBookScreenOnShift = false; + + + @SuppressWarnings("unused") + private final transient Binding doublePageViewing = Binding.of(Boolean.class, + () -> this.pagesToShow > 1, (value) -> this.pagesToShow = value ? 2 : 1); + + @UpgradeRewriter(currentVersion = 3) + public static void upgrade(int fromVersion, JsonObject config) { + if (fromVersion < 3) { + // 'show_save_load_buttons' was removed. + config.addProperty("show_action_buttons", + config.get("show_save_load_buttons").getAsBoolean() ? "WHEN_EDITING" : "NEVER"); + } + } + + + public enum ShowActionButtons { + ALWAYS, + WHEN_EDITING, + NEVER, + } +} diff --git a/src/main/java/me/chrr/scribble/config/Config.java b/src/main/java/me/chrr/scribble/config/Config.java deleted file mode 100644 index ba220bf..0000000 --- a/src/main/java/me/chrr/scribble/config/Config.java +++ /dev/null @@ -1,37 +0,0 @@ -package me.chrr.scribble.config; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public class Config { - public static final Config DEFAULT = new Config(); - - public int version = 3; - public boolean copyFormattingCodes = true; - public boolean centerBookGui = true; - public boolean showFormattingButtons = true; - public ShowActionButtons showActionButtons = ShowActionButtons.WHEN_EDITING; - public int editHistorySize = 32; - public int pagesToShow = 1; - public boolean openVanillaBookScreenOnShift = false; - - @DeprecatedConfigOption - private boolean showSaveLoadButtons = true; - - public void upgrade() { - if (this.version < 3) { - // `show_save_load_buttons` was removed. - this.showActionButtons = this.showSaveLoadButtons - ? ShowActionButtons.WHEN_EDITING - : ShowActionButtons.NEVER; - } - - this.version = DEFAULT.version; - } - - public enum ShowActionButtons { - ALWAYS, - WHEN_EDITING, - NEVER, - } -} diff --git a/src/main/java/me/chrr/scribble/config/ConfigManager.java b/src/main/java/me/chrr/scribble/config/ConfigManager.java deleted file mode 100644 index 3063aa3..0000000 --- a/src/main/java/me/chrr/scribble/config/ConfigManager.java +++ /dev/null @@ -1,56 +0,0 @@ -package me.chrr.scribble.config; - -import com.google.gson.*; -import me.chrr.scribble.Scribble; -import org.jspecify.annotations.NullMarked; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -@NullMarked -public class ConfigManager { - private static final Gson GSON = new GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .addSerializationExclusionStrategy(new SkipDeprecatedStrategy()) - .setPrettyPrinting() - .create(); - - private Config config = new Config(); - - public Config getConfig() { - return this.config; - } - - public void load() throws IOException { - Path path = this.getConfigPath(); - if (path.toFile().isFile()) { - this.config = GSON.fromJson(Files.readString(path), Config.class); - this.config.upgrade(); - } - - this.save(); - } - - public void save() throws IOException { - Files.writeString(this.getConfigPath(), GSON.toJson(this.config)); - } - - private Path getConfigPath() { - return Scribble.platform().CONFIG_DIR.resolve("scribble.json"); - } - - /// Exclusion strategy to skip all fields that are annotated with {@link DeprecatedConfigOption}. - private static class SkipDeprecatedStrategy implements ExclusionStrategy { - @Override - public boolean shouldSkipField(FieldAttributes f) { - //noinspection ConstantValue: this inspection is a false-positive. - return f.getAnnotation(DeprecatedConfigOption.class) != null; - } - - @Override - public boolean shouldSkipClass(Class clazz) { - return false; - } - } -} diff --git a/src/main/java/me/chrr/scribble/config/YACLConfigScreenFactory.java b/src/main/java/me/chrr/scribble/config/YACLConfigScreenFactory.java deleted file mode 100644 index 70b962a..0000000 --- a/src/main/java/me/chrr/scribble/config/YACLConfigScreenFactory.java +++ /dev/null @@ -1,103 +0,0 @@ -package me.chrr.scribble.config; - -import dev.isxander.yacl3.api.*; -import dev.isxander.yacl3.api.controller.EnumControllerBuilder; -import dev.isxander.yacl3.api.controller.IntegerSliderControllerBuilder; -import dev.isxander.yacl3.api.controller.TickBoxControllerBuilder; -import me.chrr.scribble.Scribble; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.Component; -import org.jspecify.annotations.NullMarked; - -import java.io.IOException; - -@NullMarked -public class YACLConfigScreenFactory { - public static Screen create(ConfigManager configManager, Screen parent) { - Config config = configManager.getConfig(); - - // FIXME: if we want to keep YACL, move text to translatable strings. - return YetAnotherConfigLib.createBuilder() - .title(Component.translatable("config.scribble.title")) - .category(ConfigCategory.createBuilder() - .name(Component.translatable("config.scribble.title")) - .group(OptionGroup.createBuilder() - .name(Component.literal("Appearance")) - .description(OptionDescription.of(Component.literal("These options affect how the user interface looks."))) - .option(Option.createBuilder() - .name(Component.literal("Double page viewing")) - .description(OptionDescription.of(Component.literal("Whether to show two pages at the same time when reading and editing books."))) - .binding(Config.DEFAULT.pagesToShow > 1, () -> config.pagesToShow > 1, - (value) -> config.pagesToShow = value ? 2 : 1) - .controller(TickBoxControllerBuilder::create) - .build()) - .option(Option.createBuilder() - .name(Component.literal("Vertically center book GUI's")) - .description(OptionDescription.of(Component.literal("If enabled, book viewing and editing GUI's will be approximately vertically centered, instead of being fixed to the top of the screen."))) - .binding(Config.DEFAULT.centerBookGui, () -> config.centerBookGui, - (value) -> config.centerBookGui = value) - .controller(TickBoxControllerBuilder::create) - .build()) - .option(Option.createBuilder() - .name(Component.literal("Show formatting buttons")) - .description(OptionDescription.of(Component.literal("Formatting buttons are the color/modifier buttons on the right of the page. With this option, you can hide these buttons.\n\nNote: You'll always be able to access their functionality using their hotkeys."))) - .binding(Config.DEFAULT.showFormattingButtons, () -> config.showFormattingButtons, - (value) -> config.showFormattingButtons = value) - .controller(TickBoxControllerBuilder::create) - .build()) - .option(Option.createBuilder() - .name(Component.literal("Show action buttons")) - .description(OptionDescription.of(Component.literal("Action buttons are the white buttons on the left of the page. This option determines when these action buttons should be shown or hidden.\n\nThe 'Show when editing' option shows the action buttons when editing a book using a book & quill, but hides them when reading a written book."))) - .binding(Config.DEFAULT.showActionButtons, () -> config.showActionButtons, - (value) -> config.showActionButtons = value) - .controller((opt) -> EnumControllerBuilder.create(opt) - .enumClass(Config.ShowActionButtons.class) - .formatValue((value) -> switch (value) { - case ALWAYS -> Component.literal("Always"); - case WHEN_EDITING -> Component.literal("Show when editing"); - case NEVER -> Component.literal("Never"); - })) - .build()) - .build()) - .group(OptionGroup.createBuilder() - .name(Component.literal("Behaviour")) - .description(OptionDescription.of(Component.literal("These options affect how you can write books."))) - .option(Option.createBuilder() - .name(Component.literal("Copy formatting codes")) - .description(OptionDescription.of(Component.literal("When copying formatted text, this option determines whether formatting codes (&) should be copied to your clipboard. This allows you to paste text back into the book using the copied formatting.\n\nNote: This option can be temporarily reversed by holding SHIFT while copying or pasting text."))) - .binding(Config.DEFAULT.copyFormattingCodes, () -> config.copyFormattingCodes, - (value) -> config.copyFormattingCodes = value) - .controller(TickBoxControllerBuilder::create) - .build()) - .option(Option.createBuilder() - .name(Component.literal("Edit history limit")) - .description(OptionDescription.of(Component.literal("How many actions Scribble should remember for you to be able to undo them. If the limit is exceeded, the oldest actions will be removed from the undo stack.\n\nNote: Higher values could lead to more RAM usage while editing."))) - .binding(Config.DEFAULT.editHistorySize, () -> config.editHistorySize, - (value) -> config.editHistorySize = value) - .controller((opt) -> IntegerSliderControllerBuilder.create(opt) - .range(8, 128).step(1)) - .build()) - .build()) - .group(OptionGroup.createBuilder() - .name(Component.literal("Miscellaneous")) - .description(OptionDescription.of(Component.literal("Options that don't fit in the other categories. You most likely will not need to change most of these."))) - .option(Option.createBuilder() - .name(Component.literal("Open vanilla GUI's when holding SHIFT")) - .description(OptionDescription.of(Component.literal("Scribble replaces the vanilla GUI with an exact copy, but sometimes (ex. with other mods) you still want to be able to access the original screen. If this option is enabled, you can access the original screen by holding down SHIFT."))) - .binding(Config.DEFAULT.openVanillaBookScreenOnShift, () -> config.openVanillaBookScreenOnShift, - (value) -> config.openVanillaBookScreenOnShift = value) - .controller(TickBoxControllerBuilder::create) - .build()) - .build()) - .build()) - .save(() -> { - try { - configManager.save(); - } catch (IOException e) { - Scribble.LOGGER.error("could not save config", e); - } - }) - .build() - .generateScreen(parent); - } -} diff --git a/src/main/java/me/chrr/scribble/gui/edit/RichMultiLineTextField.java b/src/main/java/me/chrr/scribble/gui/edit/RichMultiLineTextField.java index ea01293..b9d35cb 100644 --- a/src/main/java/me/chrr/scribble/gui/edit/RichMultiLineTextField.java +++ b/src/main/java/me/chrr/scribble/gui/edit/RichMultiLineTextField.java @@ -2,8 +2,8 @@ import com.mojang.datafixers.util.Pair; import me.chrr.scribble.KeyboardUtil; -import me.chrr.scribble.Scribble; import me.chrr.scribble.book.RichText; +import me.chrr.scribble.ScribbleConfig; import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; @@ -182,7 +182,7 @@ public void seekCursor(Whence whence, int amount) { @Override public boolean keyPressed(KeyEvent event) { // Override copy/cut/paste to remove formatting codes if the config option is set or SHIFT is held down. - boolean keepFormatting = Scribble.config().copyFormattingCodes ^ event.hasShiftDown(); + boolean keepFormatting = ScribbleConfig.INSTANCE.copyFormattingCodes ^ event.hasShiftDown(); boolean ctrlNoAlt = event.hasControlDown() && !event.hasAltDown(); if (ctrlNoAlt && (KeyboardUtil.isKey(event.key(), "C") || KeyboardUtil.isKey(event.key(), "X"))) { String text = this.getSelectedText(); diff --git a/src/main/java/me/chrr/scribble/history/CommandManager.java b/src/main/java/me/chrr/scribble/history/CommandManager.java index 2f6d3b6..93c5afa 100644 --- a/src/main/java/me/chrr/scribble/history/CommandManager.java +++ b/src/main/java/me/chrr/scribble/history/CommandManager.java @@ -1,6 +1,6 @@ package me.chrr.scribble.history; -import me.chrr.scribble.Scribble; +import me.chrr.scribble.ScribbleConfig; import me.chrr.scribble.history.command.Command; import org.jspecify.annotations.NullMarked; @@ -26,7 +26,7 @@ public void push(Command command) { this.commands.add(command); this.index += 1; - if (this.commands.size() > Scribble.config().editHistorySize) { + if (this.commands.size() > ScribbleConfig.INSTANCE.editHistorySize) { this.commands.removeFirst(); this.index -= 1; } diff --git a/src/main/java/me/chrr/scribble/mixin/BookSignScreenMixin.java b/src/main/java/me/chrr/scribble/mixin/BookSignScreenMixin.java index 7942fce..fbf5919 100644 --- a/src/main/java/me/chrr/scribble/mixin/BookSignScreenMixin.java +++ b/src/main/java/me/chrr/scribble/mixin/BookSignScreenMixin.java @@ -2,7 +2,7 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; -import me.chrr.scribble.Scribble; +import me.chrr.scribble.ScribbleConfig; import me.chrr.scribble.SetReturnScreen; import me.chrr.scribble.screen.ScribbleBookScreen; import net.minecraft.client.Minecraft; @@ -78,7 +78,7 @@ public void popRender(GuiGraphics graphics, int mouseX, int mouseY, float delta, @Unique private int scribble$getYOffset() { - if (Scribble.config().centerBookGui) { + if (ScribbleConfig.INSTANCE.centerBookGui) { // See ScribbleBookScreen#getBackgroundY(). return this.height / 3 - ScribbleBookScreen.getMenuHeight() / 3; } else { diff --git a/src/main/java/me/chrr/scribble/mixin/ClientPacketListenerMixin.java b/src/main/java/me/chrr/scribble/mixin/ClientPacketListenerMixin.java index 298dc6f..b49adac 100644 --- a/src/main/java/me/chrr/scribble/mixin/ClientPacketListenerMixin.java +++ b/src/main/java/me/chrr/scribble/mixin/ClientPacketListenerMixin.java @@ -3,7 +3,7 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import com.llamalad7.mixinextras.sugar.Local; -import me.chrr.scribble.Scribble; +import me.chrr.scribble.ScribbleConfig; import me.chrr.scribble.screen.ScribbleBookViewScreen; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; @@ -18,7 +18,7 @@ public abstract class ClientPacketListenerMixin { @WrapOperation(method = "handleOpenBook", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;setScreen(Lnet/minecraft/client/gui/screens/Screen;)V")) public void overrideBookViewScreen(Minecraft instance, Screen screen, Operation original, @Local BookViewScreen.BookAccess book) { - if (instance.hasShiftDown() && Scribble.config().openVanillaBookScreenOnShift) { + if (instance.hasShiftDown() && ScribbleConfig.INSTANCE.openVanillaBookScreenOnShift) { original.call(instance, screen); } else { // FIXME: ideally, I'd like to avoid even constructing the original BookViewScreen. diff --git a/src/main/java/me/chrr/scribble/mixin/LocalPlayerMixin.java b/src/main/java/me/chrr/scribble/mixin/LocalPlayerMixin.java index af905ad..c481f2b 100644 --- a/src/main/java/me/chrr/scribble/mixin/LocalPlayerMixin.java +++ b/src/main/java/me/chrr/scribble/mixin/LocalPlayerMixin.java @@ -3,7 +3,7 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import com.llamalad7.mixinextras.sugar.Local; -import me.chrr.scribble.Scribble; +import me.chrr.scribble.ScribbleConfig; import me.chrr.scribble.screen.ScribbleBookEditScreen; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; @@ -20,7 +20,7 @@ public abstract class LocalPlayerMixin { @WrapOperation(method = "openItemGui", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;setScreen(Lnet/minecraft/client/gui/screens/Screen;)V")) public void overrideBookViewScreen(Minecraft instance, Screen screen, Operation original, @Local(argsOnly = true) ItemStack itemStack, @Local(argsOnly = true) InteractionHand hand, @Local WritableBookContent book) { - if (instance.hasShiftDown() && Scribble.config().openVanillaBookScreenOnShift) { + if (instance.hasShiftDown() && ScribbleConfig.INSTANCE.openVanillaBookScreenOnShift) { original.call(instance, screen); } else { // FIXME: ideally, I'd like to avoid even constructing the original BookEditScreen. diff --git a/src/main/java/me/chrr/scribble/screen/ScribbleBookEditScreen.java b/src/main/java/me/chrr/scribble/screen/ScribbleBookEditScreen.java index 23c6628..e17e0f4 100644 --- a/src/main/java/me/chrr/scribble/screen/ScribbleBookEditScreen.java +++ b/src/main/java/me/chrr/scribble/screen/ScribbleBookEditScreen.java @@ -7,7 +7,7 @@ import me.chrr.scribble.book.BookFile; import me.chrr.scribble.book.FileChooser; import me.chrr.scribble.book.RichText; -import me.chrr.scribble.config.Config; +import me.chrr.scribble.ScribbleConfig; import me.chrr.scribble.gui.TextArea; import me.chrr.scribble.gui.button.ColorSwatchWidget; import me.chrr.scribble.gui.button.IconButtonWidget; @@ -102,7 +102,7 @@ public ScribbleBookEditScreen(Player player, ItemStack itemStack, InteractionHan //region Widgets (Action, Menu & TextArea) @Override protected boolean shouldShowActionButtons() { - return Scribble.config().showActionButtons != Config.ShowActionButtons.NEVER; + return ScribbleConfig.INSTANCE.showActionButtons != ScribbleConfig.ShowActionButtons.NEVER; } @Override diff --git a/src/main/java/me/chrr/scribble/screen/ScribbleBookScreen.java b/src/main/java/me/chrr/scribble/screen/ScribbleBookScreen.java index 6a4ded7..dca4d0d 100644 --- a/src/main/java/me/chrr/scribble/screen/ScribbleBookScreen.java +++ b/src/main/java/me/chrr/scribble/screen/ScribbleBookScreen.java @@ -1,10 +1,10 @@ package me.chrr.scribble.screen; import me.chrr.scribble.Scribble; +import me.chrr.scribble.ScribbleConfig; import me.chrr.scribble.gui.PageNumberWidget; import me.chrr.scribble.gui.TextArea; import me.chrr.scribble.gui.button.IconButtonWidget; -import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.screens.Screen; @@ -41,7 +41,7 @@ protected ScribbleBookScreen(Component title) { //region Widgets @Override protected void init() { - this.pagesToShow = Scribble.config().pagesToShow; + this.pagesToShow = ScribbleConfig.INSTANCE.pagesToShow; if (this.pagesToShow == 1) { this.backgroundTexture = BookViewScreen.BOOK_LOCATION; } else { @@ -81,23 +81,14 @@ protected void init() { private void initSettingsButton(int x, int y) { MutableComponent settingsText = Component.literal("Scribble " + Scribble.platform().VERSION + "\n") - .setStyle(Style.EMPTY.withBold(true)); + .setStyle(Style.EMPTY.withBold(true)) + .append(Component.translatable("text.scribble.action.settings") + .setStyle(Style.EMPTY.withBold(false))); - boolean canOpenConfigScreen = Scribble.platform().HAS_YACL; - if (canOpenConfigScreen) { - settingsText.append(Component.translatable("text.scribble.action.settings") - .setStyle(Style.EMPTY.withBold(false))); - } else { - // FIXME: make this a translatable string - settingsText.append(Component.literal("YACL needs to be installed to access the settings menu.") - .setStyle(Style.EMPTY.withBold(false).withColor(ChatFormatting.RED))); - } - - IconButtonWidget widget = addRenderableWidget(new IconButtonWidget( + addRenderableWidget(new IconButtonWidget( settingsText, () -> minecraft.setScreen(Scribble.buildConfigScreen(this)), x, y, 96, 90, 12, 12)); - widget.active = canOpenConfigScreen; } public void updateCurrentPages() { @@ -197,7 +188,7 @@ public int getBackgroundX() { } public int getBackgroundY() { - if (Scribble.config().centerBookGui) { + if (ScribbleConfig.INSTANCE.centerBookGui) { // Perfect centering actually doesn't look great, so we put it on a third. return 2 + this.height / 3 - getMenuHeight() / 3; } else { diff --git a/src/main/java/me/chrr/scribble/screen/ScribbleBookViewScreen.java b/src/main/java/me/chrr/scribble/screen/ScribbleBookViewScreen.java index 0486155..fc60086 100644 --- a/src/main/java/me/chrr/scribble/screen/ScribbleBookViewScreen.java +++ b/src/main/java/me/chrr/scribble/screen/ScribbleBookViewScreen.java @@ -4,7 +4,7 @@ import me.chrr.scribble.book.BookFile; import me.chrr.scribble.book.FileChooser; import me.chrr.scribble.book.RichText; -import me.chrr.scribble.config.Config; +import me.chrr.scribble.ScribbleConfig; import me.chrr.scribble.gui.BookTextWidget; import me.chrr.scribble.gui.TextArea; import me.chrr.scribble.gui.button.IconButtonWidget; @@ -30,7 +30,7 @@ public ScribbleBookViewScreen(BookViewScreen.BookAccess book) { @Override protected boolean shouldShowActionButtons() { - return Scribble.config().showActionButtons == Config.ShowActionButtons.ALWAYS; + return ScribbleConfig.INSTANCE.showActionButtons == ScribbleConfig.ShowActionButtons.ALWAYS; } @Override diff --git a/src/main/java/me/chrr/tapestry/config/Binding.java b/src/main/java/me/chrr/tapestry/config/Binding.java new file mode 100644 index 0000000..fd9ca12 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/Binding.java @@ -0,0 +1,31 @@ +package me.chrr.tapestry.config; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +public interface Binding { + static Binding of(Class valueClass, Supplier getter, Consumer setter) { + return new Binding<>() { + @Override + public Class getValueClass() { + return valueClass; + } + + @Override + public T get() { + return getter.get(); + } + + @Override + public void set(T value) { + setter.accept(value); + } + }; + } + + Class getValueClass(); + + T get(); + + void set(T value); +} diff --git a/src/main/java/me/chrr/tapestry/config/Config.java b/src/main/java/me/chrr/tapestry/config/Config.java new file mode 100644 index 0000000..8868729 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/Config.java @@ -0,0 +1,33 @@ +package me.chrr.tapestry.config; + +import net.minecraft.network.chat.Component; +import org.apache.logging.log4j.Logger; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +public interface Config { + Logger getLogger(); + + List> getOptions(); + + ConfigIo.@Nullable UpgradeRewriter getUpgradeRewriter(); + + @Nullable String getTranslationPrefix(); + + void save(); + + + default Component getText(String key) { + String translationPrefix = getTranslationPrefix(); + if (translationPrefix == null) { + return Component.literal(key); + } else { + return Component.translatable(translationPrefix + "." + key); + } + } + + default void ensureLoaded() { + // ... empty, if this class is loaded that means the config is loaded. + } +} diff --git a/src/main/java/me/chrr/tapestry/config/ConfigEnvironment.java b/src/main/java/me/chrr/tapestry/config/ConfigEnvironment.java new file mode 100644 index 0000000..6a8fcd1 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/ConfigEnvironment.java @@ -0,0 +1,7 @@ +package me.chrr.tapestry.config; + +import java.nio.file.Path; + +public interface ConfigEnvironment { + Path getConfigDir(); +} diff --git a/src/main/java/me/chrr/tapestry/config/ConfigIo.java b/src/main/java/me/chrr/tapestry/config/ConfigIo.java new file mode 100644 index 0000000..b47726a --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/ConfigIo.java @@ -0,0 +1,85 @@ +package me.chrr.tapestry.config; + +import com.google.gson.*; +import org.jspecify.annotations.NullMarked; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +@NullMarked +public enum ConfigIo { + ; + + public static final Gson GSON = new GsonBuilder() + .setStrictness(Strictness.LENIENT) + .setFormattingStyle(FormattingStyle.PRETTY) + .create(); + + public static void loadFromPathOrSaveDefault(Config config, Path file, List aliases) throws IOException { + if (Files.exists(file)) { + loadFromPath(config, file); + return; + } else { + for (Path alias : aliases) { + if (!Files.isRegularFile(alias)) + continue; + + loadFromPath(config, alias); + saveToPath(config, file); + + Files.delete(alias); + config.getLogger().info("Migrated config from '{}'", alias); + return; + } + } + + config.getLogger().info("No config file found, saving default config"); + saveToPath(config, file); + } + + public static void loadFromPath(Config config, Path path) throws IOException { + JsonObject object = JsonParser.parseString(Files.readString(path)).getAsJsonObject(); + + UpgradeRewriter upgradeRewriter = config.getUpgradeRewriter(); + if (upgradeRewriter != null && object.has("version")) { + int version = object.get("version").getAsInt(); + upgradeRewriter.upgrade(version, object); + object.addProperty("version", upgradeRewriter.getLatestVersion()); + config.getLogger().info("Upgraded config to version {}", upgradeRewriter.getLatestVersion()); + } + + for (Option option : config.getOptions()) { + if (object.has(option.serializeName)) { + setBindingJsonElement(option.serializeBinding, object.get(option.serializeName)); + } + } + } + + private static void setBindingJsonElement(Binding binding, JsonElement element) { + binding.set(GSON.fromJson(element, binding.getValueClass())); + } + + public static void saveToPath(Config config, Path path) throws IOException { + JsonObject object = new JsonObject(); + + UpgradeRewriter upgradeRewriter = config.getUpgradeRewriter(); + if (upgradeRewriter != null) { + object.addProperty("version", upgradeRewriter.getLatestVersion()); + } + + for (Option option : config.getOptions()) { + object.add(option.serializeName, GSON.toJsonTree(option.serializeBinding.get(), option.serializeBinding.getValueClass())); + } + + String json = GSON.toJson(object); + Files.write(path, json.getBytes()); + } + + public interface UpgradeRewriter { + void upgrade(int fromVersion, JsonObject config); + + int getLatestVersion(); + } +} diff --git a/src/main/java/me/chrr/tapestry/config/Controller.java b/src/main/java/me/chrr/tapestry/config/Controller.java new file mode 100644 index 0000000..8382506 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/Controller.java @@ -0,0 +1,32 @@ +package me.chrr.tapestry.config; + +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.NullMarked; + +import java.util.List; + +@NullMarked +public sealed class Controller { + public static final class EnumValues extends Controller { + public final List> options; + + public EnumValues(List> options) { + this.options = options; + } + + public record Value(T value, Component name) { + } + } + + public static final class Slider extends Controller { + public final N min; + public final N max; + public final N step; + + public Slider(N min, N max, N step) { + this.min = min; + this.max = max; + this.step = step; + } + } +} diff --git a/src/main/java/me/chrr/tapestry/config/Option.java b/src/main/java/me/chrr/tapestry/config/Option.java new file mode 100644 index 0000000..573028d --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/Option.java @@ -0,0 +1,44 @@ +package me.chrr.tapestry.config; + +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.function.Function; + +@NullMarked +public class Option { + public final String serializeName; + public final Component displayName; + + public final D defaultDisplayValue; + + public final Binding serializeBinding; + public final Binding displayBinding; + + public @Nullable Component header = null; + public @Nullable Controller controller = null; + + + public Option(String serializeName, Component displayName, D defaultDisplayValue, Binding serializeBinding, Binding displayBinding) { + this.serializeName = serializeName; + this.displayName = displayName; + this.defaultDisplayValue = defaultDisplayValue; + this.serializeBinding = serializeBinding; + this.displayBinding = displayBinding; + } + + public static Option of(String name, T defaultValue, Binding binding) { + return new Option<>(name, Component.literal(name), defaultValue, binding, binding); + } + + public Option mapDisplayBinding(Class toClass, Function aToB, Function bToA) { + Binding displayBinding = Binding.of(toClass, + () -> aToB.apply(this.displayBinding.get()), + (value) -> this.displayBinding.set(bToA.apply(value))); + Option option = new Option<>(this.serializeName, this.displayName, aToB.apply(this.defaultDisplayValue), + this.serializeBinding, displayBinding); + option.header = this.header; + return option; + } +} diff --git a/src/main/java/me/chrr/tapestry/config/gui/OptionList.java b/src/main/java/me/chrr/tapestry/config/gui/OptionList.java new file mode 100644 index 0000000..5a3f35d --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/gui/OptionList.java @@ -0,0 +1,125 @@ +package me.chrr.tapestry.config.gui; + +import me.chrr.tapestry.config.Controller; +import me.chrr.tapestry.config.Option; +import me.chrr.tapestry.config.gui.widget.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.ContainerObjectSelectionList; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarratableEntry; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.NullMarked; + +import java.util.List; + +@NullMarked +public class OptionList extends ContainerObjectSelectionList { + public OptionList(Minecraft minecraft, int width, int height, int y) { + super(minecraft, width, height, y, 25); + + this.centerListVertically = false; + } + + public void addHeader(Component text) { + int padding = this.children().isEmpty() ? 8 : 16; + int height = padding + OptionList.this.minecraft.font.lineHeight; + this.addEntry(new HeaderEntry(text), height); + } + + public OptionProxy addOption(Option option) { + OptionProxy proxy = new OptionProxy<>(option); + this.addEntry(new OptionEntry<>(proxy)); + return proxy; + } + + @Override + public int getRowWidth() { + return 310; + } + + private static OptionWidget getWidgetForProxy(OptionProxy optionProxy) { + Class valueClass = optionProxy.option.displayBinding.getValueClass(); + + // FIXME: very dirty and manual replacement for an interface method, but I don't + // want widgets in server-side code. + if (valueClass == boolean.class || valueClass == Boolean.class) { + return unsafeCast(new BooleanOptionWidget(unsafeCast(optionProxy))); + } else if ((valueClass == int.class || valueClass == Integer.class) && + optionProxy.option.controller instanceof Controller.Slider slider) { + return unsafeCast(new SliderOptionWidget.Int(unsafeCast(optionProxy), unsafeCast(slider))); + } else if ((valueClass == float.class || valueClass == Float.class) && + optionProxy.option.controller instanceof Controller.Slider slider) { + return unsafeCast(new SliderOptionWidget.Float(unsafeCast(optionProxy), unsafeCast(slider))); + } else if (optionProxy.option.controller instanceof Controller.EnumValues enumValues) { + return new EnumOptionWidget<>(optionProxy, unsafeCast(enumValues)); + } + + return new ReadOnlyOptionWidget<>(optionProxy); + } + + @SuppressWarnings("unchecked") + private static T unsafeCast(V value) { + return (T) value; + } + + protected abstract static class Entry extends ContainerObjectSelectionList.Entry { + } + + protected class HeaderEntry extends Entry { + private final StringWidget widget; + + public HeaderEntry(Component text) { + this.widget = new StringWidget(text, minecraft.font); + } + + public List narratables() { + return List.of(this.widget); + } + + public void renderContent(GuiGraphics graphics, int x, int y, boolean bl, float delta) { + this.widget.setPosition(this.getContentX() + 2, this.getContentBottom() - minecraft.font.lineHeight); + this.widget.render(graphics, x, y, delta); + } + + public List children() { + return List.of(this.widget); + } + } + + protected static class OptionEntry extends Entry { + private final OptionWidget widget; + private final OptionProxy optionProxy; + private final Button reset; + + public OptionEntry(OptionProxy optionProxy) { + this.widget = getWidgetForProxy(optionProxy); + this.optionProxy = optionProxy; + + this.reset = Button.builder(Component.literal("✘"), (button) -> optionProxy.reset()) + .tooltip(Tooltip.create(Component.literal("Reset to default value"))).build(); + } + + public List narratables() { + return List.of(this.widget, this.reset); + } + + public void renderContent(GuiGraphics graphics, int x, int y, boolean isHovering, float delta) { + this.widget.setPosition(this.getContentX(), this.getContentY()); + this.widget.setSize(this.getContentWidth() - this.getContentHeight() - 4, this.getContentHeight()); + this.widget.render(graphics, x, y, delta); + + this.reset.active = this.optionProxy.isChanged(); + this.reset.setPosition(this.getContentRight() - this.getContentHeight(), this.getContentY()); + this.reset.setSize(this.getContentHeight(), this.getContentHeight()); + this.reset.render(graphics, x, y, delta); + } + + public List children() { + return List.of(this.widget, this.reset); + } + } +} diff --git a/src/main/java/me/chrr/tapestry/config/gui/OptionProxy.java b/src/main/java/me/chrr/tapestry/config/gui/OptionProxy.java new file mode 100644 index 0000000..8df15e1 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/gui/OptionProxy.java @@ -0,0 +1,27 @@ +package me.chrr.tapestry.config.gui; + +import me.chrr.tapestry.config.Option; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class OptionProxy { + public final Option option; + public T value; + + public OptionProxy(Option option) { + this.option = option; + this.value = option.displayBinding.get(); + } + + public void apply() { + this.option.displayBinding.set(this.value); + } + + public void reset() { + this.value = this.option.defaultDisplayValue; + } + + public boolean isChanged() { + return this.value != this.option.defaultDisplayValue; + } +} diff --git a/src/main/java/me/chrr/tapestry/config/gui/TapestryConfigScreen.java b/src/main/java/me/chrr/tapestry/config/gui/TapestryConfigScreen.java new file mode 100644 index 0000000..4bb57b2 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/gui/TapestryConfigScreen.java @@ -0,0 +1,70 @@ +package me.chrr.tapestry.config.gui; + +import me.chrr.tapestry.config.Config; +import me.chrr.tapestry.config.Option; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.layouts.HeaderAndFooterLayout; +import net.minecraft.client.gui.layouts.LinearLayout; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.CommonComponents; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +@NullMarked +public class TapestryConfigScreen extends Screen { + private final Config config; + private final Screen parent; + + private final List> proxies = new ArrayList<>(); + + private final HeaderAndFooterLayout layout = new HeaderAndFooterLayout(this); + private @Nullable OptionList list; + + public TapestryConfigScreen(Config config, Screen parent) { + super(config.getText("title")); + + this.config = config; + this.parent = parent; + } + + @Override + protected void init() { + this.layout.addTitleHeader(this.title, this.font); + + LinearLayout footerLayout = this.layout.addToFooter(LinearLayout.horizontal().spacing(8)); + footerLayout.addChild(Button.builder(CommonComponents.GUI_CANCEL, (button) -> this.onClose()).build()); + footerLayout.addChild(Button.builder(CommonComponents.GUI_DONE, (button) -> this.saveAndClose()).build()); + + this.list = this.layout.addToContents(new OptionList(this.minecraft, this.width, this.layout.getContentHeight(), this.layout.getHeaderHeight())); + for (Option option : this.config.getOptions()) { + if (option.header != null) + this.list.addHeader(option.header); + this.proxies.add(this.list.addOption(option)); + } + + this.layout.visitWidgets(this::addRenderableWidget); + this.repositionElements(); + } + + @Override + protected void repositionElements() { + this.layout.arrangeElements(); + + if (this.list != null) + this.list.updateSize(this.width, this.layout); + } + + public void saveAndClose() { + this.proxies.forEach(OptionProxy::apply); + this.config.save(); + this.onClose(); + } + + public void onClose() { + this.minecraft.setScreen(this.parent); + } +} + diff --git a/src/main/java/me/chrr/tapestry/config/gui/widget/BooleanOptionWidget.java b/src/main/java/me/chrr/tapestry/config/gui/widget/BooleanOptionWidget.java new file mode 100644 index 0000000..8390e6b --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/gui/widget/BooleanOptionWidget.java @@ -0,0 +1,40 @@ +package me.chrr.tapestry.config.gui.widget; + +import me.chrr.tapestry.config.gui.OptionProxy; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.input.InputWithModifiers; +import net.minecraft.util.ARGB; +import net.minecraft.util.CommonColors; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BooleanOptionWidget extends OptionWidget.Clickable { + public BooleanOptionWidget(OptionProxy optionProxy) { + super(optionProxy); + } + + @Override + public void onPress(InputWithModifiers input) { + optionProxy.value = !optionProxy.value; + } + + @Override + protected void renderOptionWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + int boxSize = this.getHeight() - (2 + 4) * 2; + int boxX = this.getRight() - 2 - 4 - boxSize; + int boxY = this.getY() + 2 + 4; + + int color = CommonColors.WHITE; + int shadow = ARGB.scaleRGB(color, 0.25F); + + this.renderOptionLabel(graphics, boxX - this.getX()); + + graphics.renderOutline(boxX + 1, boxY + 1, boxSize, boxSize, shadow); + graphics.renderOutline(boxX, boxY, boxSize, boxSize, color); + + if (optionProxy.value) { + graphics.fill(boxX + 3, boxY + 3, boxX + boxSize - 1, boxY + boxSize - 1, shadow); + graphics.fill(boxX + 2, boxY + 2, boxX + boxSize - 2, boxY + boxSize - 2, color); + } + } +} diff --git a/src/main/java/me/chrr/tapestry/config/gui/widget/EnumOptionWidget.java b/src/main/java/me/chrr/tapestry/config/gui/widget/EnumOptionWidget.java new file mode 100644 index 0000000..ebf2269 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/gui/widget/EnumOptionWidget.java @@ -0,0 +1,36 @@ +package me.chrr.tapestry.config.gui.widget; + +import me.chrr.tapestry.config.Controller; +import me.chrr.tapestry.config.gui.OptionProxy; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.input.InputWithModifiers; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.NullMarked; + +import java.util.*; + +@NullMarked +public class EnumOptionWidget extends OptionWidget.Clickable { + private final List values = new ArrayList<>(); + private final Map nameByValue = new HashMap<>(); + + public EnumOptionWidget(OptionProxy optionProxy, Controller.EnumValues enumValues) { + super(optionProxy); + + for (Controller.EnumValues.Value value : enumValues.options) { + this.values.add(value.value()); + this.nameByValue.put(value.value(), value.name()); + } + } + + @Override + public void onPress(InputWithModifiers input) { + optionProxy.value = values.get((values.indexOf(optionProxy.value) + 1) % values.size()); + } + + @Override + protected void renderOptionWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + this.renderOptionLabel(graphics, this.getWidth()); + this.renderValueLabel(graphics, 0, this.getWidth(), nameByValue.get(optionProxy.value)); + } +} diff --git a/src/main/java/me/chrr/tapestry/config/gui/widget/OptionWidget.java b/src/main/java/me/chrr/tapestry/config/gui/widget/OptionWidget.java new file mode 100644 index 0000000..9605a0b --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/gui/widget/OptionWidget.java @@ -0,0 +1,105 @@ +package me.chrr.tapestry.config.gui.widget; + +import me.chrr.tapestry.config.gui.OptionProxy; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ActiveTextCollector; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.WidgetSprites; +import net.minecraft.client.gui.components.WidgetTooltipHolder; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.input.InputWithModifiers; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.ARGB; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public abstract class OptionWidget extends AbstractWidget { + private static final WidgetSprites SPRITES = new WidgetSprites( + Identifier.withDefaultNamespace("widget/button"), + Identifier.withDefaultNamespace("widget/button_disabled"), + Identifier.withDefaultNamespace("widget/button_highlighted") + ); + + public final OptionProxy optionProxy; + public final WidgetTooltipHolder customTooltip; + + public OptionWidget(OptionProxy optionProxy) { + super(0, 0, 0, 0, optionProxy.option.displayName); + + this.optionProxy = optionProxy; + this.customTooltip = new WidgetTooltipHolder(); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + this.defaultButtonNarrationText(narrationElementOutput); + } + + protected void renderOptionLabel(GuiGraphics graphics, int availableWidth) { + ActiveTextCollector textCollector = graphics.textRendererForWidget(this, GuiGraphics.HoveredTextEffects.NONE); + textCollector.acceptScrolling(this.getMessage(), + this.getX() + 2 + 4, this.getX() + 2 + 4, this.getX() + availableWidth - 2 - 4, + this.getY() + 2, this.getBottom() - 2); + } + + protected void renderValueLabel(GuiGraphics graphics, int rightOffset, int availableWidth) { + this.renderValueLabel(graphics, rightOffset, availableWidth, Component.literal(optionProxy.value.toString())); + } + + protected void renderValueLabel(GuiGraphics graphics, int rightOffset, int availableWidth, Component value) { + ActiveTextCollector textCollector = graphics.textRendererForWidget(this, GuiGraphics.HoveredTextEffects.NONE); + textCollector.acceptScrolling(value, + this.getRight() - rightOffset - 2 - 4, + this.getRight() - rightOffset - availableWidth + 2 + 4, + this.getRight() - rightOffset - 2 - 4, + this.getY() + 2, this.getBottom() - 2); + } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.blitSprite( + RenderPipelines.GUI_TEXTURED, SPRITES.get(this.active, this.isHoveredOrFocused()), + this.getX(), this.getY(), this.getWidth(), this.getHeight(), + ARGB.white(this.alpha)); + this.renderOptionWidget(graphics, mouseX, mouseY, partialTick); + } + + protected abstract void renderOptionWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta); + + public static abstract class Clickable extends OptionWidget { + public Clickable(OptionProxy optionProxy) { + super(optionProxy); + } + + @Override + public void onClick(MouseButtonEvent event, boolean isDoubleClick) { + this.onPress(event); + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (!this.isActive()) { + return false; + } else if (event.isSelection()) { + this.playDownSound(Minecraft.getInstance().getSoundManager()); + this.onPress(event); + return true; + } else { + return false; + } + } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.renderWidget(graphics, mouseX, mouseY, partialTick); + this.handleCursor(graphics); + } + + public abstract void onPress(InputWithModifiers input); + } +} diff --git a/src/main/java/me/chrr/tapestry/config/gui/widget/ReadOnlyOptionWidget.java b/src/main/java/me/chrr/tapestry/config/gui/widget/ReadOnlyOptionWidget.java new file mode 100644 index 0000000..dc332a2 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/gui/widget/ReadOnlyOptionWidget.java @@ -0,0 +1,20 @@ +package me.chrr.tapestry.config.gui.widget; + +import me.chrr.tapestry.config.gui.OptionProxy; +import net.minecraft.client.gui.GuiGraphics; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ReadOnlyOptionWidget extends OptionWidget { + public ReadOnlyOptionWidget(OptionProxy optionProxy) { + super(optionProxy); + this.active = false; + } + + @Override + protected void renderOptionWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + this.renderOptionLabel(graphics, this.getWidth()); + this.renderValueLabel(graphics, 0, this.getWidth()); + this.handleCursor(graphics); + } +} diff --git a/src/main/java/me/chrr/tapestry/config/gui/widget/SliderOptionWidget.java b/src/main/java/me/chrr/tapestry/config/gui/widget/SliderOptionWidget.java new file mode 100644 index 0000000..2589f12 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/gui/widget/SliderOptionWidget.java @@ -0,0 +1,156 @@ +package me.chrr.tapestry.config.gui.widget; + +import com.mojang.blaze3d.platform.InputConstants; +import com.mojang.blaze3d.platform.cursor.CursorTypes; +import me.chrr.tapestry.config.Controller; +import me.chrr.tapestry.config.gui.OptionProxy; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.util.ARGB; +import net.minecraft.util.CommonColors; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public abstract class SliderOptionWidget extends OptionWidget { + private static final int SLIDER_WIDTH = 96; + + private int sliderMinX = 0; + private int sliderMaxX = 0; + private boolean isSliding = false; + + public final N min; + public final N max; + public final N step; + + public SliderOptionWidget(OptionProxy optionProxy, Controller.Slider controller) { + super(optionProxy); + + this.min = controller.min; + this.max = controller.max; + this.step = controller.step; + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (this.isFocused()) { + switch (event.key()) { + case InputConstants.KEY_LEFT -> { + optionProxy.value = incrementBySteps(optionProxy.value, -1); + return true; + } + case InputConstants.KEY_RIGHT -> { + optionProxy.value = incrementBySteps(optionProxy.value, 1); + return true; + } + } + } + + return false; + } + + @Override + public void onClick(MouseButtonEvent event, boolean isDoubleClick) { + this.isSliding = event.button() == InputConstants.MOUSE_BUTTON_LEFT + && event.x() >= sliderMinX && event.x() <= sliderMaxX; + this.updateSlider(event); + } + + @Override + public void onRelease(MouseButtonEvent event) { + this.isSliding = false; + } + + @Override + protected void onDrag(MouseButtonEvent event, double mouseX, double mouseY) { + this.updateSlider(event); + } + + protected void updateSlider(MouseButtonEvent event) { + if (!isSliding || event.button() != InputConstants.MOUSE_BUTTON_LEFT) + return; + + float progress = Math.clamp(((float) event.x() - sliderMinX) / SLIDER_WIDTH, 0f, 1f); + optionProxy.value = getValueFromProgress(progress); + } + + @Override + protected void renderOptionWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + int sliderWidth = this.isHoveredOrFocused() ? SLIDER_WIDTH : SLIDER_WIDTH / 4; + int availableWidth = this.getWidth() - sliderWidth - 4; + this.renderOptionLabel(graphics, availableWidth); + this.renderValueLabel(graphics, sliderWidth + 4, availableWidth); + + int color = CommonColors.WHITE; + int shadow = ARGB.scaleRGB(color, 0.25F); + + sliderMaxX = this.getRight() - 2 - 4; + sliderMinX = sliderMaxX - sliderWidth; + int sliderY = this.getY() + this.getHeight() / 2; + + int thumbWidth = 2; + int thumbHeight = this.getHeight() - (2 + 4) * 2; + float progress = Math.clamp(getProgressFromValue(optionProxy.value), 0f, 1f); + int thumbX = sliderMinX + (int) (sliderWidth * progress) - thumbWidth / 2; + int thumbY = this.getY() + 2 + 4; + + graphics.fill(sliderMinX + 1, sliderY + 1, sliderMaxX + 1, sliderY + 2, shadow); + graphics.fill(sliderMinX, sliderY, sliderMaxX, sliderY + 1, color); + + graphics.fill(thumbX + 1, thumbY + 1, thumbX + thumbWidth + 1, thumbY + thumbHeight + 1, shadow); + graphics.fill(thumbX, thumbY, thumbX + thumbWidth, thumbY + thumbHeight, color); + + if (isSliding || (this.isHovered() && mouseX >= sliderMinX && mouseX <= sliderMaxX)) { + graphics.requestCursor(this.isActive() ? CursorTypes.RESIZE_EW : CursorTypes.NOT_ALLOWED); + } + } + + protected abstract float getProgressFromValue(N value); + + protected abstract N getValueFromProgress(float progress); + + protected abstract N incrementBySteps(N value, int steps); + + + public static class Int extends SliderOptionWidget { + public Int(OptionProxy optionProxy, Controller.Slider controller) { + super(optionProxy, controller); + } + + @Override + protected float getProgressFromValue(Integer value) { + return ((float) value - this.min) / ((float) this.max - this.min); + } + + @Override + protected Integer getValueFromProgress(float progress) { + return this.min + (int) (progress * (this.max - this.min)) / step * step; + } + + @Override + protected Integer incrementBySteps(Integer value, int steps) { + return Math.clamp(value + (long) this.step * steps, this.min, this.max); + } + } + + public static class Float extends SliderOptionWidget { + public Float(OptionProxy optionProxy, Controller.Slider controller) { + super(optionProxy, controller); + } + + @Override + protected float getProgressFromValue(java.lang.Float value) { + return (value - this.min) / (this.max - this.min); + } + + @Override + protected java.lang.Float getValueFromProgress(float progress) { + return this.min + Math.round(progress * (this.max - this.min) / step) * step; + } + + @Override + protected java.lang.Float incrementBySteps(java.lang.Float value, int steps) { + return Math.clamp(value + this.step * steps, this.min, this.max); + } + } +} diff --git a/src/main/java/me/chrr/tapestry/config/reflect/NamingStrategy.java b/src/main/java/me/chrr/tapestry/config/reflect/NamingStrategy.java new file mode 100644 index 0000000..209d82a --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/NamingStrategy.java @@ -0,0 +1,51 @@ +package me.chrr.tapestry.config.reflect; + +import java.util.ArrayList; +import java.util.List; + +public enum NamingStrategy { + KEEP, + SNAKE_CASE, + CAMEL_CASE, + SPACED_PASCAL; + + public String transform(String name) { + return switch (this) { + case KEEP -> name; + case SNAKE_CASE -> String.join("_", splitName(name)).toLowerCase(); + case CAMEL_CASE -> { + StringBuilder builder = new StringBuilder(); + List words = splitName(name); + builder.append(words.getFirst()); + words.stream().skip(1).forEach((word) -> builder.append(capitalize(word))); + yield builder.toString(); + } + case SPACED_PASCAL -> String.join(" ", splitName(name).stream().map(NamingStrategy::capitalize).toList()); + }; + } + + private static List splitName(String name) { + List words = new ArrayList<>(); + StringBuilder currentWord = new StringBuilder(); + + boolean[] inWord = new boolean[]{false}; + name.codePoints().forEach(codePoint -> { + if (Character.isUpperCase(codePoint) && inWord[0]) { + words.add(currentWord.toString()); + currentWord.setLength(0); + currentWord.appendCodePoint(codePoint); + inWord[0] = false; + } else { + currentWord.appendCodePoint(codePoint); + inWord[0] = true; + } + }); + + words.add(currentWord.toString()); + return words; + } + + private static String capitalize(String s) { + return s.substring(0, 1).toUpperCase() + s.substring(1).toLowerCase(); + } +} diff --git a/src/main/java/me/chrr/tapestry/config/reflect/ReflectedBinding.java b/src/main/java/me/chrr/tapestry/config/reflect/ReflectedBinding.java new file mode 100644 index 0000000..2a62cac --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/ReflectedBinding.java @@ -0,0 +1,57 @@ +package me.chrr.tapestry.config.reflect; + +import me.chrr.tapestry.config.Binding; +import org.jspecify.annotations.NullMarked; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; + +@NullMarked +public final class ReflectedBinding implements Binding { + private final String fieldName; + private final Class valueClass; + private final Object object; + private final Field field; + + public ReflectedBinding(Class valueClass, Object object, Field field) { + this.fieldName = field.getDeclaringClass().getTypeName() + "." + field.getName(); + this.valueClass = valueClass; + this.object = object; + this.field = field; + + if (Modifier.isFinal(field.getModifiers())) + throw new IllegalArgumentException("Reflected binding '" + this.fieldName + "' is final"); + if (!field.getType().isAssignableFrom(valueClass)) + throw new IllegalArgumentException("Reflected binding '" + this.fieldName + "' is not of type '" + valueClass.getName() + "'"); + if (!field.getDeclaringClass().isAssignableFrom(object.getClass())) + throw new IllegalArgumentException("Reflected binding '" + this.fieldName + "' is incompatible with '" + object.getClass().getName() + "'"); + if (field.getName().equals("version")) + throw new IllegalArgumentException("Reflected binding '" + this.fieldName + "' uses the reserved name 'version'"); + + field.setAccessible(true); + } + + @Override + public Class getValueClass() { + return this.valueClass; + } + + @Override + public T get() { + try { + //noinspection unchecked: we check if the type is correct in the constructor. + return (T) this.field.get(this.object); + } catch (IllegalAccessException e) { + throw new RuntimeException("Reflected binding '" + this.fieldName + "' is not accessible", e); + } + } + + @Override + public void set(T value) { + try { + this.field.set(this.object, value); + } catch (IllegalAccessException e) { + throw new RuntimeException("Reflected binding " + this.fieldName + " is not accessible", e); + } + } +} diff --git a/src/main/java/me/chrr/tapestry/config/reflect/ReflectedConfig.java b/src/main/java/me/chrr/tapestry/config/reflect/ReflectedConfig.java new file mode 100644 index 0000000..6e83819 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/ReflectedConfig.java @@ -0,0 +1,251 @@ +package me.chrr.tapestry.config.reflect; + +import com.google.gson.JsonObject; +import me.chrr.tapestry.config.*; +import me.chrr.tapestry.config.reflect.annotation.*; +import net.minecraft.network.chat.Component; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.lang.reflect.*; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +@NullMarked +public abstract class ReflectedConfig implements Config { + private final List> options = new ArrayList<>(); + private final Logger logger = LogManager.getLogger("Tapestry/" + getClass().getSimpleName()); + + private ConfigIo.@Nullable UpgradeRewriter upgradeRewriter = null; + private @Nullable Path currentConfigPath = null; + private @Nullable String translationPrefix = null; + + //region Initialization & Reflection + protected void reflectOptions() { + // Find class properties. + this.upgradeRewriter = findUpgradeRewriter(); + + TranslateDisplayNames translateAnnotation = getClass().getAnnotation(TranslateDisplayNames.class); + if (translateAnnotation != null) + translationPrefix = translateAnnotation.prefix(); + + // Get the default naming strategies. + NamingStrategy serializeNaming = NamingStrategy.SNAKE_CASE; + SerializeName.Strategy serializeNameStrategyAnnotation = getClass().getAnnotation(SerializeName.Strategy.class); + if (serializeNameStrategyAnnotation != null) + serializeNaming = serializeNameStrategyAnnotation.value(); + + NamingStrategy displayNaming = translationPrefix == null ? NamingStrategy.SPACED_PASCAL : serializeNaming; + DisplayName.Strategy displayNameStrategyAnnotation = getClass().getAnnotation(DisplayName.Strategy.class); + if (displayNameStrategyAnnotation != null) + displayNaming = displayNameStrategyAnnotation.value(); + + // Construct options for all public, non-static, non-transient fields. + for (Field field : getClass().getFields()) { + if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) + continue; + this.options.add(getOptionFromField(serializeNaming, displayNaming, field)); + } + } + + private Option getOptionFromField(NamingStrategy serializeNaming, NamingStrategy displayNaming, Field field) { + // Construct the value bindings from annotations, or otherwise simple reflection. + Binding serializeBinding = null; + Binding displayBinding = null; + + Rebind.Serialize serializeBindingAnnotation = field.getAnnotation(Rebind.Serialize.class); + if (serializeBindingAnnotation != null) + serializeBinding = getBindingFromFieldName(serializeBindingAnnotation.value()); + Rebind.Display displayBindingAnnotation = field.getAnnotation(Rebind.Display.class); + if (displayBindingAnnotation != null) + displayBinding = getBindingFromFieldName(displayBindingAnnotation.value()); + + if (serializeBinding == null || displayBinding == null) { + ReflectedBinding reflectedBinding = new ReflectedBinding<>(field.getType(), this, field); + if (serializeBinding == null) + serializeBinding = reflectedBinding; + if (displayBinding == null) + displayBinding = reflectedBinding; + } + + // Get the names from annotations or naming strategies. + String serializeName; + SerializeName serializeNameAnnotation = field.getAnnotation(SerializeName.class); + if (serializeNameAnnotation != null) { + serializeName = serializeNameAnnotation.value(); + } else { + NamingStrategy strategy = serializeNaming; + SerializeName.Strategy strategyAnnotation = field.getAnnotation(SerializeName.Strategy.class); + if (strategyAnnotation != null) + strategy = strategyAnnotation.value(); + serializeName = strategy.transform(field.getName()); + } + + String displayNameStr; + DisplayName displayNameAnnotation = field.getAnnotation(DisplayName.class); + if (displayNameAnnotation != null) { + displayNameStr = displayNameAnnotation.value(); + } else { + NamingStrategy strategy = displayNaming; + DisplayName.Strategy strategyAnnotation = field.getAnnotation(DisplayName.Strategy.class); + if (strategyAnnotation != null) + strategy = strategyAnnotation.value(); + displayNameStr = strategy.transform(field.getName()); + } + + // Actually construct the option. + Component displayName = this.getText("option." + displayNameStr); + Option option = createOptionWithDefaultValue(serializeName, displayName, serializeBinding, displayBinding); + + Header headerAnnotation = field.getAnnotation(Header.class); + if (headerAnnotation != null) + option.header = this.getText("header." + headerAnnotation.value()); + + // Get the controller. + Slider.Int intSlider = field.getAnnotation(Slider.Int.class); + if ((field.getType() == int.class || field.getType() == Integer.class) && intSlider != null) + option.controller = new Controller.Slider<>(intSlider.min(), intSlider.max(), intSlider.step()); + + Slider.Float floatSlider = field.getAnnotation(Slider.Float.class); + if ((field.getType() == float.class || field.getType() == Float.class) && floatSlider != null) + option.controller = new Controller.Slider<>(floatSlider.min(), floatSlider.max(), floatSlider.step()); + + if (field.getType().isEnum()) + option.controller = createEnumValuesController(field.getType(), displayNaming, displayNameStr); + + return option; + } + + private Option createOptionWithDefaultValue(String serializeName, Component displayName, Binding serializeBinding, Binding displayBinding) { + return new Option<>(serializeName, displayName, displayBinding.get(), serializeBinding, displayBinding); + } + + private Controller.EnumValues createEnumValuesController(Class valueClass, NamingStrategy namingStrategy, String fieldName) { + List> values = Arrays.stream(valueClass.getEnumConstants()) + .map((value) -> { + String name = namingStrategy.transform(((Enum) value).name().toLowerCase()); + Component text = this.getText("option." + fieldName + "." + name); + return new Controller.EnumValues.Value<>(value, text); + }) + .toList(); + return new Controller.EnumValues<>(values); + } + + private Binding getBindingFromFieldName(String fieldName) { + try { + Field field = getClass().getDeclaredField(fieldName); + if (!Binding.class.isAssignableFrom(field.getType())) + throw new IllegalArgumentException("Rebind '" + fieldName + "' is not a valid binding"); + + field.setAccessible(true); + return (Binding) field.get(this); + } catch (NoSuchFieldException e) { + throw new IllegalArgumentException("Field with name '" + fieldName + "' does not exist in class '" + getClass().getName() + "'", e); + } catch (IllegalAccessException e) { + throw new IllegalArgumentException("Rebind '" + fieldName + "' is not accessible", e); + } + } + + private ConfigIo.@Nullable UpgradeRewriter findUpgradeRewriter() { + ConfigIo.UpgradeRewriter upgradeRewriter = null; + + for (Method method : getClass().getDeclaredMethods()) { + UpgradeRewriter annotation = method.getAnnotation(UpgradeRewriter.class); + if (annotation == null) + continue; + + if (!Modifier.isStatic(method.getModifiers())) + throw new IllegalArgumentException("Upgrade rewriter '" + method.getName() + "' is not static"); + + if (upgradeRewriter != null) + throw new IllegalArgumentException("Config class '" + getClass().getName() + "' defines more than one upgrade rewriter"); + + method.setAccessible(true); + upgradeRewriter = new ConfigIo.UpgradeRewriter() { + @Override + public void upgrade(int fromVersion, JsonObject config) { + try { + method.invoke(null, fromVersion, config); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException("Failed to invoke upgrade rewriter", e); + } + } + + @Override + public int getLatestVersion() { + return annotation.currentVersion(); + } + }; + } + + return upgradeRewriter; + } + //endregion + + //region Config IO + @Override + public Logger getLogger() { + return this.logger; + } + + @Override + public List> getOptions() { + return this.options; + } + + @Override + public ConfigIo.@Nullable UpgradeRewriter getUpgradeRewriter() { + return this.upgradeRewriter; + } + + @Override + public @Nullable String getTranslationPrefix() { + return this.translationPrefix; + } + + @Override + public void save() { + try { + ConfigIo.saveToPath(this, Objects.requireNonNull(this.currentConfigPath)); + } catch (Exception e) { + this.logger.error("Couldn't save config file", e); + } + } + + public static T load(ConfigEnvironment environment, Class configClass, String file) { + return load(environment, configClass, file, List.of()); + } + + public static T load(ConfigEnvironment environment, Class configClass, String file, List aliases) { + try { + Constructor constructor = configClass.getConstructor(); + constructor.setAccessible(true); + + T config = constructor.newInstance(); + config.reflectOptions(); + + try { + Path confDir = environment.getConfigDir(); + Path configFile = confDir.resolve(file); + ((ReflectedConfig) config).currentConfigPath = configFile; + + ConfigIo.loadFromPathOrSaveDefault(config, configFile, + aliases.stream().map(confDir::resolve).toList()); + } catch (Exception e) { + config.getLogger().error("Couldn't load config file", e); + } + + return config; + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("Config class '" + configClass.getName() + "' does not have a default constructor", e); + } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) { + throw new IllegalArgumentException("Config class '" + configClass.getName() + "' could not be instantiated", e); + } + } + //endregion +} diff --git a/src/main/java/me/chrr/tapestry/config/reflect/annotation/DisplayName.java b/src/main/java/me/chrr/tapestry/config/reflect/annotation/DisplayName.java new file mode 100644 index 0000000..b847598 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/annotation/DisplayName.java @@ -0,0 +1,22 @@ +package me.chrr.tapestry.config.reflect.annotation; + +import me.chrr.tapestry.config.reflect.NamingStrategy; +import org.jspecify.annotations.NullMarked; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@NullMarked +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface DisplayName { + String value(); + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE, ElementType.FIELD}) + @interface Strategy { + NamingStrategy value(); + } +} diff --git a/src/main/java/me/chrr/scribble/config/DeprecatedConfigOption.java b/src/main/java/me/chrr/tapestry/config/reflect/annotation/Header.java similarity index 59% rename from src/main/java/me/chrr/scribble/config/DeprecatedConfigOption.java rename to src/main/java/me/chrr/tapestry/config/reflect/annotation/Header.java index b82dd53..b528349 100644 --- a/src/main/java/me/chrr/scribble/config/DeprecatedConfigOption.java +++ b/src/main/java/me/chrr/tapestry/config/reflect/annotation/Header.java @@ -1,11 +1,15 @@ -package me.chrr.scribble.config; +package me.chrr.tapestry.config.reflect.annotation; + +import org.jspecify.annotations.NullMarked; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -@Target(ElementType.FIELD) +@NullMarked @Retention(RetentionPolicy.RUNTIME) -public @interface DeprecatedConfigOption { +@Target(ElementType.FIELD) +public @interface Header { + String value(); } diff --git a/src/main/java/me/chrr/tapestry/config/reflect/annotation/Rebind.java b/src/main/java/me/chrr/tapestry/config/reflect/annotation/Rebind.java new file mode 100644 index 0000000..5e00560 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/annotation/Rebind.java @@ -0,0 +1,24 @@ +package me.chrr.tapestry.config.reflect.annotation; + +import org.jspecify.annotations.NullMarked; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@NullMarked +@Target({}) +public @interface Rebind { + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + @interface Serialize { + String value(); + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + @interface Display { + String value(); + } +} diff --git a/src/main/java/me/chrr/tapestry/config/reflect/annotation/SerializeName.java b/src/main/java/me/chrr/tapestry/config/reflect/annotation/SerializeName.java new file mode 100644 index 0000000..8630751 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/annotation/SerializeName.java @@ -0,0 +1,22 @@ +package me.chrr.tapestry.config.reflect.annotation; + +import me.chrr.tapestry.config.reflect.NamingStrategy; +import org.jspecify.annotations.NullMarked; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@NullMarked +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface SerializeName { + String value(); + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE, ElementType.FIELD}) + @interface Strategy { + NamingStrategy value(); + } +} diff --git a/src/main/java/me/chrr/tapestry/config/reflect/annotation/Slider.java b/src/main/java/me/chrr/tapestry/config/reflect/annotation/Slider.java new file mode 100644 index 0000000..6b3dfdc --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/annotation/Slider.java @@ -0,0 +1,32 @@ +package me.chrr.tapestry.config.reflect.annotation; + +import org.jspecify.annotations.NullMarked; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@NullMarked +@Target({}) +public @interface Slider { + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + @interface Int { + int min(); + + int max(); + + int step() default 1; + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + @interface Float { + float min(); + + float max(); + + float step() default 1; + } +} diff --git a/src/main/java/me/chrr/tapestry/config/reflect/annotation/TranslateDisplayNames.java b/src/main/java/me/chrr/tapestry/config/reflect/annotation/TranslateDisplayNames.java new file mode 100644 index 0000000..2cf0be6 --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/annotation/TranslateDisplayNames.java @@ -0,0 +1,15 @@ +package me.chrr.tapestry.config.reflect.annotation; + +import org.jspecify.annotations.NullMarked; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@NullMarked +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface TranslateDisplayNames { + String prefix(); +} diff --git a/src/main/java/me/chrr/tapestry/config/reflect/annotation/UpgradeRewriter.java b/src/main/java/me/chrr/tapestry/config/reflect/annotation/UpgradeRewriter.java new file mode 100644 index 0000000..caa4bda --- /dev/null +++ b/src/main/java/me/chrr/tapestry/config/reflect/annotation/UpgradeRewriter.java @@ -0,0 +1,15 @@ +package me.chrr.tapestry.config.reflect.annotation; + +import org.jspecify.annotations.NullMarked; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@NullMarked +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface UpgradeRewriter { + int currentVersion(); +} From 85435acb13a95f029dce34165f77edf3f31b1005 Mon Sep 17 00:00:00 2001 From: chrrrs Date: Mon, 22 Dec 2025 23:21:16 +0100 Subject: [PATCH 2/2] feat: support showing multiple configs --- src/main/java/me/chrr/scribble/Scribble.java | 2 +- .../java/me/chrr/tapestry/config/Config.java | 11 +- .../chrr/tapestry/config/gui/OptionList.java | 19 +++- .../config/gui/TapestryConfigScreen.java | 102 +++++++++++++++--- .../config/reflect/ReflectedConfig.java | 37 +++++-- .../reflect/annotation/DisplayName.java | 2 +- 6 files changed, 134 insertions(+), 39 deletions(-) diff --git a/src/main/java/me/chrr/scribble/Scribble.java b/src/main/java/me/chrr/scribble/Scribble.java index 260ea63..402b732 100644 --- a/src/main/java/me/chrr/scribble/Scribble.java +++ b/src/main/java/me/chrr/scribble/Scribble.java @@ -32,7 +32,7 @@ public static void init(Platform platform) { } public static Screen buildConfigScreen(Screen parent) { - return new TapestryConfigScreen(ScribbleConfig.INSTANCE, parent); + return new TapestryConfigScreen(parent, ScribbleConfig.INSTANCE); } public static Platform platform() { diff --git a/src/main/java/me/chrr/tapestry/config/Config.java b/src/main/java/me/chrr/tapestry/config/Config.java index 8868729..b337634 100644 --- a/src/main/java/me/chrr/tapestry/config/Config.java +++ b/src/main/java/me/chrr/tapestry/config/Config.java @@ -13,20 +13,11 @@ public interface Config { ConfigIo.@Nullable UpgradeRewriter getUpgradeRewriter(); - @Nullable String getTranslationPrefix(); + Component getTitle(); void save(); - default Component getText(String key) { - String translationPrefix = getTranslationPrefix(); - if (translationPrefix == null) { - return Component.literal(key); - } else { - return Component.translatable(translationPrefix + "." + key); - } - } - default void ensureLoaded() { // ... empty, if this class is loaded that means the config is loaded. } diff --git a/src/main/java/me/chrr/tapestry/config/gui/OptionList.java b/src/main/java/me/chrr/tapestry/config/gui/OptionList.java index 5a3f35d..c83f736 100644 --- a/src/main/java/me/chrr/tapestry/config/gui/OptionList.java +++ b/src/main/java/me/chrr/tapestry/config/gui/OptionList.java @@ -11,16 +11,22 @@ import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.narration.NarratableEntry; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; import org.jspecify.annotations.NullMarked; import java.util.List; @NullMarked public class OptionList extends ContainerObjectSelectionList { - public OptionList(Minecraft minecraft, int width, int height, int y) { + private final boolean showHeaderSeparator; + + public OptionList(Minecraft minecraft, boolean showHeaderSeparator, int width, int height, int y) { super(minecraft, width, height, y, 25); + this.showHeaderSeparator = showHeaderSeparator; this.centerListVertically = false; } @@ -41,6 +47,17 @@ public int getRowWidth() { return 310; } + @Override + protected void renderListSeparators(GuiGraphics guiGraphics) { + if (this.showHeaderSeparator) { + Identifier headerSeparator = this.minecraft.level == null ? Screen.HEADER_SEPARATOR : Screen.INWORLD_HEADER_SEPARATOR; + guiGraphics.blit(RenderPipelines.GUI_TEXTURED, headerSeparator, this.getX(), this.getY() - 2, 0f, 0f, this.getWidth(), 2, 32, 2); + } + + Identifier footerSeparator = this.minecraft.level == null ? Screen.FOOTER_SEPARATOR : Screen.INWORLD_FOOTER_SEPARATOR; + guiGraphics.blit(RenderPipelines.GUI_TEXTURED, footerSeparator, this.getX(), this.getBottom(), 0f, 0f, this.getWidth(), 2, 32, 2); + } + private static OptionWidget getWidgetForProxy(OptionProxy optionProxy) { Class valueClass = optionProxy.option.displayBinding.getValueClass(); diff --git a/src/main/java/me/chrr/tapestry/config/gui/TapestryConfigScreen.java b/src/main/java/me/chrr/tapestry/config/gui/TapestryConfigScreen.java index 4bb57b2..76bfe08 100644 --- a/src/main/java/me/chrr/tapestry/config/gui/TapestryConfigScreen.java +++ b/src/main/java/me/chrr/tapestry/config/gui/TapestryConfigScreen.java @@ -2,69 +2,137 @@ import me.chrr.tapestry.config.Config; import me.chrr.tapestry.config.Option; +import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.tabs.Tab; +import net.minecraft.client.gui.components.tabs.TabManager; +import net.minecraft.client.gui.components.tabs.TabNavigationBar; import net.minecraft.client.gui.layouts.HeaderAndFooterLayout; import net.minecraft.client.gui.layouts.LinearLayout; +import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; @NullMarked public class TapestryConfigScreen extends Screen { - private final Config config; + private final Config[] configs; private final Screen parent; private final List> proxies = new ArrayList<>(); private final HeaderAndFooterLayout layout = new HeaderAndFooterLayout(this); - private @Nullable OptionList list; + private final TabManager tabManager = new TabManager(this::addRenderableWidget, this::removeWidget); - public TapestryConfigScreen(Config config, Screen parent) { - super(config.getText("title")); + private @Nullable TabNavigationBar tabNavigationBar; + private @Nullable OptionList optionList; - this.config = config; + public TapestryConfigScreen(Screen parent, Config... configs) { + super(Component.empty()); + assert configs.length > 0; + + this.configs = configs; this.parent = parent; } @Override protected void init() { - this.layout.addTitleHeader(this.title, this.font); + // Initialize the actual option lists. + ConfigTab[] tabs = new ConfigTab[this.configs.length]; + for (int i = 0; i < this.configs.length; i++) { + OptionList list = createOptionList(this.configs[i], this.width, + this.layout.getContentHeight(), this.layout.getHeaderHeight()); + tabs[i] = new ConfigTab(this.configs[i], list); + } + + if (tabs.length == 1) { + // If we have only a single config, just show a simple title header. + this.layout.addTitleHeader(tabs[0].getTabTitle(), this.font); + this.optionList = this.layout.addToContents(createOptionList(tabs[0].config, this.width, + this.layout.getContentHeight(), this.layout.getHeaderHeight())); + } else { + // If we have more than one config, show them as tabs. + this.tabNavigationBar = this.addRenderableWidget( + TabNavigationBar.builder(this.tabManager, this.width).addTabs(tabs).build()); + this.tabNavigationBar.selectTab(0, false); + } + // Add the footer cancel and done buttons. LinearLayout footerLayout = this.layout.addToFooter(LinearLayout.horizontal().spacing(8)); footerLayout.addChild(Button.builder(CommonComponents.GUI_CANCEL, (button) -> this.onClose()).build()); footerLayout.addChild(Button.builder(CommonComponents.GUI_DONE, (button) -> this.saveAndClose()).build()); - this.list = this.layout.addToContents(new OptionList(this.minecraft, this.width, this.layout.getContentHeight(), this.layout.getHeaderHeight())); - for (Option option : this.config.getOptions()) { - if (option.header != null) - this.list.addHeader(option.header); - this.proxies.add(this.list.addOption(option)); - } - this.layout.visitWidgets(this::addRenderableWidget); this.repositionElements(); } @Override protected void repositionElements() { - this.layout.arrangeElements(); + if (this.optionList != null) { + this.layout.arrangeElements(); + this.optionList.updateSize(this.width, this.layout); + } else if (this.tabNavigationBar != null) { + this.tabNavigationBar.setWidth(this.width); + this.tabNavigationBar.arrangeElements(); + + int headerHeight = this.tabNavigationBar.getRectangle().bottom(); + ScreenRectangle screenRectangle = new ScreenRectangle(0, headerHeight, + this.width, this.height - headerHeight - this.layout.getFooterHeight()); + this.tabManager.setTabArea(screenRectangle); + this.layout.setHeaderHeight(headerHeight); + this.layout.arrangeElements(); + } + } + + private OptionList createOptionList(Config config, int width, int height, int y) { + OptionList list = new OptionList(this.minecraft, this.configs.length <= 1, width, height, y); + for (Option option : config.getOptions()) { + if (option.header != null) + list.addHeader(option.header); + this.proxies.add(list.addOption(option)); + } - if (this.list != null) - this.list.updateSize(this.width, this.layout); + return list; } public void saveAndClose() { this.proxies.forEach(OptionProxy::apply); - this.config.save(); + for (Config config : this.configs) + config.save(); + this.onClose(); } public void onClose() { this.minecraft.setScreen(this.parent); } + + public record ConfigTab(Config config, OptionList list) implements Tab { + @Override + public Component getTabTitle() { + return this.config.getTitle(); + } + + @Override + public Component getTabExtraNarration() { + return Component.empty(); + } + + @Override + public void visitChildren(Consumer consumer) { + consumer.accept(this.list); + } + + @Override + public void doLayout(ScreenRectangle rectangle) { + this.list.updateSizeAndPosition(rectangle.width(), rectangle.height(), rectangle.top()); + } + } } diff --git a/src/main/java/me/chrr/tapestry/config/reflect/ReflectedConfig.java b/src/main/java/me/chrr/tapestry/config/reflect/ReflectedConfig.java index 6e83819..e082a5d 100644 --- a/src/main/java/me/chrr/tapestry/config/reflect/ReflectedConfig.java +++ b/src/main/java/me/chrr/tapestry/config/reflect/ReflectedConfig.java @@ -24,6 +24,7 @@ public abstract class ReflectedConfig implements Config { private ConfigIo.@Nullable UpgradeRewriter upgradeRewriter = null; private @Nullable Path currentConfigPath = null; private @Nullable String translationPrefix = null; + private @Nullable Component title = null; //region Initialization & Reflection protected void reflectOptions() { @@ -45,6 +46,16 @@ protected void reflectOptions() { if (displayNameStrategyAnnotation != null) displayNaming = displayNameStrategyAnnotation.value(); + // Get the config screen title. + DisplayName displayNameAnnotation = getClass().getAnnotation(DisplayName.class); + if (displayNameAnnotation != null) { + this.title = getText("{}", displayNameAnnotation.value()); + } else { + this.title = this.translationPrefix != null + ? Component.translatable(this.translationPrefix + ".title") + : Component.literal(getClass().getSimpleName()); + } + // Construct options for all public, non-static, non-transient fields. for (Field field : getClass().getFields()) { if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) @@ -86,25 +97,25 @@ protected void reflectOptions() { serializeName = strategy.transform(field.getName()); } - String displayNameStr; + String fieldDisplayName; DisplayName displayNameAnnotation = field.getAnnotation(DisplayName.class); if (displayNameAnnotation != null) { - displayNameStr = displayNameAnnotation.value(); + fieldDisplayName = displayNameAnnotation.value(); } else { NamingStrategy strategy = displayNaming; DisplayName.Strategy strategyAnnotation = field.getAnnotation(DisplayName.Strategy.class); if (strategyAnnotation != null) strategy = strategyAnnotation.value(); - displayNameStr = strategy.transform(field.getName()); + fieldDisplayName = strategy.transform(field.getName()); } // Actually construct the option. - Component displayName = this.getText("option." + displayNameStr); + Component displayName = getText("option.{}", fieldDisplayName); Option option = createOptionWithDefaultValue(serializeName, displayName, serializeBinding, displayBinding); Header headerAnnotation = field.getAnnotation(Header.class); if (headerAnnotation != null) - option.header = this.getText("header." + headerAnnotation.value()); + option.header = getText("header.{}", headerAnnotation.value()); // Get the controller. Slider.Int intSlider = field.getAnnotation(Slider.Int.class); @@ -116,7 +127,7 @@ protected void reflectOptions() { option.controller = new Controller.Slider<>(floatSlider.min(), floatSlider.max(), floatSlider.step()); if (field.getType().isEnum()) - option.controller = createEnumValuesController(field.getType(), displayNaming, displayNameStr); + option.controller = createEnumValuesController(field.getType(), displayNaming, fieldDisplayName); return option; } @@ -129,7 +140,7 @@ private Controller.EnumValues createEnumValuesController(Class valueCl List> values = Arrays.stream(valueClass.getEnumConstants()) .map((value) -> { String name = namingStrategy.transform(((Enum) value).name().toLowerCase()); - Component text = this.getText("option." + fieldName + "." + name); + Component text = this.getText("option." + fieldName + ".{}", name); return new Controller.EnumValues.Value<>(value, text); }) .toList(); @@ -185,6 +196,14 @@ public int getLatestVersion() { return upgradeRewriter; } + + private Component getText(String template, String name) { + if (this.translationPrefix != null) { + return Component.translatable(this.translationPrefix + "." + template.replace("{}", name)); + } else { + return Component.literal(name); + } + } //endregion //region Config IO @@ -204,8 +223,8 @@ public Logger getLogger() { } @Override - public @Nullable String getTranslationPrefix() { - return this.translationPrefix; + public @Nullable Component getTitle() { + return this.title; } @Override diff --git a/src/main/java/me/chrr/tapestry/config/reflect/annotation/DisplayName.java b/src/main/java/me/chrr/tapestry/config/reflect/annotation/DisplayName.java index b847598..e788c4e 100644 --- a/src/main/java/me/chrr/tapestry/config/reflect/annotation/DisplayName.java +++ b/src/main/java/me/chrr/tapestry/config/reflect/annotation/DisplayName.java @@ -10,7 +10,7 @@ @NullMarked @Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.FIELD) +@Target({ElementType.TYPE, ElementType.FIELD}) public @interface DisplayName { String value();