From 236397e53b9169095a87d7f779cbff31b0ed0fe5 Mon Sep 17 00:00:00 2001 From: Dragon-Seeker Date: Wed, 26 Mar 2025 01:14:33 -0500 Subject: [PATCH 01/29] Initial work to adjust and update owo config - More supported types - Better config Sync - Server Config Management - Ability to handle more than one config instance - Slider Adjustments from Chyz - Adjust how section headers are on the side - Ability to switch between config instances - Simple utility to copy translation key for missing translation in config - Rework underling code to handle other types of objects and give better access to customize certain option calls without reflection # Conflicts: # src/main/java/io/wispforest/owo/Owo.java # src/main/java/io/wispforest/owo/client/OwoClient.java # src/main/java/io/wispforest/owo/config/ConfigWrapper.java # src/main/java/io/wispforest/owo/config/ui/ConfigScreen.java # src/main/java/io/wispforest/owo/config/ui/ConfigScreenProviders.java # src/main/java/io/wispforest/owo/ui/base/BaseParentComponent.java # src/main/java/io/wispforest/owo/ui/component/LabelComponent.java # src/main/java/io/wispforest/owo/ui/container/Containers.java # src/main/java/io/wispforest/owo/ui/container/ScrollContainer.java # src/main/java/io/wispforest/owo/ui/core/OwoUIDrawContext.java # src/main/java/io/wispforest/owo/ui/core/Surface.java # src/main/java/io/wispforest/owo/ui/util/MatrixStackTransformer.java # src/main/resources/owo.accesswidener # src/testmod/java/io/wispforest/uwu/client/SelectUwuScreenScreen.java --- src/main/java/io/wispforest/owo/Owo.java | 15 +- .../io/wispforest/owo/client/OwoClient.java | 5 +- .../owo/command/RecordArgumentTypeInfo.java | 54 ++ .../owo/compat/modmenu/OwoModMenuPlugin.java | 9 +- .../io/wispforest/owo/config/ConfigAP.java | 36 +- .../owo/config/ConfigReflectionUtils.java | 166 +++++ .../owo/config/ConfigSynchronizer.java | 165 +++-- .../wispforest/owo/config/ConfigWrapper.java | 335 +++++---- .../java/io/wispforest/owo/config/Option.java | 433 ------------ .../owo/config/OwoConfigCommand.java | 173 ++++- .../owo/config/annotation/Config.java | 2 + .../owo/config/annotation/Modmenu.java | 1 + .../config/annotation/RangeConstraint.java | 6 +- .../owo/config/annotation/Sync.java | 4 +- .../owo/config/base/BoundedAccess.java | 117 ++++ .../io/wispforest/owo/config/base/Key.java | 93 +++ .../wispforest/owo/config/base/SyncMode.java | 23 + .../owo/config/options/FieldOption.java | 188 +++++ .../owo/config/options/MemoryOption.java | 26 + .../owo/config/options/OptionBase.java | 118 ++++ .../owo/config/options/OptionControlSpec.java | 71 ++ .../owo/config/options/RecordOption.java | 25 + .../owo/config/options/ReflectiveOption.java | 12 + .../owo/config/ui/ConfigScreen.java | 503 ++++++++++--- .../owo/config/ui/ConfigScreenProvider.java | 24 + .../owo/config/ui/ConfigScreenProviders.java | 185 ++++- .../owo/config/ui/OptionComponentData.java | 10 + .../owo/config/ui/OptionComponentFactory.java | 267 ++++--- .../owo/config/ui/OptionComponents.java | 163 +++-- .../config/ui/component/ConfigEnumButton.java | 8 +- .../config/ui/component/ConfigTextBox.java | 13 +- .../ui/component/ListOptionContainer.java | 177 ----- .../ui/component/OrderedOptionContainer.java | 227 ++++++ .../ui/component/SearchAnchorComponent.java | 16 +- .../component/SelectableScrollContainer.java | 148 ++++ .../struct/AbstractStructOptionContainer.java | 161 +++++ .../component/struct/ListOptionContainer.java | 104 +++ .../component/struct/MapOptionContainer.java | 112 +++ .../struct/RecordStructOptionContainer.java | 117 ++++ .../struct/StructOptionContainer.java | 99 +++ .../owo/mixin/DrawContextMixin.java | 27 +- .../owo/mixin/ScreenHandlerMixin.java | 1 + .../owo/mixin/ui/ClickableWidgetMixin.java | 11 + .../io/wispforest/owo/packets/OwoPackets.java | 27 + .../owo/packets/c2s/AdjustServerConfig.java | 34 + .../packets/c2s/AskToOpenServerConfig.java | 29 + .../owo/packets/s2c/OpenServerConfig.java | 29 + .../s2c/OpenServerConfigSelection.java | 31 + .../serialization/endec/MinecraftEndecs.java | 5 + .../wispforest/owo/ui/base/BaseComponent.java | 20 +- .../owo/ui/base/BaseOwoHandledScreen.java | 11 +- .../wispforest/owo/ui/base/BaseOwoScreen.java | 10 +- .../owo/ui/base/BaseParentComponent.java | 23 +- .../owo/ui/component/LabelComponent.java | 200 +++++- .../owo/ui/component/TextBoxComponent.java | 1 + .../ui/component/VanillaWidgetComponent.java | 6 +- .../owo/ui/container/Containers.java | 4 + .../owo/ui/container/ScrollContainer.java | 169 +++-- .../owo/ui/container/SelectableContainer.java | 130 ++++ .../java/io/wispforest/owo/ui/core/Color.java | 32 + .../io/wispforest/owo/ui/core/Component.java | 7 + .../wispforest/owo/ui/core/OwoUIAdapter.java | 27 + .../owo/ui/core/OwoUIDrawContext.java | 9 + .../owo/ui/core/PositionedRectangle.java | 36 +- .../io/wispforest/owo/ui/core/Surface.java | 172 ++++- .../owo/ui/event/ComponentUpdate.java | 16 + .../owo/ui/inject/ComponentStub.java | 11 + .../owo/ui/parsing/UIModelLoader.java | 7 +- .../wispforest/owo/ui/parsing/UIParsing.java | 46 +- .../owo/ui/util/MatrixStackTransformer.java | 101 ++- .../owo/ui/util/WrappedMatrixStack.java | 15 + .../wispforest/owo/util/ReflectionUtils.java | 45 +- src/main/resources/assets/owo/lang/en_us.json | 24 +- .../resources/assets/owo/owo_ui/config.xml | 512 +++++++++----- src/main/resources/fabric.mod.json | 2 +- src/main/resources/owo.accesswidener | 4 +- src/testmod/java/io/wispforest/uwu/Uwu.java | 20 +- .../uwu/client/ExpandLabelTesting.java | 103 +++ .../uwu/client/NestedComponentTesting.java | 45 ++ .../uwu/client/SelectUwuScreenScreen.java | 84 ++- .../uwu/client/UwuConfigScreen.java | 2 +- .../uwu/config/AdditionalConfig1Model.java | 75 ++ .../uwu/config/AdditionalConfig2Model.java | 75 ++ .../uwu/config/AdditionalConfig3Model.java | 75 ++ .../uwu/config/AdditionalConfig4Model.java | 75 ++ .../uwu/config/FullConfigModel.java | 91 +++ .../wispforest/uwu/config/RecordTestObj.java | 7 + .../wispforest/uwu/config/StructTestObj1.java | 8 + .../wispforest/uwu/config/StructTestObj2.java | 16 + .../uwu/config/UowouConfigModel.java | 8 +- .../wispforest/uwu/config/UwuConfigModel.java | 10 +- .../resources/assets/uwu/lang/en_us.json | 161 ++++- .../resources/assets/uwu/owo_ui/config.xml | 461 ------------ .../assets/uwu/owo_ui/config_duplicate.xml | 661 ++++++++++++++++++ 94 files changed, 6374 insertions(+), 1848 deletions(-) create mode 100644 src/main/java/io/wispforest/owo/command/RecordArgumentTypeInfo.java create mode 100644 src/main/java/io/wispforest/owo/config/ConfigReflectionUtils.java delete mode 100644 src/main/java/io/wispforest/owo/config/Option.java create mode 100644 src/main/java/io/wispforest/owo/config/base/BoundedAccess.java create mode 100644 src/main/java/io/wispforest/owo/config/base/Key.java create mode 100644 src/main/java/io/wispforest/owo/config/base/SyncMode.java create mode 100644 src/main/java/io/wispforest/owo/config/options/FieldOption.java create mode 100644 src/main/java/io/wispforest/owo/config/options/MemoryOption.java create mode 100644 src/main/java/io/wispforest/owo/config/options/OptionBase.java create mode 100644 src/main/java/io/wispforest/owo/config/options/OptionControlSpec.java create mode 100644 src/main/java/io/wispforest/owo/config/options/RecordOption.java create mode 100644 src/main/java/io/wispforest/owo/config/options/ReflectiveOption.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/ConfigScreenProvider.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/OptionComponentData.java delete mode 100644 src/main/java/io/wispforest/owo/config/ui/component/ListOptionContainer.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/component/OrderedOptionContainer.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/component/SelectableScrollContainer.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/component/struct/AbstractStructOptionContainer.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/component/struct/ListOptionContainer.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/component/struct/MapOptionContainer.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/component/struct/RecordStructOptionContainer.java create mode 100644 src/main/java/io/wispforest/owo/config/ui/component/struct/StructOptionContainer.java create mode 100644 src/main/java/io/wispforest/owo/packets/OwoPackets.java create mode 100644 src/main/java/io/wispforest/owo/packets/c2s/AdjustServerConfig.java create mode 100644 src/main/java/io/wispforest/owo/packets/c2s/AskToOpenServerConfig.java create mode 100644 src/main/java/io/wispforest/owo/packets/s2c/OpenServerConfig.java create mode 100644 src/main/java/io/wispforest/owo/packets/s2c/OpenServerConfigSelection.java create mode 100644 src/main/java/io/wispforest/owo/ui/container/SelectableContainer.java create mode 100644 src/main/java/io/wispforest/owo/ui/event/ComponentUpdate.java create mode 100644 src/main/java/io/wispforest/owo/ui/util/WrappedMatrixStack.java create mode 100644 src/testmod/java/io/wispforest/uwu/client/ExpandLabelTesting.java create mode 100644 src/testmod/java/io/wispforest/uwu/client/NestedComponentTesting.java create mode 100644 src/testmod/java/io/wispforest/uwu/config/AdditionalConfig1Model.java create mode 100644 src/testmod/java/io/wispforest/uwu/config/AdditionalConfig2Model.java create mode 100644 src/testmod/java/io/wispforest/uwu/config/AdditionalConfig3Model.java create mode 100644 src/testmod/java/io/wispforest/uwu/config/AdditionalConfig4Model.java create mode 100644 src/testmod/java/io/wispforest/uwu/config/FullConfigModel.java create mode 100644 src/testmod/java/io/wispforest/uwu/config/RecordTestObj.java create mode 100644 src/testmod/java/io/wispforest/uwu/config/StructTestObj1.java create mode 100644 src/testmod/java/io/wispforest/uwu/config/StructTestObj2.java delete mode 100644 src/testmod/resources/assets/uwu/owo_ui/config.xml create mode 100644 src/testmod/resources/assets/uwu/owo_ui/config_duplicate.xml diff --git a/src/main/java/io/wispforest/owo/Owo.java b/src/main/java/io/wispforest/owo/Owo.java index 7a6835f5..896e0e27 100644 --- a/src/main/java/io/wispforest/owo/Owo.java +++ b/src/main/java/io/wispforest/owo/Owo.java @@ -2,16 +2,26 @@ import io.wispforest.owo.client.screens.ScreenInternals; import io.wispforest.owo.command.debug.OwoDebugCommands; +import io.wispforest.owo.config.OwoConfigCommand; +import io.wispforest.owo.network.OwoNetChannel; import io.wispforest.owo.ops.LootOps; +import io.wispforest.owo.packets.OwoPackets; import io.wispforest.owo.text.CustomTextRegistry; import io.wispforest.owo.text.InsertingTextContent; import io.wispforest.owo.util.Wisdom; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.server.MinecraftServer; import net.minecraft.text.Text; import net.minecraft.util.Formatting; +import net.minecraft.util.Identifier; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.ApiStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,6 +67,10 @@ public void onInitialize() { Wisdom.spread(); + OwoConfigCommand.register(); + + OwoPackets.initNetworking(); + if (!DEBUG) return; OwoDebugCommands.register(); @@ -82,5 +96,4 @@ public static void debugWarn(Logger logger, String message, Object... params) { public static MinecraftServer currentServer() { return SERVER; } - } \ No newline at end of file diff --git a/src/main/java/io/wispforest/owo/client/OwoClient.java b/src/main/java/io/wispforest/owo/client/OwoClient.java index a1a36652..86ac991f 100644 --- a/src/main/java/io/wispforest/owo/client/OwoClient.java +++ b/src/main/java/io/wispforest/owo/client/OwoClient.java @@ -6,6 +6,7 @@ import io.wispforest.owo.config.OwoConfigCommand; import io.wispforest.owo.itemgroup.json.OwoItemGroupLoader; import io.wispforest.owo.moddata.ModDataLoader; +import io.wispforest.owo.packets.OwoPackets; import io.wispforest.owo.ui.core.OwoUIPipelines; import io.wispforest.owo.ui.parsing.UIModelLoader; import io.wispforest.owo.ui.util.NinePatchTexture; @@ -66,7 +67,9 @@ public void onInitializeClient() { ScreenInternals.Client.init(); - ClientCommandRegistrationCallback.EVENT.register(OwoConfigCommand::register); + ClientCommandRegistrationCallback.EVENT.register(OwoConfigCommand::registerClient); + + OwoPackets.initClientNetworking(); if (Owo.DEBUG) { OwoDebugCommands.Client.register(); diff --git a/src/main/java/io/wispforest/owo/command/RecordArgumentTypeInfo.java b/src/main/java/io/wispforest/owo/command/RecordArgumentTypeInfo.java new file mode 100644 index 00000000..333c41a6 --- /dev/null +++ b/src/main/java/io/wispforest/owo/command/RecordArgumentTypeInfo.java @@ -0,0 +1,54 @@ +package io.wispforest.owo.command; + +import com.google.gson.JsonObject; +import com.mojang.brigadier.arguments.ArgumentType; +import io.wispforest.endec.Endec; +import io.wispforest.endec.StructEndec; +import io.wispforest.endec.format.bytebuf.ByteBufDeserializer; +import io.wispforest.endec.format.bytebuf.ByteBufSerializer; +import io.wispforest.endec.format.gson.GsonSerializer; +import net.minecraft.command.CommandRegistryAccess; +import net.minecraft.command.argument.serialize.ArgumentSerializer; +import net.minecraft.network.PacketByteBuf; + +import java.util.function.BiFunction; +import java.util.function.Function; + +public record RecordArgumentTypeInfo, T>(StructEndec endec, Function toTemplate, BiFunction fromTemplate) implements ArgumentSerializer> { + + public static > RecordArgumentTypeInfo of(Function argTypeConstructor) { + return new RecordArgumentTypeInfo<>(Endec.unit(() -> null), a -> null, (commandBuildContext, unused) -> argTypeConstructor.apply(commandBuildContext)); + } + + @Override + public void writePacket(RecordInfoTemplate template, PacketByteBuf buffer) { + endec.encodeFully(() -> ByteBufSerializer.of(buffer), template.data()); + } + + @Override + public RecordInfoTemplate fromPacket(PacketByteBuf buffer) { + return new RecordInfoTemplate<>(this, endec.decodeFully(ByteBufDeserializer::of, buffer), fromTemplate); + } + + @Override + public void writeJson(RecordInfoTemplate template, JsonObject json) { + json.asMap().putAll(((JsonObject) endec.encodeFully(GsonSerializer::of, template.data())).asMap()); + } + + @Override + public RecordInfoTemplate getArgumentTypeProperties(A argument) { + return new RecordInfoTemplate<>(this, toTemplate.apply(argument), fromTemplate); + } + + public record RecordInfoTemplate, T>(ArgumentSerializer type, T data, BiFunction fromTemplate) implements ArgumentTypeProperties { + @Override + public A createType(CommandRegistryAccess ctx) { + return fromTemplate.apply(ctx, data()); + } + + @Override + public ArgumentSerializer getSerializer() { + return type; + } + } +} diff --git a/src/main/java/io/wispforest/owo/compat/modmenu/OwoModMenuPlugin.java b/src/main/java/io/wispforest/owo/compat/modmenu/OwoModMenuPlugin.java index bb7308ff..fba2b928 100644 --- a/src/main/java/io/wispforest/owo/compat/modmenu/OwoModMenuPlugin.java +++ b/src/main/java/io/wispforest/owo/compat/modmenu/OwoModMenuPlugin.java @@ -4,6 +4,7 @@ import com.terraformersmc.modmenu.api.ConfigScreenFactory; import com.terraformersmc.modmenu.api.ModMenuApi; import io.wispforest.owo.config.ui.ConfigScreenProviders; +import net.minecraft.util.Identifier; import net.minecraft.util.Util; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -19,7 +20,13 @@ public class OwoModMenuPlugin implements ModMenuApi { protected @NotNull Map> delegate() { return Util.make( new HashMap<>(), - map -> ConfigScreenProviders.forEach((s, provider) -> map.put(s, provider::apply)) + factoryMap -> { + ConfigScreenProviders.getSortedProviders().forEach((modId, modSpecificProviders) -> { + var configId = Identifier.of(modId, modSpecificProviders.getFirst()); + + factoryMap.put(modId, parent -> ConfigScreenProviders.safelyCreateConfigScreen(configId, parent, Map.of())); + }); + } ); } }; diff --git a/src/main/java/io/wispforest/owo/config/ConfigAP.java b/src/main/java/io/wispforest/owo/config/ConfigAP.java index 479e2051..ce1b155b 100644 --- a/src/main/java/io/wispforest/owo/config/ConfigAP.java +++ b/src/main/java/io/wispforest/owo/config/ConfigAP.java @@ -3,6 +3,7 @@ import io.wispforest.owo.config.annotation.Config; import io.wispforest.owo.config.annotation.Hook; import io.wispforest.owo.config.annotation.Nest; +import io.wispforest.owo.config.base.Key; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -27,10 +28,13 @@ public class ConfigAP extends AbstractProcessor { package {package}; import blue.endless.jankson.Jankson; + import io.wispforest.endec.impl.ReflectiveEndecBuilder; import io.wispforest.owo.config.ConfigWrapper; import io.wispforest.owo.config.ConfigWrapper.BuilderConsumer; - import io.wispforest.owo.config.Option; + import io.wispforest.owo.config.options.FieldOption; + import io.wispforest.owo.config.base.Key; import io.wispforest.owo.util.Observable; + import it.unimi.dsi.fastutil.Pair; import java.util.HashMap; import java.util.Map; @@ -50,15 +54,19 @@ public class {wrapper_class_name} extends ConfigWrapper<{config_class_name}> { super({config_class_name}.class, consumer); } + private {wrapper_class_name}(Class<{config_class_name}> clazz, Pair dataHandlers, boolean setupConfigSyncing) { + super(clazz, dataHandlers, setupConfigSyncing); + } + public static {wrapper_class_name} createAndLoad() { var wrapper = new {wrapper_class_name}(); - wrapper.load(); + wrapper.loadFile(); return wrapper; } public static {wrapper_class_name} createAndLoad(BuilderConsumer consumer) { var wrapper = new {wrapper_class_name}(consumer); - wrapper.load(); + wrapper.loadFile(); return wrapper; } @@ -123,10 +131,12 @@ public boolean process(Set annotations, RoundEnvironment var className = clazz.getQualifiedName().toString(); var wrapperName = annotated.getAnnotation(Config.class).wrapperName(); + this.nestTypes.clear(); + try { var file = this.processingEnv.getFiler().createSourceFile(wrapperName); try (var writer = new PrintWriter(file.openWriter())) { - writer.println(makeWrapper(wrapperName, className, this.collectFields(Option.Key.ROOT, clazz, clazz.getAnnotation(Config.class).defaultHook()))); + writer.println(makeWrapper(wrapperName, className, this.collectFields(Key.ROOT, clazz, clazz.getAnnotation(Config.class).defaultHook()))); } } catch (IOException e) { throw new RuntimeException("Failed to generate config wrapper", e); @@ -137,7 +147,7 @@ public boolean process(Set annotations, RoundEnvironment return true; } - private List collectFields(Option.Key parent, TypeElement clazz, boolean defaultHook) { + private List collectFields(Key parent, TypeElement clazz, boolean defaultHook) { var messager = this.processingEnv.getMessager(); var list = new ArrayList(); @@ -209,28 +219,28 @@ private String makeWrapper(String wrapperClassName, String configClassName, List .replace("{accessors}\n", accessorMethods.finish()); } - private String makeGetAccessor(String fieldName, Option.Key fieldKey, TypeMirror fieldType) { + private String makeGetAccessor(String fieldName, Key fieldKey, TypeMirror fieldType) { return GET_ACCESSOR_TEMPLATE .replace("{option_instance}", constantNameOf(fieldKey)) .replace("{field_name}", fieldName) .replace("{field_type}", fieldType.toString()); } - private String makeSetAccessor(String fieldName, Option.Key fieldKey, TypeMirror fieldType) { + private String makeSetAccessor(String fieldName, Key fieldKey, TypeMirror fieldType) { return SET_ACCESSOR_TEMPLATE .replace("{option_instance}", constantNameOf(fieldKey)) .replace("{field_name}", fieldName) .replace("{field_type}", fieldType.toString()); } - private String makeSubscribe(String fieldName, Option.Key fieldKey, TypeMirror fieldType) { + private String makeSubscribe(String fieldName, Key fieldKey, TypeMirror fieldType) { return SUBSCRIBE_TEMPLATE .replace("{option_instance}", constantNameOf(fieldKey)) .replace("{field_name}", fieldName) .replace("{field_type}", this.primitivesToWrappers.getOrDefault(fieldType, fieldType).toString()); } - private String constantNameOf(Option.Key key) { + private String constantNameOf(Key key) { return key.asString().replace(".", "_"); } @@ -240,11 +250,11 @@ private interface ConfigField { private final class ValueField implements ConfigField { private final String name; - private final Option.Key key; + private final Key key; private final TypeMirror type; private final boolean makeSubscribe; - private ValueField(String name, Option.Key key, TypeMirror type, boolean makeSubscribe) { + private ValueField(String name, Key key, TypeMirror type, boolean makeSubscribe) { this.name = name; this.key = key; this.type = type; @@ -253,8 +263,8 @@ private ValueField(String name, Option.Key key, TypeMirror type, boolean makeSub @Override public void appendAccessors(Writer accessors, Writer optionInstances, Writer keyConstants) { - keyConstants.line("public final Option.Key " + constantNameOf(this.key) + " = new Option.Key(\"" + this.key.asString() + "\");"); - optionInstances.line("private final Option<" + primitivesToWrappers.getOrDefault(type, type) + "> " + constantNameOf(this.key) + " = this.optionForKey(this.keys." + constantNameOf(this.key) + ");"); + keyConstants.line("public final Key " + constantNameOf(this.key) + " = new Key(\"" + this.key.asString() + "\");"); + optionInstances.line("private final FieldOption<" + primitivesToWrappers.getOrDefault(type, type) + "> " + constantNameOf(this.key) + " = this.optionForKey(this.keys." + constantNameOf(this.key) + ");"); accessors.append(makeGetAccessor(this.name, this.key, this.type)).write("\n"); accessors.append(makeSetAccessor(this.name, this.key, this.type)).write("\n"); diff --git a/src/main/java/io/wispforest/owo/config/ConfigReflectionUtils.java b/src/main/java/io/wispforest/owo/config/ConfigReflectionUtils.java new file mode 100644 index 00000000..81cacebd --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ConfigReflectionUtils.java @@ -0,0 +1,166 @@ +package io.wispforest.owo.config; + +import io.wispforest.owo.config.annotation.PredicateConstraint; +import io.wispforest.owo.config.annotation.RangeConstraint; +import io.wispforest.owo.config.annotation.RegexConstraint; +import io.wispforest.owo.config.base.BoundedAccess; +import io.wispforest.owo.config.options.OptionControlSpec; +import io.wispforest.owo.config.options.ReflectiveOption; +import io.wispforest.owo.util.NumberReflection; +import io.wispforest.owo.util.ReflectionUtils; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +public class ConfigReflectionUtils { + + public static RangeConstraintData getConstraintData(Class clazz, OptionControlSpec option) { + @Nullable BoundedAccess possibleAccess = option instanceof ReflectiveOption reflectiveOption + ? reflectiveOption.backingAccess() + : null; + + return getConstraintData(clazz, possibleAccess); + } + + public static RangeConstraintData getConstraintData(Class clazz, @Nullable BoundedAccess access) { + var floatingPointType = NumberReflection.isFloatingPointType(clazz); + + var useSlider = false; + + var decimalPlaces = floatingPointType ? 2 : 0; + + double min = NumberReflection.minValue(clazz).doubleValue(), max = NumberReflection.maxValue(clazz).doubleValue(); + + if (access != null && access.hasAnnotation(RangeConstraint.class)) { + var constraintData = access.getAnnotation(RangeConstraint.class); + + useSlider = constraintData.useSlider(); + + if (floatingPointType) decimalPlaces = constraintData.decimalPlaces(); + + min = constraintData.min(); + max = constraintData.max(); + } + + return new RangeConstraintData(min, max, decimalPlaces, useSlider); + } + + @Nullable + public static ConfigWrapper.Constraint getConstraint(BoundedAccess boundField) throws IllegalAccessException, NoSuchMethodException { + var fieldType = boundField.type(); + + ConfigWrapper.Constraint constraint = null; + + if (boundField.hasAnnotation(RangeConstraint.class)) { + var annotation = boundField.getAnnotation(RangeConstraint.class); + + if (NumberReflection.isNumberType(fieldType)) { + Predicate predicate; + if (fieldType == long.class || fieldType == Long.class) { + predicate = o -> o != null && (Long) o >= annotation.min() && (Long) o <= annotation.max(); + } else { + predicate = o -> o != null && ((Number) o).doubleValue() >= annotation.min() && ((Number) o).doubleValue() <= annotation.max(); + } + + constraint = new ConfigWrapper.Constraint("Range from " + annotation.min() + " to " + annotation.max(), predicate); + } else { + throw new IllegalStateException("@RangeConstraint can only be applied to numeric fields"); + } + } + + if (boundField.hasAnnotation(RegexConstraint.class)) { + var annotation = boundField.getAnnotation(RegexConstraint.class); + + if (CharSequence.class.isAssignableFrom(fieldType)) { + var pattern = Pattern.compile(annotation.value()); + constraint = new ConfigWrapper.Constraint("Regex " + annotation.value(), o -> o != null && pattern.matcher((CharSequence) o).matches()); + } else { + throw new IllegalStateException("@RegexConstraint can only be applied to fields with a string representation"); + } + } + + if (boundField.hasAnnotation(PredicateConstraint.class)) { + var annotation = boundField.getAnnotation(PredicateConstraint.class); + var method = boundField.owner().getClass().getMethod(annotation.value(), fieldType); + + if (method.getReturnType() != boolean.class) { + throw new NoSuchMethodException("Return type of predicate implementation '" + annotation.value() + "' must be 'boolean'"); + } + + if (!Modifier.isStatic(method.getModifiers())) { + throw new IllegalStateException("Predicate implementation '" + annotation.value() + "' must be static"); + } + + var handle = MethodHandles.publicLookup().unreflect(method); + constraint = new ConfigWrapper.Constraint("Predicate method " + annotation.value(), o -> invokePredicate(handle, o)); + } + + return constraint; + } + + private static boolean invokePredicate(MethodHandle predicate, Object value) { + try { + return (boolean) predicate.invoke(value); + } catch (Throwable e) { + throw new RuntimeException("Could not invoke predicate", e); + } + } + + @Nullable + public static ConfigReflectionUtils.CollectionType getMapType(Type genericType) { + var keyType = ReflectionUtils.getTypeArgument(genericType, 0); + var valueTypeData = ReflectionUtils.getTypeAndClassArgument(genericType, 1); + + if (keyType == null || valueTypeData == null) return null; + + var valueTypeClass = valueTypeData.second(); + + if (keyType != Identifier.class && keyType != String.class && !NumberReflection.isNumberType(keyType)) { + return null; + } + + JavaType valueJavaType; + + if (valueTypeClass == Identifier.class || valueTypeClass == String.class || NumberReflection.isNumberType(valueTypeClass)) { + valueJavaType = JavaType.GENERIC; + } else if (ReflectionUtils.getTypeArgument(valueTypeClass, 0) == null) { + valueJavaType = JavaType.STRUCT; + } else { + return null; + } + + return valueJavaType == JavaType.GENERIC ? CollectionType.SIMPLE : CollectionType.COMPLEX; + } + + public enum CollectionType { + SIMPLE, // Primitive-ish to Primitive-ish + COMPLEX // Primitive-ish to Struct + } + + @Nullable + public static CollectionType getCollectionType(Type type) { + var collectionType = ReflectionUtils.getTypeArgument(type, 0); + if (collectionType == null) return null; + + if (collectionType == Identifier.class || collectionType == String.class || NumberReflection.isNumberType(collectionType)) { + return CollectionType.SIMPLE; + } else if (ReflectionUtils.getTypeArgument(collectionType, 0) == null) { + return CollectionType.COMPLEX; + } + + return null; + } + + public enum JavaType { + GENERIC, + STRUCT + } + + public record RangeConstraintData(double min, double max, int decimalPlaces, boolean useSlider) {} +} diff --git a/src/main/java/io/wispforest/owo/config/ConfigSynchronizer.java b/src/main/java/io/wispforest/owo/config/ConfigSynchronizer.java index dd017d4d..c98fcfda 100644 --- a/src/main/java/io/wispforest/owo/config/ConfigSynchronizer.java +++ b/src/main/java/io/wispforest/owo/config/ConfigSynchronizer.java @@ -3,6 +3,9 @@ import com.google.common.collect.HashMultimap; import io.wispforest.endec.impl.StructEndecBuilder; import io.wispforest.owo.Owo; +import io.wispforest.owo.config.base.Key; +import io.wispforest.owo.config.base.SyncMode; +import io.wispforest.owo.config.options.FieldOption; import io.wispforest.owo.mixin.ServerCommonNetworkHandlerAccessor; import io.wispforest.owo.ops.TextOps; import io.wispforest.endec.Endec; @@ -13,14 +16,14 @@ import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.event.Event; -import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; -import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; -import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; -import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.fabric.api.networking.v1.*; import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; import net.minecraft.network.ClientConnection; import net.minecraft.network.PacketByteBuf; import net.minecraft.network.packet.CustomPayload; +import net.minecraft.network.packet.Packet; +import net.minecraft.server.network.ServerPlayNetworkHandler; import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.text.MutableText; import net.minecraft.text.Text; @@ -30,21 +33,21 @@ import org.jetbrains.annotations.Nullable; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.WeakHashMap; import java.util.function.BiConsumer; +import java.util.function.Consumer; public class ConfigSynchronizer { - public static final Identifier CONFIG_SYNC_CHANNEL = Identifier.of("owo", "config_sync"); + private static final Map>> CLIENT_OPTION_STORAGE = new WeakHashMap<>(); - private static final Map>> CLIENT_OPTION_STORAGE = new WeakHashMap<>(); - - private static final Map> KNOWN_CONFIGS = new HashMap<>(); + private static final Map> KNOWN_CONFIGS = new HashMap<>(); private static final MutableText PREFIX = TextOps.concat(Owo.PREFIX, Text.of("§cunrecoverable config mismatch\n\n")); static void register(ConfigWrapper config) { - KNOWN_CONFIGS.put(config.name(), config); + KNOWN_CONFIGS.put(config.id(), config); } /** @@ -56,7 +59,7 @@ static void register(ConfigWrapper config) { * @return The player's client's values of the given config options, * or {@code null} if no config with the given name was synced */ - public static @Nullable Map getClientOptions(ServerPlayerEntity player, String configName) { + public static @Nullable Map getClientOptions(ServerPlayerEntity player, String configName) { var storage = CLIENT_OPTION_STORAGE.get(((ServerCommonNetworkHandlerAccessor) player.networkHandler).owo$getConnection()); if (storage == null) return null; @@ -69,14 +72,35 @@ static void register(ConfigWrapper config) { * * @see #getClientOptions(ServerPlayerEntity, String) */ - public static @Nullable Map getClientOptions(ServerPlayerEntity player, ConfigWrapper config) { + public static @Nullable Map getClientOptions(ServerPlayerEntity player, ConfigWrapper config) { return getClientOptions(player, config.name()); } - private static ConfigSyncPacket toPacket(Option.SyncMode targetMode) { - Map configs = new HashMap<>(); + private static ConfigSyncPacket toPacket(Identifier configId, SyncMode targetMode) { + var config = KNOWN_CONFIGS.get(configId); + + if (config == null) { + throw new IllegalStateException("Unable to sync a reloaded config due to it not being registered for Config Synchronization!"); + } + + var entry = new ConfigEntry(new HashMap<>()); + + config.allOptions().forEach((key, option) -> { + if (option.syncMode().ordinal() < targetMode.ordinal()) return; + + PacketByteBuf optionBuf = PacketByteBufs.create(); + option.write(optionBuf); + + entry.options().put(key.asString(), optionBuf); + }); + + return new ConfigSyncPacket(Map.of(configId, entry)); + } + + private static ConfigSyncPacket toPacket(SyncMode targetMode) { + Map configs = new HashMap<>(); - KNOWN_CONFIGS.forEach((configName, config) -> { + KNOWN_CONFIGS.forEach((configId, config) -> { var entry = new ConfigEntry(new HashMap<>()); config.allOptions().forEach((key, option) -> { @@ -88,27 +112,29 @@ private static ConfigSyncPacket toPacket(Option.SyncMode targetMode) { entry.options().put(key.asString(), optionBuf); }); - configs.put(configName, entry); + configs.put(configId, entry); }); return new ConfigSyncPacket(configs); } - private static void read(ConfigSyncPacket packet, BiConsumer, PacketByteBuf> optionConsumer) { - for (var configEntry : packet.configs().entrySet()) { - var configName = configEntry.getKey(); - var config = KNOWN_CONFIGS.get(configName); + private static void read(Map configs, BiConsumer, PacketByteBuf> optionConsumer) { + for (var entry : configs.entrySet()) { + var configId = entry.getKey(); + var configEntry = entry.getValue(); + + var config = KNOWN_CONFIGS.get(configId); if (config == null) { - Owo.LOGGER.error("Received overrides for unknown config '{}', skipping", configName); - continue; + Owo.LOGGER.error("Received overrides for unknown config '{}', skipping", configId); + return; } - for (var optionEntry : configEntry.getValue().options().entrySet()) { - var optionKey = new Option.Key(optionEntry.getKey()); + for (var optionEntry : configEntry.options().entrySet()) { + var optionKey = new Key(optionEntry.getKey()); var option = config.optionForKey(optionKey); if (option == null) { - Owo.LOGGER.error("Received override for unknown option '{}' in config '{}', skipping", optionKey, configName); - continue; + Owo.LOGGER.error("Received override for unknown option '{}' in config '{}', skipping", optionKey, configId); + return; } optionConsumer.accept(option, optionEntry.getValue()); @@ -117,12 +143,12 @@ private static void read(ConfigSyncPacket packet, BiConsumer, PacketBy } @Environment(EnvType.CLIENT) - private static void applyClient(ConfigSyncPacket payload, ClientPlayNetworking.Context context) { + private static void applyClient(Map configs, ClientPlayNetworking.Context context) { Owo.LOGGER.info("Applying server overrides"); - var mismatchedOptions = new HashMap, Object>(); + var mismatchedOptions = new HashMap, Object>(); if (!(context.client().isIntegratedServerRunning() && context.client().getServer().isSingleplayer())) { - read(payload, (option, packetByteBuf) -> { + read(configs, (option, packetByteBuf) -> { var mismatchedValue = option.read(packetByteBuf); if (mismatchedValue != null) mismatchedOptions.put(option, mismatchedValue); }); @@ -135,7 +161,7 @@ private static void applyClient(ConfigSyncPacket payload, ClientPlayNetworking.C }); var errorMessage = Text.empty(); - var optionsByConfig = HashMultimap., Object>>create(); + var optionsByConfig = HashMultimap., Object>>create(); mismatchedOptions.forEach((option, serverValue) -> optionsByConfig.put(option.configName(), new Pair<>(option, serverValue))); for (var configName : optionsByConfig.keys()) { @@ -159,23 +185,28 @@ private static void applyClient(ConfigSyncPacket payload, ClientPlayNetworking.C } Owo.LOGGER.info("Responding with client values"); - context.responseSender().sendPacket(toPacket(Option.SyncMode.INFORM_SERVER)); + + var packet = configs.size() == 1 + ? toPacket(List.copyOf(configs.keySet()).getFirst(), SyncMode.INFORM_SERVER) + : toPacket(SyncMode.INFORM_SERVER); + + context.responseSender().sendPacket(packet); } - private static void applyServer(ConfigSyncPacket payload, ServerPlayNetworking.Context context) { + private static void applyServer(Map configs, ServerPlayNetworking.Context context) { Owo.LOGGER.info("Receiving client config"); var connection = ((ServerCommonNetworkHandlerAccessor) context.player().networkHandler).owo$getConnection(); - read(payload, (option, optionBuf) -> { + read(configs, (option, optionBuf) -> { var config = CLIENT_OPTION_STORAGE.computeIfAbsent(connection, $ -> new HashMap<>()).computeIfAbsent(option.configName(), s -> new HashMap<>()); config.put(option.key(), optionBuf.read(option.endec())); }); } - private record ConfigSyncPacket(Map configs) implements CustomPayload { - public static final Id ID = new Id<>(CONFIG_SYNC_CHANNEL); + private record ConfigSyncPacket(Map configs) implements CustomPayload { + public static final Id ID = new Id<>(Identifier.of("owo", "config_sync")); public static final Endec ENDEC = StructEndecBuilder.of( - ConfigEntry.ENDEC.mapOf().fieldOf("configs", ConfigSyncPacket::configs), + Endec.map(Identifier::toString, Identifier::of, ConfigEntry.ENDEC).fieldOf("configs", ConfigSyncPacket::configs), ConfigSyncPacket::new ); @@ -185,6 +216,20 @@ public Id getId() { } } + private record ConfigEntrySyncPacket(Identifier configId, ConfigEntry entry) implements CustomPayload { + public static final Id ID = new Id<>(Identifier.of("owo", "config_entry_sync")); + public static final Endec ENDEC = StructEndecBuilder.of( + MinecraftEndecs.IDENTIFIER.fieldOf("config_id", ConfigEntrySyncPacket::configId), + ConfigEntry.ENDEC.fieldOf("entry", ConfigEntrySyncPacket::entry), + ConfigEntrySyncPacket::new + ); + + @Override + public Id getId() { + return ID; + } + } + private record ConfigEntry(Map options) { public static final Endec ENDEC = StructEndecBuilder.of( MinecraftEndecs.PACKET_BYTE_BUF.mapOf().fieldOf("options", ConfigEntry::options), @@ -192,28 +237,64 @@ private record ConfigEntry(Map options) { ); } + public static void sendLoadedServerConfig(Consumer> packetSender, Identifier configId) { + if (!KNOWN_CONFIGS.containsKey(configId)) return; + + Owo.LOGGER.info("Resending server config values to client"); + + packetSender.accept(ServerPlayNetworking.createS2CPacket(toPacket(configId, SyncMode.OVERRIDE_CLIENT))); + } + + @Environment(EnvType.CLIENT) + public static void sendChangedConfigValues(Consumer> packetSender, Identifier configId) { + if (!KNOWN_CONFIGS.containsKey(configId)) return; + + Owo.LOGGER.info("Sending client config values to server"); + + packetSender.accept(ClientPlayNetworking.createC2SPacket(toPacket(configId, SyncMode.INFORM_SERVER))); + } + static { - var packetCodec = CodecUtils.toPacketCodec(ConfigSyncPacket.ENDEC); + var configSyncCodec = CodecUtils.toPacketCodec(ConfigSyncPacket.ENDEC); + + PayloadTypeRegistry.playS2C().register(ConfigSyncPacket.ID, configSyncCodec); + PayloadTypeRegistry.playC2S().register(ConfigSyncPacket.ID, configSyncCodec); - PayloadTypeRegistry.playS2C().register(ConfigSyncPacket.ID, packetCodec); - PayloadTypeRegistry.playC2S().register(ConfigSyncPacket.ID, packetCodec); + var configEntrySyncCodec = CodecUtils.toPacketCodec(ConfigEntrySyncPacket.ENDEC); + + PayloadTypeRegistry.playS2C().register(ConfigEntrySyncPacket.ID, configEntrySyncCodec); + PayloadTypeRegistry.playC2S().register(ConfigEntrySyncPacket.ID, configEntrySyncCodec); var earlyPhase = Identifier.of("owo", "early"); ServerPlayConnectionEvents.JOIN.addPhaseOrdering(earlyPhase, Event.DEFAULT_PHASE); ServerPlayConnectionEvents.JOIN.register(earlyPhase, (handler, sender, server) -> { Owo.LOGGER.info("Sending server config values to client"); - sender.sendPacket(toPacket(Option.SyncMode.OVERRIDE_CLIENT)); + sender.sendPacket(toPacket(SyncMode.OVERRIDE_CLIENT)); }); if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT) { - ClientPlayNetworking.registerGlobalReceiver(ConfigSyncPacket.ID, ConfigSynchronizer::applyClient); + ClientPlayNetworking.registerGlobalReceiver(ConfigSyncPacket.ID, (payload, context) -> ConfigSynchronizer.applyClient(payload.configs(), context)); ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> { - KNOWN_CONFIGS.forEach((name, config) -> config.forEachOption(Option::reattach)); + KNOWN_CONFIGS.forEach((name, config) -> config.forEachOption(FieldOption::reattach)); + }); + + KNOWN_CONFIGS.values().forEach(wrapper -> { + wrapper.forEachOption(option -> { + if (option.syncMode() == SyncMode.INFORM_SERVER) { + option.observe(object -> { + var player = MinecraftClient.getInstance().player; + + if (player == null) return; + + sendChangedConfigValues(player.networkHandler::sendPacket, wrapper.id()); + }); + } + }); }); } - ServerPlayNetworking.registerGlobalReceiver(ConfigSyncPacket.ID, ConfigSynchronizer::applyServer); + ServerPlayNetworking.registerGlobalReceiver(ConfigSyncPacket.ID, (payload, context) -> ConfigSynchronizer.applyServer(payload.configs(), context)); } } diff --git a/src/main/java/io/wispforest/owo/config/ConfigWrapper.java b/src/main/java/io/wispforest/owo/config/ConfigWrapper.java index e827d31a..19267e52 100644 --- a/src/main/java/io/wispforest/owo/config/ConfigWrapper.java +++ b/src/main/java/io/wispforest/owo/config/ConfigWrapper.java @@ -1,9 +1,6 @@ package io.wispforest.owo.config; -import blue.endless.jankson.Jankson; -import blue.endless.jankson.JsonElement; -import blue.endless.jankson.JsonGrammar; -import blue.endless.jankson.JsonPrimitive; +import blue.endless.jankson.*; import blue.endless.jankson.api.DeserializationException; import blue.endless.jankson.api.SyntaxError; import blue.endless.jankson.impl.POJODeserializer; @@ -14,22 +11,28 @@ import io.wispforest.endec.impl.ReflectiveEndecBuilder; import io.wispforest.owo.Owo; import io.wispforest.owo.config.annotation.*; +import io.wispforest.owo.config.base.BoundedAccess; +import io.wispforest.owo.config.base.Key; +import io.wispforest.owo.config.base.SyncMode; +import io.wispforest.owo.config.options.FieldOption; import io.wispforest.owo.config.ui.ConfigScreen; import io.wispforest.owo.config.ui.ConfigScreenProviders; import io.wispforest.owo.serialization.endec.MinecraftEndecs; import io.wispforest.owo.ui.core.Color; -import io.wispforest.owo.util.NumberReflection; import io.wispforest.owo.util.Observable; import io.wispforest.owo.util.ReflectionUtils; +import it.unimi.dsi.fastutil.Pair; import net.fabricmc.api.EnvType; import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.gui.screen.Screen; import net.minecraft.util.Identifier; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Modifier; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -37,8 +40,6 @@ import java.util.*; import java.util.function.Consumer; import java.util.function.Predicate; -import java.util.function.Supplier; -import java.util.regex.Pattern; /** * The common base class of all generated config classes. @@ -52,88 +53,138 @@ */ public abstract class ConfigWrapper { - private static final Map> KNOWN_CONFIG_CLASSES = new HashMap<>(); + private static final Map> KNOWN_CONFIG_INSTANCES = new LinkedHashMap<>(); - protected final String name; + protected final Identifier id; protected final C instance; protected boolean loading = false; protected final Jankson jankson; - @SuppressWarnings("rawtypes") protected final Map options = new LinkedHashMap<>(); - @SuppressWarnings("rawtypes") protected final Map optionsView = Collections.unmodifiableMap(options); + @SuppressWarnings("rawtypes") protected final Map options = new LinkedHashMap<>(); + @SuppressWarnings("rawtypes") protected final Map optionsView = Collections.unmodifiableMap(options); protected final ReflectiveEndecBuilder builder; - @Deprecated - protected ConfigWrapper(Class clazz, Consumer janksonBuilder) { - this(clazz, (SerializationBuilder serializationBuilder) -> janksonBuilder.accept(serializationBuilder.janksonBuilder())); - } - protected ConfigWrapper(Class clazz) { this(clazz, (SerializationBuilder builder) -> {}); } protected ConfigWrapper(Class clazz, BuilderConsumer consumer) { - this.builder = MinecraftEndecs.addDefaults(new ReflectiveEndecBuilder()); + this(clazz, BuilderConsumer.fullyBuild(consumer), true); + + if (KNOWN_CONFIG_INSTANCES.containsKey(this.id)) { + throw new IllegalStateException("Config name '" + this.id + "'" + + " is already taken an by instance of class '" + KNOWN_CONFIG_INSTANCES.get(this.id).getClass().getName() + "'"); + } else { + KNOWN_CONFIG_INSTANCES.put(this.id, this); + } + + if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT && clazz.isAnnotationPresent(Modmenu.class)) { + var modmenuAnnotation = clazz.getAnnotation(Modmenu.class); + ConfigScreenProviders.>register( + this.id, + modmenuAnnotation.priorityOrder(), + (Class>) this.getClass(), + (screen, wrapper) -> ConfigScreen.createWithCustomModel(Identifier.of(modmenuAnnotation.uiModelId()), wrapper, screen) + ); + } + } + + protected ConfigWrapper(Class clazz, Pair dataHandlers, boolean setupConfigSyncing) { + this.jankson = dataHandlers.left(); + this.builder = dataHandlers.right(); ReflectionUtils.requireZeroArgsConstructor(clazz, s -> "Config model class " + s + " must provide a zero-args constructor"); this.instance = ReflectionUtils.tryInstantiateWithNoArgs(clazz); - var janksonBuilder = Jankson.builder(); - - var builder = new SerializationBuilder(janksonBuilder, this.builder); + var configAnnotation = clazz.getAnnotation(Config.class); + this.id = Identifier.of(configAnnotation.modId(), configAnnotation.name()); - builder.janksonBuilder() - .registerSerializer(Identifier.class, (identifier, marshaller) -> new JsonPrimitive(identifier.toString())) - .registerDeserializer(JsonPrimitive.class, Identifier.class, (primitive, m) -> Identifier.tryParse(primitive.asString())); + try { + this.initializeOptions(configAnnotation.saveOnModification()); - builder.addEndec(Color.class, Color.RGBA_HEX_ENDEC); + if (setupConfigSyncing) { + for (var option : this.options.values()) { + if (option.syncMode().isNone()) continue; - consumer.build(builder); + ConfigSynchronizer.register(this); + break; + } + } + } catch (IllegalAccessException | NoSuchMethodException e) { + throw new RuntimeException("Failed to initialize config " + this.id, e); + } + } - this.jankson = janksonBuilder.build(); + public static Map> getKnownConfigInstances() { + return Collections.unmodifiableMap(KNOWN_CONFIG_INSTANCES); + } - var configAnnotation = clazz.getAnnotation(Config.class); - this.name = configAnnotation.name(); + public static ConfigWrapper getConfig(Identifier id) { + var wrapper = KNOWN_CONFIG_INSTANCES.get(id); - if (KNOWN_CONFIG_CLASSES.put(this.name, this.getClass()) != null) { - throw new IllegalStateException("Config name '" + this.name + "'" - + " is already taken an by instance of class '" + KNOWN_CONFIG_CLASSES.get(this.name).getName() + "'"); + if (wrapper == null) { + throw new IllegalStateException("Unable to locate the given wrapper instance with the following id: " + id); } - if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT && clazz.isAnnotationPresent(Modmenu.class)) { - var modmenuAnnotation = clazz.getAnnotation(Modmenu.class); - ConfigScreenProviders.register( - modmenuAnnotation.modId(), - screen -> ConfigScreen.createWithCustomModel(Identifier.of(modmenuAnnotation.uiModelId()), this, screen) - ); - } + return wrapper; + } - try { - this.initializeOptions(configAnnotation.saveOnModification()); - for (var option : this.options.values()) { - if (option.syncMode().isNone()) continue; + public static Map>> getGroupedConfigInstances() { + Map>> baseMap = new HashMap<>(); - ConfigSynchronizer.register(this); - break; - } - } catch (IllegalAccessException | NoSuchMethodException e) { - throw new RuntimeException("Failed to initialize config " + this.name, e); + for (var entry : KNOWN_CONFIG_INSTANCES.entrySet()) { + var configId = entry.getKey(); + var wrapper = entry.getValue(); + + baseMap.computeIfAbsent(configId.getNamespace(), string -> new LinkedHashMap<>()) + .put(configId.getPath(), wrapper); } + + return baseMap; } /** * Save the config represented by this wrapper */ - public void save() { + public void saveToFile() { if (this.loading) return; try { this.fileLocation().getParent().toFile().mkdirs(); - Files.writeString(this.fileLocation(), this.jankson.toJson(this.instance).toJson(JsonGrammar.JANKSON), StandardCharsets.UTF_8); + Files.writeString(this.fileLocation(), saveToObject().toJson(JsonGrammar.JANKSON), StandardCharsets.UTF_8); } catch (IOException e) { - Owo.LOGGER.warn("Could not save config {}", this.name, e); + Owo.LOGGER.warn("Could not save config {}", this.id, e); + } + } + + public JsonObject saveToObject() { + return (JsonObject) this.jankson.toJson(this.instance); + } + + public void reload() { + if (memoryData == null) { + loadFile(); + } else { + load(memoryData, false); + } + } + + public void loadFile() { + if (!Files.exists(this.fileLocation())) { + this.saveToFile(); + return; + } + + try { + var configObject = this.jankson.load(Files.readString(this.fileLocation(), StandardCharsets.UTF_8)); + + load(configObject, true); + } catch (IOException | SyntaxError e) { + Owo.LOGGER.warn("Could not load config {}", this.id, e); + } finally { + this.loading = false; } } @@ -142,15 +193,9 @@ public void save() { * its associated file, or create it if it does not exist */ @SuppressWarnings({"unchecked"}) - public void load() { - if (!Files.exists(this.fileLocation())) { - this.save(); - return; - } - + public boolean load(JsonObject configObject, boolean allowServerSync) { try { this.loading = true; - var configObject = this.jankson.load(Files.readString(this.fileLocation(), StandardCharsets.UTF_8)); for (var option : this.options.values()) { Object newValue; @@ -163,13 +208,13 @@ public void load() { } if (Map.class.isAssignableFrom(clazz)) { - var field = option.backingField().field(); + var genericType = option.getGenericType(); newValue = TypeMagic.createAndCast(clazz); POJODeserializer.unpackMap( (Map) newValue, - ReflectionUtils.getTypeArgument(field.getGenericType(), 0), - ReflectionUtils.getTypeArgument(field.getGenericType(), 1), + ReflectionUtils.getTypeArgument(genericType, 0), + ReflectionUtils.getTypeArgument(genericType, 1), element, this.jankson.getMarshaller() ); @@ -177,7 +222,7 @@ public void load() { newValue = TypeMagic.createAndCast(clazz); POJODeserializer.unpackCollection( (Collection) newValue, - ReflectionUtils.getTypeArgument(option.backingField().field().getGenericType(), 0), + ReflectionUtils.getTypeArgument(option.getGenericType(), 0), element, this.jankson.getMarshaller() ); @@ -189,24 +234,34 @@ public void load() { option.set(newValue == null ? option.defaultValue() : newValue); } - } catch (IOException | SyntaxError | DeserializationException e) { - Owo.LOGGER.warn("Could not load config {}", this.name, e); - } finally { - this.loading = false; + + var server = Owo.currentServer(); + + if (server != null && allowServerSync) { + for (var player : server.getPlayerManager().getPlayerList()) { + ConfigSynchronizer.sendLoadedServerConfig(player.networkHandler::sendPacket, this.id); + } + } + + return true; + } catch (DeserializationException e) { + Owo.LOGGER.warn("Could not load config {}", this.id, e); + + return false; } } /** * Query the field associated with a given key. This is relevant * in cases where said field is annotated with {@link Nest}, meaning - * that {@link #optionForKey(Option.Key)} would return {@code null} + * that {@link #optionForKey(Key)} would return {@code null} * because the field won't be treated as an option in itself. * * @param key The for which to query the field * @return The field described by {@code key}, or {@code null} * if it does not point to a valid field in the config tree */ - public @Nullable Field fieldForKey(Option.Key key) { + public @Nullable Field fieldForKey(Key key) { try { var path = new ArrayList<>(List.of(key.path())); var clazz = this.instance.getClass(); @@ -221,19 +276,23 @@ public void load() { } } + public Identifier id() { + return this.id; + } + /** * @return The name of this config, used for translation * keys and the filename */ public String name() { - return this.name; + return this.id.getPath(); } /** * @return The location to which this config is saved */ public Path fileLocation() { - return FabricLoader.getInstance().getConfigDir().resolve(this.name + ".json5"); + return FabricLoader.getInstance().getConfigDir().resolve(this.name() + ".json5"); } /** @@ -244,7 +303,7 @@ public Path fileLocation() { * if no such option exists */ @SuppressWarnings("unchecked") - public @Nullable Option optionForKey(Option.Key key) { + public @Nullable FieldOption optionForKey(Key key) { return this.options.get(key); } @@ -252,83 +311,39 @@ public Path fileLocation() { * @return A view of all options contained in this config */ @SuppressWarnings("unchecked") - public Map> allOptions() { - return (Map>) (Object) this.optionsView; + public Map> allOptions() { + return (Map>) (Object) this.optionsView; } /** * Execute the given action once for each option in this config */ - public void forEachOption(Consumer> action) { + public void forEachOption(Consumer> action) { for (var option : this.options.values()) { action.accept(option); } } private void initializeOptions(boolean hookSave) throws IllegalAccessException, NoSuchMethodException { - var fields = new LinkedHashMap>(); - collectFieldValues(Option.Key.ROOT, this.instance, fields); + var fields = new LinkedHashMap>(); + collectFieldValues(Key.ROOT, this.instance, fields); var instanceSyncMode = this.instance.getClass().isAnnotationPresent(Sync.class) ? this.instance.getClass().getAnnotation(Sync.class).value() - : Option.SyncMode.NONE; + : SyncMode.NONE; for (var entry : fields.entrySet()) { var key = entry.getKey(); var boundField = entry.getValue(); var field = boundField.field(); - var fieldType = field.getType(); - - Constraint constraint = null; - if (field.isAnnotationPresent(RangeConstraint.class)) { - var annotation = field.getAnnotation(RangeConstraint.class); - - if (NumberReflection.isNumberType(fieldType)) { - Predicate predicate; - if (fieldType == long.class || fieldType == Long.class) { - predicate = o -> o != null && (Long) o >= annotation.min() && (Long) o <= annotation.max(); - } else { - predicate = o -> o != null && ((Number) o).doubleValue() >= annotation.min() && ((Number) o).doubleValue() <= annotation.max(); - } - - constraint = new Constraint("Range from " + annotation.min() + " to " + annotation.max(), predicate); - } else { - throw new IllegalStateException("@RangeConstraint can only be applied to numeric fields"); - } - } - if (field.isAnnotationPresent(RegexConstraint.class)) { - var annotation = field.getAnnotation(RegexConstraint.class); - - if (CharSequence.class.isAssignableFrom(fieldType)) { - var pattern = Pattern.compile(annotation.value()); - constraint = new Constraint("Regex " + annotation.value(), o -> o != null && pattern.matcher((CharSequence) o).matches()); - } else { - throw new IllegalStateException("@RegexConstraint can only be applied to fields with a string representation"); - } - } - - if (field.isAnnotationPresent(PredicateConstraint.class)) { - var annotation = field.getAnnotation(PredicateConstraint.class); - var method = boundField.owner().getClass().getMethod(annotation.value(), fieldType); - - if (method.getReturnType() != boolean.class) { - throw new NoSuchMethodException("Return type of predicate implementation '" + annotation.value() + "' must be 'boolean'"); - } - - if (!Modifier.isStatic(method.getModifiers())) { - throw new IllegalStateException("Predicate implementation '" + annotation.value() + "' must be static"); - } - - var handle = MethodHandles.publicLookup().unreflect(method); - constraint = new Constraint("Predicate method " + annotation.value(), o -> this.invokePredicate(handle, o)); - } + var constraint = ConfigReflectionUtils.getConstraint(boundField); final var defaultValue = boundField.getValue(); final var observable = Observable.of(defaultValue); - if (hookSave) observable.observe(o -> this.save()); + if (hookSave) observable.observe(o -> this.saveToFile()); var syncMode = instanceSyncMode; if (field.isAnnotationPresent(Sync.class)) { @@ -345,11 +360,11 @@ private void initializeOptions(boolean hookSave) throws IllegalAccessException, } } - this.options.put(key, new Option<>(this.name, key, defaultValue, observable, boundField, constraint, syncMode, this.builder)); + this.options.put(key, new FieldOption<>(this.id(), key, defaultValue, observable, boundField, constraint, syncMode, this.builder)); } } - private void collectFieldValues(Option.Key parent, Object instance, Map> fields) throws IllegalAccessException { + private void collectFieldValues(Key parent, Object instance, Map> fields) throws IllegalAccessException { for (var field : instance.getClass().getDeclaredFields()) { if (Modifier.isTransient(field.getModifiers()) || Modifier.isStatic(field.getModifiers())) continue; @@ -361,7 +376,7 @@ private void collectFieldValues(Option.Key parent, Object instance, Map(instance, field)); + fields.put(parent.child(field.getName()), new BoundedAccess.BoundField<>(instance, field)); } } } @@ -395,5 +410,73 @@ public SerializationBuilder addEndec(Class clazz, Endec endec) { public interface BuilderConsumer { void build(SerializationBuilder builder); + + static Pair fullyBuild(BuilderConsumer consumer) { + var builder = new SerializationBuilder(Jankson.builder(), MinecraftEndecs.addDefaults(new ReflectiveEndecBuilder())); + + builder.janksonBuilder() + .registerSerializer(Identifier.class, (identifier, marshaller) -> new JsonPrimitive(identifier.toString())) + .registerDeserializer(JsonPrimitive.class, Identifier.class, (primitive, m) -> Identifier.tryParse(primitive.asString())); + + builder.addEndec(Color.class, Color.RGBA_HEX_ENDEC); + + consumer.build(builder); + + return Pair.of(builder.janksonBuilder().build(), builder.endecBuilder()); + } + } + + public static ConfigWrapper getOrDuplicateWrapper(Identifier configId, @Nullable JsonObject jsonObject) { + var wrapper = ConfigWrapper.getKnownConfigInstances().get(configId); + + if (wrapper == null) { + throw new IllegalStateException("Unable to locate the given wrapper instance with the following id: " + configId); + } + + if (jsonObject != null) { + wrapper = wrapper.attemptToDuplicate(jsonObject); + } + + return wrapper; + } + + //-- + + private boolean serverConfig = false; + + public boolean isServerConfig() { + return this.serverConfig; + } + + @Nullable + private JsonObject memoryData = null; + + @ApiStatus.Internal + @Nullable + private ConfigWrapper attemptToDuplicate(JsonObject jsonObject) { + var clazz = this.getClass(); + + try { + var constructor = clazz.getDeclaredConstructor(Class.class, Pair.class, boolean.class); + + if (constructor.trySetAccessible()) { + var newWrapper = constructor.newInstance(this.instance.getClass(), Pair.of(this.jankson, this.builder), false); + + if (newWrapper.load(jsonObject, false)) { + newWrapper.serverConfig = true; + newWrapper.memoryData = jsonObject; + + return newWrapper; + } else { + Owo.LOGGER.warn("Could not load the given duplicated config {}", this.id); + } + } else { + Owo.LOGGER.warn("Could not construct the given duplicated config {} due to construct being in accessible", this.id); + } + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) { + Owo.LOGGER.warn("Could not duplicate the given config {}", this.id, e); + } + + return null; } } diff --git a/src/main/java/io/wispforest/owo/config/Option.java b/src/main/java/io/wispforest/owo/config/Option.java deleted file mode 100644 index 0744eb30..00000000 --- a/src/main/java/io/wispforest/owo/config/Option.java +++ /dev/null @@ -1,433 +0,0 @@ -package io.wispforest.owo.config; - -import io.wispforest.endec.impl.ReflectiveEndecBuilder; -import io.wispforest.owo.Owo; -import io.wispforest.owo.config.annotation.RestartRequired; -import io.wispforest.endec.Endec; -import io.wispforest.owo.util.Observable; -import net.minecraft.network.PacketByteBuf; -import org.jetbrains.annotations.Nullable; - -import java.lang.annotation.Annotation; -import java.lang.reflect.Field; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.function.Consumer; - -/** - * Describes a single option in a config. Instances - * of this class keep a reference to the field in - * the model class which stores the value used for serialization. - *

- * An option may enter the so-called "detached" state, which means - * its value is being overridden by the server. In this state, the option - * is completely immutable and can only be changed again afterwards - */ -public final class Option { - - private final String configName; - private final Key key; - private final String translationKey; - - private final T defaultValue; - private final Observable mirror; - - private final BoundField backingField; - private final Class clazz; - - private final ConfigWrapper.@Nullable Constraint constraint; - private final @Nullable Endec endec; - private final SyncMode syncMode; - - /** - * Indicates whether this option is currently being overridden - * by the server and should thus never synchronize with its backing - * field and behave immutably to the client - */ - private boolean detached = false; - - /** - * @param configName The name of the config this option is contained in - * @param key The key of this option - * @param defaultValue The default value of this option - * @param mirror A mirror of the value of this option, used for - * emitting events when it changes as well as correcting - * invalid values after deserialization - * @param backingField The backing field in the config model class - * which this option describes - * @param constraint The constraint placed on the value of this option, - * or {@code null} if the option is unconstrained - */ - @SuppressWarnings("unchecked") - public Option(String configName, - Key key, - T defaultValue, - Observable mirror, - BoundField backingField, - @Nullable ConfigWrapper.Constraint constraint, - SyncMode syncMode, - ReflectiveEndecBuilder builder - ) { - this.configName = configName; - this.key = key; - this.translationKey = "text.config." + this.configName + ".option." + this.key.asString(); - - this.defaultValue = defaultValue; - this.mirror = mirror; - - this.backingField = backingField; - this.clazz = (Class) backingField.field().getType(); - - this.constraint = constraint; - this.syncMode = syncMode; - this.endec = syncMode.isNone() ? null : (Endec) builder.get(this.backingField.field.getGenericType()); - } - - /** - * Update the current value of this option, - * or do nothing if the given value is invalid - * - * @param value The new value of the option - */ - public void set(T value) { - if (this.detached) return; - - if (!this.verifyConstraint(value)) return; - - this.backingField.setValue(value); - this.mirror.set(value); - } - - /** - * @return The current value of this option - */ - public T value() { - return this.mirror.get(); - } - - /** - * @return The class of this option's value - */ - public Class clazz() { - return this.clazz; - } - - /** - * Synchronize the value stored in the backing field - * and this option's mirror - used for either correcting an - * invalid value after updating the field or updating the mirror - */ - public void synchronizeWithBackingField() { - if (this.detached) return; - - final var fieldValue = (T) this.backingField.getValue(); - if (verifyConstraint(fieldValue)) { - this.mirror.set(fieldValue); - } else { - this.backingField.setValue(this.mirror.get()); - } - } - - /** - * Check whether the given value passes the constraint - * of this option and emit a warning if it does not - * - * @param value The value to test - * @return {@code true} if either the given value - * passes the constraint put on this option or this - * option is unconstrained - */ - public boolean verifyConstraint(T value) { - if (this.constraint == null) return true; - - final var matched = this.constraint.test(value); - if (!matched) { - Owo.LOGGER.warn( - "Option {} in config '{}' could not be updated, as the given value '{}' does not match its constraint: {}", - this.key, this.configName, value, this.constraint.formatted() - ); - } - - return matched; - } - - /** - * Add an observer function to be run every time - * the value of this option changes - */ - public void observe(Consumer observer) { - this.mirror.observe(observer); - } - - /** - * Write the current value of this option into the given buffer - * - * @param buf The packet buffer to write to - */ - void write(PacketByteBuf buf) { - buf.write(this.endec, this.value()); - } - - /** - * Read a new value of this option from the given buffer - * and enter a detached state - * - * @param buf The packet buffer to read from - * @return {@code null} if this option was successfully detached, - * the server's value otherwise - */ - T read(PacketByteBuf buf) { - final var newValue = buf.read(this.endec); - - if (!Objects.equals(newValue, this.value()) && this.backingField.hasAnnotation(RestartRequired.class)) { - return newValue; - } - - this.mirror.set(newValue); - this.detached = true; - - return null; - } - - /** - * @return The serializer for this option's value - */ - Endec endec() { - return this.endec; - } - - /** - * Reset this option's attached state and synchronize - * it with the backing field again - */ - void reattach() { - if (!this.detached) return; - - this.detached = false; - this.synchronizeWithBackingField(); - } - - // ------------- - - /** - * @return The translation key of this option - */ - public String translationKey() { - return this.translationKey; - } - - /** - * @return The name of the config this option is contained in - */ - public String configName() { - return configName; - } - - /** - * @return The key of this option - */ - public Key key() { - return key; - } - - /** - * @return The default value of this option - */ - public T defaultValue() { - return defaultValue; - } - - /** - * @return The field which is backing this option, - * used for serialization as well as storing the client's - * value while the option is detached - */ - public BoundField backingField() { - return backingField; - } - - /** - * @return The constraint placed on the value of this option, - * or {@code null} if the option is unconstrained - */ - public ConfigWrapper.@Nullable Constraint constraint() { - return constraint; - } - - /** - * @return {@code true} if this option is currently detached - */ - public boolean detached() { - return this.detached; - } - - /** - * @return The way in which this option - * should be synchronized between sever and client - */ - public SyncMode syncMode() { - return this.syncMode; - } - - @Override - public String toString() { - return "Option[" + - "configName=" + configName + ", " + - "key=" + key + ", " + - "defaultValue=" + defaultValue + ", " + - "constraint=" + (constraint == null ? null : constraint.formatted()) - + "]"; - } - - // ------------- - - public enum SyncMode { - /** - * Do not ever send this option over the network - */ - NONE, - /** - * Only send the client's value to the server, - * but not vice-versa - */ - INFORM_SERVER, - /** - * Send the client's value to the server - * and send the server's value back, - * overriding the client's value - */ - OVERRIDE_CLIENT; - - public boolean isNone() { - return this == NONE; - } - } - - /** - * Describes an option's location inside a - * config, generated from its name a potential - * parents it is nested in - * - * @param path The segments of the path making up this key - */ - public record Key(String[] path) { - - public static final Key ROOT = new Key(new String[0]); - - public Key(List path) { - this(path.toArray(String[]::new)); - } - - public Key(String key) { - this(key.split("\\.")); - } - - /** - * @return The immediate parent of this key, - * or {@link #ROOT} if the parent is the root key - */ - public Key parent() { - if (this.path.length <= 1) return ROOT; - - var newPath = new String[this.path.length - 1]; - System.arraycopy(this.path, 0, newPath, 0, this.path.length - 1); - return new Key(newPath); - } - - /** - * Create the key for a child of this key - * - * @param childName The name of the child - */ - public Key child(String childName) { - var newPath = new String[this.path.length + 1]; - System.arraycopy(this.path, 0, newPath, 0, this.path.length); - newPath[this.path.length] = childName; - return new Key(newPath); - } - - /** - * @return The segments of this key joined with {@code .} - */ - public String asString() { - return String.join(".", this.path); - } - - /** - * @return The name of the element this key describes, - * without any of its parents - */ - public String name() { - if (this.path.length < 1) return ""; - return this.path[this.path.length - 1]; - } - - /** - * @return {@code true} if and only if this - * key is reference-equal to {@link #ROOT} - */ - public boolean isRoot() { - return this == ROOT; - } - - // Records don't play nicely with arrays, thus need to manually - // declare all the record autogenerated stuff here - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Key key = (Key) o; - return Arrays.equals(path, key.path); - } - - @Override - public int hashCode() { - return Arrays.hashCode(path); - } - - @Override - public String toString() { - return "Key{" + "path=" + Arrays.toString(path) + '}'; - } - } - - /** - * A simple container which stores both a non-static field - * and an instance of the containing class on which to query - * values - * - * @param owner The owner object which holds the value - * the field points to - * @param field The field itself - * @param The type of object this field stores - */ - @SuppressWarnings("unchecked") - public record BoundField(Object owner, Field field) { - - public boolean hasAnnotation(Class annotationClass) { - return field.isAnnotationPresent(annotationClass); - } - - public A getAnnotation(Class annotationClass) { - return this.field.getAnnotation(annotationClass); - } - - public T getValue() { - try { - return (T) this.field.get(this.owner); - } catch (IllegalAccessException e) { - throw new RuntimeException("Could not access config option field " + field.getName(), e); - } - } - - public void setValue(T value) { - try { - this.field.set(this.owner, value); - } catch (IllegalAccessException e) { - throw new RuntimeException("Could not set config option field " + field.getName(), e); - } - } - } -} diff --git a/src/main/java/io/wispforest/owo/config/OwoConfigCommand.java b/src/main/java/io/wispforest/owo/config/OwoConfigCommand.java index 4398ad32..8c9ff5e5 100644 --- a/src/main/java/io/wispforest/owo/config/OwoConfigCommand.java +++ b/src/main/java/io/wispforest/owo/config/OwoConfigCommand.java @@ -1,5 +1,6 @@ package io.wispforest.owo.config; +import blue.endless.jankson.JsonObject; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.StringReader; import com.mojang.brigadier.arguments.ArgumentType; @@ -9,53 +10,191 @@ import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import io.wispforest.owo.Owo; -import io.wispforest.owo.config.ui.ConfigScreen; +import io.wispforest.owo.command.RecordArgumentTypeInfo; import io.wispforest.owo.config.ui.ConfigScreenProviders; import io.wispforest.owo.ops.TextOps; +import io.wispforest.owo.packets.OwoPackets; +import io.wispforest.owo.packets.s2c.OpenServerConfig; +import io.wispforest.owo.packets.s2c.OpenServerConfigSelection; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.fabricmc.fabric.api.command.v2.ArgumentTypeRegistry; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.screen.Screen; import net.minecraft.command.CommandRegistryAccess; import net.minecraft.command.CommandSource; +import net.minecraft.server.command.CommandManager; import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.apache.commons.lang3.mutable.MutableInt; import org.jetbrains.annotations.ApiStatus; -import java.util.ArrayList; +import java.util.*; import java.util.concurrent.CompletableFuture; @ApiStatus.Internal public class OwoConfigCommand { - public static void register(CommandDispatcher dispatcher, CommandRegistryAccess access) { + private static final SimpleCommandExceptionType NO_SUCH_CONFIG_SCREEN = new SimpleCommandExceptionType( + TextOps.concat(Owo.PREFIX, Text.literal("no config screen with that id")) + ); + + @Environment(EnvType.CLIENT) + public static void registerClient(CommandDispatcher dispatcher, CommandRegistryAccess access) { dispatcher.register(ClientCommandManager.literal("owo-config") - .then(ClientCommandManager.argument("config_id", new ConfigScreenArgumentType()) - .executes(context -> { - var screen = context.getArgument("config_id", ConfigScreen.class); - MinecraftClient.getInstance().send(() -> MinecraftClient.getInstance().setScreen(screen)); - return 0; - }))); + .then(ClientCommandManager.literal("reload") + .then(ClientCommandManager.argument("config_id", ConfigIdentifierArgumentType.INSTANCE) + .executes(context -> { + var configWrapper = context.getArgument("config_id", ConfigWrapper.class); + configWrapper.loadFile(); + return 0; + })) + ) + .then(ClientCommandManager.literal("open") + .then(ClientCommandManager.argument("config_id", ConfigIdentifierArgumentType.INSTANCE) + .executes(context -> { + if (!ConfigScreenProviders.safelyOpenConfigScreen(context.getArgument("config_id", Identifier.class), MinecraftClient.getInstance().currentScreen, Map.of())) { + throw NO_SUCH_CONFIG_SCREEN.create(); + } + + return 0; + }) + ) + .then(ClientCommandManager.argument("mod_id", ConfigurableModArgumentType.INSTANCE) + .executes(context -> { + var modId = context.getArgument("mod_id", String.class); + + ConfigScreenProviders.safelyOpenConfigScreen(modId, null, Map.of()); + + return 0; + }) + ) + )); } - private static class ConfigScreenArgumentType implements ArgumentType { + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { + dispatcher.register(CommandManager.literal("owo-config-server") + .then(CommandManager.literal("reload") + .then(CommandManager.argument("config_id", ConfigIdentifierArgumentType.INSTANCE) + .executes(context -> { + var configWrapper = context.getArgument("config_id", ConfigWrapper.class); + configWrapper.loadFile(); + return 0; + })) + ) + .then(CommandManager.literal("open") + .then(CommandManager.argument("config_id", ConfigIdentifierArgumentType.INSTANCE) + .executes(context -> { + var wrapper = ConfigWrapper.getKnownConfigInstances().get(context.getArgument("config_id", Identifier.class)); + + if (wrapper == null) throw NO_SUCH_CONFIG_SCREEN.create(); + + OwoPackets.MAIN.serverHandle(context.getSource().getPlayerOrThrow()) + .send(new OpenServerConfig(wrapper.id(), wrapper.saveToObject())); + + return 0; + }) + ) + .then(CommandManager.argument("mod_id", ConfigurableModArgumentType.INSTANCE) + .executes(context -> { + var handler = OwoPackets.MAIN.serverHandle(context.getSource().getPlayerOrThrow()); + var modId = context.getArgument("mod_id", String.class); + + var modSpecificConfigs = ConfigWrapper.getGroupedConfigInstances().get(modId); + + if (modSpecificConfigs.size() == 1) { + var wrapper = List.copyOf(modSpecificConfigs.values()).getFirst(); + + handler.send(new OpenServerConfig(wrapper.id(), wrapper.saveToObject())); + } else { + var availableConfigs = new LinkedHashMap(); + + modSpecificConfigs.forEach((string, wrapper) -> availableConfigs.put(wrapper.id(), wrapper.saveToObject())); + + handler.send(new OpenServerConfigSelection(modId, availableConfigs)); + } + + return 0; + }) + ) + ) + ); + }); + + ArgumentTypeRegistry.registerArgumentType( + Identifier.of("owo", "config_argument"), + ConfigIdentifierArgumentType.class, + RecordArgumentTypeInfo.of(commandRegistryAccess -> new ConfigIdentifierArgumentType()) + ); + + ArgumentTypeRegistry.registerArgumentType( + Identifier.of("owo", "configurable_mod_argument"), + ConfigurableModArgumentType.class, + RecordArgumentTypeInfo.of(commandRegistryAccess -> new ConfigurableModArgumentType()) + ); + } + + private static class ConfigIdentifierArgumentType implements ArgumentType { + + public static final ConfigIdentifierArgumentType INSTANCE = new ConfigIdentifierArgumentType(); private static final SimpleCommandExceptionType NO_SUCH_CONFIG_SCREEN = new SimpleCommandExceptionType( - TextOps.concat(Owo.PREFIX, Text.literal("no config screen with that id")) + TextOps.concat(Owo.PREFIX, Text.literal("no config with that id")) ); @Override - public Screen parse(StringReader reader) throws CommandSyntaxException { - var provider = ConfigScreenProviders.get(reader.readString()); - if (provider == null) throw NO_SUCH_CONFIG_SCREEN.create(); + public Identifier parse(StringReader reader) throws CommandSyntaxException { + var id = Identifier.fromCommandInput(reader); + var wrapper = ConfigWrapper.getKnownConfigInstances().get(id); + if (wrapper == null) throw NO_SUCH_CONFIG_SCREEN.create(); - return provider.apply(null); + return id; } @Override public CompletableFuture listSuggestions(CommandContext context, SuggestionsBuilder builder) { var configNames = new ArrayList(); - ConfigScreenProviders.forEach((s, screenFunction) -> configNames.add(s)); + ConfigWrapper.getKnownConfigInstances().keySet().forEach(s -> configNames.add(s.toString())); return CommandSource.suggestMatching(configNames, builder); } } + + private static class ConfigurableModArgumentType implements ArgumentType { + public static final ConfigurableModArgumentType INSTANCE = new ConfigurableModArgumentType(); + + private static final SimpleCommandExceptionType NO_SUCH_CONFIGURABLE_MOD = new SimpleCommandExceptionType( + TextOps.concat(Owo.PREFIX, Text.literal("no mod with that id")) + ); + + @Override + public String parse(StringReader reader) throws CommandSyntaxException { + var mod = reader.readUnquotedString(); + + if (getConfigurableMods().get(mod) == null) throw NO_SUCH_CONFIGURABLE_MOD.create(); + + return mod; + } + + @Override + public CompletableFuture listSuggestions(CommandContext context, SuggestionsBuilder builder) { + return CommandSource.suggestMatching(getConfigurableMods().keySet(), builder); + } + + private static Map getConfigurableMods() { + return ConfigWrapper.getKnownConfigInstances().keySet().stream() + .map(Identifier::getNamespace) + .>collect(LinkedHashMap::new, (map, string) -> { + map.computeIfAbsent(string, string1 -> new MutableInt(0)) + .add(1); + }, (map1, map2) -> { + map2.forEach((key, value) -> { + map1.computeIfAbsent(key, string1 -> new MutableInt(0)) + .add(value); + }); + }); + } + } } diff --git a/src/main/java/io/wispforest/owo/config/annotation/Config.java b/src/main/java/io/wispforest/owo/config/annotation/Config.java index aeafacb2..83dee2d8 100644 --- a/src/main/java/io/wispforest/owo/config/annotation/Config.java +++ b/src/main/java/io/wispforest/owo/config/annotation/Config.java @@ -26,6 +26,8 @@ */ String name(); + String modId(); + /** * @return {@code true} if all fields should be treated * as if they were annotated with {@link Hook} diff --git a/src/main/java/io/wispforest/owo/config/annotation/Modmenu.java b/src/main/java/io/wispforest/owo/config/annotation/Modmenu.java index e36cd13c..83ef96bb 100644 --- a/src/main/java/io/wispforest/owo/config/annotation/Modmenu.java +++ b/src/main/java/io/wispforest/owo/config/annotation/Modmenu.java @@ -31,4 +31,5 @@ */ String uiModelId() default "owo:config"; + int priorityOrder() default 0; } diff --git a/src/main/java/io/wispforest/owo/config/annotation/RangeConstraint.java b/src/main/java/io/wispforest/owo/config/annotation/RangeConstraint.java index b9839cb7..05397aa6 100644 --- a/src/main/java/io/wispforest/owo/config/annotation/RangeConstraint.java +++ b/src/main/java/io/wispforest/owo/config/annotation/RangeConstraint.java @@ -12,13 +12,15 @@ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface RangeConstraint { - double min(); + double min() default -Double.MAX_VALUE; - double max(); + double max() default Double.MAX_VALUE; /** * @return How many decimals places to show in the config * screen, if this is a floating point option */ int decimalPlaces() default 2; + + boolean useSlider() default true; } diff --git a/src/main/java/io/wispforest/owo/config/annotation/Sync.java b/src/main/java/io/wispforest/owo/config/annotation/Sync.java index 68a041b6..e4c4f5fe 100644 --- a/src/main/java/io/wispforest/owo/config/annotation/Sync.java +++ b/src/main/java/io/wispforest/owo/config/annotation/Sync.java @@ -1,6 +1,6 @@ package io.wispforest.owo.config.annotation; -import io.wispforest.owo.config.Option; +import io.wispforest.owo.config.base.SyncMode; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -15,5 +15,5 @@ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD, ElementType.TYPE}) public @interface Sync { - Option.SyncMode value(); + SyncMode value(); } diff --git a/src/main/java/io/wispforest/owo/config/base/BoundedAccess.java b/src/main/java/io/wispforest/owo/config/base/BoundedAccess.java new file mode 100644 index 00000000..bc89c2ad --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/base/BoundedAccess.java @@ -0,0 +1,117 @@ +package io.wispforest.owo.config.base; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.util.function.Function; + +public interface BoundedAccess { + + String name(); + + Object owner(); + + Class type(); + + Type genericType(); + + boolean hasAnnotation(Class annotationClass); + + A getAnnotation(Class annotationClass); + + T getValue(); + + /** + * A simple container which stores both a record component + * and an instance of the containing class on which to query + * values + * + * @param owner The owner object which holds the value the field points to + * @param component The component itself + * @param The type of object this field stores + */ + record BoundRecordComponent(Record owner, RecordComponent component, Function getter) implements BoundedAccess { + public String name() { + return this.component.getName(); + } + + @Override + public Class type() { + return this.component.getType(); + } + + public Type genericType() { + return this.component.getGenericType(); + } + + public boolean hasAnnotation(Class annotationClass) { + return this.component.isAnnotationPresent(annotationClass); + } + + public A getAnnotation(Class annotationClass) { + return this.component.getAnnotation(annotationClass); + } + + public T getValue() { + try { + return (T) getter.apply(owner); + } catch (Throwable e) { + throw new RuntimeException("Could not access config option field " + this.name(), e); + } + } + + public BoundRecordComponent withOwner(Record owner) { + return new BoundRecordComponent<>(owner, this.component(), this.getter()); + } + } + + /** + * A simple container which stores both a non-static field + * and an instance of the containing class on which to query + * values + * + * @param owner The owner object which holds the value the field points to + * @param field The field itself + * @param The type of object this field stores + */ + @SuppressWarnings("unchecked") + record BoundField(Object owner, Field field, Class type, Type genericType) implements BoundedAccess { + + public BoundField(Object owner, Field field) { + this(owner, field, field.getType(), field.getGenericType()); + } + + public String name() { + return this.field.getName(); + } + + public boolean hasAnnotation(Class annotationClass) { + return this.field.isAnnotationPresent(annotationClass); + } + + public A getAnnotation(Class annotationClass) { + return this.field.getAnnotation(annotationClass); + } + + public T getValue() { + try { + return (T) this.field.get(this.owner); + } catch (IllegalAccessException e) { + throw new RuntimeException("Could not access config option field " + this.name(), e); + } + } + + public void setValue(T value) { + try { + this.field.set(this.owner, value); + } catch (IllegalAccessException e) { + throw new RuntimeException("Could not set config option field " + this.name(), e); + } + } + + public BoundField withOwner(Object owner) { + return new BoundField<>(owner, this.field(), this.type(), this.genericType()); + } + } +} diff --git a/src/main/java/io/wispforest/owo/config/base/Key.java b/src/main/java/io/wispforest/owo/config/base/Key.java new file mode 100644 index 00000000..f21c87de --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/base/Key.java @@ -0,0 +1,93 @@ +package io.wispforest.owo.config.base; + +import java.util.Arrays; +import java.util.List; + +/** + * Describes an option's location inside a + * config, generated from its name a potential + * parents it is nested in + * + * @param path The segments of the path making up this key + */ +public record Key(String[] path) { + + public static final Key ROOT = new Key(new String[0]); + + public Key(List path) { + this(path.toArray(String[]::new)); + } + + public Key(String key) { + this(key.split("\\.")); + } + + /** + * @return The immediate parent of this key, + * or {@link #ROOT} if the parent is the root key + */ + public Key parent() { + if (this.path.length <= 1) return ROOT; + + var newPath = new String[this.path.length - 1]; + System.arraycopy(this.path, 0, newPath, 0, this.path.length - 1); + return new Key(newPath); + } + + /** + * Create the key for a child of this key + * + * @param childName The name of the child + */ + public Key child(String childName) { + var newPath = new String[this.path.length + 1]; + System.arraycopy(this.path, 0, newPath, 0, this.path.length); + newPath[this.path.length] = childName; + return new Key(newPath); + } + + /** + * @return The segments of this key joined with {@code .} + */ + public String asString() { + return String.join(".", this.path); + } + + /** + * @return The name of the element this key describes, + * without any of its parents + */ + public String name() { + if (this.path.length < 1) return ""; + return this.path[this.path.length - 1]; + } + + /** + * @return {@code true} if and only if this + * key is reference-equal to {@link #ROOT} + */ + public boolean isRoot() { + return this == ROOT; + } + + // Records don't play nicely with arrays, thus need to manually + // declare all the record autogenerated stuff here + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Key key = (Key) o; + return Arrays.equals(path, key.path); + } + + @Override + public int hashCode() { + return Arrays.hashCode(path); + } + + @Override + public String toString() { + return "Key{" + "path=" + Arrays.toString(path) + '}'; + } +} diff --git a/src/main/java/io/wispforest/owo/config/base/SyncMode.java b/src/main/java/io/wispforest/owo/config/base/SyncMode.java new file mode 100644 index 00000000..903f434d --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/base/SyncMode.java @@ -0,0 +1,23 @@ +package io.wispforest.owo.config.base; + +public enum SyncMode { + /** + * Do not ever send this option over the network + */ + NONE, + /** + * Only send the client's value to the server, + * but not vice-versa + */ + INFORM_SERVER, + /** + * Send the client's value to the server + * and send the server's value back, + * overriding the client's value + */ + OVERRIDE_CLIENT; + + public boolean isNone() { + return this == NONE; + } +} diff --git a/src/main/java/io/wispforest/owo/config/options/FieldOption.java b/src/main/java/io/wispforest/owo/config/options/FieldOption.java new file mode 100644 index 00000000..e4e5a05a --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/options/FieldOption.java @@ -0,0 +1,188 @@ +package io.wispforest.owo.config.options; + +import io.wispforest.endec.impl.ReflectiveEndecBuilder; +import io.wispforest.owo.config.ConfigWrapper; +import io.wispforest.owo.config.annotation.RestartRequired; +import io.wispforest.endec.Endec; +import io.wispforest.owo.config.base.BoundedAccess; +import io.wispforest.owo.config.base.Key; +import io.wispforest.owo.config.base.SyncMode; +import io.wispforest.owo.util.Observable; +import net.minecraft.network.PacketByteBuf; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Describes a single option in a config. Instances + * of this class keep a reference to the field in + * the model class which stores the value used for serialization. + *

+ * An option may enter the so-called "detached" state, which means + * its value is being overridden by the server. In this state, the option + * is completely immutable and can only be changed again afterwards + */ +public final class FieldOption extends OptionBase implements ReflectiveOption { + + private final BoundedAccess.BoundField backingField; + + private final Observable mirror; + + private final @Nullable Endec endec; + private final SyncMode syncMode; + + /** + * Indicates whether this option is currently being overridden + * by the server and should thus never synchronize with its backing + * field and behave immutably to the client + */ + private boolean detached = false; + + /** + * @param configName The name of the config this option is contained in + * @param key The key of this option + * @param defaultValue The default value of this option + * @param mirror A mirror of the value of this option, used for + * emitting events when it changes as well as correcting + * invalid values after deserialization + * @param backingField The backing field in the config model class + * which this option describes + * @param constraint The constraint placed on the value of this option, + * or {@code null} if the option is unconstrained + */ + @SuppressWarnings("unchecked") + public FieldOption(Identifier configId, + Key key, + T defaultValue, + Observable mirror, + BoundedAccess.BoundField backingField, + @Nullable ConfigWrapper.Constraint constraint, + SyncMode syncMode, + ReflectiveEndecBuilder builder + ) { + super(configId, key, defaultValue, (Class) backingField.type(), backingField.genericType(), constraint); + + this.backingField = backingField; + + this.mirror = mirror; + + this.syncMode = syncMode; + this.endec = syncMode.isNone() ? null : (Endec) builder.get(this.getGenericType()); + } + + @Override + public BoundedAccess backingAccess() { + return this.backingField; + } + + /** + * Update the current value of this option, + * or do nothing if the given value is invalid + * + * @param value The new value of the option + */ + public void set(T value) { + if (this.detached) return; + + if (!this.verifyConstraint(value)) return; + + // Technically safe cast since constructor requires BoundField type + this.backingField.setValue(value); + this.mirror.set(value); + } + + @Override + public T value() { + return this.mirror.get(); + } + + /** + * Synchronize the value stored in the backing field + * and this option's mirror - used for either correcting an + * invalid value after updating the field or updating the mirror + */ + public void synchronizeWithBackingField() { + if (this.detached) return; + + final var fieldValue = this.backingField.getValue(); + if (verifyConstraint(fieldValue)) { + this.mirror.set(fieldValue); + } else { + // Technically safe cast since constructor requires BoundField type + this.backingField.setValue(this.mirror.get()); + } + } + + /** + * Add an observer function to be run every time + * the value of this option changes + */ + public void observe(Consumer observer) { + this.mirror.observe(observer); + } + + /** + * Write the current value of this option into the given buffer + * + * @param buf The packet buffer to write to + */ + public void write(PacketByteBuf buf) { + buf.write(this.endec, this.value()); + } + + /** + * Read a new value of this option from the given buffer + * and enter a detached state + * + * @param buf The packet buffer to read from + * @return {@code null} if this option was successfully detached, + * the server's value otherwise + */ + public T read(PacketByteBuf buf) { + final var newValue = buf.read(this.endec); + + if (!Objects.equals(newValue, this.value()) && this.backingField.hasAnnotation(RestartRequired.class)) { + return newValue; + } + + this.mirror.set(newValue); + this.detached = true; + + return null; + } + + /** + * @return The serializer for this option's value + */ + public Endec endec() { + return this.endec; + } + + /** + * Reset this option's attached state and synchronize + * it with the backing field again + */ + public void reattach() { + if (!this.detached) return; + + this.detached = false; + this.synchronizeWithBackingField(); + } + + /** + * @return {@code true} if this option is currently detached + */ + public boolean detached() { + return this.detached; + } + + /** + * @return The way in which this option + * should be synchronized between sever and client + */ + public SyncMode syncMode() { + return this.syncMode; + } +} diff --git a/src/main/java/io/wispforest/owo/config/options/MemoryOption.java b/src/main/java/io/wispforest/owo/config/options/MemoryOption.java new file mode 100644 index 00000000..ba952a5f --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/options/MemoryOption.java @@ -0,0 +1,26 @@ +package io.wispforest.owo.config.options; + +import io.wispforest.owo.config.ConfigWrapper; +import io.wispforest.owo.config.base.Key; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; + +public sealed class MemoryOption extends OptionBase permits RecordOption { + private T currentValue; + + public MemoryOption(Identifier configId, Key key, T defaultValue, Class clazz, Type genericType, @Nullable ConfigWrapper.Constraint constraint, T currentValue) { + super(configId, key, defaultValue, clazz, genericType, constraint); + this.currentValue = currentValue; + } + + @Override + public T value() { + return currentValue; + } + + public void set(T t) { + this.currentValue = t; + } +} diff --git a/src/main/java/io/wispforest/owo/config/options/OptionBase.java b/src/main/java/io/wispforest/owo/config/options/OptionBase.java new file mode 100644 index 00000000..9597de20 --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/options/OptionBase.java @@ -0,0 +1,118 @@ +package io.wispforest.owo.config.options; + +import io.wispforest.owo.Owo; +import io.wispforest.owo.config.ConfigWrapper; +import io.wispforest.owo.config.base.Key; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; + +public sealed abstract class OptionBase implements OptionControlSpec permits FieldOption, MemoryOption { + + private final Identifier configId; + private final Key key; + private final String translationKey; + + private final T defaultValue; + + private final Class clazz; + private final Type genericType; + + private final ConfigWrapper.@Nullable Constraint constraint; + + /** + * @param configId The name of the config this option is contained in + * @param key The key of this option + * @param defaultValue The default value of this option + * emitting events when it changes as well as correcting + * invalid values after deserialization + * @param constraint The constraint placed on the value of this option, + * or {@code null} if the option is unconstrained + */ + @SuppressWarnings("unchecked") + public OptionBase(Identifier configId, + Key key, + T defaultValue, + Class clazz, + Type genericType, + @Nullable ConfigWrapper.Constraint constraint + ) { + this.configId = configId; + this.key = key; + this.translationKey = "text.config." + this.configId.getPath() + ".option." + this.key.asString(); + + this.defaultValue = defaultValue; + + this.clazz = clazz; + this.genericType = genericType; + + this.constraint = constraint; + } + + @Override + public T defaultValue() { + return this.defaultValue; + } + + @Override + public abstract T value(); + + @Override + public abstract void set(T t); + + @Override + public Class clazz() { + return this.clazz; + } + + @Override + public Type getGenericType() { + return this.genericType; + } + + @Override + public boolean verifyConstraint(T value) { + if (this.constraint == null) return true; + + final var matched = this.constraint.test(value); + if (!matched) { + Owo.LOGGER.warn( + "Option {} in config '{}' could not be updated, as the given value '{}' does not match its constraint: {}", + this.key, this.configId, value, this.constraint.formatted() + ); + } + + return matched; + } + + @Override + public String translationKey() { + return this.translationKey; + } + + @Override + public Identifier configId() { + return this.configId; + } + + @Override + public Key key() { + return this.key; + } + + @Override + public @Nullable ConfigWrapper.Constraint constraint() { + return this.constraint; + } + + @Override + public String toString() { + return "Option[" + + "configId=" + configId + ", " + + "key=" + key + ", " + + "defaultValue=" + defaultValue + ", " + + "constraint=" + (constraint == null ? null : constraint.formatted()) + + "]"; + } +} diff --git a/src/main/java/io/wispforest/owo/config/options/OptionControlSpec.java b/src/main/java/io/wispforest/owo/config/options/OptionControlSpec.java new file mode 100644 index 00000000..d397d281 --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/options/OptionControlSpec.java @@ -0,0 +1,71 @@ +package io.wispforest.owo.config.options; + +import io.wispforest.owo.config.ConfigWrapper; +import io.wispforest.owo.config.base.Key; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; + +public sealed interface OptionControlSpec permits OptionBase, ReflectiveOption { + + /** + * @return The default value of this option + */ + T defaultValue(); + + /** + * @return The current value of this option + */ + T value(); + + void set(T t); + + /** + * @return The class of this option's value + */ + Class clazz(); + + Type getGenericType(); + + /** + * Check whether the given value passes the constraint + * of this option and emit a warning if it does not + * + * @param value The value to test + * @return {@code true} if either the given value + * passes the constraint put on this option or this + * option is unconstrained + */ + boolean verifyConstraint(T value); + + //-- + + /** + * @return The translation key of this option + */ + String translationKey(); + + /** + * @return The id of the config this option is contained in + */ + Identifier configId(); + + /** + * @return The name of the config this option is contained in + */ + default String configName() { + return this.configId().getPath(); + } + + /** + * @return The key of this option + */ + Key key(); + + /** + * @return The constraint placed on the value of this option, + * or {@code null} if the option is unconstrained + */ + ConfigWrapper.@Nullable Constraint constraint(); +} diff --git a/src/main/java/io/wispforest/owo/config/options/RecordOption.java b/src/main/java/io/wispforest/owo/config/options/RecordOption.java new file mode 100644 index 00000000..f1ea5bf0 --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/options/RecordOption.java @@ -0,0 +1,25 @@ +package io.wispforest.owo.config.options; + +import io.wispforest.owo.config.ConfigWrapper; +import io.wispforest.owo.config.base.BoundedAccess; +import io.wispforest.owo.config.base.Key; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; + +public final class RecordOption extends MemoryOption implements ReflectiveOption { + + private final BoundedAccess.BoundRecordComponent backingComponent; + + public RecordOption(Identifier configId, Key key, T defaultValue, BoundedAccess.BoundRecordComponent backingComponent, ConfigWrapper.@Nullable Constraint constraint, T currentValue) { + super(configId, key, defaultValue, (Class) backingComponent.type(), backingComponent.genericType(), constraint, currentValue); + + this.backingComponent = backingComponent; + } + + @Override + public BoundedAccess backingAccess() { + return this.backingComponent; + } +} diff --git a/src/main/java/io/wispforest/owo/config/options/ReflectiveOption.java b/src/main/java/io/wispforest/owo/config/options/ReflectiveOption.java new file mode 100644 index 00000000..aa1cc715 --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/options/ReflectiveOption.java @@ -0,0 +1,12 @@ +package io.wispforest.owo.config.options; + +import io.wispforest.owo.config.base.BoundedAccess; + +public sealed interface ReflectiveOption extends OptionControlSpec permits FieldOption, RecordOption { + + BoundedAccess backingAccess(); + + default boolean detached() { + return false; + } +} diff --git a/src/main/java/io/wispforest/owo/config/ui/ConfigScreen.java b/src/main/java/io/wispforest/owo/config/ui/ConfigScreen.java index 5396cd60..7e6889b9 100644 --- a/src/main/java/io/wispforest/owo/config/ui/ConfigScreen.java +++ b/src/main/java/io/wispforest/owo/config/ui/ConfigScreen.java @@ -1,13 +1,18 @@ package io.wispforest.owo.config.ui; +import blue.endless.jankson.JsonObject; import io.wispforest.owo.Owo; -import io.wispforest.owo.config.ConfigWrapper; -import io.wispforest.owo.config.Option; +import io.wispforest.owo.config.*; import io.wispforest.owo.config.annotation.ExcludeFromScreen; import io.wispforest.owo.config.annotation.Expanded; import io.wispforest.owo.config.annotation.RestartRequired; import io.wispforest.owo.config.annotation.SectionHeader; +import io.wispforest.owo.config.base.BoundedAccess; +import io.wispforest.owo.config.base.Key; +import io.wispforest.owo.config.options.FieldOption; import io.wispforest.owo.config.ui.component.*; +import io.wispforest.owo.packets.OwoPackets; +import io.wispforest.owo.packets.c2s.AskToOpenServerConfig; import io.wispforest.owo.ui.base.BaseComponent; import io.wispforest.owo.ui.base.BaseUIModelScreen; import io.wispforest.owo.ui.component.ButtonComponent; @@ -20,6 +25,7 @@ import io.wispforest.owo.ui.util.UISounds; import io.wispforest.owo.util.NumberReflection; import io.wispforest.owo.util.ReflectionUtils; +import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.tooltip.TooltipComponent; import net.minecraft.client.resource.language.I18n; @@ -34,7 +40,6 @@ import java.lang.reflect.Field; import java.util.*; import java.util.function.BiConsumer; -import java.util.function.Function; import java.util.function.Predicate; /** @@ -54,28 +59,51 @@ public class ConfigScreen extends BaseUIModelScreen { public static final Identifier DEFAULT_MODEL_ID = Identifier.of("owo", "config"); - private static final Map>, OptionComponentFactory> DEFAULT_FACTORIES = new HashMap<>(); + private static final Map>, OptionComponentFactory> DEFAULT_FACTORIES = new LinkedHashMap<>(); /** * A set of extra option factories - add to this if you want to override * some default factories or add extra ones for specific config options * the standard ones don't support */ - protected final Map>, OptionComponentFactory> extraFactories = new HashMap<>(); + protected final Map>, OptionComponentFactory> extraFactories = new LinkedHashMap<>(); protected final Screen parent; protected final ConfigWrapper config; - @SuppressWarnings("rawtypes") protected final Map options = new HashMap<>(); + @SuppressWarnings("rawtypes") protected final Map options = new HashMap<>(); protected String lastSearchFieldText = ""; protected @Nullable SearchMatches currentMatches = null; protected int currentMatchIndex = 0; + @Nullable + protected ConfigWrapper alternativeConfig = null; + protected ConfigScreen(Identifier modelId, ConfigWrapper config, @Nullable Screen parent) { super(FlowLayout.class, DataSource.asset(modelId)); this.parent = parent; this.config = config; } + protected Map serverConfigData = Map.of(); + + protected Map prevLabels = Map.of(); + protected Map currentLabels = new HashMap<>(); + + protected double prevScrollProgress = -1; + + protected ConfigScreen setConfigScreenData(ConfigScreen prevScreen) { + this.prevLabels = prevScreen.currentLabels; + this.prevScrollProgress = prevScreen.uiAdapter.rootComponent.childById(ScrollContainer.class, "titles-scroll").scrollProgress(); + + return this; + } + + protected ConfigScreen setServerConfigData(Map configData) { + this.serverConfigData = configData; + + return this; + } + /** * Create a config screen with the default model ({@code owo:config}) * @@ -84,7 +112,11 @@ protected ConfigScreen(Identifier modelId, ConfigWrapper config, @Nullable Sc * when the created screen is closed */ public static ConfigScreen create(ConfigWrapper config, @Nullable Screen parent) { - return new ConfigScreen(DEFAULT_MODEL_ID, config, parent); + return createWithCustomModel(DEFAULT_MODEL_ID, config, parent); + } + + public static ConfigScreen create(ConfigWrapper config, @Nullable Screen parent, ConfigComponentBuilder builder) { + return createWithCustomModel(DEFAULT_MODEL_ID, config, parent, builder); } /** @@ -100,19 +132,198 @@ public static ConfigScreen createWithCustomModel(Identifier modelId, ConfigWrapp return new ConfigScreen(modelId, config, parent); } + public static ConfigScreen createWithCustomModel(Identifier modelId, ConfigWrapper config, @Nullable Screen parent, ConfigComponentBuilder builder) { + var screen = createWithCustomModel(modelId, config, parent); + builder.build(config, new ComponentFactoryRegister() { + @Override + public void register(FieldOption option, OptionComponentFactory factory) { + if (!config.allOptions().containsKey(option.key())) { + throw new IllegalStateException("Option Component Factory was registered for an option not found within the config!"); + } + + registerPredicate(option1 -> option1.equals(option), factory); + } + + @Override + public void registerPredicate(Predicate> predicate, OptionComponentFactory factory) { + screen.extraFactories.put(predicate, factory); + } + }); + return screen; + } + + public interface ConfigComponentBuilder { + void build(ConfigWrapper wrapper, ComponentFactoryRegister registerCallback); + } + + public interface ComponentFactoryRegister { + void register(FieldOption option, OptionComponentFactory factory); + + void registerPredicate(Predicate> predicate, OptionComponentFactory factory); + } + @Override @SuppressWarnings({"ConstantConditions", "unchecked"}) protected void build(FlowLayout rootComponent) { this.options.clear(); - rootComponent.childById(LabelComponent.class, "title").text(Text.translatable("text.config." + this.config.name() + ".title")); - if (this.client.world == null) { - rootComponent.surface(Surface.optionsBackground()); + var isServer = new MutableBoolean(config.isServerConfig()); + + var btn = rootComponent.childById(ButtonComponent.class, "environment-type"); + + if (MinecraftClient.getInstance().getServer() != null || MinecraftClient.getInstance().world == null) { + rootComponent.childById(ParentComponent.class, "button-config-controls") + .removeChild(btn); + } else { + btn.onPress(buttonComponent -> { + if (this.config.isServerConfig()) { + this.alternativeConfig = ConfigWrapper.getConfig(this.config.id()); + } + + if (this.alternativeConfig == null) { + if (!this.config.isServerConfig()) { + OwoPackets.MAIN.clientHandle().send(new AskToOpenServerConfig(this.config.id())); + + return; + } else { + var envType = this.config.isServerConfig() ? "client" : "server"; + + throw new IllegalStateException("Unable to transfer to the desired environment [" + envType + "] for the given config: " + this.config.id()); + } + } + + var newScreen = ConfigScreenProviders.get(config.id()).openScreenSafely(this.parent, alternativeConfig); + + if (newScreen instanceof ConfigScreen configScreen) { + configScreen.alternativeConfig = this.config; + } + + MinecraftClient.getInstance().setScreen(newScreen); + }); + + btn.setMessage(Text.translatable("text.owo.config.label.environment." + (isServer.getValue() ? "server" : "client"))); + } + + var topHolder = rootComponent.childById(FlowLayout.class, "titles-and-option-holder"); + + var titles = topHolder.childById(FlowLayout.class, "titles"); + + var modProviders = ConfigScreenProviders.getSortedProviders() + .get(config.id().getNamespace()); + + if (modProviders.isEmpty()) { + var titleKey = "text.config." + this.config.name() + ".title"; + + var titleHolder = this.model.expandTemplate(FlowLayout.class, "current-config-selection", Map.of("title-translation-key", titleKey)); + + OptionComponentFactory.addEasyCopyLabel(titleHolder, titleKey); + + titles.child(titleHolder); + } else { + var titleScroll = topHolder.childById(ScrollContainer.class, "titles-scroll"); + + Component selectedComponent = null; + + for (var configName : modProviders) { + var titleKey = "text.config." + configName + ".title"; + + ParentComponent titleHolder; + + if (this.config.name().equals(configName)) { + titleHolder = this.model.expandTemplate(FlowLayout.class, "current-config-selection", Map.of("title-translation-key", titleKey)); + + OptionComponentFactory.addEasyCopyLabel((FlowLayout) titleHolder, titleKey); + + titles.child(titleHolder); + + selectedComponent = titleHolder; + } else { + titleHolder = this.model.expandTemplate(SelectableContainer.class, "alternative-config-selection", Map.of("title-translation-key", titleKey)); + + OptionComponentFactory.addEasyCopyLabel(titleHolder.childById(FlowLayout.class, "alternative-config-title-holder"), titleKey); + + titles.child(titleHolder); + + titleHolder.mouseDown().subscribe((mouseX, mouseY, button) -> { + if (ConfigScreenProviders.safelyOpenConfigScreen(this.config.id().withPath(configName), parent, this)) { + UISounds.playButtonSound(); + + return true; + } + + return false; + }); + + titleHolder.keyPress().subscribe((keyCode, scanCode, modifiers) -> { + if (keyCode == GLFW.GLFW_KEY_ENTER) { + if (ConfigScreenProviders.safelyOpenConfigScreen(this.config.id().withPath(configName), parent, this)) { + UISounds.playButtonSound(); + + return true; + } + } + + return false; + }); + } + + var titleLabel = titleHolder.childById(LabelComponent.class, "title"); + + if (this.prevLabels.containsKey(configName)) { + titleLabel.copyScrollData(this.prevLabels.get(configName)); + } + + currentLabels.put(configName, titleLabel); + } + + if (prevScrollProgress != -1) { + titleScroll.scrollTo(this.prevScrollProgress); + } + } + + if (topHolder.surface() == Surface.BLANK) { + var brightColor = Color.ofArgb(0x4dFFFFFF); + var darkerColor = Color.ofArgb(0x99000000); + + topHolder.surface( + Surface.partialOutline(brightColor.argb(), Surface.OutlineSide.BOTTOM) + .and(Surface.partialOutline(darkerColor.argb(), 1, Surface.OutlineSide.BOTTOM)) + .and((context, component) -> { + var titleHolder = component.childById(FlowLayout.class, "title-holder"); + var mainPanel = component.childById(FlowLayout.class, "main-panel-stack"); + + var lineY = mainPanel.y(); + + // Left Line X values + var lineStart1 = mainPanel.x(); + var lineEnd1 = titleHolder.x() + 1; + + // Right Line X values + var lineStart2 = titleHolder.x() + titleHolder.width() - 2; + var lineEnd2 = mainPanel.x() + mainPanel.width(); + + context.drawHorizontalLine(lineStart1, lineEnd1, lineY, brightColor.argb()); + context.drawHorizontalLine(lineStart1, lineEnd1, lineY + 1, darkerColor.argb()); + + context.drawHorizontalLine(lineStart2, lineEnd2, lineY, brightColor.argb()); + context.drawHorizontalLine(lineStart2, lineEnd2, lineY + 1, darkerColor.argb()); + + var bqColor = Color.BLACK.withAlpha(0.20f).argb(); + + context.fill(lineStart1, lineY + 2, lineEnd2, mainPanel.y() + mainPanel.height() - 2, bqColor); + context.fill(lineEnd1 + 1, titleHolder.y() + 2, lineStart2, titleHolder.y() + titleHolder.height() + 2, bqColor); + + var selectedLineWidth = Math.round(titleHolder.width() * 0.33f); + var selectedLineStart = titleHolder.x() + ((titleHolder.width() - selectedLineWidth) / 2); + + context.drawHorizontalLine(selectedLineStart, selectedLineStart + selectedLineWidth, lineY, Color.WHITE.argb()); + }) + ); } rootComponent.childById(ButtonComponent.class, "done-button").onPress(button -> this.close()); rootComponent.childById(ButtonComponent.class, "reload-button").onPress(button -> { - this.config.load(); + this.config.reload(); this.uiAdapter = null; this.clearAndInit(); @@ -120,10 +331,10 @@ protected void build(FlowLayout rootComponent) { }); var optionPanel = rootComponent.childById(FlowLayout.class, "option-panel"); - var sections = new LinkedHashMap(); + var sections = new LinkedHashMap(); - var containers = new HashMap(); - containers.put(Option.Key.ROOT, optionPanel); + var containers = new HashMap(); + containers.put(Key.ROOT, optionPanel); rootComponent.childById(TextBoxComponent.class, "search-field").configure(searchField -> { var matchIndicator = rootComponent.childById(LabelComponent.class, "search-match-indicator"); @@ -174,7 +385,7 @@ protected void build(FlowLayout rootComponent) { // we specifically build the path backwards, so we can then iterate // it root -> key, otherwise we could potentially be manipulating // unmounted components which is absolutely not desirable - var pathToRoot = new ArrayDeque(); + var pathToRoot = new ArrayDeque(); var key = selectedMatch.key(); while (!key.isRoot()) { pathToRoot.push(key); @@ -203,10 +414,11 @@ protected void build(FlowLayout rootComponent) { }); this.config.forEachOption(option -> { - if (option.backingField().hasAnnotation(ExcludeFromScreen.class)) return; + if (option.backingAccess().hasAnnotation(ExcludeFromScreen.class)) return; var parentKey = option.key().parent(); - if (!parentKey.isRoot() && this.config.fieldForKey(parentKey).isAnnotationPresent(ExcludeFromScreen.class)) return; + if (!parentKey.isRoot() && this.config.fieldForKey(parentKey).isAnnotationPresent(ExcludeFromScreen.class)) + return; var factory = this.factoryForOption(option); if (factory == null) { @@ -218,38 +430,45 @@ protected void build(FlowLayout rootComponent) { this.options.put(option, result.optionProvider()); var expanded = !parentKey.isRoot() && this.config.fieldForKey(parentKey).isAnnotationPresent(Expanded.class); - var container = containers.getOrDefault( + var nestedClassKey = "text.config." + this.config.name() + ".category." + parentKey.asString(); + + var container = containers.computeIfAbsent( parentKey, - Containers.collapsible( - Sizing.fill(100), Sizing.content(), - Text.translatable("text.config." + this.config.name() + ".category." + parentKey.asString()), - expanded - ).configure(nestedContainer -> { - final var categoryKey = "text.config." + this.config.name() + ".category." + parentKey.asString(); - if (I18n.hasTranslation(categoryKey + ".tooltip")) { - nestedContainer.titleLayout().tooltip(Text.translatable(categoryKey + ".tooltip")); + key -> { + var collapsibleContainer = Containers.collapsible( + Sizing.fill(100), Sizing.content(), + Text.translatable(nestedClassKey), + expanded + ).configure(nestedContainer -> { + final var categoryKey = nestedClassKey; + if (I18n.hasTranslation(categoryKey + ".tooltip")) { + nestedContainer.titleLayout().tooltip(Text.translatable(categoryKey + ".tooltip")); + } + + nestedContainer.titleLayout().child(new SearchAnchorComponent( + nestedContainer.titleLayout(), + option.key(), + () -> I18n.translate(categoryKey) + ).highlightConfigurator(highlight -> + highlight.positioning(Positioning.absolute(-5, -5)) + .verticalSizing(Sizing.fixed(19)) + )); + }); + + OptionComponentFactory.addEasyCopyLabel(collapsibleContainer.titleLayout(), nestedClassKey); + + if (containers.containsKey(parentKey.parent())) { + if (this.config.fieldForKey(parentKey).isAnnotationPresent(SectionHeader.class)) { + this.appendSection(sections, this.config.fieldForKey(parentKey), containers.get(parentKey.parent())); + } + + containers.get(parentKey.parent()).child(collapsibleContainer); } - nestedContainer.titleLayout().child(new SearchAnchorComponent( - nestedContainer.titleLayout(), - option.key(), - () -> I18n.translate(categoryKey) - ).highlightConfigurator(highlight -> - highlight.positioning(Positioning.absolute(-5, -5)) - .verticalSizing(Sizing.fixed(19)) - )); - }) + return collapsibleContainer; + } ); - if (!containers.containsKey(parentKey) && containers.containsKey(parentKey.parent())) { - if (this.config.fieldForKey(parentKey).isAnnotationPresent(SectionHeader.class)) { - this.appendSection(sections, this.config.fieldForKey(parentKey), containers.get(parentKey.parent())); - } - - containers.put(parentKey, container); - containers.get(parentKey.parent()).child(container); - } - if (option.detached()) { result.baseComponent().tooltip( this.client.textRenderer.wrapLines(Text.translatable("text.owo.config.managed_by_server"), Integer.MAX_VALUE) @@ -263,7 +482,7 @@ protected void build(FlowLayout rootComponent) { tooltipText.addAll(this.client.textRenderer.wrapLines(Text.translatable(tooltipTranslationKey), Integer.MAX_VALUE)); } - if (option.backingField().hasAnnotation(RestartRequired.class)) { + if (option.backingAccess().hasAnnotation(RestartRequired.class)) { tooltipText.add(Text.translatable("text.owo.config.applies_after_restart").asOrderedText()); } @@ -272,27 +491,40 @@ protected void build(FlowLayout rootComponent) { } } - if (option.backingField().hasAnnotation(SectionHeader.class)) { - this.appendSection(sections, option.backingField().field(), container); + if (option.backingAccess().hasAnnotation(SectionHeader.class)) { + this.appendSection(sections, option.backingAccess(), container); } container.child(result.baseComponent()); }); if (!sections.isEmpty()) { - var panelContainer = rootComponent.childById(FlowLayout.class, "option-panel-container"); - var panelScroll = rootComponent.childById(ScrollContainer.class, "option-panel-scroll"); - panelScroll.margins(Insets.right(10)); + boolean sectionsOnRight = true; - var buttonPanel = this.model.expandTemplate(FlowLayout.class, "section-buttons", Map.of()); - sections.forEach((component, text) -> { - var hoveredText = text.copy().formatted(Formatting.YELLOW); + var overlay = this.model.expandTemplate(FlowLayout.class, "section-overlay", Map.of("overlay-side", sectionsOnRight ? "left" : "right")); - final var label = Components.label(text); - label.cursorStyle(CursorStyle.HAND).margins(Insets.of(2)); + var sectionState = new SectionPanelState(overlay, sectionsOnRight); - label.mouseEnter().subscribe(() -> label.text(hoveredText)); - label.mouseLeave().subscribe(() -> label.text(text)); + overlay.configure((FlowLayout overlayComponent) -> { + overlayComponent.mouseDown().subscribe((mouseX, mouseY, button) -> true); + overlayComponent.mouseUp().subscribe((mouseX, mouseY, button) -> true); + + overlayComponent.componentUpdate().subscribe((delta, mouseX, mouseY) -> { + if (!overlayComponent.isInBoundingBox(mouseX, mouseY) && !sectionState.isPanelMoving && sectionState.isPanelOpened) { + sectionState.togglePanel(); + } + }); + + overlayComponent.positioning(Positioning.relative(sectionsOnRight ? 100 : 0, 0)) + .zIndex(10); + }); + + var panelScroll = rootComponent.childById(ScrollContainer.class, "option-panel-scroll"); + panelScroll.margins(sectionsOnRight ? Insets.right(10) : Insets.left(10)); + + var buttonPanel = overlay.childById(FlowLayout.class, "section-buttons"); + sections.forEach((component, text) -> { + final var label = this.model.expandTemplate(LabelComponent.class, "section-overlay-label", Map.of("section-name", text)); label.mouseDown().subscribe((mouseX, mouseY, button) -> { panelScroll.scrollTo(component); @@ -303,40 +535,98 @@ protected void build(FlowLayout rootComponent) { buttonPanel.child(label); }); - var closeButton = Components.label(Text.literal("<").formatted(Formatting.BOLD)); - closeButton.tooltip(Text.translatable("text.owo.config.sections_tooltip")); - closeButton.positioning(Positioning.relative(100, 50)).cursorStyle(CursorStyle.HAND).margins(Insets.right(2)); + var panelContainer = rootComponent.childById(FlowLayout.class, "option-panel-container"); + + panelContainer.child(sectionState.closeButton); - panelContainer.child(closeButton); panelContainer.mouseDown().subscribe((mouseX, mouseY, button) -> { - if (mouseX < panelContainer.width() - 10) return false; + if ((sectionsOnRight && mouseX > panelContainer.width() - 10) || (!sectionsOnRight && mouseX < 10)) { + sectionState.togglePanel(); - if (buttonPanel.horizontalSizing().animation() == null) { - buttonPanel.horizontalSizing().animate(350, Easing.CUBIC, Sizing.content()); + return true; } - buttonPanel.horizontalSizing().animation().reverse(); - closeButton.text(Text.literal(closeButton.text().getString().equals(">") ? "<" : ">").formatted(Formatting.BOLD)); - - UISounds.playInteractionSound(); - return true; + return false; + }); + panelContainer.mouseEnter().subscribe(() -> { + if (sectionState.isPanelOpened) { + sectionState.togglePanel(); + } }); - rootComponent.childById(FlowLayout.class, "main-panel").child(buttonPanel); + rootComponent.childById(FlowLayout.class, "main-panel-stack").child(overlay); + } + } + + private static class SectionPanelState { + + private boolean isPanelOpened = false; + private boolean isPanelMoving = false; + + private final Component overlay; + + private final LabelComponent closeButton; + + private final String disabledChar; + private final String enabledChar; + + SectionPanelState(Component overlay, boolean sectionsOnRight) { + this.overlay = overlay; + + if (sectionsOnRight) { + this.disabledChar = "<"; + this.enabledChar = ">"; + } else { + this.disabledChar = ">"; + this.enabledChar = "<"; + } + + this.closeButton = Components.label(Text.literal(this.disabledChar).formatted(Formatting.BOLD)) + .configure((LabelComponent label) -> { + label.tooltip(Text.translatable("text.owo.config.sections_tooltip")) + .positioning(Positioning.relative(sectionsOnRight ? 100 : 0, 50)) + .cursorStyle(CursorStyle.HAND) + .margins(Insets.right(2)); + }); + } + + public void togglePanel() { + if (overlay.horizontalSizing().animation() == null) { + var animation = overlay.horizontalSizing().animate(350, Easing.CUBIC, Sizing.content()); + + animation.finished().subscribe((direction, looping) -> isPanelMoving = false); + } + + isPanelOpened = !isPanelOpened; + + overlay.horizontalSizing().animation().reverse(); + isPanelMoving = true; + + closeButton.text(Text.literal(closeButton.text().getString().equals(enabledChar) ? disabledChar : enabledChar).formatted(Formatting.BOLD)); + + UISounds.playInteractionSound(); } } - protected void appendSection(Map sections, Field field, FlowLayout container) { - var translationKey = "text.config." + this.config.name() + ".section." - + field.getAnnotation(SectionHeader.class).value(); + protected void appendSection(Map sections, Field field, FlowLayout container) { + appendSection(sections, field.getAnnotation(SectionHeader.class), container); + } + + protected void appendSection(Map sections, BoundedAccess access, FlowLayout container) { + appendSection(sections, access.getAnnotation(SectionHeader.class), container); + } - final var header = this.model.expandTemplate(FlowLayout.class, "section-header", Map.of()); + protected void appendSection(Map sections, SectionHeader annotation, FlowLayout container) { + var translationKey = "text.config." + this.config.name() + ".section." + annotation.value(); + + final var header = this.model.expandTemplate(FlowLayout.class, "section-header", Map.of("section-name", translationKey)); header.childById(LabelComponent.class, "header").configure(label -> { - label.text(Text.translatable(translationKey).formatted(Formatting.YELLOW, Formatting.BOLD)); - header.child(new SearchAnchorComponent(header, Option.Key.ROOT, () -> label.text().getString())); + header.child(new SearchAnchorComponent(header, Key.ROOT, () -> label.text().getString())); }); - sections.put(header, Text.translatable(translationKey)); + OptionComponentFactory.addEasyCopyLabel(header.childById(FlowLayout.class, "label-holder"), translationKey); + + sections.put(header, translationKey); container.child(header); } @@ -373,12 +663,18 @@ public boolean keyPressed(int keyCode, int scanCode, int modifiers) { } } + private BiConsumer, Boolean> onConfigChanges = (configWrapper, bl) -> {}; + + void addRemovedHook(BiConsumer, Boolean> value) { + onConfigChanges = value; + } + @Override @SuppressWarnings("unchecked") public void close() { var shouldRestart = new MutableBoolean(); this.options.forEach((option, component) -> { - if (!option.backingField().hasAnnotation(RestartRequired.class)) return; + if (!option.backingAccess().hasAnnotation(RestartRequired.class)) return; if (Objects.equals(option.value(), component.parsedValue())) return; shouldRestart.setTrue(); @@ -390,15 +686,34 @@ public void close() { @Override @SuppressWarnings("unchecked") public void removed() { - this.options.forEach((option, component) -> { + boolean hasOptionsChanged = false; + boolean restartRequired = false; + + for (var entry : this.options.entrySet()) { + var option = entry.getKey(); + var component = entry.getValue(); + if (!component.isValid()) return; + + if (Objects.equals(option.value(), component.parsedValue())) return; + if (option.backingAccess().hasAnnotation(RestartRequired.class)) { + restartRequired = true; + } + + hasOptionsChanged = true; + option.set(component.parsedValue()); - }); + } + + if (hasOptionsChanged) { + onConfigChanges.accept(this.config, restartRequired); + } + super.removed(); } @SuppressWarnings("rawtypes") - protected @Nullable OptionComponentFactory factoryForOption(Option option) { + protected @Nullable OptionComponentFactory factoryForOption(FieldOption option) { for (var predicate : this.extraFactories.keySet()) { if (!predicate.test(option)) continue; return this.extraFactories.get(predicate); @@ -418,13 +733,28 @@ public void removed() { DEFAULT_FACTORIES.put(option -> option.clazz() == Boolean.class || option.clazz() == boolean.class, OptionComponentFactory.BOOLEAN); DEFAULT_FACTORIES.put(option -> option.clazz() == Identifier.class, OptionComponentFactory.IDENTIFIER); DEFAULT_FACTORIES.put(option -> option.clazz() == Color.class, OptionComponentFactory.COLOR); - DEFAULT_FACTORIES.put(option -> isStringOrNumberList(option.backingField().field()), OptionComponentFactory.LIST); + DEFAULT_FACTORIES.put(option -> option.clazz() == List.class && ConfigReflectionUtils.getCollectionType(option.getGenericType()) != null, OptionComponentFactory.LIST); + DEFAULT_FACTORIES.put(option -> option.clazz() == Set.class && ConfigReflectionUtils.getCollectionType(option.getGenericType()) != null, OptionComponentFactory.SET); + DEFAULT_FACTORIES.put(option -> option.clazz() == Map.class && ConfigReflectionUtils.getMapType(option.getGenericType()) == ConfigReflectionUtils.CollectionType.SIMPLE, OptionComponentFactory.SIMPLE_MAP); DEFAULT_FACTORIES.put(option -> option.clazz().isEnum(), OptionComponentFactory.ENUM); + DEFAULT_FACTORIES.put(option -> { + if (option.clazz() != Map.class) { + try { + ReflectionUtils.getNoArgsConstructor(option.clazz()); + + return true; + } catch (IllegalStateException ignored) { + } + } + + return false; + } , OptionComponentFactory.STRUCT); UIParsing.registerFactory("config-slider", element -> new ConfigSlider()); UIParsing.registerFactory("config-toggle-button", element -> new ConfigToggleButton()); UIParsing.registerFactory("config-enum-button", element -> new ConfigEnumButton()); UIParsing.registerFactory("config-text-box", element -> new ConfigTextBox()); + UIParsing.registerFactory("selectable-scroll", SelectableScrollContainer::parse); } protected record SearchMatches(String query, List matches) {} @@ -470,13 +800,4 @@ public void update(float delta, int mouseX, int mouseY) { } } } - - private static boolean isStringOrNumberList(Field field) { - if (field.getType() != List.class) return false; - - var listType = ReflectionUtils.getTypeArgument(field.getGenericType(), 0); - if (listType == null) return false; - - return String.class == listType || NumberReflection.isNumberType(listType); - } } diff --git a/src/main/java/io/wispforest/owo/config/ui/ConfigScreenProvider.java b/src/main/java/io/wispforest/owo/config/ui/ConfigScreenProvider.java new file mode 100644 index 00000000..6f9c91fd --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ui/ConfigScreenProvider.java @@ -0,0 +1,24 @@ +package io.wispforest.owo.config.ui; + +import io.wispforest.owo.config.ConfigWrapper; +import it.unimi.dsi.fastutil.Pair; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.function.BiFunction; + +public interface ConfigScreenProvider> { + + static , S extends Screen> ConfigScreenProvider of(Class wrapperClass, BiFunction<@Nullable Screen, W, S> supplier) { + return (screen, wrapper) -> { + if (!wrapperClass.isInstance(wrapper)) { + throw new IllegalStateException("Unable to cast the given wrapper [" + wrapper.id() + "] to the required class [" + wrapperClass + "]"); + } + + return supplier.apply(screen, wrapperClass.cast(wrapper)); + }; + } + + Screen openScreenSafely(@Nullable Screen screen, ConfigWrapper wrapper) throws IllegalStateException; +} diff --git a/src/main/java/io/wispforest/owo/config/ui/ConfigScreenProviders.java b/src/main/java/io/wispforest/owo/config/ui/ConfigScreenProviders.java index 76a33433..bc8b9ab9 100644 --- a/src/main/java/io/wispforest/owo/config/ui/ConfigScreenProviders.java +++ b/src/main/java/io/wispforest/owo/config/ui/ConfigScreenProviders.java @@ -1,34 +1,58 @@ package io.wispforest.owo.config.ui; +import blue.endless.jankson.JsonObject; +import com.terraformersmc.modmenu.gui.ModsScreen; +import io.wispforest.owo.Owo; +import io.wispforest.owo.config.ConfigWrapper; +import io.wispforest.owo.packets.OwoPackets; +import io.wispforest.owo.packets.c2s.AdjustServerConfig; +import io.wispforest.owo.ui.util.UIErrorToast; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.Screen; -import org.jetbrains.annotations.ApiStatus; +import net.minecraft.util.Identifier; import org.jetbrains.annotations.Nullable; -import java.util.HashMap; -import java.util.Map; +import java.util.*; import java.util.function.BiConsumer; -import java.util.function.Function; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +@Environment(EnvType.CLIENT) public class ConfigScreenProviders { - private static final Map> PROVIDERS = new HashMap<>(); - private static final Map> OWO_SCREEN_PROVIDERS = new HashMap<>(); + public static final Identifier NONE = Identifier.of("owo", "none"); + + private static boolean rebuildSortedProviders = false; + + private static final Map PROVIDERS = new LinkedHashMap<>(); /** * Register the given config screen provider. This is primarily * used for making a config screen available in ModMenu and to the * {@code /owo-config} command, although other places my use it as well * - * @param modId The mod id for which to supply a config screen + * @param configId The mod id for which to supply a config screen * @param supplier The supplier to register - this gets the parent screen * as argument * @throws IllegalArgumentException If a config screen provider is * already registered for the given mod id */ - public static void register(String modId, Function supplier) { - if (PROVIDERS.put(modId, supplier) != null) { - throw new IllegalArgumentException("Tried to register config screen provider for mod id " + modId + " twice"); + public static > void register(Identifier configId, Class wrapperClass, BiFunction<@Nullable Screen, W, S> supplier) { + register(configId, 0, wrapperClass, supplier); + } + + public static > void register(Identifier configId, int order, Class wrapperClass, BiFunction<@Nullable Screen, W, S> supplier) { + if (PROVIDERS.containsKey(configId)) { + throw new IllegalArgumentException("Tried to register config screen provider for mod id " + configId.toString() + " twice"); } + + PROVIDERS.put(configId, new ScreenProviderData(configId, ConfigScreenProvider.of(wrapperClass, supplier), order)); + + if (!rebuildSortedProviders) rebuildSortedProviders = true; } /** @@ -38,11 +62,144 @@ public static void register(String modId, Function * @return The associated config screen provider, or {@code null} if * none is registered */ - public static @Nullable Function get(String modId) { - return PROVIDERS.get(modId); + public static @Nullable ConfigScreenProvider> get(Identifier configId) { + return PROVIDERS.get(configId).provider(); + } + + public static void forEach(BiConsumer>> action) { + PROVIDERS.forEach((identifier, data) -> action.accept(identifier, data.provider())); + } + + private static final Map> SORTED_PROVIDER_CACHE = new HashMap<>(); + + public static Map> getSortedProviders() { + if (rebuildSortedProviders) { + SORTED_PROVIDER_CACHE.clear(); + + var tempMap = new HashMap>(); + + for (var entry : PROVIDERS.entrySet()) { + var configId = entry.getKey(); + var data = entry.getValue(); + + tempMap.computeIfAbsent(configId.getNamespace(), string -> new ArrayList<>()) + .add(data); + } + + var baseMap = new HashMap>(); + + for (var entry : tempMap.entrySet()) { + /* + * 1. Sort by natural ordering of Strings + * 2. Sort by Order Number + * 3. Sort by primary configs + */ + var sortedSet = entry.getValue().stream() + .sorted(Comparator.comparing(data -> data.configId().getPath(), CharSequence::compare)) + .sorted(Comparator.comparingInt(ScreenProviderData::order)) + .map(data -> data.configId().getPath()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + baseMap.put(entry.getKey(), sortedSet); + } + + SORTED_PROVIDER_CACHE.putAll(baseMap); + } + + return SORTED_PROVIDER_CACHE; + } + + public static Identifier getPrimaryModProvider(String modid) { + var modProviders = getSortedProviders().get(modid); + + if (modProviders == null || modProviders.isEmpty()) return NONE; + + return Identifier.of(modid, modProviders.getFirst()); } - public static void forEach(BiConsumer> action) { - PROVIDERS.forEach(action); + public static boolean safelyOpenConfigScreen(Identifier configId, @Nullable Screen parent, ConfigScreen prevConfigScreen) { + var result = safelyOpenConfigScreen(configId, parent, prevConfigScreen.serverConfigData); + + if (result && MinecraftClient.getInstance().currentScreen instanceof ConfigScreen configScreen) { + configScreen.setConfigScreenData(prevConfigScreen); + } + + return result; } + + public static boolean safelyOpenConfigScreen(String modid, @Nullable Screen parent, Map configData) { + return safelyOpenConfigScreen(getPrimaryModProvider(modid), parent, configData); + } + + public static boolean safelyOpenConfigScreen(Identifier configId, @Nullable Screen parent, Map configData) { + var screen = safelyCreateConfigScreen(configId, parent, configData); + + if (screen == null) return false; + + MinecraftClient.getInstance().setScreen(screen); + + return true; + } + + @Nullable + public static Screen safelyCreateConfigScreen(Identifier configId, @Nullable Screen parent, Map configData) { + try { + var data = configData != null && MinecraftClient.getInstance().getServer() == null + ? configData.get(configId) + : null; + + var wrapper = ConfigWrapper.getOrDuplicateWrapper(configId, data); + + var providerData = PROVIDERS.get(configId); + + if (providerData == null) return null; + + Screen screen = providerData.provider().openScreenSafely(parent, wrapper); + + if (screen instanceof ConfigScreen configScreen) { + if (providerData != null) { + configScreen.addRemovedHook((config, restartRequired) -> { + OwoPackets.MAIN.clientHandle().send(new AdjustServerConfig(config.id(), config.saveToObject(), restartRequired)); + }); + } + + configScreen.setServerConfigData(configData); + } else if (providerData != null){ + ScreenEvents.remove(screen).register(screen1 -> { + OwoPackets.MAIN.clientHandle().send(new AdjustServerConfig(wrapper.id(), wrapper.saveToObject(), false)); + }); + } + + return screen; + } catch (java.lang.NoClassDefFoundError e) { + Owo.LOGGER.warn("The '{}' mod config screen is not available because {} is missing.", configId, e.getLocalizedMessage()); + handleError(parent, configId, e); + } catch (Throwable e) { + Owo.LOGGER.error("Error from mod '{}'", configId, e); + handleError(parent, configId, e); + } + + return null; + } + + private static void handleError(Screen startingScreen, Identifier configId, Throwable e) { + if(!FabricLoader.getInstance().isModLoaded("modmenu") || !handleModScreenError(startingScreen, configId, e)) { + //Owo.LOGGER.warn("Could not set owo config screen [" + modId + ":" + configName + "]", e); + UIErrorToast.report(e); + } + + MinecraftClient.getInstance().setScreen(startingScreen); + } + + private static boolean handleModScreenError(Screen startingScreen, Identifier configId, Throwable e) { + if(startingScreen instanceof ModsScreen screen) { + screen.modScreenErrors.put(configId.getNamespace(), e); + + return true; + } + + return false; + } + + private record ScreenProviderData(Identifier configId, ConfigScreenProvider> provider, int order){} } diff --git a/src/main/java/io/wispforest/owo/config/ui/OptionComponentData.java b/src/main/java/io/wispforest/owo/config/ui/OptionComponentData.java new file mode 100644 index 00000000..7eebbc1b --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ui/OptionComponentData.java @@ -0,0 +1,10 @@ +package io.wispforest.owo.config.ui; + +import io.wispforest.owo.config.ui.component.OptionValueProvider; +import io.wispforest.owo.ui.core.Component; + +import java.util.function.Supplier; + +public record OptionComponentData(B baseComponent, P optionProvider, + Supplier searchTextSource) { +} diff --git a/src/main/java/io/wispforest/owo/config/ui/OptionComponentFactory.java b/src/main/java/io/wispforest/owo/config/ui/OptionComponentFactory.java index 09df8b83..1208f63e 100644 --- a/src/main/java/io/wispforest/owo/config/ui/OptionComponentFactory.java +++ b/src/main/java/io/wispforest/owo/config/ui/OptionComponentFactory.java @@ -1,24 +1,35 @@ package io.wispforest.owo.config.ui; -import io.wispforest.owo.config.Option; +import io.wispforest.owo.Owo; +import io.wispforest.owo.config.options.FieldOption; +import io.wispforest.owo.config.options.OptionControlSpec; +import io.wispforest.owo.config.options.ReflectiveOption; +import io.wispforest.owo.config.annotation.Expanded; import io.wispforest.owo.config.annotation.RangeConstraint; import io.wispforest.owo.config.annotation.WithAlpha; -import io.wispforest.owo.config.ui.component.ListOptionContainer; import io.wispforest.owo.config.ui.component.OptionValueProvider; -import io.wispforest.owo.ui.component.BoxComponent; -import io.wispforest.owo.ui.component.ButtonComponent; -import io.wispforest.owo.ui.component.ColorPickerComponent; +import io.wispforest.owo.config.ui.component.SearchAnchorComponent; +import io.wispforest.owo.config.ui.component.struct.*; import io.wispforest.owo.ui.component.Components; +import io.wispforest.owo.ui.component.LabelComponent; +import io.wispforest.owo.ui.container.CollapsibleContainer; import io.wispforest.owo.ui.container.Containers; import io.wispforest.owo.ui.container.FlowLayout; import io.wispforest.owo.ui.core.*; import io.wispforest.owo.ui.parsing.UIModel; +import io.wispforest.owo.ui.util.UISounds; import io.wispforest.owo.util.NumberReflection; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.client.sound.PositionedSoundInstance; +import net.minecraft.sound.SoundEvents; +import net.minecraft.text.ClickEvent; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; +import java.util.*; /** * A function which creates an instance of {@link OptionValueProvider} @@ -31,100 +42,112 @@ public interface OptionComponentFactory { OptionComponentFactory NUMBER = (model, option) -> { - var field = option.backingField().field(); - - if (field.isAnnotationPresent(RangeConstraint.class)) { - return OptionComponents.createRangeControls( - model, option, - NumberReflection.isFloatingPointType(field.getType()) - ? field.getAnnotation(RangeConstraint.class).decimalPlaces() - : 0 - ); - } else { - return OptionComponents.createTextBox(model, option, configTextBox -> { - configTextBox.configureForNumber(option.clazz()); - }); + var access = option.backingAccess(); + + var floatingPointType = NumberReflection.isFloatingPointType(option.clazz()); + + var useSlider = false; + + var decimalPlaces = floatingPointType ? 2 : 0; + + Double min = NumberReflection.minValue(option.clazz()).doubleValue(), max = NumberReflection.maxValue(option.clazz()).doubleValue(); + + if (access.hasAnnotation(RangeConstraint.class)) { + var constraintData = access.getAnnotation(RangeConstraint.class); + + useSlider = constraintData.useSlider(); + + if (floatingPointType) decimalPlaces = constraintData.decimalPlaces(); + + min = constraintData.min(); + max = constraintData.max(); } + + return attachOptionLabel(OptionComponents.createNumberComponent(model, option, decimalPlaces, min, max, useSlider, option.detached()), model, option); }; OptionComponentFactory STRING = (model, option) -> { - return OptionComponents.createTextBox(model, option, configTextBox -> { - if (option.constraint() != null) { - configTextBox.applyPredicate(option.constraint()::test); - } - }); + return attachOptionLabel(OptionComponents.createStringComponent(model, option, option.detached()), model, option); }; OptionComponentFactory IDENTIFIER = (model, option) -> { - return OptionComponents.createTextBox(model, option, configTextBox -> { - configTextBox.inputPredicate(s -> s.matches("[a-z0-9_.:\\-]*")); - configTextBox.applyPredicate(s -> Identifier.tryParse(s) != null); - configTextBox.valueParser(Identifier::of); - }); + return attachOptionLabel(OptionComponents.createIdentifierComponent(model, option, option.detached()), model, option); }; - @SuppressWarnings("DataFlowIssue") OptionComponentFactory COLOR = (model, option) -> { - boolean withAlpha = option.backingField().hasAnnotation(WithAlpha.class); - - final var result = OptionComponents.createTextBox(model, option, color -> color.asHexString(withAlpha), configTextBox -> { - configTextBox.inputPredicate(withAlpha ? s -> s.matches("#[a-zA-Z\\d]{0,8}") : s -> s.matches("#[a-zA-Z\\d]{0,6}")); - configTextBox.applyPredicate(withAlpha ? s -> s.matches("#[a-zA-Z\\d]{8}") : s -> s.matches("#[a-zA-Z\\d]{6}")); - configTextBox.valueParser(withAlpha - ? s -> Color.ofArgb(Integer.parseUnsignedInt(s.substring(1), 16)) - : s -> Color.ofRgb(Integer.parseUnsignedInt(s.substring(1), 16)) - ); - }); + boolean withAlpha = option.backingAccess().hasAnnotation(WithAlpha.class); + return attachOptionLabel(OptionComponents.createColorComponent(model, option, withAlpha, option.detached()), model, option); + }; - result.baseComponent.childById(FlowLayout.class, "controls-flow").configure(controls -> { - Supplier valueGetter = () -> result.optionProvider.isValid() - ? (Color) result.optionProvider.parsedValue() - : Color.BLACK; + OptionComponentFactory BOOLEAN = (model, option) -> { + return attachOptionLabel(OptionComponents.createToggleButton(model, option, option.detached()), model, option); + }; - var box = Components.box(Sizing.fixed(15), Sizing.fixed(15)).color(valueGetter.get()).fill(true); - box.margins(Insets.right(5)).cursorStyle(CursorStyle.HAND); - controls.child(0, box); + OptionComponentFactory> ENUM = (model, option) -> { + return attachOptionLabel(OptionComponents.createEnumButton(model, option, option.detached()), model, option); + }; - result.optionProvider.onChanged().subscribe(value -> box.color(valueGetter.get())); + @SuppressWarnings({"unchecked"}) + OptionComponentFactory> LIST = (model, option) -> { + var expanded = option.backingAccess().hasAnnotation(Expanded.class); + var layout = new ListOptionContainer<>(model, (OptionControlSpec) option, ArrayList::new, expanded, option.detached()); + return new Result<>(layout, layout); + }; - box.mouseDown().subscribe((mouseX, mouseY, button) -> { - ((FlowLayout) box.root()).child(Containers.overlay( - model.expandTemplate( - FlowLayout.class, - "color-picker-panel", - Map.of("color", valueGetter.get().asHexString(withAlpha), "with-alpha", String.valueOf(withAlpha)) - ).configure(flowLayout -> { - var picker = flowLayout.childById(ColorPickerComponent.class, "color-picker"); - var previewBox = flowLayout.childById(BoxComponent.class, "current-color"); + @SuppressWarnings({"unchecked"}) + OptionComponentFactory> SET = (model, option) -> { + var expanded = option.backingAccess().hasAnnotation(Expanded.class); + var layout = new ListOptionContainer<>(model, (OptionControlSpec) option, LinkedHashSet::new, expanded, option.detached()); + return new Result<>(layout, layout); + }; - picker.onChanged().subscribe(previewBox::color); + @SuppressWarnings({"unchecked"}) + OptionComponentFactory> SIMPLE_MAP = (model, option) -> { + var expanded = option.backingAccess().hasAnnotation(Expanded.class); + var layout = new MapOptionContainer<>(model, (OptionControlSpec) option, expanded, option.detached()); + return new Result<>(layout, layout); + }; - flowLayout.childById(ButtonComponent.class, "confirm-button").onPress(confirmButton -> { - result.optionProvider.text(picker.selectedColor().asHexString(withAlpha)); - flowLayout.parent().remove(); - }); + @SuppressWarnings({"unchecked"}) + OptionComponentFactory STRUCT = (model, option) -> { + var key = option.key(); - flowLayout.childById(ButtonComponent.class, "cancel-button").onPress(cancelButton -> { - flowLayout.parent().remove(); - }); - }) - ).zIndex(100)); + var expanded = !key.isRoot() && option.backingAccess().hasAnnotation(Expanded.class); - return true; - }); - }); + AbstractStructOptionContainer layout; - return result; - }; + if (option.value() instanceof Record) { + layout = RecordStructOptionContainer.of(model, (FieldOption) (Object) option); + } else { + layout = StructOptionContainer.of(model, option); + } - OptionComponentFactory BOOLEAN = OptionComponents::createToggleButton; + var titleKey = "text.config." + option.configName() + ".option." + key.asString(); - OptionComponentFactory> ENUM = OptionComponents::createEnumButton; + var container = Containers.collapsible( + Sizing.fill(100), Sizing.content(), + Text.translatable(titleKey), + expanded + ).configure(nestedContainer -> { + if (I18n.hasTranslation(titleKey + ".tooltip")) { + nestedContainer.titleLayout().tooltip(Text.translatable(titleKey + ".tooltip")); + } - @SuppressWarnings({"unchecked", "rawtypes"}) - OptionComponentFactory> LIST = (model, option) -> { - var layout = new ListOptionContainer(option); - return new Result(layout, layout); + nestedContainer.titleLayout().child(new SearchAnchorComponent( + nestedContainer.titleLayout(), + key, + () -> I18n.translate(titleKey) + ).highlightConfigurator(highlight -> + highlight.positioning(Positioning.absolute(-5, -5)) + .verticalSizing(Sizing.fixed(19)) + )); + }); + + OptionComponentFactory.addEasyCopyLabel(container.titleLayout(), titleKey); + + container.child(layout); + + return new Result(container, layout); }; /** @@ -137,7 +160,89 @@ public interface OptionComponentFactory { * @return The option component as well as a potential wrapping * component, this simply be the option component itself */ - Result make(UIModel model, Option option); + Result make(UIModel model, ReflectiveOption option); record Result(B baseComponent, P optionProvider) {} + + static Result attachOptionLabel(Result result, UIModel model, OptionControlSpec option) { + var baseComponent = model.expandTemplate(FlowLayout.class, + "config-option-base", + Map.of("config-option-name", option.translationKey()) + ); + + var optionNameHolder = baseComponent.childById(FlowLayout.class, "option-name-holder"); + + addEasyCopyLabel(optionNameHolder, option.translationKey()); + + if (result.baseComponent() instanceof ParentComponent parentComponent) { + var deque = new ArrayDeque<>(List.of(parentComponent)); + + while (!deque.isEmpty()) { + var currentParent = deque.poll(); + + for (var child : currentParent.children()) { + if (child instanceof SearchAnchorComponent searchAnchorComponent) { + searchAnchorComponent.anchorFrame(baseComponent); + } else if (child instanceof ParentComponent parentComponent1) { + deque.push(parentComponent1); + } + } + } + } + + baseComponent + .child( + new SearchAnchorComponent( + baseComponent, + option.key(), + () -> baseComponent.childById(LabelComponent.class, "option-name").text().getString()) + ); + + baseComponent.childById(FlowLayout.class, "controls") + .child(result.baseComponent()); + + return new Result<>(baseComponent, result.optionProvider()); + } + + static void addEasyCopyLabel(@NotNull FlowLayout nameHolder, String translationKey) { + Objects.requireNonNull(nameHolder); + + if (!Owo.DEBUG || I18n.hasTranslation(translationKey)) return; + + var baseColor = Color.ofFormatting(Formatting.AQUA); + + var unhoveredColor = baseColor.interpolate(Color.BLACK, 0.3f); + var hoveredColor = baseColor.interpolate(Color.WHITE, 0.6f); + + nameHolder.child(0, + Components.label(Text.literal("\uD83D\uDCCB")) + .configure((LabelComponent component) -> { + component.mouseEnter().subscribe(() -> { + component.color(hoveredColor); + + component.tooltip(Text.of("Copy Translation Key")); + }); + + component.mouseLeave().subscribe(() -> { + component.color(unhoveredColor); + }); + + component.mouseDown().subscribe((mouseX, mouseY, button) -> { + var client = MinecraftClient.getInstance(); + + client.keyboard.setClipboard(translationKey); + UISounds.playButtonSound(); + + component.tooltip( + Text.literal("Translation Key Copied!") + .formatted(Formatting.GREEN) + ); + + return true; + }); + }) + .color(unhoveredColor) + .margins(Insets.of(0,0,2,2)) + ); + } } diff --git a/src/main/java/io/wispforest/owo/config/ui/OptionComponents.java b/src/main/java/io/wispforest/owo/config/ui/OptionComponents.java index 433d5ec2..3317fb1c 100644 --- a/src/main/java/io/wispforest/owo/config/ui/OptionComponents.java +++ b/src/main/java/io/wispforest/owo/config/ui/OptionComponents.java @@ -1,37 +1,107 @@ package io.wispforest.owo.config.ui; -import io.wispforest.owo.config.Option; -import io.wispforest.owo.config.annotation.RangeConstraint; +import io.wispforest.owo.config.options.OptionControlSpec; import io.wispforest.owo.config.ui.component.*; -import io.wispforest.owo.ui.component.ButtonComponent; -import io.wispforest.owo.ui.component.LabelComponent; +import io.wispforest.owo.ui.component.*; +import io.wispforest.owo.ui.container.Containers; import io.wispforest.owo.ui.container.FlowLayout; -import io.wispforest.owo.ui.core.Positioning; +import io.wispforest.owo.ui.core.*; import io.wispforest.owo.ui.parsing.UIModel; import net.minecraft.text.Text; +import net.minecraft.util.Identifier; import org.apache.commons.lang3.mutable.MutableBoolean; import java.util.Map; +import java.util.Objects; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; +import static io.wispforest.owo.config.ui.OptionComponentFactory.Result; + +// TODO: [Config Rewrite] CHANGE USE OF Option TO SOME ABSTRACTED INTERFACE @SuppressWarnings("ConstantConditions") public class OptionComponents { - public static OptionComponentFactory.Result createTextBox(UIModel model, Option option, Consumer processor) { - return createTextBox(model, option, Object::toString, processor); + public static Result createStringComponent(UIModel model, OptionControlSpec option, boolean isDetached) { + return OptionComponents.createTextBox(model, option, configTextBox -> { + if (option.constraint() != null) { + configTextBox.applyPredicate(option.constraint()::test); + } + }, isDetached); + } + + public static Result createIdentifierComponent(UIModel model, OptionControlSpec option, boolean isDetached) { + return OptionComponents.createTextBox(model, option, ConfigTextBox::configureForIdentifier, isDetached); } - public static OptionComponentFactory.Result createTextBox(UIModel model, Option option, Function toStringFunction, Consumer processor) { - var optionComponent = model.expandTemplate(FlowLayout.class, - "text-box-config-option", - packParameters(option.translationKey(), toStringFunction.apply(option.value())) - ); + @SuppressWarnings("DataFlowIssue") + public static Result createColorComponent(UIModel model, OptionControlSpec option, boolean withAlpha, boolean isDetached) { + final var result = OptionComponents.createTextBox(model, option, color -> color.asHexString(withAlpha), configTextBox -> { + configTextBox.inputPredicate(withAlpha ? s -> s.matches("#[a-zA-Z\\d]{0,8}") : s -> s.matches("#[a-zA-Z\\d]{0,6}")); + configTextBox.applyPredicate(withAlpha ? s -> s.matches("#[a-zA-Z\\d]{8}") : s -> s.matches("#[a-zA-Z\\d]{6}")); + configTextBox.valueParser(withAlpha + ? s -> Color.ofArgb(Integer.parseUnsignedInt(s.substring(1), 16)) + : s -> Color.ofRgb(Integer.parseUnsignedInt(s.substring(1), 16)) + ); + }, isDetached); + + result.baseComponent().configure(controls -> { + Supplier valueGetter = () -> result.optionProvider().isValid() + ? (Color) result.optionProvider().parsedValue() + : Color.BLACK; + + var box = Components.box(Sizing.fixed(15), Sizing.fixed(15)).color(valueGetter.get()).fill(true); + box.margins(Insets.right(5)).cursorStyle(CursorStyle.HAND); + controls.child(0, box); + + result.optionProvider().onChanged().subscribe(value -> box.color(valueGetter.get())); + + box.mouseDown().subscribe((mouseX, mouseY, button) -> { + ((FlowLayout) box.root()).child(Containers.overlay( + model.expandTemplate( + FlowLayout.class, + "color-picker-panel", + Map.of("color", valueGetter.get().asHexString(withAlpha), "with-alpha", String.valueOf(withAlpha)) + ).configure(flowLayout -> { + var picker = flowLayout.childById(ColorPickerComponent.class, "color-picker"); + var previewBox = flowLayout.childById(BoxComponent.class, "current-color"); + + picker.onChanged().subscribe(previewBox::color); + + flowLayout.childById(ButtonComponent.class, "confirm-button").onPress(confirmButton -> { + result.optionProvider().text(picker.selectedColor().asHexString(withAlpha)); + flowLayout.parent().remove(); + }); + + flowLayout.childById(ButtonComponent.class, "cancel-button").onPress(cancelButton -> { + flowLayout.parent().remove(); + }); + }) + ).zIndex(100)); + + return true; + }); + }); + + return result; + } + + public static Result createTextBox(UIModel model, OptionControlSpec option, Consumer processor, boolean isDetached) { + return createTextBox(model, option, Objects::toString, processor, isDetached); + } + + public static Result createTextBox(UIModel model, OptionControlSpec option, Function toStringFunction, Consumer processor, boolean isDetached) { + var optionComponent = model.expandTemplate(FlowLayout.class, "text-box-config-option", Map.of()); var valueBox = optionComponent.childById(ConfigTextBox.class, "value-box"); var resetButton = optionComponent.childById(ButtonComponent.class, "reset-button"); - if (option.detached()) { + valueBox.text(toStringFunction.apply(option.value())); + + valueBox.horizontalSizing(Sizing.fixed(Math.round(valueBox.horizontalSizing().get().value / 1.25f))); // Difference 2 + + if (isDetached) { resetButton.active = false; valueBox.setEditable(false); } else { @@ -49,14 +119,19 @@ public static OptionComponentFactory.Result creat optionComponent.child(new SearchAnchorComponent( optionComponent, option.key(), - () -> optionComponent.childById(LabelComponent.class, "option-name").text().getString(), valueBox::getText )); - return new OptionComponentFactory.Result<>(optionComponent, valueBox); + return new Result<>(optionComponent, valueBox); + } + + public static Result createNumberComponent(UIModel model, OptionControlSpec option, int decimalPlaces, Double min, Double max, boolean createRange, boolean isDetached) { + return (createRange) + ? OptionComponents.createRangeControls(model, option, decimalPlaces, min, max, isDetached) + : OptionComponents.createTextBox(model, option, configTextBox -> configTextBox.configureForNumber(option.clazz(), min, max), isDetached); } - public static OptionComponentFactory.Result createRangeControls(UIModel model, Option option, int decimalPlaces) { + public static Result createRangeControls(UIModel model, OptionControlSpec option, int decimalPlaces, Double min, Double max, boolean isDetached) { boolean withDecimals = decimalPlaces > 0; // ------------ @@ -64,13 +139,7 @@ public static OptionComponentFactory.Result cre // ------------ var value = option.value(); - var optionComponent = model.expandTemplate(FlowLayout.class, - "range-config-option", - packParameters(option.translationKey(), value.toString()) - ); - - var constraint = option.backingField().field().getAnnotation(RangeConstraint.class); - double min = constraint.min(), max = constraint.max(); + var optionComponent = model.expandTemplate(FlowLayout.class, "range-config-option", Map.of()); var sliderInput = optionComponent.childById(ConfigSlider.class, "value-slider"); sliderInput.min(min).max(max).decimalPlaces(decimalPlaces).snap(!withDecimals).setFromDiscreteValue(value.doubleValue()); @@ -78,7 +147,7 @@ public static OptionComponentFactory.Result cre var resetButton = optionComponent.childById(ButtonComponent.class, "reset-button"); - if (option.detached()) { + if (isDetached) { resetButton.active = false; sliderInput.active = false; } else { @@ -99,21 +168,21 @@ public static OptionComponentFactory.Result cre var sliderControls = optionComponent.childById(FlowLayout.class, "slider-controls"); var textControls = createTextBox(model, option, configTextBox -> { - configTextBox.configureForNumber(option.clazz()); + configTextBox.configureForNumber(option.clazz(), min, max); var predicate = configTextBox.applyPredicate(); configTextBox.applyPredicate(predicate.and(s -> { final var parsed = Double.parseDouble(s); return parsed >= min && parsed <= max; })); - }).baseComponent().childById(FlowLayout.class, "controls-flow").positioning(Positioning.layout()); + }, isDetached).baseComponent().positioning(Positioning.layout()); var textInput = textControls.childById(ConfigTextBox.class, "value-box"); // ------------ // Toggle setup // ------------ - var controlsLayout = optionComponent.childById(FlowLayout.class, "controls-flow"); + var controlsLayout = optionComponent; var toggleButton = optionComponent.childById(ButtonComponent.class, "toggle-button"); var textMode = new MutableBoolean(false); @@ -141,11 +210,10 @@ public static OptionComponentFactory.Result cre optionComponent.child(new SearchAnchorComponent( optionComponent, option.key(), - () -> optionComponent.childById(LabelComponent.class, "option-name").text().getString(), () -> textMode.isTrue() ? textInput.getText() : sliderInput.getMessage().getString() )); - return new OptionComponentFactory.Result<>(optionComponent, new OptionValueProvider() { + return new Result<>(optionComponent, new OptionValueProvider() { @Override public boolean isValid() { return textMode.isTrue() @@ -162,18 +230,18 @@ public Object parsedValue() { }); } - public static OptionComponentFactory.Result createToggleButton(UIModel model, Option option) { - var optionComponent = model.expandTemplate(FlowLayout.class, - "boolean-toggle-config-option", - packParameters(option.translationKey(), option.value().toString()) - ); + public static Result createToggleButton(UIModel model, OptionControlSpec option, boolean isDetached) { + var optionComponent = model.expandTemplate(FlowLayout.class, "boolean-toggle-config-option", Map.of()); var toggleButton = optionComponent.childById(ConfigToggleButton.class, "toggle-button"); var resetButton = optionComponent.childById(ButtonComponent.class, "reset-button"); + toggleButton.horizontalSizing(Sizing.fixed(Math.round(toggleButton.horizontalSizing().get().value / 1.25f))); // Difference 2 + toggleButton.margins(Insets.horizontal(1)); + toggleButton.enabled(option.value()); - if (option.detached()) { + if (isDetached) { resetButton.active = false; toggleButton.active = false; } else { @@ -189,25 +257,24 @@ public static OptionComponentFactory.Result crea optionComponent.child(new SearchAnchorComponent( optionComponent, option.key(), - () -> optionComponent.childById(LabelComponent.class, "option-name").text().getString(), () -> toggleButton.getMessage().getString() )); - return new OptionComponentFactory.Result<>(optionComponent, toggleButton); + return new Result<>(optionComponent, toggleButton); } - public static OptionComponentFactory.Result createEnumButton(UIModel model, Option> option) { - var optionComponent = model.expandTemplate(FlowLayout.class, - "enum-config-option", - packParameters(option.translationKey(), option.value().toString()) - ); + public static Result createEnumButton(UIModel model, OptionControlSpec> option, boolean isDetached) { + var optionComponent = model.expandTemplate(FlowLayout.class, "enum-config-option", Map.of()); var enumButton = optionComponent.childById(ConfigEnumButton.class, "enum-button"); var resetButton = optionComponent.childById(ButtonComponent.class, "reset-button"); + enumButton.horizontalSizing(Sizing.fixed(Math.round(enumButton.horizontalSizing().get().value / 1.25f))); // Difference 2 + enumButton.margins(Insets.horizontal(1)); + enumButton.init(option, option.value().ordinal()); - if (option.detached()) { + if (isDetached) { resetButton.active = false; enumButton.active = false; } else { @@ -223,17 +290,9 @@ public static OptionComponentFactory.Result create optionComponent.child(new SearchAnchorComponent( optionComponent, option.key(), - () -> optionComponent.childById(LabelComponent.class, "option-name").text().getString(), () -> enumButton.getMessage().getString() )); - return new OptionComponentFactory.Result<>(optionComponent, enumButton); - } - - public static Map packParameters(String name, String value) { - return Map.of( - "config-option-name", name, - "config-option-value", value - ); + return new Result<>(optionComponent, enumButton); } } diff --git a/src/main/java/io/wispforest/owo/config/ui/component/ConfigEnumButton.java b/src/main/java/io/wispforest/owo/config/ui/component/ConfigEnumButton.java index 8f2f4ed9..b637a25c 100644 --- a/src/main/java/io/wispforest/owo/config/ui/component/ConfigEnumButton.java +++ b/src/main/java/io/wispforest/owo/config/ui/component/ConfigEnumButton.java @@ -1,6 +1,6 @@ package io.wispforest.owo.config.ui.component; -import io.wispforest.owo.config.Option; +import io.wispforest.owo.config.options.OptionControlSpec; import io.wispforest.owo.ui.component.ButtonComponent; import io.wispforest.owo.ui.core.Sizing; import net.minecraft.client.gui.screen.Screen; @@ -16,7 +16,7 @@ @ApiStatus.Internal public class ConfigEnumButton extends ButtonComponent implements OptionValueProvider { - @Nullable protected Option> backingOption = null; + @Nullable protected OptionControlSpec> backingOption = null; @Nullable protected Enum[] backingValues = null; protected int selectedIndex = 0; @@ -68,9 +68,9 @@ protected void updateMessage() { ); } - public ConfigEnumButton init(Option> option, int selectedIndex) { + public ConfigEnumButton init(OptionControlSpec> option, int selectedIndex) { this.backingOption = option; - this.backingValues = (Enum[]) option.backingField().field().getType().getEnumConstants(); + this.backingValues = option.clazz().getEnumConstants(); this.selectedIndex = selectedIndex; this.updateMessage(); diff --git a/src/main/java/io/wispforest/owo/config/ui/component/ConfigTextBox.java b/src/main/java/io/wispforest/owo/config/ui/component/ConfigTextBox.java index 92a08e18..e2287b6c 100644 --- a/src/main/java/io/wispforest/owo/config/ui/component/ConfigTextBox.java +++ b/src/main/java/io/wispforest/owo/config/ui/component/ConfigTextBox.java @@ -6,6 +6,7 @@ import io.wispforest.owo.ui.parsing.UIModel; import io.wispforest.owo.ui.parsing.UIParsing; import io.wispforest.owo.util.NumberReflection; +import net.minecraft.util.Identifier; import org.jetbrains.annotations.ApiStatus; import org.w3c.dom.Element; @@ -30,9 +31,9 @@ public ConfigTextBox() { }); } - public ConfigTextBox configureForNumber(Class fieldType) { + public ConfigTextBox configureForNumber(Class fieldType, Double minNumber, Double maxNumber) { final boolean floatingPoint = NumberReflection.isFloatingPointType(fieldType); - final double min = NumberReflection.minValue(fieldType).doubleValue(), max = NumberReflection.maxValue(fieldType).doubleValue(); + final double min = minNumber.doubleValue(), max = maxNumber.doubleValue(); this.valueParser = s -> { try { @@ -55,6 +56,14 @@ public ConfigTextBox configureForNumber(Class fieldType) { return this; } + public ConfigTextBox configureForIdentifier() { + this.inputPredicate(s -> s.matches("[a-z0-9_.:\\-]*")) + .applyPredicate(s -> Identifier.tryParse(s) != null) + .valueParser(Identifier::of); + + return this; + } + @Override public boolean isValid() { return this.applyPredicate.test(this.getText()); diff --git a/src/main/java/io/wispforest/owo/config/ui/component/ListOptionContainer.java b/src/main/java/io/wispforest/owo/config/ui/component/ListOptionContainer.java deleted file mode 100644 index 91fed89e..00000000 --- a/src/main/java/io/wispforest/owo/config/ui/component/ListOptionContainer.java +++ /dev/null @@ -1,177 +0,0 @@ -package io.wispforest.owo.config.ui.component; - -import io.wispforest.owo.config.Option; -import io.wispforest.owo.config.annotation.Expanded; -import io.wispforest.owo.ops.TextOps; -import io.wispforest.owo.ui.component.ButtonComponent; -import io.wispforest.owo.ui.component.Components; -import io.wispforest.owo.ui.component.LabelComponent; -import io.wispforest.owo.ui.container.CollapsibleContainer; -import io.wispforest.owo.ui.container.Containers; -import io.wispforest.owo.ui.container.FlowLayout; -import io.wispforest.owo.ui.core.*; -import io.wispforest.owo.ui.util.UISounds; -import io.wispforest.owo.util.NumberReflection; -import io.wispforest.owo.util.ReflectionUtils; -import net.minecraft.client.gui.widget.ButtonWidget; -import net.minecraft.client.resource.language.I18n; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; -import org.jetbrains.annotations.ApiStatus; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -@ApiStatus.Internal -public class ListOptionContainer extends CollapsibleContainer implements OptionValueProvider { - - protected final Option> backingOption; - protected final List backingList; - - protected final ButtonWidget resetButton; - - @SuppressWarnings("unchecked") - public ListOptionContainer(Option> option) { - super( - Sizing.fill(100), Sizing.content(), - Text.translatable("text.config." + option.configName() + ".option." + option.key().asString()), - option.backingField().field().isAnnotationPresent(Expanded.class) - ); - - this.backingOption = option; - this.backingList = new ArrayList<>(option.value()); - - this.padding(this.padding.get().add(0, 5, 0, 0)); - - this.titleLayout.horizontalSizing(Sizing.fill(100)); - this.titleLayout.verticalSizing(Sizing.fixed(30)); - this.titleLayout.verticalAlignment(VerticalAlignment.CENTER); - - if (!option.detached()) { - this.titleLayout.child(Components.label(Text.translatable("text.owo.config.list.add_entry").formatted(Formatting.GRAY)).configure(label -> { - label.cursorStyle(CursorStyle.HAND); - - label.mouseEnter().subscribe(() -> label.text(label.text().copy().styled(style -> style.withColor(Formatting.YELLOW)))); - label.mouseLeave().subscribe(() -> label.text(label.text().copy().styled(style -> style.withColor(Formatting.GRAY)))); - label.mouseDown().subscribe((mouseX, mouseY, button) -> { - UISounds.playInteractionSound(); - this.backingList.add((T) ""); - - if (!this.expanded) this.toggleExpansion(); - this.refreshOptions(); - - var lastEntry = (ParentComponent) this.collapsibleChildren.get(this.collapsibleChildren.size() - 1); - this.focusHandler().focus( - lastEntry.children().get(lastEntry.children().size() - 1), - FocusSource.MOUSE_CLICK - ); - - return true; - }); - })); - } - - this.resetButton = Components.button(Text.literal("⇄"), (ButtonComponent button) -> { - this.backingList.clear(); - this.backingList.addAll(option.defaultValue()); - - this.refreshOptions(); - button.active = false; - }); - this.resetButton.margins(Insets.right(10)); - this.resetButton.positioning(Positioning.relative(100, 50)); - this.titleLayout.child(resetButton); - this.refreshResetButton(); - - this.refreshOptions(); - - this.titleLayout.child(new SearchAnchorComponent( - this.titleLayout, - option.key(), - () -> I18n.translate("text.config." + option.configName() + ".option." + option.key().asString()), - () -> this.backingList.stream().map(Objects::toString).collect(Collectors.joining()) - )); - } - - @SuppressWarnings({"unchecked", "ConstantConditions"}) - protected void refreshOptions() { - this.collapsibleChildren.clear(); - - var listType = ReflectionUtils.getTypeArgument(this.backingOption.backingField().field().getGenericType(), 0); - for (int i = 0; i < this.backingList.size(); i++) { - var container = Containers.horizontalFlow(Sizing.fill(100), Sizing.content()); - container.verticalAlignment(VerticalAlignment.CENTER); - - int optionIndex = i; - final var label = Components.label(TextOps.withFormatting("- ", Formatting.GRAY)); - label.margins(Insets.left(10)); - if (!this.backingOption.detached()) { - label.cursorStyle(CursorStyle.HAND); - label.mouseEnter().subscribe(() -> label.text(TextOps.withFormatting("x ", Formatting.GRAY))); - label.mouseLeave().subscribe(() -> label.text(TextOps.withFormatting("- ", Formatting.GRAY))); - label.mouseDown().subscribe((mouseX, mouseY, button) -> { - this.backingList.remove(optionIndex); - this.refreshResetButton(); - this.refreshOptions(); - UISounds.playInteractionSound(); - - return true; - }); - } - container.child(label); - - final var box = new ConfigTextBox(); - box.setText(this.backingList.get(i).toString()); - box.setCursorToStart(false); - box.setDrawsBackground(false); - box.margins(Insets.vertical(2)); - box.horizontalSizing(Sizing.fill(95)); - box.verticalSizing(Sizing.fixed(8)); - - if (!this.backingOption.detached()) { - box.onChanged().subscribe(s -> { - if (!box.isValid()) return; - - this.backingList.set(optionIndex, (T) box.parsedValue()); - this.refreshResetButton(); - }); - } else { - box.active = false; - } - - if (NumberReflection.isNumberType(listType)) { - box.configureForNumber((Class) listType); - } - - container.child(box); - this.collapsibleChildren.add(container); - } - - this.contentLayout.configure(layout -> { - layout.clearChildren(); - if (this.expanded) layout.children(this.collapsibleChildren); - }); - this.refreshResetButton(); - } - - protected void refreshResetButton() { - this.resetButton.active = !this.backingOption.detached() && !this.backingList.equals(this.backingOption.defaultValue()); - } - - @Override - public boolean shouldDrawTooltip(double mouseX, double mouseY) { - return ((mouseY - this.y) <= this.titleLayout.height()) && super.shouldDrawTooltip(mouseX, mouseY); - } - - @Override - public boolean isValid() { - return true; - } - - @Override - public Object parsedValue() { - return this.backingList; - } -} diff --git a/src/main/java/io/wispforest/owo/config/ui/component/OrderedOptionContainer.java b/src/main/java/io/wispforest/owo/config/ui/component/OrderedOptionContainer.java new file mode 100644 index 00000000..794f8907 --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ui/component/OrderedOptionContainer.java @@ -0,0 +1,227 @@ +package io.wispforest.owo.config.ui.component; + +import io.wispforest.owo.config.options.OptionControlSpec; +import io.wispforest.owo.config.ui.OptionComponentFactory; +import io.wispforest.owo.ops.TextOps; +import io.wispforest.owo.ui.component.ButtonComponent; +import io.wispforest.owo.ui.component.Components; +import io.wispforest.owo.ui.component.LabelComponent; +import io.wispforest.owo.ui.container.CollapsibleContainer; +import io.wispforest.owo.ui.container.Containers; +import io.wispforest.owo.ui.container.FlowLayout; +import io.wispforest.owo.ui.core.*; +import io.wispforest.owo.ui.parsing.UIModel; +import io.wispforest.owo.ui.util.UISounds; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.jetbrains.annotations.ApiStatus; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@ApiStatus.Internal +public abstract class OrderedOptionContainer extends CollapsibleContainer implements OptionValueProvider { + + protected final boolean isDetached; + + protected final UIModel uiModel; + + protected final OptionControlSpec backingOption; + + protected final List backingList; + + protected final List backingProviders; + + protected final ButtonWidget resetButton; + + public OrderedOptionContainer(UIModel uiModel, OptionControlSpec option, boolean expanded, boolean isDetached) { + super( + Sizing.fill(100), Sizing.content(), + Text.translatable(createTitleTranslation(option)), + expanded + ); + + this.isDetached = isDetached; + + this.uiModel = uiModel; + + this.backingOption = option; + this.backingList = convertToList(option.value()); + this.backingProviders = new ArrayList<>(); + + this.padding(this.padding.get().add(0, 5, 0, 0)); + + this.titleLayout + .verticalAlignment(VerticalAlignment.CENTER) + .padding(Insets.of(5)) + .horizontalSizing(Sizing.fill(100)) + .verticalSizing(Sizing.fixed(30)); + + OptionComponentFactory.addEasyCopyLabel(this.titleLayout, createTitleTranslation(option)); + + if (!this.isDetached) { + var addLabel = uiModel.expandTemplate(LabelComponent.class, "collection-add-label", Map.of()).configure(label -> { + label.mouseDown().subscribe((mouseX, mouseY, button) -> { + UISounds.playInteractionSound(); + + var index = this.backingList.size(); + var newEntry = createDefaultValue(); + + this.backingList.add(newEntry); + + if (!this.expanded) this.toggleExpansion(); + + var provider = createProviderComponent(this.backingList.get(index)); + + this.collapsibleChildren.add(createEntryContainer(index).child(provider)); + this.backingProviders.add(provider); + + this.refreshOptions(); + + var lastEntry = (ParentComponent) this.collapsibleChildren.getLast(); + this.focusHandler().focus( + lastEntry.children().get(lastEntry.children().size() - 1), + FocusSource.MOUSE_CLICK + ); + + return true; + }); + }); + + this.titleLayout.child(addLabel); + } + + this.resetButton = uiModel.expandTemplate(ButtonComponent.class, "control-reset-button", Map.of()) + .configure(buttonWidget -> { + buttonWidget.onPress(btn -> { + this.backingList.clear(); + this.backingList.addAll(convertToList(option.defaultValue())); + + this.refreshOptions(); + btn.active = false; + }).positioning(Positioning.relative(100, 50)); + }); + + this.titleLayout.child(resetButton); + + this.refreshResetButton(); + + this.refreshOptions(); + + this.titleLayout.child(new SearchAnchorComponent( + this.titleLayout, + option.key(), + () -> I18n.translate(createTitleTranslation(option)), + () -> this.backingList.stream().map(Objects::toString).collect(Collectors.joining()) + )); + } + + private static String createTitleTranslation(OptionControlSpec option) { + return "text.config." + option.configName() + ".option." + option.key().asString(); + } + + protected boolean tickAtTop() { + return true; + } + + protected abstract

P createProviderComponent(T listEntry); + + protected abstract List convertToList(C data); + + protected abstract C convertToCollection(List backingList); + + protected abstract T createDefaultValue(); + + protected FlowLayout createEntryContainer(int optionIndex) { + var tickAtTop = tickAtTop(); + + var container = Containers.horizontalFlow(Sizing.fill(100), Sizing.content()); + container.verticalAlignment(tickAtTop ? VerticalAlignment.TOP : VerticalAlignment.CENTER); + + if (tickAtTop && optionIndex + 1 < this.backingList.size()) container.padding(Insets.bottom(10)); + + final var label = this.uiModel.expandTemplate(LabelComponent.class, "collection-entry-tick", Map.of()); + + if (this.isDetached) { + // Remove hoverablity implementation indicators + label.hoverText(null); + label.cursorStyle(CursorStyle.NONE); + } else { + label.mouseDown().subscribe((mouseX, mouseY, button) -> { + this.backingList.remove(optionIndex); + this.collapsibleChildren.remove(optionIndex); + this.backingProviders.remove(optionIndex); + this.refreshResetButton(); + this.refreshOptions(); + UISounds.playInteractionSound(); + + return true; + }); + } + + container.child( + Containers.verticalFlow(Sizing.fixed(19), Sizing.content()) + .child(label) + .margins(tickAtTop ? Insets.top(12) : Insets.none()) + ); + + return container; + } + + protected void refreshOptions() { + this.collapsibleChildren.clear(); + + if (this.backingProviders.isEmpty()) { + for (int i = 0; i < this.backingList.size(); i++) { + var provider = createProviderComponent(this.backingList.get(i)); + + this.collapsibleChildren.add(createEntryContainer(i).child(provider)); + this.backingProviders.add(provider); + } + } else { + for (int i = 0; i < this.backingProviders.size(); i++) { + this.collapsibleChildren.add(createEntryContainer(i).child((Component) this.backingProviders.get(i))); + } + } + + refreshLayout(); + } + + protected void refreshLayout() { + this.contentLayout.configure(layout -> { + layout.clearChildren(); + if (this.expanded) layout.children(this.collapsibleChildren); + }); + + this.refreshResetButton(); + } + + protected void refreshResetButton() { + this.resetButton.active = !this.isDetached && !this.backingList.equals(this.backingOption.defaultValue()); + } + + @Override + public boolean shouldDrawTooltip(double mouseX, double mouseY) { + return ((mouseY - this.y) <= this.titleLayout.height()) && super.shouldDrawTooltip(mouseX, mouseY); + } + + @Override + public boolean isValid() { + return true; + } + + @Override + public Object parsedValue() { + for (int i = 0; i < this.backingProviders.size(); i++) { + var optionProvider = this.backingProviders.get(i); + + if (optionProvider.isValid()) this.backingList.set(i, (T) optionProvider.parsedValue()); + } + return this.convertToCollection(this.backingList); + } +} diff --git a/src/main/java/io/wispforest/owo/config/ui/component/SearchAnchorComponent.java b/src/main/java/io/wispforest/owo/config/ui/component/SearchAnchorComponent.java index d653ae96..22f1038c 100644 --- a/src/main/java/io/wispforest/owo/config/ui/component/SearchAnchorComponent.java +++ b/src/main/java/io/wispforest/owo/config/ui/component/SearchAnchorComponent.java @@ -1,6 +1,6 @@ package io.wispforest.owo.config.ui.component; -import io.wispforest.owo.config.Option; +import io.wispforest.owo.config.base.Key; import io.wispforest.owo.config.ui.ConfigScreen; import io.wispforest.owo.ui.base.BaseComponent; import io.wispforest.owo.ui.core.OwoUIDrawContext; @@ -16,14 +16,14 @@ public class SearchAnchorComponent extends BaseComponent { - protected final ParentComponent anchorFrame; + protected ParentComponent anchorFrame; protected final Supplier[] searchTextSources; - protected final Option.Key key; + protected final Key key; protected Consumer highlightConfigurator = highlight -> {}; @SafeVarargs - public SearchAnchorComponent(ParentComponent anchorFrame, Option.Key key, Supplier... searchTextSources) { + public SearchAnchorComponent(ParentComponent anchorFrame, Key key, Supplier... searchTextSources) { this.anchorFrame = anchorFrame; this.searchTextSources = searchTextSources; this.key = key; @@ -35,6 +35,12 @@ public SearchAnchorComponent(ParentComponent anchorFrame, Option.Key key, Suppli @Override public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partialTicks, float delta) {} + public SearchAnchorComponent anchorFrame(ParentComponent anchorFrame) { + this.anchorFrame = anchorFrame; + + return this; + } + public ParentComponent anchorFrame() { return this.anchorFrame; } @@ -49,7 +55,7 @@ public SearchAnchorComponent highlightConfigurator(Consumer { + + // The amount the user has scrolled + protected double scrolledAmount = 0; + protected List rangeSections = new ArrayList<>(); + + protected SelectableScrollContainer(ScrollDirection direction, Sizing horizontalSizing, Sizing verticalSizing, FlowLayout layout) { + super(direction, horizontalSizing, verticalSizing, layout); + } + + @Override + protected ScrollContainer scrollTo(Runnable target) { + return super.scrollTo(() -> { + target.run(); + this.scrolledAmount = MathHelper.clamp(this.scrollOffset, 0, this.maxScroll + .5);; + }); + } + + @Override + public void layout(Size space) { + super.layout(space); + + this.lastScrollOffset = -1; + + this.rangeSections.clear(); + + Map componentToSize = new LinkedHashMap<>(); + int totalChildrenSize = 0; + + for (var component : this.child.children()) { + var size = (int) this.direction.choose(component.width(), component.height()); + + totalChildrenSize += size; + componentToSize.put(component, size); + } + + var currentOffset = 0.0; + + for (var entry : componentToSize.entrySet()) { + var size = entry.getValue(); + + var start = currentOffset; + var end = currentOffset += (size / (float) totalChildrenSize) * this.maxScroll; + + this.rangeSections.add(new RangedComponentSelection(entry.getKey(), com.google.common.collect.Range.closedOpen(start, end))); + } + + this.scrolledAmount = scrollOffset; + } + + @Override + public boolean onKeyPress(int keyCode, int scanCode, int modifiers) { + if (this.targetComponent != null && keyCode == GLFW.GLFW_KEY_ENTER) { + this.focusHandler().focus(this.targetComponent, FocusSource.KEYBOARD_CYCLE); + + return this.targetComponent.onKeyPress(keyCode, scanCode, modifiers); + } + + return super.onKeyPress(keyCode, scanCode, modifiers); + } + + @Override + protected double scrolledAmount() { + return this.scrolledAmount; + } + + @Override + protected double mouseScrollStepAmount() { + return 1; + } + + private Component targetComponent = null; + + @Override + protected void scrollBy(double offset, boolean instant, boolean showScrollbar, boolean scrollStepped) { + this.scrolledAmount = MathHelper.clamp(this.scrolledAmount + offset, 0, this.maxScroll + .5); + + int i = 0; + + for (var rangeSection : this.rangeSections) { + if (rangeSection.range().contains(this.scrolledAmount)) { + i += (scrollStepped ? (int) Math.signum(offset) : 0); + + break; + } else { + i++; + } + } + + i = Math.clamp(i, 0, this.rangeSections.size() - 1); + + var section = this.rangeSections.get(i); + + var component = section.component(); + + if (this.targetComponent instanceof SelectableContainer container) { + container.setSelected(false); + } + + this.targetComponent = component; + + if (this.targetComponent instanceof SelectableContainer container) { + container.setSelected(true); + } + + var amount = this.direction.choose( + (this.x /*+ (this.width / 2)*/) - (component.x() - (component.width() / 2)) + component.margins().get().right(), + (this.y /*+ (this.height / 2)*/) - (component.y() - (component.height() / 2)) + component.margins().get().top()); + + this.scrollOffset = MathHelper.clamp(this.scrollOffset - amount, 0, this.maxScroll); + + if (scrollStepped) { + this.scrolledAmount = (i == this.rangeSections.size() - 1) + ? section.range().upperEndpoint() + 0.5 + : section.range().lowerEndpoint(); + } + + scrollByPost(true, showScrollbar); + } + + private record RangedComponentSelection(Component component, com.google.common.collect.Range range){}; + + public static SelectableScrollContainer parse(Element element) { + return element.getAttribute("direction").equals("vertical") + ? new SelectableScrollContainer(ScrollDirection.VERTICAL, Sizing.content(), Sizing.content(), null) + : new SelectableScrollContainer(ScrollDirection.HORIZONTAL, Sizing.content(), Sizing.content(), null); + } + +} diff --git a/src/main/java/io/wispforest/owo/config/ui/component/struct/AbstractStructOptionContainer.java b/src/main/java/io/wispforest/owo/config/ui/component/struct/AbstractStructOptionContainer.java new file mode 100644 index 00000000..4e0a395d --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ui/component/struct/AbstractStructOptionContainer.java @@ -0,0 +1,161 @@ +package io.wispforest.owo.config.ui.component.struct; + +import io.wispforest.owo.config.options.FieldOption; +import io.wispforest.owo.config.options.ReflectiveOption; +import io.wispforest.owo.config.base.Key; +import io.wispforest.owo.config.options.OptionControlSpec; +import io.wispforest.owo.config.ui.OptionComponentFactory; +import io.wispforest.owo.config.ui.component.*; +import io.wispforest.owo.ui.container.Containers; +import io.wispforest.owo.ui.container.FlowLayout; +import io.wispforest.owo.ui.core.*; +import io.wispforest.owo.ui.parsing.UIModel; +import io.wispforest.owo.util.NumberReflection; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.stream.Stream; + +public abstract class AbstractStructOptionContainer extends FlowLayout implements OptionValueProvider { + + protected final Map optionsProviders = new LinkedHashMap<>(); + + protected final Map options = new LinkedHashMap<>(); + + protected final UIModel model; + + protected final Identifier configId; + protected final Key optionKey; + + protected boolean sideBySideFormat = true; + + protected AbstractStructOptionContainer(Sizing horizontalSizing, Sizing verticalSizing, UIModel uiModel, OptionControlSpec option) { + super(horizontalSizing, verticalSizing, Algorithm.VERTICAL); + + this.model = uiModel; + + this.configId = option.configId(); + this.optionKey = option.key(); + + this.horizontalAlignment(HorizontalAlignment.LEFT); + +// this.padding(Insets.vertical(4)); + } + + @Nullable + protected final OptionValueProvider getProvider(Key key) { + var option = this.options.get(key); + + if (option != null) return optionsProviders.get(option); + + return null; + } + + public AbstractStructOptionContainer sideBySideFormating(boolean value) { + this.sideBySideFormat = value; + + return this; + } + + @Nullable + private FlowLayout currentRow = null; + + private void addToRow(Component component, boolean resetRow) { + if (this.currentRow == null || resetRow) { + this.currentRow = Containers.horizontalFlow(Sizing.content(), Sizing.content()); + this.child(currentRow); + } + + this.currentRow.child(component); + } + + private int componentIndex = 0; + + protected final void addOptionComponent(Class clazz, O option) { + var optionClazz = (Class) option.clazz(); + + OptionComponentFactory.Result result; + + boolean unpackResult = false; + + // TODO: DEHARDCODE? + if (NumberReflection.isNumberType(option.clazz())) { + result = attachOptionLabel(OptionComponentFactory.NUMBER.make(model, option), sideBySideFormat, true); + } else if (optionClazz == String.class) { + result = attachOptionLabel(OptionComponentFactory.STRING.make(model, option), sideBySideFormat, true); + } else if (optionClazz == Boolean.class || optionClazz == boolean.class){ + result = attachOptionLabel(OptionComponentFactory.BOOLEAN.make(model, option), sideBySideFormat, true); + } else if (optionClazz == Identifier.class) { + result = attachOptionLabel(OptionComponentFactory.IDENTIFIER.make(model, option), sideBySideFormat, true); + } else if (optionClazz == Color.class) { + result = attachOptionLabel(OptionComponentFactory.COLOR.make(model, option), sideBySideFormat, true); + } else if (optionClazz == List.class) { + var layout = new ListOptionContainer<>(model, (FieldOption>) option, ArrayList::new, false, false); + layout.horizontalSizing(Sizing.fill(50)); + result = new OptionComponentFactory.Result(layout, layout); + //sideBySideFormat = false; + } else if (optionClazz == Set.class) { + var layout = new ListOptionContainer<>(model, (FieldOption>) option, LinkedHashSet::new, false, false); + layout.horizontalSizing(Sizing.fill(50)); + result = new OptionComponentFactory.Result(layout, layout); + //sideBySideFormat = false; + } else if (optionClazz.isEnum()) { + result = attachOptionLabel(OptionComponentFactory.ENUM.make(model, option), sideBySideFormat, true); + } else if (optionClazz.isRecord()) { + var layout = RecordStructOptionContainer.of(model, option); + result = new OptionComponentFactory.Result<>(layout, layout); + unpackResult = true; + } else { + var layout = StructOptionContainer.of(model, option); + result = new OptionComponentFactory.Result<>(layout, layout); + unpackResult = true; + } + + if(unpackResult) { + var layout = (AbstractStructOptionContainer) result.baseComponent(); + + var components = layout.children.stream().flatMap(component -> { + if (component instanceof ParentComponent parentComponent) { + return parentComponent.children().stream(); + } + + return Stream.of(component); + }).toList(); + + for (var child : components) { + this.addToRow(child, componentIndex % 2 == 0 || !sideBySideFormat); + + componentIndex++; + } + } else { + this.addToRow(result.baseComponent(), componentIndex % 2 == 0 || !sideBySideFormat); + + componentIndex++; + } + + this.optionsProviders.put(option, result.optionProvider()); + } + + private static OptionComponentFactory.Result attachOptionLabel(OptionComponentFactory.Result labeledResult, boolean sideBySideFormat, boolean reducedPadding) { + var optionComponent = labeledResult.baseComponent(); + + if (sideBySideFormat) optionComponent.horizontalSizing(Sizing.fill(50)); // Difference 1 + + if (reducedPadding && optionComponent instanceof ParentComponent parentComponent) { + var currentPadding = parentComponent.padding().get(); + + parentComponent.padding(Insets.of(currentPadding.top() - 5, currentPadding.bottom() - 5, currentPadding.left(), currentPadding.right())); + } + + return labeledResult; + } + + @Override + public abstract Object parsedValue(); + + @Override + public boolean isValid() { + return true; + } +} diff --git a/src/main/java/io/wispforest/owo/config/ui/component/struct/ListOptionContainer.java b/src/main/java/io/wispforest/owo/config/ui/component/struct/ListOptionContainer.java new file mode 100644 index 00000000..de5c4a52 --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ui/component/struct/ListOptionContainer.java @@ -0,0 +1,104 @@ +package io.wispforest.owo.config.ui.component.struct; + +import io.wispforest.owo.config.ConfigReflectionUtils; +import io.wispforest.owo.config.options.OptionControlSpec; +import io.wispforest.owo.config.options.ReflectiveOption; +import io.wispforest.owo.config.ui.component.ConfigTextBox; +import io.wispforest.owo.config.ui.component.OptionValueProvider; +import io.wispforest.owo.config.ui.component.OrderedOptionContainer; +import io.wispforest.owo.ui.core.*; +import io.wispforest.owo.ui.parsing.UIModel; +import io.wispforest.owo.util.NumberReflection; +import io.wispforest.owo.util.ReflectionUtils; +import net.minecraft.util.Identifier; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public class ListOptionContainer, T> extends OrderedOptionContainer { + + protected final Function, C> collectionConstructor; + protected Class listType = null; + + protected ConfigReflectionUtils.CollectionType type; + + public ListOptionContainer(UIModel uiModel, OptionControlSpec option, Function, C> collectionConstructor, boolean expanded, boolean isDetached) { + super(uiModel, option, expanded, isDetached); + + this.collectionConstructor = collectionConstructor; + } + + protected ConfigReflectionUtils.CollectionType type() { + if (this.type == null) { + this.type = ConfigReflectionUtils.getCollectionType(backingOption.getGenericType()); + } + + return this.type; + } + + protected Class listType() { + if (this.listType == null) { + this.listType = (Class) ReflectionUtils.getTypeArgument(this.backingOption.getGenericType(), 0); + } + + return this.listType; + } + + @Override + protected

P createProviderComponent(T listEntry) { + var structLike = this.type() == ConfigReflectionUtils.CollectionType.COMPLEX; + + if (structLike) { + return (P) ((listEntry instanceof Record) + ? RecordStructOptionContainer.of(this.uiModel, this.backingOption, (Class) listType(), (Record) listEntry) + : StructOptionContainer.of(this.uiModel, this.backingOption, listType(), listEntry)); + } else { + final var box = this.uiModel.expandTemplate(ConfigTextBox.class, "collection-text-box", Map.of("initial-value", listEntry.toString())); + + if (!this.isDetached) { + box.onChanged().subscribe(s -> { + if (!box.isValid()) return; + + this.refreshResetButton(); + }); + } else { + box.active = false; + } + + if (NumberReflection.isNumberType(listType())) { + var numberType = (Class) listType(); + var data = ConfigReflectionUtils.getConstraintData(numberType, this.backingOption); + + box.configureForNumber(numberType, data.min(), data.max()); + } else if(listType() == Identifier.class) { + box.configureForIdentifier(); + } + + return (P) box; + } + } + + @Override + protected boolean tickAtTop() { + return this.type() == ConfigReflectionUtils.CollectionType.COMPLEX; + } + + public T createDefaultValue() { + return this.type == ConfigReflectionUtils.CollectionType.COMPLEX + ? ReflectionUtils.tryInstantiateWithNoArgs(this.listType()) + : (T) ""; + } + + @Override + protected List convertToList(C data) { + return new ArrayList<>(data); + } + + @Override + protected C convertToCollection(List backingList) { + return collectionConstructor.apply(backingList); + } +} diff --git a/src/main/java/io/wispforest/owo/config/ui/component/struct/MapOptionContainer.java b/src/main/java/io/wispforest/owo/config/ui/component/struct/MapOptionContainer.java new file mode 100644 index 00000000..01b5e0f5 --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ui/component/struct/MapOptionContainer.java @@ -0,0 +1,112 @@ +package io.wispforest.owo.config.ui.component.struct; + +import io.wispforest.owo.config.options.OptionControlSpec; +import io.wispforest.owo.config.ui.component.ConfigTextBox; +import io.wispforest.owo.config.ui.component.OptionValueProvider; +import io.wispforest.owo.config.ui.component.OrderedOptionContainer; +import io.wispforest.owo.ui.core.*; +import io.wispforest.owo.ui.parsing.UIModel; +import io.wispforest.owo.util.ReflectionUtils; +import it.unimi.dsi.fastutil.Pair; +import net.minecraft.text.Text; +import net.minecraft.util.Util; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; +import java.util.*; + +public class MapOptionContainer extends OrderedOptionContainer, MapOptionContainer.KeyValuePair> { + + public MapOptionContainer(UIModel uiModel, OptionControlSpec> option, boolean expanded, boolean isDetached) { + super(uiModel, option, expanded, isDetached); + } + + private Pair> keyType = null; + private Pair> valueType = null; + + public Pair> keyType() { + if (keyType == null) { + this.keyType = ReflectionUtils.getTypeAndClassArgument(this.backingOption.getGenericType(), 0); + } + + return this.keyType; + } + + public Pair> valueType() { + if (valueType == null) { + this.valueType = ReflectionUtils.getTypeAndClassArgument(this.backingOption.getGenericType(), 1); + } + + return this.valueType; + } + + @Override + protected

P createProviderComponent(MapOptionContainer.KeyValuePair listEntry) { + var layout = StructOptionContainer.of(this.uiModel, this.backingOption, KeyValuePair.class, List.of(keyType(), valueType()), listEntry, clazz -> createDefaultValue()); + + var keyProvider = layout.getProvider(this.backingOption.key().child("key")); + + if (keyProvider instanceof ConfigTextBox configTextBox) { + configTextBox.applyPredicate(configTextBox.applyPredicate().and(string -> { + var bl = !this.backingList.stream().anyMatch(keyValuePair -> keyValuePair.key.equals(string)); + + configTextBox.tooltip(bl ? Text.empty() : Text.of("Duplicate Key detected!")); + + return bl; + })); + } + + return (P) layout; + } + + @Override + protected List.KeyValuePair> convertToList(Map data) { + var list = new ArrayList(); + + data.forEach((key, value) -> list.add(new KeyValuePair(key, value))); + + return list; + } + + @Override + protected Map convertToCollection(List.KeyValuePair> backingList) { + return Util.make(new LinkedHashMap<>(), map -> backingList.forEach(kvEntry -> map.putIfAbsent(kvEntry.left(), kvEntry.right()))); + } + + @Override + public KeyValuePair createDefaultValue() { + return new KeyValuePair(ReflectionUtils.tryInstantiation((Class) keyType().second()), ReflectionUtils.tryInstantiation((Class) valueType().second())); + } + + public final class KeyValuePair implements Pair { + public K key; + public V value; + + private KeyValuePair(K key, V value) { + this.key = key; + this.value = value; + } + + @Override + public K left() { + return this.key; + } + + @Override + public V right() { + return this.value; + } + + @Override + public int hashCode() { + return Objects.hash(key, value); + } + + @Override + public String toString() { + return "KeyValuePair[" + + "key=" + key + ", " + + "value=" + value + ']'; + } + } +} diff --git a/src/main/java/io/wispforest/owo/config/ui/component/struct/RecordStructOptionContainer.java b/src/main/java/io/wispforest/owo/config/ui/component/struct/RecordStructOptionContainer.java new file mode 100644 index 00000000..dfa53f82 --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ui/component/struct/RecordStructOptionContainer.java @@ -0,0 +1,117 @@ +package io.wispforest.owo.config.ui.component.struct; + +import io.wispforest.owo.config.*; +import io.wispforest.owo.config.base.BoundedAccess; +import io.wispforest.owo.config.options.OptionControlSpec; +import io.wispforest.owo.config.options.RecordOption; +import io.wispforest.owo.config.ui.component.OptionValueProvider; +import io.wispforest.owo.ui.core.Sizing; +import io.wispforest.owo.ui.parsing.UIModel; +import io.wispforest.owo.util.ReflectionUtils; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.RecordComponent; +import java.util.LinkedHashMap; +import java.util.function.Function; + +public class RecordStructOptionContainer extends AbstractStructOptionContainer { + + private Function canonicalConstructor; + + protected T backingValue; + + protected RecordStructOptionContainer(UIModel uiModel, OptionControlSpec option, Class clazz, T value) { + super(Sizing.expand(), Sizing.content(), uiModel, option); + + this.buildContainer(clazz, value); + } + + public static RecordStructOptionContainer of(UIModel uiModel, OptionControlSpec option) { + return of(uiModel, option, option.clazz(), option.value()); + } + + public static RecordStructOptionContainer of(UIModel uiModel, OptionControlSpec option, Class clazz, T value) { + return new RecordStructOptionContainer(uiModel, option, clazz, value); + } + + public void buildContainer(Class clazz, T value) { + var lookup = MethodHandles.publicLookup(); + + var fields = new LinkedHashMap>(); + var canonicalConstructorArgs = new Class[clazz.getRecordComponents().length]; + + for (int i = 0; i < clazz.getRecordComponents().length; ++i) { + try { + var component = clazz.getRecordComponents()[i]; + var handle = lookup.unreflect(component.getAccessor()); + + fields.put(component, (instance) -> getRecordEntry(instance, handle)); + canonicalConstructorArgs[i] = component.getType(); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Failed to create method handle for record component accessor", e); + } + } + + try { + var constructor = clazz.getConstructor(canonicalConstructorArgs); + + this.canonicalConstructor = fieldValues -> { + try { + return constructor.newInstance(fieldValues); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException var8) { + throw new IllegalStateException("Error while deserializing record", var8); + } + }; + } catch (NoSuchMethodException var9) { + throw new IllegalStateException("Could not locate canonical record constructor"); + } + + this.backingValue = this.canonicalConstructor.apply(fields.values().stream().map(func -> func.apply(value)).toArray()); + + //-- + + //this.sideBySideFormat(fields.size() >= 2); + + var defaultValue = ReflectionUtils.tryInstantiateWithNoArgs(clazz); + + this.sideBySideFormating(sideBySideFormat && fields.size() > 1); + + for (var entry : fields.entrySet()) { + var component = entry.getKey(); + + var boundField = new BoundedAccess.BoundRecordComponent<>(backingValue, component, entry.getValue()); + + try { + var innerOption = new RecordOption<>( + configId, + optionKey.child(boundField.component().getName()), + boundField.withOwner(defaultValue).getValue(), + boundField, + ConfigReflectionUtils.getConstraint(boundField), + boundField.getValue() + ); + + options.put(innerOption.key(), innerOption); + + this.addOptionComponent(clazz, innerOption); + } catch (IllegalAccessException | NoSuchMethodException e) { + throw new RuntimeException("Failed to initialize Struct Layout for config [" + this.configId + "] due to an error with field [" + component.getName() + "]", e); + } + } + } + + private static Object getRecordEntry(R instance, MethodHandle accessor) { + try { + return accessor.invoke(instance); + } catch (Throwable e) { + throw new IllegalStateException("Unable to get record component value", e); + } + } + + public Object parsedValue() { + var objects = this.optionsProviders.values().stream().map(OptionValueProvider::parsedValue).toArray(); + return this.canonicalConstructor.apply(objects); + } +} diff --git a/src/main/java/io/wispforest/owo/config/ui/component/struct/StructOptionContainer.java b/src/main/java/io/wispforest/owo/config/ui/component/struct/StructOptionContainer.java new file mode 100644 index 00000000..60068aca --- /dev/null +++ b/src/main/java/io/wispforest/owo/config/ui/component/struct/StructOptionContainer.java @@ -0,0 +1,99 @@ +package io.wispforest.owo.config.ui.component.struct; + +import io.wispforest.owo.config.*; +import io.wispforest.owo.config.base.BoundedAccess; +import io.wispforest.owo.config.options.FieldOption; +import io.wispforest.owo.config.options.OptionControlSpec; +import io.wispforest.owo.config.base.SyncMode; +import io.wispforest.owo.ui.core.Sizing; +import io.wispforest.owo.ui.parsing.UIModel; +import io.wispforest.owo.util.Observable; +import io.wispforest.owo.util.ReflectionUtils; +import it.unimi.dsi.fastutil.Pair; + +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.util.*; +import java.util.function.Function; + +public class StructOptionContainer extends AbstractStructOptionContainer { + + protected T backingValue; + + protected StructOptionContainer(UIModel uiModel, OptionControlSpec option, Class clazz, List>> genericTypes, T value, Function, T> defaultConstructor) { + super(Sizing.expand(), Sizing.content(), uiModel, option); + + this.buildContainer(clazz, genericTypes, value, defaultConstructor); + } + + public static StructOptionContainer of(UIModel uiModel, OptionControlSpec option) { + return of(uiModel, option, option.clazz(), option.value()); + } + + public static StructOptionContainer of(UIModel uiModel, OptionControlSpec option, Class clazz, T value) { + return new StructOptionContainer(uiModel, option, clazz, List.of(), value, ReflectionUtils::tryInstantiateWithNoArgs); + } + + public static StructOptionContainer of(UIModel uiModel, OptionControlSpec option, Class clazz, List>> genericTypes, T value, Function, T> defaultConstructor) { + return new StructOptionContainer(uiModel, option, clazz, genericTypes, value, defaultConstructor); + } + + public void buildContainer(Class clazz, List>> genericTypes, T value, Function, T> defaultConstructor) { + var fields = Arrays.stream(clazz.getFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers()) && !Modifier.isTransient(field.getModifiers())) + .toList(); + + var extraTypeInfo = !genericTypes.isEmpty(); + + if (extraTypeInfo && fields.size() != genericTypes.size()) { + throw new IllegalStateException("Unable to create Struct Option Container due to mismatch field amount to the passed generic types!"); + } + + var defaultValue = defaultConstructor.apply(clazz); + + this.backingValue = defaultConstructor.apply(clazz); + + this.sideBySideFormating(sideBySideFormat && fields.size() > 1); + + for (int i = 0; i < fields.size(); i++) { + var field = fields.get(i); + var typeInfo = extraTypeInfo ? genericTypes.get(i) : null; + + var genericType = typeInfo != null ? typeInfo.left() : field.getGenericType(); + var type = typeInfo != null ? typeInfo.right() : field.getType(); + + var boundField = new BoundedAccess.BoundField<>(backingValue, field, type, genericType); + + try { + var currentValue = boundField.withOwner(value).getValue(); + + boundField.setValue(currentValue); + + var option = new FieldOption<>( + configId, + optionKey.child(field.getName()), + boundField.withOwner(defaultValue).getValue(), + Observable.of(currentValue), + boundField, + ConfigReflectionUtils.getConstraint(boundField), + SyncMode.NONE, + null + ); + + options.put(option.key(), option); + + this.addOptionComponent(clazz, option); + } catch (IllegalAccessException | NoSuchMethodException e) { + throw new RuntimeException("Failed to initialize Struct Layout for config [" + this.configId + "] due to an error with the given class [" + clazz.getSimpleName() + "] field [" + field.getName() + "]", e); + } + } + } + + public Object parsedValue() { + this.optionsProviders.forEach((option, optionValueProvider) -> option.set(optionValueProvider.parsedValue())); + + this.options.values().forEach(FieldOption::synchronizeWithBackingField); + + return backingValue; + } +} diff --git a/src/main/java/io/wispforest/owo/mixin/DrawContextMixin.java b/src/main/java/io/wispforest/owo/mixin/DrawContextMixin.java index 1f90e0b6..f3841f67 100644 --- a/src/main/java/io/wispforest/owo/mixin/DrawContextMixin.java +++ b/src/main/java/io/wispforest/owo/mixin/DrawContextMixin.java @@ -1,6 +1,8 @@ package io.wispforest.owo.mixin; +import io.wispforest.owo.ui.core.OwoUIDrawContext; import io.wispforest.owo.ui.util.MatrixStackTransformer; +import io.wispforest.owo.ui.util.ScissorStack; import net.minecraft.client.gui.DrawContext; import net.minecraft.client.util.math.MatrixStack; import org.joml.Matrix3x2fStack; @@ -8,12 +10,35 @@ import org.spongepowered.asm.mixin.Shadow; @Mixin(DrawContext.class) -public abstract class DrawContextMixin implements MatrixStackTransformer { +public abstract class DrawContextMixin implements MatrixStackTransformer { @Shadow public abstract Matrix3x2fStack getMatrices(); + @Shadow public abstract void draw(); + @Override public Matrix3x2fStack getMatrixStack() { return this.getMatrices(); } + + @Override + public DrawContext pushScissor(int x, int y, int width, int height) { + ScissorStack.push(x, y, width, height, (DrawContext) (Object) this); + + return owo$cast(); + } + + @Override + public DrawContext popScissor() { + this.draw(); + + ScissorStack.pop(); + + return owo$cast(); + } + + @Override + public DrawContext owo$cast() { + return (DrawContext)(Object) this; + } } diff --git a/src/main/java/io/wispforest/owo/mixin/ScreenHandlerMixin.java b/src/main/java/io/wispforest/owo/mixin/ScreenHandlerMixin.java index 6b3d7385..1fbf609c 100644 --- a/src/main/java/io/wispforest/owo/mixin/ScreenHandlerMixin.java +++ b/src/main/java/io/wispforest/owo/mixin/ScreenHandlerMixin.java @@ -35,6 +35,7 @@ import java.util.Map; import java.util.function.Consumer; +// TODO: Convert networking to use OwoNetChannel? @Mixin(ScreenHandler.class) public abstract class ScreenHandlerMixin implements OwoScreenHandler, OwoScreenHandlerExtension { diff --git a/src/main/java/io/wispforest/owo/mixin/ui/ClickableWidgetMixin.java b/src/main/java/io/wispforest/owo/mixin/ui/ClickableWidgetMixin.java index fa6ce587..00aa5b62 100644 --- a/src/main/java/io/wispforest/owo/mixin/ui/ClickableWidgetMixin.java +++ b/src/main/java/io/wispforest/owo/mixin/ui/ClickableWidgetMixin.java @@ -9,6 +9,7 @@ import io.wispforest.owo.ui.parsing.UIParsing; import io.wispforest.owo.ui.util.FocusHandler; import io.wispforest.owo.util.EventSource; +import io.wispforest.owo.util.EventStream; import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.tooltip.TooltipComponent; import net.minecraft.client.gui.widget.ClickableWidget; @@ -370,4 +371,14 @@ public void updateY(int y) { private void setHovered(DrawContext context, int mouseX, int mouseY, float delta, CallbackInfo ci) { if (this.owo$wrapper != null) this.hovered = this.hovered && this.owo$wrapper.hovered(); } + + @Override + public EventSource componentUpdate() { + return this.owo$getWrapper().componentUpdate(); + } + + @Override + public boolean hovered() { + return this.hovered; + } } diff --git a/src/main/java/io/wispforest/owo/packets/OwoPackets.java b/src/main/java/io/wispforest/owo/packets/OwoPackets.java new file mode 100644 index 00000000..6fdb7938 --- /dev/null +++ b/src/main/java/io/wispforest/owo/packets/OwoPackets.java @@ -0,0 +1,27 @@ +package io.wispforest.owo.packets; + +import io.wispforest.owo.network.OwoNetChannel; +import io.wispforest.owo.packets.c2s.AskToOpenServerConfig; +import io.wispforest.owo.packets.s2c.OpenServerConfig; +import io.wispforest.owo.packets.s2c.OpenServerConfigSelection; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.util.Identifier; + +public class OwoPackets { + + public static final OwoNetChannel MAIN = OwoNetChannel.create(Identifier.of("owo", "main")); + + public static void initNetworking() { + MAIN.registerServerbound(AskToOpenServerConfig.class, AskToOpenServerConfig::handle); + + MAIN.registerClientboundDeferred(OpenServerConfig.class, OpenServerConfig.ENDEC); + MAIN.registerClientboundDeferred(OpenServerConfigSelection.class, OpenServerConfigSelection.ENDEC); + } + + @Environment(EnvType.CLIENT) + public static void initClientNetworking () { + MAIN.registerClientbound(OpenServerConfig.class, OpenServerConfig.ENDEC, OpenServerConfig::handle); + MAIN.registerClientbound(OpenServerConfigSelection.class, OpenServerConfigSelection.ENDEC, OpenServerConfigSelection::handle); + } +} diff --git a/src/main/java/io/wispforest/owo/packets/c2s/AdjustServerConfig.java b/src/main/java/io/wispforest/owo/packets/c2s/AdjustServerConfig.java new file mode 100644 index 00000000..07ef7b0b --- /dev/null +++ b/src/main/java/io/wispforest/owo/packets/c2s/AdjustServerConfig.java @@ -0,0 +1,34 @@ +package io.wispforest.owo.packets.c2s; + +import blue.endless.jankson.JsonObject; +import io.wispforest.owo.config.ConfigWrapper; +import io.wispforest.owo.network.ServerAccess; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +public record AdjustServerConfig(Identifier configId, JsonObject configData, boolean restartRequired) { + + public static void handle(AdjustServerConfig packet, ServerAccess access) { + if(!access.player().hasPermissionLevel(3)) { + access.player().sendMessage(Text.of("Unable to adjust config as player is missing proper Admin Perms (Level 3).")); + + return; + } + + var wrapper = ConfigWrapper.getKnownConfigInstances().get(packet.configId()); + + if (wrapper == null) { + access.player().sendMessage(Text.of("Unable to adjust config as such dose not exist on the server! [Id: " + packet.configId() + "]")); + + return; + } + + wrapper.load(packet.configData, true); + + wrapper.saveToFile(); + + if (packet.restartRequired()) { + access.player().sendMessage(Text.of("Config has been saved but a restart is required to have the given changes take effect.")); + } + } +} diff --git a/src/main/java/io/wispforest/owo/packets/c2s/AskToOpenServerConfig.java b/src/main/java/io/wispforest/owo/packets/c2s/AskToOpenServerConfig.java new file mode 100644 index 00000000..39bae587 --- /dev/null +++ b/src/main/java/io/wispforest/owo/packets/c2s/AskToOpenServerConfig.java @@ -0,0 +1,29 @@ +package io.wispforest.owo.packets.c2s; + +import io.wispforest.owo.config.ConfigWrapper; +import io.wispforest.owo.network.ServerAccess; +import io.wispforest.owo.packets.OwoPackets; +import io.wispforest.owo.packets.s2c.OpenServerConfig; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +public record AskToOpenServerConfig(Identifier configId) { + + public static void handle(AskToOpenServerConfig packet, ServerAccess access) { + if(!access.player().hasPermissionLevel(3)) { + access.player().sendMessage(Text.of("Unable to open config as player is missing proper Admin Perms (Level 3).")); + + return; + } + + var wrapper = ConfigWrapper.getConfig(packet.configId()); + + if (wrapper == null) { + access.player().sendMessage(Text.of("Unable to open config as such dose not exist on the server! [Id: " + packet.configId() + "]")); + + return; + } + + OwoPackets.MAIN.serverHandle(access.player()).send(new OpenServerConfig(packet.configId(), wrapper.saveToObject())); + } +} diff --git a/src/main/java/io/wispforest/owo/packets/s2c/OpenServerConfig.java b/src/main/java/io/wispforest/owo/packets/s2c/OpenServerConfig.java new file mode 100644 index 00000000..618bf9f2 --- /dev/null +++ b/src/main/java/io/wispforest/owo/packets/s2c/OpenServerConfig.java @@ -0,0 +1,29 @@ +package io.wispforest.owo.packets.s2c; + +import blue.endless.jankson.JsonObject; +import io.wispforest.endec.StructEndec; +import io.wispforest.endec.impl.StructEndecBuilder; +import io.wispforest.owo.Owo; +import io.wispforest.owo.config.ui.ConfigScreenProviders; +import io.wispforest.owo.network.ClientAccess; +import io.wispforest.owo.serialization.endec.MinecraftEndecs; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.util.Identifier; + +import java.util.Map; + +public record OpenServerConfig(Identifier configId, JsonObject configData){ + + public static final StructEndec ENDEC = StructEndecBuilder.of( + MinecraftEndecs.IDENTIFIER.fieldOf("configId", OpenServerConfig::configId), + MinecraftEndecs.JANK_JSON_OBJECT.fieldOf("configData", OpenServerConfig::configData), + OpenServerConfig::new); + + @Environment(EnvType.CLIENT) + public static void handle(OpenServerConfig packet, ClientAccess access) { + if (!ConfigScreenProviders.safelyOpenConfigScreen(packet.configId(), access.runtime().currentScreen, Map.of(packet.configId(), packet.configData()))) { + Owo.LOGGER.warn("Unable to open the given config screen: {}", packet.configId()); + } + } +} diff --git a/src/main/java/io/wispforest/owo/packets/s2c/OpenServerConfigSelection.java b/src/main/java/io/wispforest/owo/packets/s2c/OpenServerConfigSelection.java new file mode 100644 index 00000000..f99a4d65 --- /dev/null +++ b/src/main/java/io/wispforest/owo/packets/s2c/OpenServerConfigSelection.java @@ -0,0 +1,31 @@ +package io.wispforest.owo.packets.s2c; + +import blue.endless.jankson.JsonObject; +import io.wispforest.endec.Endec; +import io.wispforest.endec.StructEndec; +import io.wispforest.endec.impl.StructEndecBuilder; +import io.wispforest.owo.Owo; +import io.wispforest.owo.config.ui.ConfigScreenProviders; +import io.wispforest.owo.network.ClientAccess; +import io.wispforest.owo.serialization.endec.MinecraftEndecs; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.util.Identifier; + +import java.util.Map; + +public record OpenServerConfigSelection(String modId, Map modConfigData) { + + public static final StructEndec ENDEC = StructEndecBuilder.of( + Endec.STRING.fieldOf("modId", OpenServerConfigSelection::modId), + Endec.map(Identifier::toString, Identifier::tryParse, MinecraftEndecs.JANK_JSON_OBJECT).fieldOf("modConfigData", OpenServerConfigSelection::modConfigData), + OpenServerConfigSelection::new + ); + + @Environment(EnvType.CLIENT) + public static void handle(OpenServerConfigSelection packet, ClientAccess access) { + if (ConfigScreenProviders.safelyOpenConfigScreen(packet.modId(), access.runtime().currentScreen, packet.modConfigData())) { + Owo.LOGGER.warn("Unable to open the selection of configs for the given modid: {}", packet.modId()); + } + } +} diff --git a/src/main/java/io/wispforest/owo/serialization/endec/MinecraftEndecs.java b/src/main/java/io/wispforest/owo/serialization/endec/MinecraftEndecs.java index 96df755f..ee477720 100644 --- a/src/main/java/io/wispforest/owo/serialization/endec/MinecraftEndecs.java +++ b/src/main/java/io/wispforest/owo/serialization/endec/MinecraftEndecs.java @@ -1,10 +1,13 @@ package io.wispforest.owo.serialization.endec; +import blue.endless.jankson.JsonObject; import com.mojang.datafixers.util.Function3; import io.wispforest.endec.Endec; import io.wispforest.endec.SerializationAttributes; +import io.wispforest.endec.format.jankson.JanksonEndec; import io.wispforest.endec.impl.ReflectiveEndecBuilder; import io.wispforest.endec.impl.StructEndecBuilder; +import io.wispforest.owo.packets.s2c.OpenServerConfig; import io.wispforest.owo.serialization.CodecUtils; import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; import net.minecraft.item.ItemStack; @@ -87,6 +90,8 @@ private MinecraftEndecs() {} : BlockHitResult.createMissed(pos, side, blockPos) ); + public static final Endec JANK_JSON_OBJECT = JanksonEndec.INSTANCE.xmap(e -> (JsonObject) e, obj -> obj); + // --- Constructors for MC types --- public static ReflectiveEndecBuilder addDefaults(ReflectiveEndecBuilder builder) { diff --git a/src/main/java/io/wispforest/owo/ui/base/BaseComponent.java b/src/main/java/io/wispforest/owo/ui/base/BaseComponent.java index bd820ef5..80807f24 100644 --- a/src/main/java/io/wispforest/owo/ui/base/BaseComponent.java +++ b/src/main/java/io/wispforest/owo/ui/base/BaseComponent.java @@ -43,6 +43,9 @@ public abstract class BaseComponent implements Component { protected final EventStream mouseEnterEvents = MouseEnter.newStream(); protected final EventStream mouseLeaveEvents = MouseLeave.newStream(); + protected final EventStream componentUpdateEvents = ComponentUpdate.newStream(); + + protected boolean prioritizedHover = false; protected boolean hovered = false; protected boolean dirty = false; @@ -143,13 +146,23 @@ public void update(float delta, int mouseX, int mouseY) { if (this.hovered != nowHovered) { this.updateHoveredState(mouseX, mouseY, nowHovered); } + + // Will be called after parent components update method + if (!(this instanceof ParentComponent)) { + this.componentUpdateEvents.sink().onUpdate(delta, mouseX, mouseY); + } + } + + @Override + public EventSource componentUpdate() { + return this.componentUpdateEvents.source(); } protected void updateHoveredState(int mouseX, int mouseY, boolean nowHovered) { this.hovered = nowHovered; if (nowHovered) { - if (this.root() == null || this.root().childAt(mouseX, mouseY) != this) { + if (!this.prioritizedHover && (this.root() == null || this.root().childAt(mouseX, mouseY) != this)) { this.hovered = false; return; } @@ -390,4 +403,9 @@ public int width() { public int height() { return this.height; } + + @Override + public boolean hovered() { + return this.hovered; + } } diff --git a/src/main/java/io/wispforest/owo/ui/base/BaseOwoHandledScreen.java b/src/main/java/io/wispforest/owo/ui/base/BaseOwoHandledScreen.java index 3b4a6a9d..76ddeaab 100644 --- a/src/main/java/io/wispforest/owo/ui/base/BaseOwoHandledScreen.java +++ b/src/main/java/io/wispforest/owo/ui/base/BaseOwoHandledScreen.java @@ -81,7 +81,7 @@ protected void init() { this.addDrawableChild(this.uiAdapter); } else { try { - this.uiAdapter = this.createAdapter(); + this.uiAdapter = this.createAdapter().allowInvalidRendering(false); this.build(this.uiAdapter.rootComponent); this.uiAdapter.inflateAndMount(); @@ -205,6 +205,15 @@ public void renderBackground(DrawContext context, int mouseX, int mouseY, float @Override public void render(DrawContext vanillaContext, int mouseX, int mouseY, float delta) { var context = OwoUIDrawContext.of(vanillaContext); + + var error = uiAdapter.currentError(); + + if (error != null) { + Owo.LOGGER.warn("Could not render owo screen", uiAdapter.currentError()); + UIErrorToast.report(error); + this.invalid = true; + } + if (!this.invalid) { super.render(context, mouseX, mouseY, delta); diff --git a/src/main/java/io/wispforest/owo/ui/base/BaseOwoScreen.java b/src/main/java/io/wispforest/owo/ui/base/BaseOwoScreen.java index 44c353be..93a7f866 100644 --- a/src/main/java/io/wispforest/owo/ui/base/BaseOwoScreen.java +++ b/src/main/java/io/wispforest/owo/ui/base/BaseOwoScreen.java @@ -88,7 +88,7 @@ protected void init() { this.addDrawableChild(this.uiAdapter); } else { try { - this.uiAdapter = this.createAdapter(); + this.uiAdapter = this.createAdapter().allowInvalidRendering(false); this.build(this.uiAdapter.rootComponent); this.uiAdapter.inflateAndMount(); @@ -126,6 +126,14 @@ public void renderBackground(DrawContext context, int mouseX, int mouseY, float @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { + var error = uiAdapter.currentError(); + + if (error != null) { + Owo.LOGGER.warn("Could not render owo screen", uiAdapter.currentError()); + UIErrorToast.report(error); + this.invalid = true; + } + if (!this.invalid) { super.render(context, mouseX, mouseY, delta); } else { diff --git a/src/main/java/io/wispforest/owo/ui/base/BaseParentComponent.java b/src/main/java/io/wispforest/owo/ui/base/BaseParentComponent.java index 2805e0f0..90f70d9e 100644 --- a/src/main/java/io/wispforest/owo/ui/base/BaseParentComponent.java +++ b/src/main/java/io/wispforest/owo/ui/base/BaseParentComponent.java @@ -42,6 +42,7 @@ public final void update(float delta, int mouseX, int mouseY) { ParentComponent.super.update(delta, mouseX, mouseY); super.update(delta, mouseX, mouseY); this.parentUpdate(delta, mouseX, mouseY); + this.componentUpdateEvents.sink().onUpdate(delta, mouseX, mouseY); if (this.taskQueue != null) { this.taskQueue.forEach(Runnable::run); @@ -238,15 +239,15 @@ public boolean onMouseDrag(double mouseX, double mouseY, double deltaX, double d @Override public boolean onKeyPress(int keyCode, int scanCode, int modifiers) { - if (this.focusHandler == null) return false; - - if (keyCode == GLFW.GLFW_KEY_TAB) { - this.focusHandler.cycle((modifiers & GLFW.GLFW_MOD_SHIFT) == 0); - } else if ((keyCode == GLFW.GLFW_KEY_RIGHT || keyCode == GLFW.GLFW_KEY_LEFT || keyCode == GLFW.GLFW_KEY_DOWN || keyCode == GLFW.GLFW_KEY_UP) - && (modifiers & GLFW.GLFW_MOD_ALT) != 0) { - this.focusHandler.moveFocus(keyCode); - } else if (this.focusHandler.focused() != null) { - return this.focusHandler.focused().onKeyPress(keyCode, scanCode, modifiers); + if (this.focusHandler != null) { + if (keyCode == GLFW.GLFW_KEY_TAB) { + this.focusHandler.cycle((modifiers & GLFW.GLFW_MOD_SHIFT) == 0); + } else if ((keyCode == GLFW.GLFW_KEY_RIGHT || keyCode == GLFW.GLFW_KEY_LEFT || keyCode == GLFW.GLFW_KEY_DOWN || keyCode == GLFW.GLFW_KEY_UP) + && (modifiers & GLFW.GLFW_MOD_ALT) != 0) { + this.focusHandler.moveFocus(keyCode); + } else if (this.focusHandler.focused() != null) { + return this.focusHandler.focused().onKeyPress(keyCode, scanCode, modifiers); + } } return super.onKeyPress(keyCode, scanCode, modifiers); @@ -254,9 +255,7 @@ public boolean onKeyPress(int keyCode, int scanCode, int modifiers) { @Override public boolean onCharTyped(char chr, int modifiers) { - if (this.focusHandler == null) return false; - - if (this.focusHandler.focused() != null) { + if (this.focusHandler != null && this.focusHandler.focused() != null) { return this.focusHandler.focused().onCharTyped(chr, modifiers); } diff --git a/src/main/java/io/wispforest/owo/ui/component/LabelComponent.java b/src/main/java/io/wispforest/owo/ui/component/LabelComponent.java index 8caf8319..d4a988f5 100644 --- a/src/main/java/io/wispforest/owo/ui/component/LabelComponent.java +++ b/src/main/java/io/wispforest/owo/ui/component/LabelComponent.java @@ -2,14 +2,22 @@ import io.wispforest.owo.ui.base.BaseComponent; import io.wispforest.owo.ui.core.*; +import io.wispforest.owo.ui.event.MouseEnter; import io.wispforest.owo.ui.parsing.UIModel; import io.wispforest.owo.ui.parsing.UIParsing; +import io.wispforest.owo.ui.util.ScissorStack; +import io.wispforest.owo.util.EventSource; import io.wispforest.owo.util.Observable; import net.minecraft.client.MinecraftClient; import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; import net.minecraft.text.OrderedText; import net.minecraft.text.Style; import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.Util; +import net.minecraft.util.math.MathHelper; +import org.jetbrains.annotations.Nullable; import org.w3c.dom.Element; import java.util.ArrayList; @@ -28,11 +36,19 @@ public class LabelComponent extends BaseComponent { protected HorizontalAlignment horizontalTextAlignment = HorizontalAlignment.LEFT; protected final AnimatableProperty color = AnimatableProperty.of(Color.WHITE); + + @Nullable + protected Text hoverText = null; + @Nullable + protected AnimatableProperty hoverColor = null; + protected final Observable lineHeight = Observable.of(this.textRenderer.fontHeight); protected final Observable lineSpacing = Observable.of(2); protected boolean shadow; protected int maxWidth; + protected boolean scrolling = false; + protected Function textClickHandler = style -> { OwoUIDrawContext.utilityScreen().captureLinkSource(); var success = OwoUIDrawContext.utilityScreen().handleTextClick(style); @@ -61,6 +77,28 @@ public Text text() { return this.text; } + public LabelComponent hoverText(@Nullable Text text) { + if (this.text.equals(text)) { + this.hoverText = null; + } else { + this.hoverText = text; + } + + if (this.hoverText != null && this.hovered) { + this.notifyParentIfMounted(); + } + + return this; + } + + public Text hoverText() { + return this.hoverText; + } + + public Text currentText() { + return this.hoverText != null && this.hovered ? this.hoverText : this.text; + } + public LabelComponent maxWidth(int maxWidth) { this.maxWidth = maxWidth; this.notifyParentIfMounted(); @@ -89,6 +127,24 @@ public AnimatableProperty color() { return this.color; } + public Color currentColor() { + return this.hoverColor != null && this.hovered ? this.hoverColor.get() : this.color.get(); + } + + public LabelComponent hoverColor(Color hoverColor) { + if (this.hoverColor == null) { + this.hoverColor = AnimatableProperty.of(hoverColor); + } else { + this.hoverColor.set(hoverColor); + } + + return this; + } + + public AnimatableProperty hoverColor() { + return this.hoverColor; + } + public LabelComponent verticalTextAlignment(VerticalAlignment verticalAlignment) { this.verticalTextAlignment = verticalAlignment; return this; @@ -134,6 +190,23 @@ public Function textClickHandler() { return textClickHandler; } + public LabelComponent scrolling(boolean value) { + this.scrolling = value; + + return this; + } + + public boolean scrolling() { + return this.scrolling; + } + + @Override + protected void updateHoveredState(int mouseX, int mouseY, boolean nowHovered) { + super.updateHoveredState(mouseX, mouseY, nowHovered); + + if (this.hoverText != null) this.notifyParentIfMounted(); + } + @Override protected int determineHorizontalContentSize(Sizing sizing) { int widestText = 0; @@ -163,7 +236,17 @@ public void inflate(Size space) { } private void wrapLines() { - this.wrappedText = this.textRenderer.wrapLines(this.text, this.horizontalSizing.get().isContent() ? this.maxWidth : this.width); + int width; + + if (scrolling) { + width = Integer.MAX_VALUE; + } else if(this.horizontalSizing.get().isContent()) { + width = this.maxWidth; + } else { + width = this.width; + } + + this.wrappedText = this.textRenderer.wrapLines(this.currentText(), width); } protected int textHeight() { @@ -178,10 +261,8 @@ public void update(float delta, int mouseX, int mouseY) { @Override public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partialTicks, float delta) { - var matrices = context.getMatrices(); - - matrices.pushMatrix(); - matrices.translate(0, 1f / MinecraftClient.getInstance().getWindow().getScaleFactor()); + context.push() + .translate(0, 1 / MinecraftClient.getInstance().getWindow().getScaleFactor()); int x = this.x; int y = this.y; @@ -198,25 +279,119 @@ public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partial case BOTTOM -> y += this.height - (this.textHeight()); } - final int lambdaX = x; - final int lambdaY = y; + var color = currentColor(); + if (this.scrolling) { + drawScrollableText(context, delta, x, y, color); + } else { + drawWrappedText(context, x, y, color); + } + + context.pop(); + } + + protected void drawWrappedText(DrawContext context, int x, int y, Color color) { for (int i = 0; i < this.wrappedText.size(); i++) { var renderText = this.wrappedText.get(i); - int renderX = lambdaX; + int renderX = x; switch (this.horizontalTextAlignment) { case CENTER -> renderX += (this.width - this.textRenderer.getWidth(renderText)) / 2; case RIGHT -> renderX += this.width - this.textRenderer.getWidth(renderText); } - int renderY = lambdaY + i * (this.lineHeight() + this.lineSpacing()); + int renderY = y + i * (this.lineHeight() + this.lineSpacing()); renderY += this.lineHeight() - this.textRenderer.fontHeight; - context.drawText(this.textRenderer, renderText, renderX, renderY, this.color.get().argb(), this.shadow); + context.drawText(this.textRenderer, renderText, renderX, renderY, color.argb(), this.shadow); + } + + context.draw(); + } + + public LabelComponent copyScrollData(LabelComponent component) { + this.offsetTotal = component.offsetTotal; + this.prevDirection = component.prevDirection; + this.currentDirection = component.currentDirection; + this.pausedTimeTotal = component.pausedTimeTotal; + + return this; + } + + private float offsetTotal = 0; + + private Direction prevDirection = Direction.FORWARDS; + private Direction currentDirection = Direction.FORWARDS; + + private float pausedTimeTotal = 0; + + protected void drawScrollableText(OwoUIDrawContext context, float delta, int startX, int startY, Color color) { + int textWidth = textRenderer.getWidth(text); + + int j = (startY + startY + this.height() - 9) / 2 + 1; + + if (textWidth > this.width()) { + int scrollAmount = textWidth - this.width(); + + // 0 -> total / 2 : total / 2 -> total + // -1 : 1 + var baseRangeValue = (offsetTotal - (scrollAmount / 2f)) / (scrollAmount / 2f); + + var offset = delta * ((baseRangeValue * baseRangeValue) / -1.1f + 1); + + switch (currentDirection) { + case FORWARDS -> { + if (offsetTotal + offset >= scrollAmount) { + currentDirection = Direction.PAUSED; + prevDirection = Direction.FORWARDS; + + offsetTotal = scrollAmount; + } else { + offsetTotal += offset; + } + } + case BACKWARDS -> { + if (offsetTotal - offset <= 0) { + currentDirection = Direction.PAUSED; + prevDirection = Direction.BACKWARDS; + + offsetTotal = 0; + } else { + offsetTotal -= offset; + } + } + case PAUSED -> { + if (pausedTimeTotal > (4 * 20)) { + currentDirection = switch (prevDirection) { + case FORWARDS -> Direction.BACKWARDS; + case BACKWARDS, PAUSED -> Direction.FORWARDS; + }; + + pausedTimeTotal = 0; + } else { + pausedTimeTotal += delta; + } + } + } + + context.drawWithScissor(startX, startY, this.width(), this.height(), ctx -> { + ctx.drawText(textRenderer, this.currentText(), startX - Math.round(offsetTotal)/*- (int)scrolledOffset*/, y, color.argb(), this.shadow); + }); + } else { + var offset = switch (this.horizontalTextAlignment) { + case CENTER -> (this.width - this.textRenderer.getWidth(this.currentText())) / 2; + case RIGHT -> this.width - this.textRenderer.getWidth(this.currentText()); + case null, default -> 0; + }; + + context.drawText(textRenderer, this.currentText(), startX + offset, startY, color.argb(), this.shadow); } + } - matrices.popMatrix(); + private enum Direction { + FORWARDS, + BACKWARDS, + PAUSED; } @Override @@ -244,11 +419,14 @@ protected Style styleAt(int mouseX, int mouseY) { public void parseProperties(UIModel model, Element element, Map children) { super.parseProperties(model, element, children); UIParsing.apply(children, "text", UIParsing::parseText, this::text); + UIParsing.apply(children, "hover-text", UIParsing::parseText, this::hoverText); UIParsing.apply(children, "max-width", UIParsing::parseUnsignedInt, this::maxWidth); UIParsing.apply(children, "color", Color::parse, this::color); + UIParsing.apply(children, "hover-color", Color::parse, this::hoverColor); UIParsing.apply(children, "shadow", UIParsing::parseBool, this::shadow); UIParsing.apply(children, "line-height", UIParsing::parseUnsignedInt, this::lineHeight); UIParsing.apply(children, "line-spacing", UIParsing::parseUnsignedInt, this::lineSpacing); + UIParsing.apply(children, "scrolling", UIParsing::parseBool, this::scrolling); UIParsing.apply(children, "vertical-text-alignment", VerticalAlignment::parse, this::verticalTextAlignment); UIParsing.apply(children, "horizontal-text-alignment", HorizontalAlignment::parse, this::horizontalTextAlignment); diff --git a/src/main/java/io/wispforest/owo/ui/component/TextBoxComponent.java b/src/main/java/io/wispforest/owo/ui/component/TextBoxComponent.java index ab475e37..8b725cbc 100644 --- a/src/main/java/io/wispforest/owo/ui/component/TextBoxComponent.java +++ b/src/main/java/io/wispforest/owo/ui/component/TextBoxComponent.java @@ -91,6 +91,7 @@ public TextBoxComponent text(String text) { @Override public void parseProperties(UIModel spec, Element element, Map children) { super.parseProperties(spec, element, children); + UIParsing.apply(children, "cursor-start", UIParsing::parseBool, this::setCursorToStart); UIParsing.apply(children, "show-background", UIParsing::parseBool, this::setDrawsBackground); UIParsing.apply(children, "max-length", UIParsing::parseUnsignedInt, this::setMaxLength); UIParsing.apply(children, "text", e -> e.getTextContent().strip(), this::text); diff --git a/src/main/java/io/wispforest/owo/ui/component/VanillaWidgetComponent.java b/src/main/java/io/wispforest/owo/ui/component/VanillaWidgetComponent.java index eba3fbcc..5ba3d940 100644 --- a/src/main/java/io/wispforest/owo/ui/component/VanillaWidgetComponent.java +++ b/src/main/java/io/wispforest/owo/ui/component/VanillaWidgetComponent.java @@ -25,10 +25,6 @@ protected VanillaWidgetComponent(ClickableWidget widget) { } } - public boolean hovered() { - return this.hovered; - } - @Override public void mount(ParentComponent parent, int x, int y) { super.mount(parent, x, y); @@ -40,7 +36,7 @@ protected void updateHoveredState(int mouseX, int mouseY, boolean nowHovered) { this.hovered = nowHovered; if (nowHovered) { - if (this.root() == null || this.root().childAt(mouseX, mouseY) != this.widget) { + if (!this.prioritizedHover && (this.root() == null || this.root().childAt(mouseX, mouseY) != this.widget)) { this.hovered = false; return; } diff --git a/src/main/java/io/wispforest/owo/ui/container/Containers.java b/src/main/java/io/wispforest/owo/ui/container/Containers.java index 7f297542..5d42e7ac 100644 --- a/src/main/java/io/wispforest/owo/ui/container/Containers.java +++ b/src/main/java/io/wispforest/owo/ui/container/Containers.java @@ -59,4 +59,8 @@ public static CollapsibleContainer collapsible(Sizing horizontalSizing, Sizing v public static OverlayContainer overlay(C child) { return new OverlayContainer<>(child); } + + public static SelectableContainer selectable(Sizing horizontalSizing, Sizing verticalSizing, C child) { + return new SelectableContainer<>(horizontalSizing, verticalSizing, child); + } } diff --git a/src/main/java/io/wispforest/owo/ui/container/ScrollContainer.java b/src/main/java/io/wispforest/owo/ui/container/ScrollContainer.java index b8edc133..da833ef6 100644 --- a/src/main/java/io/wispforest/owo/ui/container/ScrollContainer.java +++ b/src/main/java/io/wispforest/owo/ui/container/ScrollContainer.java @@ -1,6 +1,9 @@ package io.wispforest.owo.ui.container; import io.wispforest.owo.ui.core.*; +import io.wispforest.owo.ui.core.Color; +import io.wispforest.owo.ui.core.Component; +import io.wispforest.owo.ui.core.Insets; import io.wispforest.owo.ui.parsing.UIModel; import io.wispforest.owo.ui.parsing.UIModelParsingException; import io.wispforest.owo.ui.parsing.UIParsing; @@ -14,7 +17,8 @@ import org.w3c.dom.Element; import org.w3c.dom.Node; -import java.util.Map; +import java.awt.*; +import java.util.*; import java.util.function.BiConsumer; import java.util.function.Function; @@ -27,9 +31,13 @@ public class ScrollContainer extends WrappingParentComponen public static final Identifier VANILLA_SCROLLBAR_TRACK_TEXTURE = Identifier.of("owo", "scrollbar/track"); public static final Identifier FLAT_VANILLA_SCROLLBAR_TEXTURE = Identifier.of("owo", "scrollbar/vanilla_flat"); + @Nullable + protected Runnable queuedScrollTarget = null; + protected double scrollOffset = 0; - protected double currentScrollPosition = 0; - protected int lastScrollPosition = -1; + protected double currentScrollOffset = 0; + protected int lastScrollOffset = -1; + protected int scrollStep = 0; protected int fixedScrollbarLength = 0; @@ -47,11 +55,17 @@ public class ScrollContainer extends WrappingParentComponen protected final ScrollDirection direction; + protected boolean scrollbarOppositeSide = false; + protected ScrollContainer(ScrollDirection direction, Sizing horizontalSizing, Sizing verticalSizing, C child) { super(horizontalSizing, verticalSizing, child); this.direction = direction; } + protected double scrolledAmount() { + return this.currentScrollOffset; + } + @Override protected int determineHorizontalContentSize(Sizing sizing) { if (this.direction == ScrollDirection.VERTICAL) { @@ -75,55 +89,66 @@ public void layout(Size space) { super.layout(space); this.maxScroll = Math.max(0, this.direction.sizeGetter.apply(child) - (this.direction.sizeGetter.apply(this) - this.direction.insetGetter.apply(this.padding.get()))); + + if (this.queuedScrollTarget != null) { + this.queuedScrollTarget.run(); + } + this.scrollOffset = MathHelper.clamp(this.scrollOffset, 0, this.maxScroll + .5); + + if (this.queuedScrollTarget != null) { + this.queuedScrollTarget = null; + + this.currentScrollOffset = this.scrollOffset; + } + this.childSize = this.direction.sizeGetter.apply(this.child); - this.lastScrollPosition = -1; } @Override protected int childMountX() { - return (int) (super.childMountX() - this.direction.choose(this.currentScrollPosition, 0)); + return (int) (super.childMountX() - this.direction.choose(this.currentScrollOffset, 0.0)); } @Override protected int childMountY() { - return (int) (super.childMountY() - this.direction.choose(0, this.currentScrollPosition)); + return (int) (super.childMountY() - this.direction.choose(0.0, this.currentScrollOffset)); } @Override protected void parentUpdate(float delta, int mouseX, int mouseY) { super.parentUpdate(delta, mouseX, mouseY); - this.currentScrollPosition += Delta.compute(this.currentScrollPosition, this.scrollOffset, delta * .5); - } - - @Override - public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partialTicks, float delta) { - super.draw(context, mouseX, mouseY, partialTicks, delta); + this.currentScrollOffset += Delta.compute(this.currentScrollOffset, this.scrollOffset, delta * .5); // Update child int effectiveScrollOffset = this.scrollStep > 0 ? ((int) this.scrollOffset / this.scrollStep) * this.scrollStep - : (int) this.currentScrollPosition; + : (int) this.currentScrollOffset; if (this.scrollStep > 0 && this.maxScroll - this.scrollOffset == -1) { effectiveScrollOffset += this.scrollOffset % this.scrollStep; } - int newScrollPosition = this.direction.coordinateGetter.apply(this) - effectiveScrollOffset; - if (newScrollPosition != this.lastScrollPosition) { - this.direction.coordinateSetter.accept(this.child, newScrollPosition + (this.direction == ScrollDirection.VERTICAL + int newScrollOffset = this.direction.coordinateGetter.apply(this) - effectiveScrollOffset; + if (newScrollOffset != this.lastScrollOffset) { + this.direction.coordinateSetter.accept(this.child, newScrollOffset + (this.direction == ScrollDirection.VERTICAL ? this.padding.get().top() + this.child.margins().get().top() : this.padding.get().left() + this.child.margins().get().left()) ); - this.lastScrollPosition = newScrollPosition; + this.lastScrollOffset = newScrollOffset; } + } + + @Override + public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partialTicks, float delta) { + super.draw(context, mouseX, mouseY, partialTicks, delta); // Draw, adding the fractional part of the offset via matrix translation context.getMatrices().pushMatrix(); - double visualOffset = -(this.currentScrollPosition % 1d); + double visualOffset = -(this.currentScrollOffset % 1d); if (visualOffset > 9999999e-7 || visualOffset < .1e-6) visualOffset = 0; - context.getMatrices().translate((float) this.direction.choose(visualOffset, 0), (float) this.direction.choose(0, visualOffset)); + context.getMatrices().translate((float) this.direction.choose(visualOffset, 0.0), (float) this.direction.choose(0.0, visualOffset)); this.drawChildren(context, mouseX, mouseY, partialTicks, delta, this.childView); context.getMatrices().popMatrix(); @@ -142,14 +167,14 @@ public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partial // Determine the offset of the scrollbar on the // *opposite* axis to the one we scroll on this.scrollbarOffset = this.direction == ScrollDirection.VERTICAL - ? this.x + this.width - padding.right() - scrollbarThiccness - : this.y + this.height - padding.bottom() - scrollbarThiccness; + ? this.x + (this.scrollbarOppositeSide ? 0 : this.width - padding.right() - scrollbarThiccness) + : this.y + (this.scrollbarOppositeSide ? 0 : this.height - padding.bottom() - scrollbarThiccness); this.lastScrollbarLength = this.fixedScrollbarLength == 0 ? Math.min(Math.floor(((float) selfSize / this.childSize) * contentSize), contentSize) : this.fixedScrollbarLength; double scrollbarPosition = this.maxScroll != 0 - ? (this.currentScrollPosition / this.maxScroll) * (contentSize - this.lastScrollbarLength) + ? (this.scrolledAmount() / this.maxScroll) * (contentSize - this.lastScrollbarLength) : 0; if (this.direction == ScrollDirection.VERTICAL) { @@ -158,9 +183,12 @@ public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partial (int) (this.y + scrollbarPosition + padding.top()), this.scrollbarThiccness, (int) (this.lastScrollbarLength), - this.scrollbarOffset, this.y + padding.top(), - this.scrollbarThiccness, this.height - padding.vertical(), - lastScrollbarInteractTime, this.direction, + this.scrollbarOffset, + this.y + padding.top(), + this.scrollbarThiccness, + this.height - padding.vertical(), + lastScrollbarInteractTime, + this.direction, this.maxScroll > 0 ); } else { @@ -169,9 +197,12 @@ public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partial this.scrollbarOffset, (int) (this.lastScrollbarLength), this.scrollbarThiccness, - this.x + padding.left(), this.scrollbarOffset, - this.width - padding.horizontal(), this.scrollbarThiccness, - lastScrollbarInteractTime, this.direction, + this.x + padding.left(), + this.scrollbarOffset, + this.width - padding.horizontal(), + this.scrollbarThiccness, + lastScrollbarInteractTime, + this.direction, this.maxScroll > 0 ); } @@ -182,16 +213,16 @@ public boolean canFocus(FocusSource source) { return true; } + protected double mouseScrollStepAmount() { + return this.scrollStep < 1 ? 15 : 1; + } + @Override public boolean onMouseScroll(double mouseX, double mouseY, double amount) { if (this.child.onMouseScroll(this.x + mouseX - this.child.x(), this.y + mouseY - this.child.y(), amount)) return true; - if (this.scrollStep < 1) { - this.scrollBy(-amount * 15, false, true); - } else { - this.scrollBy(-amount * this.scrollStep, true, true); - } + this.scrollBy(-amount * mouseScrollStepAmount(), false, true, true); return true; } @@ -254,25 +285,41 @@ public boolean onMouseUp(double mouseX, double mouseY, int button) { } protected void scrollBy(double offset, boolean instant, boolean showScrollbar) { + scrollBy(offset, instant, showScrollbar, false); + } + + protected void scrollBy(double offset, boolean instant, boolean showScrollbar, boolean scrollStepped) { + if (scrollStepped && this.scrollStep > 1) offset *= this.scrollStep; + this.scrollOffset = MathHelper.clamp(this.scrollOffset + offset, 0, this.maxScroll + .5); - if (instant) this.currentScrollPosition = this.scrollOffset; + + scrollByPost(instant, showScrollbar); + } + + protected void scrollByPost(boolean instant, boolean showScrollbar) { + if (instant) this.currentScrollOffset = this.scrollOffset; if (showScrollbar) this.lastScrollbarInteractTime = System.currentTimeMillis() + 1250; } protected boolean isInScrollbar(double mouseX, double mouseY) { - return this.isInBoundingBox(mouseX, mouseY) && this.direction.choose(mouseY, mouseX) >= this.scrollbarOffset; + var value = this.direction.choose(mouseY, mouseX); + + return this.isInBoundingBox(mouseX, mouseY) + && value >= this.scrollbarOffset + && (!this.scrollbarOppositeSide || (value < this.scrollbarOffset + this.scrollbarThiccness)); } /** * Scroll to the given component */ public ScrollContainer scrollTo(Component component) { - if (this.direction == ScrollDirection.VERTICAL) { - this.scrollOffset = MathHelper.clamp(this.scrollOffset - (this.y - component.y() + component.margins().get().top()), 0, this.maxScroll); - } else { - this.scrollOffset = MathHelper.clamp(this.scrollOffset - (this.x - component.x() + component.margins().get().right()), 0, this.maxScroll); - } - return this; + return scrollTo(() -> { + var amount = this.direction.choose( + this.x - component.x() + component.margins().get().right(), + this.y - component.y() + component.margins().get().top()); + + this.scrollOffset = MathHelper.clamp(this.scrollOffset - amount, 0, this.maxScroll); + }); } /** @@ -280,10 +327,31 @@ public ScrollContainer scrollTo(Component component) { * length of this container's content */ public ScrollContainer scrollTo(@Range(from = 0, to = 1) double progress) { - this.scrollOffset = this.maxScroll * progress; + return scrollTo(() -> this.scrollOffset = this.maxScroll * progress); + } + + public ScrollContainer scrollToOffset(double value) { + return scrollTo(() -> this.scrollOffset = value); + } + + protected ScrollContainer scrollTo(Runnable target) { + if (this.mounted) { + target.run(); + } else { + this.queuedScrollTarget = target; + } + return this; } + public double scrollProgress() { + return this.scrollOffset / this.maxScroll; + } + + public double scrollOffset() { + return this.scrollOffset; + } + /** * Set the thickness of this container's scrollbar, * in logical pixels @@ -343,6 +411,16 @@ public ScrollContainer fixedScrollbarLength(int fixedScrollbarLength) { return this; } + public boolean scrollbarOppositeSide(){ + return this.scrollbarOppositeSide; + } + + public ScrollContainer scrollbarOppositeSide(boolean value){ + this.scrollbarOppositeSide = value; + + return this; + } + /** * @return The current fixed length of this container's scrollbar, * or {@code 0} if it adjusts based on the content @@ -356,9 +434,9 @@ public void parseProperties(UIModel model, Element element, Map super.parseProperties(model, element, children); UIParsing.apply(children, "fixed-scrollbar-length", UIParsing::parseUnsignedInt, this::fixedScrollbarLength); UIParsing.apply(children, "scrollbar-thiccness", UIParsing::parseUnsignedInt, this::scrollbarThiccness); - UIParsing.apply(children, "scrollbar", Scrollbar::parse, this::scrollbar); - UIParsing.apply(children, "scroll-step", UIParsing::parseUnsignedInt, this::scrollStep); + UIParsing.apply(children, "scrollbar", Scrollbar::parse, this::scrollbar); + UIParsing.apply(children, "scrollbar-opposite-side", UIParsing::parseBool, this::scrollbarOppositeSide); } public static ScrollContainer parse(Element element) { @@ -454,12 +532,11 @@ public enum ScrollDirection { this.moreKeycode = moreKeycode; } - public double choose(double horizontal, double vertical) { + public N choose(N horizontal, N vertical) { return switch (this) { case VERTICAL -> vertical; case HORIZONTAL -> horizontal; }; } - } } diff --git a/src/main/java/io/wispforest/owo/ui/container/SelectableContainer.java b/src/main/java/io/wispforest/owo/ui/container/SelectableContainer.java new file mode 100644 index 00000000..203b0fa3 --- /dev/null +++ b/src/main/java/io/wispforest/owo/ui/container/SelectableContainer.java @@ -0,0 +1,130 @@ +package io.wispforest.owo.ui.container; + +import io.wispforest.owo.ui.core.*; +import io.wispforest.owo.ui.parsing.UIModel; +import io.wispforest.owo.ui.parsing.UIParsing; +import org.w3c.dom.Element; + +import java.util.Map; + +public class SelectableContainer extends WrappingParentComponent { + + protected Surface highlightSurface = Surface.BLANK; + protected ValidFocusSource validFocusSource = ValidFocusSource.ANY; + + protected boolean isSelected = false; + + protected SelectableContainer(Sizing horizontalSizing, Sizing verticalSizing, C child) { + super(horizontalSizing, verticalSizing, child); + } + + public void setSelected(boolean value) { + this.isSelected = value; + } + + @Override + protected void updateHoveredState(int mouseX, int mouseY, boolean nowHovered) { + this.hovered = nowHovered; + + if (nowHovered) { + this.mouseEnterEvents.sink().onMouseEnter(); + } else { + this.mouseLeaveEvents.sink().onMouseLeave(); + } + } + + public SelectableContainer validFocusSource(ValidFocusSource value) { + this.validFocusSource = value; + + return this; + } + + public ValidFocusSource validFocusSource() { + return this.validFocusSource; + } + + public SelectableContainer highlightSurface(Surface surface) { + this.highlightSurface = surface; + + return this; + } + + public Surface highlightSurface() { + return this.highlightSurface; + } + + @Override + public void onFocusGained(FocusSource source) { + this.isSelected = true; + + super.onFocusGained(source); + } + + @Override + public void onFocusLost() { + this.isSelected = false; + + super.onFocusLost(); + } + + @Override + public boolean canFocus(FocusSource source) { + return this.validFocusSource.canFocus(source); + } + + @Override + public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partialTicks, float delta) { + super.draw(context, mouseX, mouseY, partialTicks, delta); + + if (this.isSelected || this.hovered) { + this.highlightSurface.draw(context, this); + } + + this.drawChildren(context, mouseX, mouseY, partialTicks, delta, this.children()); + } + + @Override + public void drawFocusHighlight(OwoUIDrawContext context, int mouseX, int mouseY, float partialTicks, float delta) { + if (this.highlightSurface != Surface.BLANK) { + this.highlightSurface.draw(context, this); + } else { + super.drawFocusHighlight(context, mouseX, mouseY, partialTicks, delta); + } + } + + @Override + public void parseProperties(UIModel model, Element element, Map children) { + super.parseProperties(model, element, children); + UIParsing.apply(children, "highlight-surface", Surface::parse, this::highlightSurface); + UIParsing.apply(children, "valid-focus-source", UIParsing.parseEnum(ValidFocusSource.class), this::validFocusSource); + } + + public enum ValidFocusSource { + NONE, + + /** + * The component has been clicked + */ + MOUSE_CLICK, + + /** + * The component has been selected by + * cycling focus via the keyboard + */ + KEYBOARD_CYCLE, + + /** + * Any of the above sources are valid to allow focusing + */ + ANY; + + public boolean canFocus(FocusSource source){ + return switch (this) { + case MOUSE_CLICK -> source.equals(FocusSource.MOUSE_CLICK); + case KEYBOARD_CYCLE -> source.equals(FocusSource.KEYBOARD_CYCLE); + case ANY -> true; + case NONE -> false; + }; + } + } +} diff --git a/src/main/java/io/wispforest/owo/ui/core/Color.java b/src/main/java/io/wispforest/owo/ui/core/Color.java index 07c21912..a01eb119 100644 --- a/src/main/java/io/wispforest/owo/ui/core/Color.java +++ b/src/main/java/io/wispforest/owo/ui/core/Color.java @@ -180,4 +180,36 @@ public static Color parse(Node node) { public static int parseAndPack(Node node) { return parse(node).argb(); } + + + public Color withRed(int red) { + return withRed(red / 255f); + } + + public Color withRed(float red) { + return new Color(red, this.green(), this.blue(), this.alpha()); + } + + public Color withGreen(int green) { + return withGreen(green / 255f); + } + + public Color withGreen(float green) { + return new Color(this.red(), green, this.blue(), this.alpha()); + } + public Color withBlue(int blue) { + return withBlue(blue / 255f); + } + + public Color withBlue(float blue) { + return new Color(this.red(), this.green(), blue, this.alpha()); + } + + public Color withAlpha(int alpha) { + return withAlpha(alpha / 255f); + } + + public Color withAlpha(float alpha) { + return new Color(this.red(), this.green(), this.blue(), alpha); + } } diff --git a/src/main/java/io/wispforest/owo/ui/core/Component.java b/src/main/java/io/wispforest/owo/ui/core/Component.java index 39d2c416..908adbb0 100644 --- a/src/main/java/io/wispforest/owo/ui/core/Component.java +++ b/src/main/java/io/wispforest/owo/ui/core/Component.java @@ -465,6 +465,8 @@ default void update(float delta, int mouseX, int mouseY) { this.verticalSizing().update(delta); } + EventSource componentUpdate(); + /** * Test whether the given coordinates * are inside this component's bounding box @@ -638,6 +640,11 @@ default void moveTo(int x, int y) { this.updateY(y); } + /** + * @return If the given component is currently hovered + */ + boolean hovered(); + enum FocusSource { /** * The component has been clicked diff --git a/src/main/java/io/wispforest/owo/ui/core/OwoUIAdapter.java b/src/main/java/io/wispforest/owo/ui/core/OwoUIAdapter.java index ac50954e..9b249fbd 100644 --- a/src/main/java/io/wispforest/owo/ui/core/OwoUIAdapter.java +++ b/src/main/java/io/wispforest/owo/ui/core/OwoUIAdapter.java @@ -5,6 +5,7 @@ import io.wispforest.owo.Owo; import io.wispforest.owo.renderdoc.RenderDoc; import io.wispforest.owo.ui.util.CursorAdapter; +import io.wispforest.owo.ui.util.UIErrorToast; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.Drawable; @@ -12,6 +13,7 @@ import net.minecraft.client.gui.Selectable; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; import java.util.function.BiFunction; @@ -52,6 +54,10 @@ public class OwoUIAdapter implements Element, Drawabl public boolean globalInspector = false; public int inspectorZOffset = 1000; + @Nullable + private Throwable currentError = null; + private boolean allowInvalidRendering = true; + protected OwoUIAdapter(int x, int y, int width, int height, R rootComponent) { this.x = x; this.y = y; @@ -62,6 +68,21 @@ protected OwoUIAdapter(int x, int y, int width, int height, R rootComponent) { this.rootComponent = rootComponent; } + public boolean isValid() { + return this.currentError != null; + } + + @Nullable + public Throwable currentError() { + return this.currentError; + } + + public OwoUIAdapter allowInvalidRendering(boolean value) { + this.allowInvalidRendering = value; + + return this; + } + /** * Create a UI adapter for the given screen. This also sets it up * to be rendered and receive input events, without needing you to @@ -191,6 +212,12 @@ public void render(DrawContext context, int mouseX, int mouseY, float partialTic } if (this.captureFrame) RenderDoc.endFrameCapture(); + } catch (Exception error) { + if (this.allowInvalidRendering) { + throw error; + } else if(this.currentError == null) { + this.currentError = error; + } } finally { isRendering = false; this.captureFrame = false; diff --git a/src/main/java/io/wispforest/owo/ui/core/OwoUIDrawContext.java b/src/main/java/io/wispforest/owo/ui/core/OwoUIDrawContext.java index 3b68a247..05de779c 100644 --- a/src/main/java/io/wispforest/owo/ui/core/OwoUIDrawContext.java +++ b/src/main/java/io/wispforest/owo/ui/core/OwoUIDrawContext.java @@ -4,6 +4,7 @@ import com.mojang.blaze3d.pipeline.RenderPipeline; import io.wispforest.owo.mixin.ui.access.DrawContextAccessor; import io.wispforest.owo.ui.event.WindowResizeCallback; +import io.wispforest.owo.ui.util.MatrixStackTransformer; import io.wispforest.owo.ui.renderstate.CircleElementRenderState; import io.wispforest.owo.ui.renderstate.GradientQuadElementRenderState; import io.wispforest.owo.ui.renderstate.LineElementRenderState; @@ -167,10 +168,18 @@ public enum TextAnchor { } public void drawLine(int x1, int y1, int x2, int y2, double thiccness, Color color) { + drawLine(x1, y1, x2, y2, thiccness, color.argb()); + } + + public void drawLine(int x1, int y1, int x2, int y2, double thiccness, int color) { drawLine(RenderPipelines.GUI, x1, y1, x2, y2, thiccness, color); } public void drawLine(RenderPipeline pipeline, int x1, int y1, int x2, int y2, double thiccness, Color color) { + drawLine(pipeline, x1, y1, x2, y2, thiccness, color.argb()); + } + + public void drawLine(RenderPipeline pipeline, int x1, int y1, int x2, int y2, double thiccness, int color) { this.state.addSimpleElement(new LineElementRenderState( pipeline, new Matrix3x2f(this.getMatrices()), diff --git a/src/main/java/io/wispforest/owo/ui/core/PositionedRectangle.java b/src/main/java/io/wispforest/owo/ui/core/PositionedRectangle.java index 959811c9..c88e9bb5 100644 --- a/src/main/java/io/wispforest/owo/ui/core/PositionedRectangle.java +++ b/src/main/java/io/wispforest/owo/ui/core/PositionedRectangle.java @@ -74,31 +74,25 @@ default PositionedRectangle interpolate(PositionedRectangle next, float delta) { ); } + static boolean areEqual(PositionedRectangle rect1, PositionedRectangle rect2) { + if (rect1 == rect2) return true; + return rect1.x() == rect2.x() && + rect1.y() == rect2.y() && + rect1.width() == rect2.width() && + rect1.height() == rect2.height(); + } + + static

PositionedRectangle of(P rectangle) { + return of(rectangle.x(), rectangle.y(), rectangle.width(), rectangle.height()); + } + static PositionedRectangle of(int x, int y, Size size) { return of(x, y, size.width(), size.height()); } static PositionedRectangle of(int x, int y, int width, int height) { - return new PositionedRectangle() { - @Override - public int x() { - return x; - } - - @Override - public int y() { - return y; - } - - @Override - public int width() { - return width; - } - - @Override - public int height() { - return height; - } - }; + return new PositionedRectangleImpl(x, y, width, height); } + + record PositionedRectangleImpl(int x, int y, int width, int height) implements PositionedRectangle {} } diff --git a/src/main/java/io/wispforest/owo/ui/core/Surface.java b/src/main/java/io/wispforest/owo/ui/core/Surface.java index 456572d8..a6ed8aa4 100644 --- a/src/main/java/io/wispforest/owo/ui/core/Surface.java +++ b/src/main/java/io/wispforest/owo/ui/core/Surface.java @@ -5,17 +5,30 @@ import io.wispforest.owo.ui.renderstate.BlurQuadElementRenderState; import io.wispforest.owo.ui.renderstate.CubeMapElementRenderState; import io.wispforest.owo.ui.util.NinePatchTexture; +import io.wispforest.owo.ui.util.WrappedMatrixStack; +import it.unimi.dsi.fastutil.Pair; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gl.RenderPipelines; import net.minecraft.client.gui.RotatingCubeMapRenderer; import net.minecraft.client.gui.ScreenRect; +import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.tooltip.TooltipBackgroundRenderer; +import net.minecraft.client.render.*; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; import net.minecraft.util.Identifier; +import net.minecraft.util.Util; +import net.minecraft.util.math.MathHelper; import org.jetbrains.annotations.Nullable; import org.joml.Matrix3x2f; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector2i; import org.w3c.dom.Element; import org.w3c.dom.Node; +import java.util.*; + public interface Surface { Surface BLANK = (context, component) -> {}; @@ -39,6 +52,13 @@ public interface Surface { ); }; + Surface OPTIONS_BACKGROUND = Surface.panorama(ScreenAccessor.owo$ROTATING_PANORAMA_RENDERER(), false) + .and(Surface.blur(5, 10)) + .and((context, component) -> { + var texture = MinecraftClient.getInstance().world == null ? Screen.MENU_BACKGROUND_TEXTURE : Identifier.ofVanilla("textures/gui/inworld_menu_background.png"); + context.drawTexture(RenderLayer::getGuiTextured, texture, component.x(), component.y(), 0.0F, 0.0F, component.width(), component.height(), 32, 32); + }); + Surface TOOLTIP = tooltip(null); static Surface tooltip(@Nullable Identifier texture) { @@ -82,7 +102,80 @@ static Surface flat(int color) { } static Surface outline(int color) { - return (context, component) -> context.drawRectOutline(component.x(), component.y(), component.width(), component.height(), color); + return outline(color, 0); + } + + static Surface outline(int color, int insetWidth) { + return (context, component) -> + context.drawRectOutline( + component.x() + insetWidth, + component.y() + insetWidth, + component.width() - insetWidth * 2, + component.height() - insetWidth * 2, + color + ); + } + + static Surface partialOutline(int color, OutlineSide ...targetedSides) { + return partialOutline(color, 0, targetedSides); + } + + static Surface partialOutline(int color, int insetWidth, OutlineSide ...targetedSides) { + return partialOutline(color, insetWidth, false, targetedSides); + } + + List DEBUG_COLORS = List.of(Color.BLACK, Color.RED, Color.GREEN, Color.BLUE); + + List TRANSPARENT_DEBUG_COLORS = Util.make(new ArrayList<>(), colors -> { + colors.add(new Color(0, 0, 0, 0.5f)); // BLACK + colors.add(new Color(1, 0, 0, 0.5f)); // RED + colors.add(new Color(0, 1, 0, 0.5f)); // GREEN + colors.add(new Color(0, 0, 1, 0.5f)); // BLUE + }); + + static Surface partialOutline(int color, int insetWidth, boolean extendToBounds, OutlineSide ...targetedSides) { + var sides = Set.of(targetedSides); + + if (OutlineSide.ALL_SIDES.equals(sides)) return outline(color, insetWidth); + + return (context, component) -> { + for (var side : sides) { + // Just some janky debug coloring +// var tempColor = TRANSPARENT_DEBUG_COLORS.get(side.ordinal()); + + if (side == OutlineSide.TOP || side == OutlineSide.BOTTOM) { + var hasLeftBound = sides.contains(OutlineSide.LEFT) || !extendToBounds; + var hasRightBound = sides.contains(OutlineSide.RIGHT) || !extendToBounds; + + var lineStart = component.x() + (hasLeftBound ? insetWidth : 0); + var lineLength = component.width() - ((hasLeftBound ? insetWidth : 0) + (hasRightBound ? insetWidth : 0)); + + var lineY = component.y() + (side == OutlineSide.BOTTOM ? component.height() - insetWidth - 1 : insetWidth); + + context.drawHorizontalLine(lineStart, lineStart + lineLength - 1, lineY, color); + } else { + var hasTopBound = sides.contains(OutlineSide.TOP) || !extendToBounds; + var hasBottomBound = sides.contains(OutlineSide.BOTTOM) || !extendToBounds; + + var lineStart = component.y() + (hasTopBound ? insetWidth : -1); + var lineLength = component.height() - ((hasTopBound ? insetWidth : 0) + (hasBottomBound ? insetWidth : 0)); + var lineEnd = lineStart + lineLength + (hasBottomBound && hasTopBound ? -1 : (hasBottomBound || hasTopBound) ? 0 : 1); + + var lineX = component.x() + (side == OutlineSide.RIGHT ? component.width() - insetWidth - 1 : insetWidth); + + context.drawVerticalLine(lineX, lineStart, lineEnd, color); + } + } + }; + } + + enum OutlineSide { + TOP, + BOTTOM, + LEFT, + RIGHT; + + public static final Set ALL_SIDES = Set.of(OutlineSide.values()); } static Surface tiled(Identifier texture, int textureWidth, int textureHeight) { @@ -142,12 +235,87 @@ static Surface parse(Element surfaceElement) { case "vanilla-translucent" -> surface.and(VANILLA_TRANSLUCENT); case "panel-inset" -> surface.and(PANEL_INSET); case "tooltip" -> surface.and(TOOLTIP); - case "outline" -> surface.and(outline(Color.parseAndPack(child))); + case "outline" -> { + var insetWidth = 0; + var insetWidthAttr = child.getAttributeNode("insetWidth"); + + if (insetWidthAttr != null) insetWidth = UIParsing.parseUnsignedInt(insetWidthAttr); + + yield surface.and(outline(Color.parseAndPack(child), insetWidth)); + } case "flat" -> surface.and(flat(Color.parseAndPack(child))); + case "partial-outline" -> { + UIParsing.expectAttributes(child, "color"); + var color = Color.parseAndPack(child.getAttributeNode("color")); + + var insetWidth = Optional.ofNullable(child.getAttributeNode("insetWidth")) + .map(UIParsing::parseUnsignedInt) + .orElse(0); + + var extendToBounds = Optional.ofNullable(child.getAttributeNode("extendToBounds")) + .map(UIParsing::parseBool) + .orElse(false); + + var sides = Arrays.stream(child.getFirstChild().getTextContent().split(" ")) + .map(string -> OutlineSide.valueOf(string.toUpperCase(Locale.ROOT))) + .toArray(OutlineSide[]::new); + + yield surface.and(Surface.partialOutline(color, insetWidth, extendToBounds, sides)); + } + case "transformed" -> { + var innerChildren = UIParsing.childElements(child); + + UIParsing.expectChildren(child, innerChildren, "transforms", "surface"); + + var transforms = UIParsing.allChildrenOfType(innerChildren.get("transforms"), Node.ELEMENT_NODE); + var stack = new WrappedMatrixStack(); + + for (var transform : transforms) { + switch (transform.getNodeName()) { + case "translate" -> stack.translate(UIParsing.parseVector3f(transform)); + case "scale" -> stack.scale(UIParsing.parseVector3f(transform)); + case "multiply" -> { + var quaternion = UIParsing.parseQuaternionf(transform); + var origin = UIParsing.get(UIParsing.childElements(transform), "origin", UIParsing::parseVector3f); + + if (origin.isEmpty()) { + stack.multiply(quaternion); + } else { + stack.multiply(quaternion, origin.get()); + } + } + case "matrix" -> { + var list = Arrays.stream(transform.getTextContent().split(" ")) + .map(Float::parseFloat) + .toList(); + + var array = new float[list.size()]; + + for (int i = 0; i < list.size(); i++) array[i] = list.get(i); + + var matrix = new Matrix4f().set(array); + + stack.multiplyPositionMatrix(matrix); + } + default -> throw new UIModelParsingException("Unknown transform type '" + child.getNodeName() + "'"); + } + } + + var transformedSurface = Surface.parse(innerChildren.get("surface")); + + yield surface.and((context, component) -> { + context.push().applyStackTransformer(stack); + + transformedSurface.draw(context, component); + + context.pop(); + }); + } default -> throw new UIModelParsingException("Unknown surface type '" + child.getNodeName() + "'"); }; } return surface; } + } diff --git a/src/main/java/io/wispforest/owo/ui/event/ComponentUpdate.java b/src/main/java/io/wispforest/owo/ui/event/ComponentUpdate.java new file mode 100644 index 00000000..870be7a1 --- /dev/null +++ b/src/main/java/io/wispforest/owo/ui/event/ComponentUpdate.java @@ -0,0 +1,16 @@ +package io.wispforest.owo.ui.event; + +import io.wispforest.owo.util.EventStream; + +public interface ComponentUpdate { + + void onUpdate(float delta, int mouseX, int mouseY); + + static EventStream newStream() { + return new EventStream<>(subscribers -> (delta, mouseX, mouseY) -> { + for (var subscriber : subscribers) { + subscriber.onUpdate(delta, mouseX, mouseY); + } + }); + } +} diff --git a/src/main/java/io/wispforest/owo/ui/inject/ComponentStub.java b/src/main/java/io/wispforest/owo/ui/inject/ComponentStub.java index 19eb9025..9e028e98 100644 --- a/src/main/java/io/wispforest/owo/ui/inject/ComponentStub.java +++ b/src/main/java/io/wispforest/owo/ui/inject/ComponentStub.java @@ -5,6 +5,7 @@ import io.wispforest.owo.ui.event.*; import io.wispforest.owo.ui.util.FocusHandler; import io.wispforest.owo.util.EventSource; +import io.wispforest.owo.util.EventStream; import net.minecraft.client.gui.tooltip.TooltipComponent; import org.jetbrains.annotations.Nullable; @@ -272,4 +273,14 @@ default int widthOffset() { default int heightOffset() { throw new IllegalStateException("Interface stub method called"); } + + @Override + default EventSource componentUpdate() { + throw new IllegalStateException("Interface stub method called"); + } + + @Override + default boolean hovered() { + throw new IllegalStateException("Interface stub method called"); + } } diff --git a/src/main/java/io/wispforest/owo/ui/parsing/UIModelLoader.java b/src/main/java/io/wispforest/owo/ui/parsing/UIModelLoader.java index 923b8a55..0e120dd3 100644 --- a/src/main/java/io/wispforest/owo/ui/parsing/UIModelLoader.java +++ b/src/main/java/io/wispforest/owo/ui/parsing/UIModelLoader.java @@ -55,7 +55,12 @@ public class UIModelLoader implements SynchronousResourceReloader, IdentifiableR try (var stream = Files.newInputStream(HOT_RELOAD_LOCATIONS.get(id))) { return UIModel.load(stream); } catch (ParserConfigurationException | IOException | SAXException e) { - MinecraftClient.getInstance().player.sendMessage(TextOps.concat(Owo.PREFIX, TextOps.withFormatting("hot ui model reload failed, check the log for details", Formatting.RED)), false); + var player = MinecraftClient.getInstance().player; + + if (player != null){ + player.sendMessage(TextOps.concat(Owo.PREFIX, TextOps.withFormatting("hot ui model reload failed, check the log for details", Formatting.RED)), false); + } + Owo.LOGGER.error("Hot UI model reload failed", e); } } diff --git a/src/main/java/io/wispforest/owo/ui/parsing/UIParsing.java b/src/main/java/io/wispforest/owo/ui/parsing/UIParsing.java index e5084092..6cfb895a 100644 --- a/src/main/java/io/wispforest/owo/ui/parsing/UIParsing.java +++ b/src/main/java/io/wispforest/owo/ui/parsing/UIParsing.java @@ -6,9 +6,14 @@ import io.wispforest.owo.ui.core.Sizing; import net.minecraft.item.ItemStack; import net.minecraft.text.Text; +import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; import net.minecraft.util.InvalidIdentifierException; import org.jetbrains.annotations.ApiStatus; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.joml.Vector4f; +import org.w3c.dom.Attr; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -201,9 +206,15 @@ public static Identifier parseIdentifier(Node node) { * returned literally */ public static Text parseText(Element element) { - return element.getAttribute("translate").equalsIgnoreCase("true") + var text = element.getAttribute("translate").equalsIgnoreCase("true") ? Text.translatable(element.getTextContent()) : Text.literal(element.getTextContent()); + + if (element.getAttribute("bold").equalsIgnoreCase("true")) { + text.formatted(Formatting.BOLD); + } + + return text; } public static > Function parseEnum(Class enumClass) { @@ -217,6 +228,38 @@ public static > Function parseEnum(Class enumCl }; } + public static Vector3f parseVector3f(Element element) { + return new Vector3f( + parseOptionalAttribute(element, "x", 0f, UIParsing::parseFloat), + parseOptionalAttribute(element, "y", 0f, UIParsing::parseFloat), + parseOptionalAttribute(element, "z", 0f, UIParsing::parseFloat) + ); + } + + public static Vector4f parseVector4f(Element element) { + return new Vector4f( + parseOptionalAttribute(element, "x", 0f, UIParsing::parseFloat), + parseOptionalAttribute(element, "y", 0f, UIParsing::parseFloat), + parseOptionalAttribute(element, "z", 0f, UIParsing::parseFloat), + parseOptionalAttribute(element, "w", 0f, UIParsing::parseFloat) + ); + } + + public static Quaternionf parseQuaternionf(Element element) { + return new Quaternionf( + parseOptionalAttribute(element, "x", 0f, UIParsing::parseFloat), + parseOptionalAttribute(element, "y", 0f, UIParsing::parseFloat), + parseOptionalAttribute(element, "z", 0f, UIParsing::parseFloat), + parseOptionalAttribute(element, "w", 0f, UIParsing::parseFloat) + ); + } + + public static T parseOptionalAttribute(Element element, String key, T defaultValue, Function parser) { + return Optional.ofNullable(element.getAttributeNode(key)) + .map(parser) + .orElse(defaultValue); + } + /** * Parse the property indicated by {@code key} into an object of type {@code T} * @@ -298,6 +341,7 @@ protected static int parseInt(Node node, boolean allowNegative) { registerFactory("scroll", ScrollContainer::parse); registerFactory("collapsible", CollapsibleContainer::parse); registerFactory("draggable", element -> Containers.draggable(Sizing.content(), Sizing.content(), null)); + registerFactory("selectable", element -> Containers.selectable(Sizing.content(), Sizing.content(), null)); // Textures registerFactory("sprite", SpriteComponent::parse); diff --git a/src/main/java/io/wispforest/owo/ui/util/MatrixStackTransformer.java b/src/main/java/io/wispforest/owo/ui/util/MatrixStackTransformer.java index 37da91a1..96a0f36f 100644 --- a/src/main/java/io/wispforest/owo/ui/util/MatrixStackTransformer.java +++ b/src/main/java/io/wispforest/owo/ui/util/MatrixStackTransformer.java @@ -6,40 +6,107 @@ import org.joml.Matrix3x2fStack; import org.joml.Matrix4f; import org.joml.Quaternionf; +import org.joml.Vector3d; +import org.joml.Vector3f; + +import java.util.function.Consumer; /** * Helper interface implemented on top of the {@link DrawContext} to allow for easier matrix stack transformations */ -public interface MatrixStackTransformer { +public interface MatrixStackTransformer> { + + default T drawWithScissor(int x, int y, int width, int height, Consumer consumer) { + pushScissor(x, y, width, height); + + var t = this.owo$cast(); + + consumer.accept(t); + + popScissor(); + + return t; + } + + default T pushScissor(int x, int y, int width, int height) { + throw new IllegalStateException("pushScissor() method hasn't been override leading to exception!"); + } + + default T popScissor() { + throw new IllegalStateException("popScissor() method hasn't been override leading to exception!"); + } + + default T translate(Vector3f vec) { + return translate(vec.x(), vec.y(), vec.z()); + } + + default T translate(double x, double y, double z) { + this.getMatrixStack().translate(x, y, z); + return owo$cast(); + } - default MatrixStackTransformer translate(double x, double y) { - this.getMatrixStack().translate((float) x, (float) y); - return this; + default T translate(float x, float y, float z) { + this.getMatrixStack().translate(x, y, z); + return owo$cast(); } - default MatrixStackTransformer translate(float x, float y) { - this.getMatrixStack().translate(x, y); - return this; + default T scale(Vector3f vec) { + return scale(vec.x(), vec.y(), vec.z()); } - default MatrixStackTransformer scale(float x, float y) { - this.getMatrixStack().scale(x, y); - return this; + default T scale(float x, float y, float z) { + this.getMatrixStack().scale(x, y, z); + return owo$cast(); } - default MatrixStackTransformer push() { + default T multiply(Quaternionf quaternion) { + this.getMatrixStack().multiply(quaternion); + return owo$cast(); + } + + default T multiply(Quaternionf quaternion, Vector3f origin) { + return multiply(quaternion, origin.x(), origin.y(), origin.z()); + } + + default T multiply(Quaternionf quaternion, float originX, float originY, float originZ) { + this.getMatrixStack().multiply(quaternion, originX, originY, originZ); + return owo$cast(); + } + + default T push() { this.getMatrixStack().pushMatrix(); - return this; + return owo$cast(); } - default MatrixStackTransformer pop() { + default T pop() { this.getMatrixStack().popMatrix(); - return this; + return owo$cast(); + } + + default T multiplyPositionMatrix(Matrix4f matrix) { + this.getMatrixStack().multiplyPositionMatrix(matrix); + return owo$cast(); + } + + default T applyStackTransformer(MatrixStackTransformer transformer) { + return applyStack(transformer.getMatrixStack()); + } + + default T applyStack(MatrixStack stack) { + return applyStackEntry(stack.peek()); + } + + default T applyStackEntry(MatrixStack.Entry entry) { + var currentEntry = this.getMatrixStack().peek(); + + currentEntry.getPositionMatrix().mul(entry.getPositionMatrix()); + currentEntry.getNormalMatrix().mul(entry.getNormalMatrix()); + + return owo$cast(); } - default MatrixStackTransformer mul(Matrix3x2f matrix) { - this.getMatrixStack().mul(matrix); - return this; + default T owo$cast() { + return (T) this; } default Matrix3x2fStack getMatrixStack(){ diff --git a/src/main/java/io/wispforest/owo/ui/util/WrappedMatrixStack.java b/src/main/java/io/wispforest/owo/ui/util/WrappedMatrixStack.java new file mode 100644 index 00000000..bf114b1d --- /dev/null +++ b/src/main/java/io/wispforest/owo/ui/util/WrappedMatrixStack.java @@ -0,0 +1,15 @@ +package io.wispforest.owo.ui.util; + +import net.minecraft.client.util.math.MatrixStack; + +public record WrappedMatrixStack(MatrixStack matrixStack) implements MatrixStackTransformer { + + public WrappedMatrixStack() { + this(new MatrixStack()); + } + + @Override + public MatrixStack getMatrixStack() { + return matrixStack(); + } +} diff --git a/src/main/java/io/wispforest/owo/util/ReflectionUtils.java b/src/main/java/io/wispforest/owo/util/ReflectionUtils.java index f430d603..6ebc2b43 100644 --- a/src/main/java/io/wispforest/owo/util/ReflectionUtils.java +++ b/src/main/java/io/wispforest/owo/util/ReflectionUtils.java @@ -2,11 +2,12 @@ import io.wispforest.owo.registration.annotations.AssignedName; import io.wispforest.owo.registration.annotations.IterationIgnored; +import it.unimi.dsi.fastutil.Pair; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import java.lang.reflect.*; -import java.util.Locale; +import java.util.*; import java.util.function.Consumer; import java.util.function.Function; @@ -30,6 +31,25 @@ public static C tryInstantiateWithNoArgs(Class clazz) { throw new RuntimeException((e instanceof NoSuchMethodException ? "No zero-args constructor defined on class " : "Could not instantiate class ") + clazz, e); } } + public static C tryInstantiation(Class clazz) { + Object returnObj; + + if(NumberReflection.isNumberType(clazz)) { + returnObj = NumberReflection.convert(0, (Class) clazz); + } else if (clazz == String.class) { + returnObj = ""; + } else if (clazz == List.class) { + returnObj = new ArrayList<>(); + } else if (clazz == Set.class) { + returnObj = new LinkedHashSet<>(); + } else if (clazz == Map.class) { + returnObj = new LinkedHashMap<>(); + } else { + returnObj = ReflectionUtils.tryInstantiateWithNoArgs(clazz); + } + + return (C) returnObj; + } /** * Calls the {@link Constructor#newInstance(Object...)} method and @@ -179,6 +199,29 @@ public static String getCallingClassName(int depth) { return typeClass; } + @Nullable + public static Pair> getTypeAndClassArgument(Type type, int index) { + if (!(type instanceof ParameterizedType parameterizedType)) return null; + + var typeArgs = parameterizedType.getActualTypeArguments(); + if (index > typeArgs.length - 1) return null; + + var typeArgument = typeArgs[index]; + + Class typeClass = null; + + if (typeArgument instanceof Class clazz) { + typeClass = clazz; + } else if(typeArgument instanceof ParameterizedType innerParameterizedType) { + if (!(innerParameterizedType.getRawType() instanceof Class clazz)) { + return null; + } + typeClass = clazz; + } + + return Pair.of(typeArgument, typeClass); + } + @FunctionalInterface public interface FieldConsumer { void accept(F value, String name, Field field); diff --git a/src/main/resources/assets/owo/lang/en_us.json b/src/main/resources/assets/owo/lang/en_us.json index b521ea50..acccfe66 100644 --- a/src/main/resources/assets/owo/lang/en_us.json +++ b/src/main/resources/assets/owo/lang/en_us.json @@ -35,6 +35,27 @@ ], "text.owo.config.button.reload": "Reload", "text.owo.config.button.done": "Done", + "text.owo.config.label.environment": "Config Environment: ", + "#text.owo.config.label.environment.client": [ + "Side: ", + {"text" : "Client", "color": "#32a852"}, + "" + ], + "#text.owo.config.label.environment.server": [ + "Side: ", + {"text" : "Server", "color": "#4287f5"}, + "" + ], + "text.owo.config.label.environment.client": [ + "", + {"text" : "Client", "color": "#32a852"}, + "" + ], + "text.owo.config.label.environment.server": [ + "", + {"text" : "Server", "color": "#4287f5"}, + "" + ], "text.owo.config.sections_tooltip": "Sections", "text.owo.config.sections": {"text": "Sections", "underlined": true}, "text.owo.config.list.add_entry": "Add entry", @@ -51,5 +72,6 @@ {"text": "❌", "color": "#EB1D36"}, {"text": "]", "color": "gray"}, " Disabled" - ] + ], + "text.owo.config_selection": "Configs from %d" } \ No newline at end of file diff --git a/src/main/resources/assets/owo/owo_ui/config.xml b/src/main/resources/assets/owo/owo_ui/config.xml index e15fdc82..514ca73c 100644 --- a/src/main/resources/assets/owo/owo_ui/config.xml +++ b/src/main/resources/assets/owo/owo_ui/config.xml @@ -3,86 +3,94 @@ - + - - + + 62 + 63 + + + + 100 + - center - + true - - - + center + bottom + + + - + - - - - - 3 - - - - 3 + + + + + + + 3 + + + 100 + + + + 3 + + + 100 + 100 + + + 100 100 - - - 1 - - + + + 50 + + - 100 + 100 100 - - 50 - - 100 - 100 + 100 + + 2 + - - #77000000 - #99121212 - - - 1 - - - 101 + 100 100 - - #33FFFFFF - - + center + - + @@ -93,18 +101,37 @@ 2 - - - false - 128 - - - 50 - 9 - - + + + + + + false + 128 + + + 50 + 9 + + + 2 + + + + + + + + + + #4dFFFFFF + + + 1 + + - - - - center - - - 3 - @@ -130,10 +149,20 @@ 0,50 + + @@ -180,6 +209,105 @@ + + + + -