aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/java/dev/isxander/yacl3/api/Binding.java64
-rw-r--r--src/main/java/dev/isxander/yacl3/api/ButtonOption.java55
-rw-r--r--src/main/java/dev/isxander/yacl3/api/ConfigCategory.java138
-rw-r--r--src/main/java/dev/isxander/yacl3/api/Controller.java28
-rw-r--r--src/main/java/dev/isxander/yacl3/api/LabelOption.java41
-rw-r--r--src/main/java/dev/isxander/yacl3/api/ListOption.java178
-rw-r--r--src/main/java/dev/isxander/yacl3/api/ListOptionEntry.java18
-rw-r--r--src/main/java/dev/isxander/yacl3/api/NameableEnum.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/Option.java223
-rw-r--r--src/main/java/dev/isxander/yacl3/api/OptionAddable.java51
-rw-r--r--src/main/java/dev/isxander/yacl3/api/OptionDescription.java161
-rw-r--r--src/main/java/dev/isxander/yacl3/api/OptionFlag.java23
-rw-r--r--src/main/java/dev/isxander/yacl3/api/OptionGroup.java131
-rw-r--r--src/main/java/dev/isxander/yacl3/api/PlaceholderCategory.java55
-rw-r--r--src/main/java/dev/isxander/yacl3/api/YetAnotherConfigLib.java113
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/BooleanControllerBuilder.java16
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/ColorControllerBuilder.java14
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/ControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/CyclingListControllerBuilder.java15
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/DoubleFieldControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/DoubleSliderControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/DropdownStringControllerBuilder.java18
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/EnumControllerBuilder.java12
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/EnumDropdownControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/FloatFieldControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/FloatSliderControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/IntegerFieldControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/IntegerSliderControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/ItemControllerBuilder.java11
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/LongFieldControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/LongSliderControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/NumberFieldControllerBuilder.java7
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/SliderControllerBuilder.java6
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/StringControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/TickBoxControllerBuilder.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/ValueFormattableController.java14
-rw-r--r--src/main/java/dev/isxander/yacl3/api/controller/ValueFormatter.java7
-rw-r--r--src/main/java/dev/isxander/yacl3/api/utils/Dimension.java33
-rw-r--r--src/main/java/dev/isxander/yacl3/api/utils/MutableDimension.java11
-rw-r--r--src/main/java/dev/isxander/yacl3/api/utils/OptionUtils.java39
-rw-r--r--src/main/java/dev/isxander/yacl3/config/ConfigEntry.java15
-rw-r--r--src/main/java/dev/isxander/yacl3/config/ConfigInstance.java50
-rw-r--r--src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java259
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/ConfigClassHandler.java107
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/ConfigField.java40
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/ConfigSerializer.java64
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/FieldAccess.java14
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/ReadOnlyFieldAccess.java36
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/SerialEntry.java39
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/SerialField.java16
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/AutoGen.java32
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/AutoGenField.java12
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Boolean.java41
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ColorField.java21
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomDescription.java12
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomFormat.java17
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomImage.java69
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomName.java18
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/DoubleField.java46
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/DoubleSlider.java48
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Dropdown.java43
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/EnumCycler.java35
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FloatField.java46
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FloatSlider.java48
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FormatTranslation.java25
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/IntField.java41
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/IntSlider.java35
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ItemField.java17
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Label.java18
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ListGroup.java60
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/LongField.java41
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/LongSlider.java35
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/MasterTickBox.java26
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OptionAccess.java35
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OptionFactory.java40
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/SimpleOptionFactory.java138
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/StringField.java17
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/autogen/TickBox.java17
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/api/serializer/GsonConfigSerializerBuilder.java98
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigClassHandlerImpl.java274
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigFieldImpl.java75
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/FieldBackedBinding.java22
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/ReflectionFieldAccess.java49
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/AutoGenUtils.java54
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/BooleanImpl.java25
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ColorFieldImpl.java19
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DoubleFieldImpl.java32
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DoubleSliderImpl.java33
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DropdownImpl.java19
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EmptyCustomImageFactory.java17
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EnumCyclerImpl.java42
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/FloatFieldImpl.java32
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/FloatSliderImpl.java33
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/IntFieldImpl.java28
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/IntSliderImpl.java29
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ItemFieldImpl.java17
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LabelImpl.java16
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ListGroupImpl.java102
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LongFieldImpl.java28
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LongSliderImpl.java29
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/MasterTickBoxImpl.java25
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionAccessImpl.java44
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionFactoryRegistry.java64
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/StringFieldImpl.java16
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/TickBoxImpl.java16
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/YACLAutoGenException.java11
-rw-r--r--src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java275
-rw-r--r--src/main/java/dev/isxander/yacl3/debug/DebugProperties.java13
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/AbstractWidget.java100
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/DescriptionWithName.java11
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/ElementListWidgetExt.java274
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/LowProfileButtonWidget.java28
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/OptionDescriptionWidget.java222
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/OptionListWidget.java578
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/RequireRestartScreen.java21
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/SearchFieldWidget.java61
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/TextScaledButtonWidget.java34
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/TooltipButtonWidget.java21
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/ValueFormatters.java21
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/YACLScreen.java426
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/YACLTooltip.java23
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/YACLTooltipPositioner.java48
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/ActionController.java120
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/BooleanController.java164
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/ColorController.java220
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/ControllerWidget.java148
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/LabelController.java193
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/ListEntryWidget.java128
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/TickBoxController.java119
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/cycling/CyclingControllerElement.java60
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/cycling/CyclingListController.java86
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/cycling/EnumController.java48
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/cycling/ICyclingController.java38
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownController.java87
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownControllerElement.java248
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringController.java34
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringControllerElement.java31
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/EnumDropdownController.java92
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/EnumDropdownControllerElement.java25
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemController.java68
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemControllerElement.java87
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/package-info.java12
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/slider/DoubleSliderController.java119
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/slider/FloatSliderController.java119
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/slider/ISliderController.java54
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/slider/IntegerSliderController.java116
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/slider/LongSliderController.java116
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/slider/SliderControllerElement.java157
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/slider/package-info.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/IStringController.java44
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/StringController.java37
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/StringControllerElement.java466
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/number/DoubleFieldController.java111
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/number/FloatFieldController.java111
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/number/IntegerFieldController.java111
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/number/LongFieldController.java111
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/number/NumberFieldController.java80
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/controllers/string/number/package-info.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/image/ImageRenderer.java11
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/image/ImageRendererFactory.java24
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/image/ImageRendererManager.java120
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/image/YACLImageReloadListener.java110
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/image/impl/AnimatedDynamicTextureImage.java286
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/image/impl/DynamicTextureImage.java72
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/image/impl/ResourceTextureImage.java56
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/tab/ListHolderWidget.java116
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/tab/ScrollableNavigationBar.java120
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/tab/TabExt.java14
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/utils/ButtonTextureRenderer.java34
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/utils/GuiUtils.java32
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/utils/ItemRegistryHelper.java116
-rw-r--r--src/main/java/dev/isxander/yacl3/gui/utils/UndoRedoHelper.java42
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/ButtonOptionImpl.java205
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/ConfigCategoryImpl.java134
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/GenericBindingImpl.java35
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/HiddenNameListOptionEntry.java109
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/LabelOptionImpl.java160
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/ListOptionEntryImpl.java154
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/ListOptionImpl.java402
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/OptionDescriptionImpl.java133
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/OptionGroupImpl.java121
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/OptionImpl.java295
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/PlaceholderCategoryImpl.java99
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/SafeBinding.java29
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/YetAnotherConfigLibImpl.java122
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/AbstractControllerBuilderImpl.java12
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/BooleanControllerBuilderImpl.java57
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/ColorControllerBuilderImpl.java27
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/CyclingListControllerBuilderImpl.java41
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/DoubleFieldControllerBuilderImpl.java51
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/DoubleSliderControllerBuilderImpl.java44
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/DropdownStringControllerBuilderImpl.java49
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/EnumControllerBuilderImpl.java42
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/EnumDropdownControllerBuilderImpl.java27
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/FloatFieldControllerBuilderImpl.java51
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/FloatSliderControllerBuilderImpl.java44
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/IntegerFieldControllerBuilderImpl.java51
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/IntegerSliderControllerBuilderImpl.java44
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/ItemControllerBuilderImpl.java18
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/LongFieldControllerBuilderImpl.java51
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/LongSliderControllerBuilderImpl.java44
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/StringControllerBuilderImpl.java17
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/controller/TickBoxControllerBuilderImpl.java17
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/utils/DimensionIntegerImpl.java115
-rw-r--r--src/main/java/dev/isxander/yacl3/impl/utils/YACLConstants.java8
-rw-r--r--src/main/java/dev/isxander/yacl3/mixin/AbstractSelectionListMixin.java25
-rw-r--r--src/main/java/dev/isxander/yacl3/mixin/ContainerEventHandlerMixin.java37
-rw-r--r--src/main/java/dev/isxander/yacl3/mixin/MinecraftMixin.java16
-rw-r--r--src/main/java/dev/isxander/yacl3/mixin/OptionInstanceAccessor.java13
-rw-r--r--src/main/java/dev/isxander/yacl3/mixin/TabNavigationBarAccessor.java16
-rw-r--r--src/main/java/dev/isxander/yacl3/platform/Env.java10
-rw-r--r--src/main/java/dev/isxander/yacl3/platform/PlatformEntrypoint.java42
-rw-r--r--src/main/java/dev/isxander/yacl3/platform/YACLPlatform.java45
-rw-r--r--src/main/resources/META-INF/mods.toml31
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/be_by.json29
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/el_gr.json23
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/en_us.json31
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/et_ee.json18
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/fr_fr.json29
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/it_it.json31
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/nl_nl.json31
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/pl_pl.json23
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/pt_br.json18
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/ru_ru.json24
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/sl_si.json22
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/tt_ru.json34
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/zh_cn.json29
-rw-r--r--src/main/resources/assets/yet_another_config_lib/lang/zh_tw.json29
-rw-r--r--src/main/resources/fabric.mod.json38
-rw-r--r--src/main/resources/pack.mcmeta6
-rw-r--r--src/main/resources/yacl-128x.pngbin0 -> 13813 bytes
-rw-r--r--src/main/resources/yacl-fabric.mixins.json11
-rw-r--r--src/main/resources/yacl.accesswidener12
-rw-r--r--src/main/resources/yacl.mixins.json14
-rw-r--r--src/testmod/java/dev/isxander/yacl3/test/AutogenConfigTest.java130
-rw-r--r--src/testmod/java/dev/isxander/yacl3/test/ConfigTest.java78
-rw-r--r--src/testmod/java/dev/isxander/yacl3/test/Entrypoint.java23
-rw-r--r--src/testmod/java/dev/isxander/yacl3/test/GuiTest.java453
-rw-r--r--src/testmod/java/dev/isxander/yacl3/test/mixin/TitleScreenMixin.java25
-rw-r--r--src/testmod/resources/META-INF/mods.toml25
-rw-r--r--src/testmod/resources/assets/yacl3/textures/reach-around-placement.webpbin0 -> 14840 bytes
-rw-r--r--src/testmod/resources/assets/yacl3/textures/sample-1.webpbin0 -> 10474 bytes
-rw-r--r--src/testmod/resources/assets/yacl3/textures/sample-2.webpbin0 -> 22308 bytes
-rw-r--r--src/testmod/resources/assets/yacl3/textures/sample-3.webpbin0 -> 17078 bytes
-rw-r--r--src/testmod/resources/assets/yacl3/textures/sample-4.webpbin0 -> 20772 bytes
-rw-r--r--src/testmod/resources/assets/yacl3/textures/sample-5.webpbin0 -> 11166 bytes
-rw-r--r--src/testmod/resources/fabric.mod.json33
-rw-r--r--src/testmod/resources/pack.mcmeta6
-rw-r--r--src/testmod/resources/yacl-test.mixins.json11
249 files changed, 15902 insertions, 0 deletions
diff --git a/src/main/java/dev/isxander/yacl3/api/Binding.java b/src/main/java/dev/isxander/yacl3/api/Binding.java
new file mode 100644
index 0000000..f41b78b
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/Binding.java
@@ -0,0 +1,64 @@
+package dev.isxander.yacl3.api;
+
+import dev.isxander.yacl3.impl.GenericBindingImpl;
+import dev.isxander.yacl3.mixin.OptionInstanceAccessor;
+import net.minecraft.client.OptionInstance;
+import org.apache.commons.lang3.Validate;
+
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * Controls modifying the bound option.
+ * Provides the default value, a setter and a getter.
+ */
+public interface Binding<T> {
+ void setValue(T value);
+
+ T getValue();
+
+ T defaultValue();
+
+ /**
+ * Creates a generic binding.
+ *
+ * @param def default value of the option, used to reset
+ * @param getter should return the current value of the option
+ * @param setter should set the option to the supplied value
+ */
+ static <T> Binding<T> generic(T def, Supplier<T> getter, Consumer<T> setter) {
+ Validate.notNull(def, "`def` must not be null");
+ Validate.notNull(getter, "`getter` must not be null");
+ Validate.notNull(setter, "`setter` must not be null");
+
+ return new GenericBindingImpl<>(def, getter, setter);
+ }
+
+ /**
+ * Creates a {@link Binding} for Minecraft's {@link OptionInstance}
+ */
+ static <T> Binding<T> minecraft(OptionInstance<T> minecraftOption) {
+ Validate.notNull(minecraftOption, "`minecraftOption` must not be null");
+
+ return new GenericBindingImpl<>(
+ ((OptionInstanceAccessor<T>) (Object) minecraftOption).getInitialValue(),
+ minecraftOption::get,
+ minecraftOption::set
+ );
+ }
+
+ /**
+ * Creates an immutable binding that has no default and cannot be modified.
+ *
+ * @param value the value for the binding
+ */
+ static <T> Binding<T> immutable(T value) {
+ Validate.notNull(value, "`value` must not be null");
+
+ return new GenericBindingImpl<>(
+ value,
+ () -> value,
+ changed -> {}
+ );
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/ButtonOption.java b/src/main/java/dev/isxander/yacl3/api/ButtonOption.java
new file mode 100644
index 0000000..4f53dd4
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/ButtonOption.java
@@ -0,0 +1,55 @@
+package dev.isxander.yacl3.api;
+
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.impl.ButtonOptionImpl;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+public interface ButtonOption extends Option<BiConsumer<YACLScreen, ButtonOption>> {
+ /**
+ * Action to be executed upon button press
+ */
+ BiConsumer<YACLScreen, ButtonOption> action();
+
+ static Builder createBuilder() {
+ return new ButtonOptionImpl.BuilderImpl();
+ }
+
+ interface Builder {
+ /**
+ * Sets the name to be used by the option.
+ *
+ * @see Option#name()
+ */
+ Builder name(@NotNull Component name);
+
+ /**
+ * Sets the button text to be displayed next to the name.
+ */
+ Builder text(@NotNull Component text);
+
+ Builder description(@NotNull OptionDescription description);
+
+ Builder action(@NotNull BiConsumer<YACLScreen, ButtonOption> action);
+
+ /**
+ * Action to be executed upon button press
+ *
+ * @see ButtonOption#action()
+ */
+ @Deprecated
+ Builder action(@NotNull Consumer<YACLScreen> action);
+
+ /**
+ * Sets if the option can be configured
+ *
+ * @see Option#available()
+ */
+ Builder available(boolean available);
+
+ ButtonOption build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/ConfigCategory.java b/src/main/java/dev/isxander/yacl3/api/ConfigCategory.java
new file mode 100644
index 0000000..b3d68fc
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/ConfigCategory.java
@@ -0,0 +1,138 @@
+package dev.isxander.yacl3.api;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.impl.ConfigCategoryImpl;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collection;
+import java.util.function.Supplier;
+
+/**
+ * Separates {@link Option}s or {@link OptionGroup}s into multiple distinct sections.
+ * Served to a user as a button in the left column,
+ * upon pressing, the options list is filled with options contained within this category.
+ */
+public interface ConfigCategory {
+ /**
+ * Name of category, displayed as a button on the left column.
+ */
+ @NotNull Component name();
+
+ /**
+ * Gets every {@link OptionGroup} in this category.
+ */
+ @NotNull ImmutableList<OptionGroup> groups();
+
+ /**
+ * Tooltip (or description) of the category.
+ * Rendered on hover.
+ */
+ @NotNull Component tooltip();
+
+ /**
+ * Creates a builder to construct a {@link ConfigCategory}
+ */
+ static Builder createBuilder() {
+ return new ConfigCategoryImpl.BuilderImpl();
+ }
+
+ interface Builder extends OptionAddable {
+ /**
+ * Sets name of the category
+ *
+ * @see ConfigCategory#name()
+ */
+ Builder name(@NotNull Component name);
+
+ /**
+ * Adds an option to the root group of the category.
+ * To add to another group, use {@link Builder#group(OptionGroup)}.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @see ConfigCategory#groups()
+ * @see OptionGroup#isRoot()
+ */
+ @Override
+ Builder option(@NotNull Option<?> option);
+
+ /**
+ * Adds an option to the root group of the category.
+ * To add to another group, use {@link Builder#group(OptionGroup)}.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @param optionSupplier to be called to initialise the option. called immediately
+ * @return this
+ */
+ @Override
+ default Builder option(@NotNull Supplier<@NotNull Option<?>> optionSupplier) {
+ OptionAddable.super.option(optionSupplier);
+ return this;
+ }
+
+ /**
+ * Adds an option to the root group of the category if a condition is met.
+ * To add to another group, use {@link Builder#group(OptionGroup)}.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @param condition only if true is the option added
+ * @return this
+ */
+ @Override
+ default Builder optionIf(boolean condition, @NotNull Option<?> option) {
+ OptionAddable.super.optionIf(condition, option);
+ return this;
+ }
+
+ /**
+ * Adds an option to the root group of the category if a condition is met.
+ * To add to another group, use {@link Builder#group(OptionGroup)}.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @param condition only if true is the option added
+ * @param optionSupplier to be called to initialise the option. called immediately only if condition is true
+ * @return this
+ */
+ @Override
+ default Builder optionIf(boolean condition, @NotNull Supplier<@NotNull Option<?>> optionSupplier) {
+ OptionAddable.super.optionIf(condition, optionSupplier);
+ return this;
+ }
+
+ /**
+ * Adds multiple options to the root group of the category.
+ * To add to another group, use {@link Builder#groups(Collection)}.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @see ConfigCategory#groups()
+ * @see OptionGroup#isRoot()
+ */
+ @Override
+ Builder options(@NotNull Collection<? extends Option<?>> options);
+
+ /**
+ * Adds an option group.
+ * To add an option to the root group, use {@link Builder#option(Option)}
+ * To construct a group, use {@link OptionGroup#createBuilder()}
+ */
+ Builder group(@NotNull OptionGroup group);
+
+ /**
+ * Adds multiple option groups.
+ * To add multiple options to the root group, use {@link Builder#options(Collection)}
+ * To construct a group, use {@link OptionGroup#createBuilder()}
+ */
+ Builder groups(@NotNull Collection<OptionGroup> groups);
+
+ /**
+ * Sets the tooltip to be used by the category.
+ * Can be invoked twice to append more lines.
+ * No need to wrap the text yourself, the gui does this itself.
+ *
+ * @param tooltips text lines - merged with a new-line on {@link Builder#build()}.
+ */
+ Builder tooltip(@NotNull Component... tooltips);
+
+ ConfigCategory build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/Controller.java b/src/main/java/dev/isxander/yacl3/api/Controller.java
new file mode 100644
index 0000000..25e4465
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/Controller.java
@@ -0,0 +1,28 @@
+package dev.isxander.yacl3.api;
+
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.network.chat.Component;
+
+/**
+ * Provides a widget to control the option.
+ */
+public interface Controller<T> {
+ /**
+ * Gets the dedicated {@link Option} for this controller
+ */
+ Option<T> option();
+
+ /**
+ * Gets the formatted value based on {@link Option#pendingValue()}
+ */
+ Component formatValue();
+
+ /**
+ * Provides a widget to display
+ *
+ * @param screen parent screen
+ */
+ AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension);
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/LabelOption.java b/src/main/java/dev/isxander/yacl3/api/LabelOption.java
new file mode 100644
index 0000000..a5f015e
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/LabelOption.java
@@ -0,0 +1,41 @@
+package dev.isxander.yacl3.api;
+
+import dev.isxander.yacl3.impl.LabelOptionImpl;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collection;
+
+/**
+ * A label option is an easier way of creating a label with a {@link dev.isxander.yacl3.gui.controllers.LabelController}.
+ * This option is immutable and cannot be disabled. Tooltips are supported through
+ * {@link Component} styling.
+ */
+public interface LabelOption extends Option<Component> {
+ @NotNull Component label();
+
+ /**
+ * Creates a new label option with the given label, skipping a builder for ease.
+ */
+ static LabelOption create(@NotNull Component label) {
+ return new LabelOptionImpl(label);
+ }
+
+ static Builder createBuilder() {
+ return new LabelOptionImpl.BuilderImpl();
+ }
+
+ interface Builder {
+ /**
+ * Appends a line to the label
+ */
+ Builder line(@NotNull Component line);
+
+ /**
+ * Appends multiple lines to the label
+ */
+ Builder lines(@NotNull Collection<? extends Component> lines);
+
+ LabelOption build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/ListOption.java b/src/main/java/dev/isxander/yacl3/api/ListOption.java
new file mode 100644
index 0000000..1f4adfa
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/ListOption.java
@@ -0,0 +1,178 @@
+package dev.isxander.yacl3.api;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.impl.ListOptionImpl;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * A list option that takes form as an option group for UX.
+ * You add this option through {@link ConfigCategory.Builder#group(OptionGroup)}. Do NOT add as an option.
+ * Users can add remove and reshuffle a list type. You can use any controller you wish, there are no dedicated
+ * controllers for list types. List options do not manipulate your list but get and set the list with a
+ * regular binding for simplicity.
+ *
+ * You may apply option flags like a normal option and collapse like a normal group, it is a merge of them both.
+ * Methods in this interface marked with {@link ApiStatus.Internal} should not be used, and could be subject to
+ * change at any time
+ * @param <T>
+ */
+public interface ListOption<T> extends OptionGroup, Option<List<T>> {
+ @Override
+ @NotNull ImmutableList<ListOptionEntry<T>> options();
+
+ @ApiStatus.Internal
+ int numberOfEntries();
+
+ @ApiStatus.Internal
+ int maximumNumberOfEntries();
+
+ @ApiStatus.Internal
+ int minimumNumberOfEntries();
+
+ @ApiStatus.Internal
+ ListOptionEntry<T> insertNewEntry();
+
+ @ApiStatus.Internal
+ void insertEntry(int index, ListOptionEntry<?> entry);
+
+ @ApiStatus.Internal
+ int indexOf(ListOptionEntry<?> entry);
+
+ @ApiStatus.Internal
+ void removeEntry(ListOptionEntry<?> entry);
+
+ @ApiStatus.Internal
+ void addRefreshListener(Runnable changedListener);
+
+ static <T> Builder<T> createBuilder() {
+ return new ListOptionImpl.BuilderImpl<>();
+ }
+
+ @Deprecated
+ static <T> Builder<T> createBuilder(Class<T> typeClass) {
+ return createBuilder();
+ }
+
+ interface Builder<T> {
+ /**
+ * Sets name of the list, for UX purposes, a name should always be given,
+ * but isn't enforced.
+ *
+ * @see ListOption#name()
+ */
+ Builder<T> name(@NotNull Component name);
+
+ Builder<T> description(@NotNull OptionDescription description);
+
+ /**
+ * Sets the value that is used when creating new entries
+ */
+ Builder<T> initial(@NotNull Supplier<T> initialValue);
+
+ /**
+ * Sets the value that is used when creating new entries
+ */
+ Builder<T> initial(@NotNull T initialValue);
+
+ Builder<T> controller(@NotNull Function<Option<T>, ControllerBuilder<T>> controller);
+
+ /**
+ * Sets the controller for the option.
+ * This is how you interact and change the options.
+ *
+ * @see dev.isxander.yacl3.gui.controllers
+ */
+ Builder<T> customController(@NotNull Function<ListOptionEntry<T>, Controller<T>> control);
+
+ /**
+ * Sets the binding for the option.
+ * Used for default, getter and setter.
+ *
+ * @see Binding
+ */
+ Builder<T> binding(@NotNull Binding<List<T>> binding);
+
+ /**
+ * Sets the binding for the option.
+ * Shorthand of {@link Binding#generic(Object, Supplier, Consumer)}
+ *
+ * @param def default value of the option, used to reset
+ * @param getter should return the current value of the option
+ * @param setter should set the option to the supplied value
+ * @see Binding
+ */
+ Builder<T> binding(@NotNull List<T> def, @NotNull Supplier<@NotNull List<T>> getter, @NotNull Consumer<@NotNull List<T>> setter);
+
+ /**
+ * Sets if the option can be configured
+ *
+ * @see Option#available()
+ */
+ Builder<T> available(boolean available);
+
+ /**
+ * Sets a minimum size for the list. Once this size is reached,
+ * no further entries may be removed.
+ */
+ Builder<T> minimumNumberOfEntries(int number);
+
+ /**
+ * Sets a maximum size for the list. Once this size is reached,
+ * no further entries may be added.
+ */
+ Builder<T> maximumNumberOfEntries(int number);
+
+ /**
+ * Dictates if new entries should be added to the end of the list
+ * rather than the top.
+ */
+ Builder<T> insertEntriesAtEnd(boolean insertAtEnd);
+
+ /**
+ * Adds a flag to the option.
+ * Upon applying changes, all flags are executed.
+ * {@link Option#flags()}
+ */
+ Builder<T> flag(@NotNull OptionFlag... flag);
+
+ /**
+ * Adds a flag to the option.
+ * Upon applying changes, all flags are executed.
+ * {@link Option#flags()}
+ */
+ Builder<T> flags(@NotNull Collection<OptionFlag> flags);
+
+ /**
+ * Dictates if the group should be collapsed by default.
+ * If not set, it will not be collapsed by default.
+ *
+ * @see OptionGroup#collapsed()
+ */
+ Builder<T> collapsed(boolean collapsible);
+
+ /**
+ * Adds a listener to the option. Invoked upon changing any of the list's entries.
+ *
+ * @see Option#addListener(BiConsumer)
+ */
+ ListOption.Builder<T> listener(@NotNull BiConsumer<Option<List<T>>, List<T>> listener);
+
+ /**
+ * Adds multiple listeners to the option. Invoked upon changing of any of the list's entries.
+ *
+ * @see Option#addListener(BiConsumer)
+ */
+ ListOption.Builder<T> listeners(@NotNull Collection<BiConsumer<Option<List<T>>, List<T>>> listeners);
+
+ ListOption<T> build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/ListOptionEntry.java b/src/main/java/dev/isxander/yacl3/api/ListOptionEntry.java
new file mode 100644
index 0000000..23ec657
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/ListOptionEntry.java
@@ -0,0 +1,18 @@
+package dev.isxander.yacl3.api;
+
+import com.google.common.collect.ImmutableSet;
+import org.jetbrains.annotations.NotNull;
+
+public interface ListOptionEntry<T> extends Option<T> {
+ ListOption<T> parentGroup();
+
+ @Override
+ default @NotNull ImmutableSet<OptionFlag> flags() {
+ return parentGroup().flags();
+ }
+
+ @Override
+ default boolean available() {
+ return parentGroup().available();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/NameableEnum.java b/src/main/java/dev/isxander/yacl3/api/NameableEnum.java
new file mode 100644
index 0000000..425623f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/NameableEnum.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api;
+
+import net.minecraft.network.chat.Component;
+
+/**
+ * Used for the default value formatter of {@link dev.isxander.yacl3.gui.controllers.cycling.EnumController} and {@link dev.isxander.yacl3.gui.controllers.dropdown.EnumDropdownController}
+ */
+public interface NameableEnum {
+ Component getDisplayName();
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/Option.java b/src/main/java/dev/isxander/yacl3/api/Option.java
new file mode 100644
index 0000000..38bd8ca
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/Option.java
@@ -0,0 +1,223 @@
+package dev.isxander.yacl3.api;
+
+import com.google.common.collect.ImmutableSet;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.impl.OptionImpl;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+public interface Option<T> {
+ /**
+ * Name of the option
+ */
+ @NotNull Component name();
+
+ @NotNull OptionDescription description();
+
+ /**
+ * Tooltip (or description) of the option.
+ * Rendered on hover.
+ */
+ @Deprecated
+ @NotNull Component tooltip();
+
+ /**
+ * Widget provider for a type of option.
+ *
+ * @see dev.isxander.yacl3.gui.controllers
+ */
+ @NotNull Controller<T> controller();
+
+ /**
+ * Binding for the option.
+ * Controls setting, getting and default value.
+ *
+ * @see Binding
+ */
+ @NotNull Binding<T> binding();
+
+ /**
+ * If the option can be configured
+ */
+ boolean available();
+
+ /**
+ * Sets if the option can be configured after being built
+ *
+ * @see Option#available()
+ */
+ void setAvailable(boolean available);
+
+ /**
+ * Tasks that needs to be executed upon applying changes.
+ */
+ @NotNull ImmutableSet<OptionFlag> flags();
+
+ /**
+ * Checks if the pending value is not equal to the current set value
+ */
+ boolean changed();
+
+ /**
+ * Value in the GUI, ready to set the actual bound value or be undone.
+ */
+ @NotNull T pendingValue();
+
+ /**
+ * Sets the pending value
+ */
+ void requestSet(@NotNull T value);
+
+ /**
+ * Applies the pending value to the bound value.
+ * Cannot be undone.
+ *
+ * @return if there were changes to apply {@link Option#changed()}
+ */
+ boolean applyValue();
+
+ /**
+ * Sets the pending value to the bound value.
+ */
+ void forgetPendingValue();
+
+ /**
+ * Sets the pending value to the default bound value.
+ */
+ void requestSetDefault();
+
+ /**
+ * Checks if the current pending value is equal to its default value
+ */
+ boolean isPendingValueDefault();
+
+ default boolean canResetToDefault() {
+ return true;
+ }
+
+ /**
+ * Adds a listener for when the pending value changes
+ */
+ void addListener(BiConsumer<Option<T>, T> changedListener);
+
+ static <T> Builder<T> createBuilder() {
+ return new OptionImpl.BuilderImpl<>();
+ }
+
+ /**
+ * Creates a builder to construct an {@link Option}
+ *
+ * @param <T> type of the option's value
+ * @param typeClass used to capture the type
+ */
+ @Deprecated
+ static <T> Builder<T> createBuilder(Class<T> typeClass) {
+ return createBuilder();
+ }
+
+ interface Builder<T> {
+ /**
+ * Sets the name to be used by the option.
+ *
+ * @see Option#name()
+ */
+ Builder<T> name(@NotNull Component name);
+
+ /**
+ * Sets the description to be used by the option.
+ * @see OptionDescription
+ * @param description the static description.
+ * @return this builder
+ */
+ Builder<T> description(@NotNull OptionDescription description);
+
+ /**
+ * Sets the function to get the description by the option's current value.
+ *
+ * @see OptionDescription
+ * @param descriptionFunction the function to get the description by the option's current value.
+ * @return this builder
+ */
+ Builder<T> description(@NotNull Function<T, OptionDescription> descriptionFunction);
+
+ Builder<T> controller(@NotNull Function<Option<T>, ControllerBuilder<T>> controllerBuilder);
+
+ /**
+ * Sets the controller for the option.
+ * This is how you interact and change the options.
+ *
+ * @see dev.isxander.yacl3.gui.controllers
+ */
+ Builder<T> customController(@NotNull Function<Option<T>, Controller<T>> control);
+
+ /**
+ * Sets the binding for the option.
+ * Used for default, getter and setter.
+ *
+ * @see Binding
+ */
+ Builder<T> binding(@NotNull Binding<T> binding);
+
+ /**
+ * Sets the binding for the option.
+ * Shorthand of {@link Binding#generic(Object, Supplier, Consumer)}
+ *
+ * @param def default value of the option, used to reset
+ * @param getter should return the current value of the option
+ * @param setter should set the option to the supplied value
+ * @see Binding
+ */
+ Builder<T> binding(@NotNull T def, @NotNull Supplier<@NotNull T> getter, @NotNull Consumer<@NotNull T> setter);
+
+ /**
+ * Sets if the option can be configured
+ *
+ * @see Option#available()
+ */
+ Builder<T> available(boolean available);
+
+ /**
+ * Adds a flag to the option.
+ * Upon applying changes, all flags are executed.
+ * {@link Option#flags()}
+ */
+ Builder<T> flag(@NotNull OptionFlag... flag);
+
+ /**
+ * Adds a flag to the option.
+ * Upon applying changes, all flags are executed.
+ * {@link Option#flags()}
+ */
+ Builder<T> flags(@NotNull Collection<? extends OptionFlag> flags);
+
+ /**
+ * Instantly invokes the binder's setter when modified in the GUI.
+ * Prevents the user from undoing the change
+ * <p>
+ * Does not support {@link Option#flags()}!
+ */
+ Builder<T> instant(boolean instant);
+
+ /**
+ * Adds a listener to the option. Invoked upon changing the pending value.
+ *
+ * @see Option#addListener(BiConsumer)
+ */
+ Builder<T> listener(@NotNull BiConsumer<Option<T>, T> listener);
+
+ /**
+ * Adds multiple listeners to the option. Invoked upon changing the pending value.
+ *
+ * @see Option#addListener(BiConsumer)
+ */
+ Builder<T> listeners(@NotNull Collection<BiConsumer<Option<T>, T>> listeners);
+
+ Option<T> build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/OptionAddable.java b/src/main/java/dev/isxander/yacl3/api/OptionAddable.java
new file mode 100644
index 0000000..606e8ca
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/OptionAddable.java
@@ -0,0 +1,51 @@
+package dev.isxander.yacl3.api;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collection;
+import java.util.function.Supplier;
+
+public interface OptionAddable {
+ /**
+ * Adds an option to an abstract builder.
+ * To construct an option, use {@link Option#createBuilder()}
+ */
+ OptionAddable option(@NotNull Option<?> option);
+
+ /**
+ * Adds an option to an abstract builder.
+ * To construct an option, use {@link Option#createBuilder()}
+ * @param optionSupplier to be called to initialise the option. called immediately
+ */
+ default OptionAddable option(@NotNull Supplier<@NotNull Option<?>> optionSupplier) {
+ return option(optionSupplier.get());
+ }
+
+ /**
+ * Adds an option to an abstract builder if a condition is met.
+ * To construct an option, use {@link Option#createBuilder()}
+ * @param condition only if true is the option added
+ * @param option the option to add
+ * @return this
+ */
+ default OptionAddable optionIf(boolean condition, @NotNull Option<?> option) {
+ return condition ? option(option) : this;
+ }
+
+ /**
+ * Adds an option to an abstract builder if a condition is met.
+ * To construct an option, use {@link Option#createBuilder()}
+ * @param condition only if true is the option added
+ * @param optionSupplier to be called to initialise the option. called immediately only if condition is true
+ * @return this
+ */
+ default OptionAddable optionIf(boolean condition, @NotNull Supplier<@NotNull Option<?>> optionSupplier) {
+ return condition ? option(optionSupplier) : this;
+ }
+
+ /**
+ * Adds multiple options to an abstract builder.
+ * To construct an option, use {@link Option#createBuilder()}
+ */
+ OptionAddable options(@NotNull Collection<? extends Option<?>> options);
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/OptionDescription.java b/src/main/java/dev/isxander/yacl3/api/OptionDescription.java
new file mode 100644
index 0000000..7336379
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/OptionDescription.java
@@ -0,0 +1,161 @@
+package dev.isxander.yacl3.api;
+
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+import dev.isxander.yacl3.impl.OptionDescriptionImpl;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+
+/**
+ * Provides all information for the description panel in the GUI.
+ * This provides no functional benefit, and is purely for UX.
+ */
+public interface OptionDescription {
+ /**
+ * The description of the option, this is automatically wrapped and supports all styling,
+ * including {@link net.minecraft.network.chat.ClickEvent}s and {@link net.minecraft.network.chat.HoverEvent}s.
+ * @return The description of the option, with all lines merged with \n.
+ */
+ Component text();
+
+ /**
+ * The image to display with the description. If the Optional is empty, no image has been provided.
+ * Usually, the image renderers are constructed asynchronously, so this method returns a {@link CompletableFuture}.
+ * <p>
+ * Image renderers are cached throughout the whole lifecycle of the game, and should not be generated more than once
+ * per image. See {@link ImageRenderer#getOrMakeAsync(ResourceLocation, Supplier)} for implementation details.
+ */
+ CompletableFuture<Optional<ImageRenderer>> image();
+
+ /**
+ * @return a new builder for an {@link OptionDescription}.
+ */
+ static Builder createBuilder() {
+ return new OptionDescriptionImpl.BuilderImpl();
+ }
+
+ static OptionDescription of(Component... description) {
+ return createBuilder().text(description).build();
+ }
+
+ OptionDescription EMPTY = new OptionDescriptionImpl(CommonComponents.EMPTY, CompletableFuture.completedFuture(Optional.empty()));
+
+ interface Builder {
+ /**
+ * Appends lines to the main description of the option. This can be called multiple times.
+ * On {@link Builder#build()}, the lines are merged with \n.
+ * @see OptionDescription#text()
+ *
+ * @param description the lines to append to the description.
+ * @return this builder
+ */
+ Builder text(Component... description);
+
+ /**
+ * Appends lines to the main description of the option. This can be called multiple times.
+ * On {@link Builder#build()}, the lines are merged with \n.
+ * @see OptionDescription#text()
+ *
+ * @param lines the lines to append to the description.
+ * @return this builder
+ */
+ Builder text(Collection<? extends Component> lines);
+
+ /**
+ * Sets a static image to display with the description. This is backed by a regular minecraft resource
+ * in your mod's /assets folder.
+ *
+ * @param image the location of the image to display from the resource manager
+ * @param width the width of the texture
+ * @param height the height of the texture
+ * @return this builder
+ */
+ Builder image(ResourceLocation image, int width, int height);
+
+ /**
+ * Sets a static image to display with the description. This is backed by a regular minecraft resource
+ * in your mod's /assets folder. This overload method allows you to specify a subsection of the texture to render.
+ *
+ * @param image the location of the image to display from the resource manager
+ * @param u the u coordinate
+ * @param v the v coordinate
+ * @param width the width of the subsection
+ * @param height the height of the subsection
+ * @param textureWidth the width of the whole texture file
+ * @param textureHeight the height of whole texture file
+ * @return this builder
+ */
+ Builder image(ResourceLocation image, float u, float v, int width, int height, int textureWidth, int textureHeight);
+
+ /**
+ * Sets a static image to display with the description. This is backed by a file on disk.
+ * The width and height is automatically determined from the image processing.
+ *
+ * @param path the absolute path to the image file
+ * @param uniqueLocation the unique identifier for the image, used for caching and resource manager registrar
+ * @return this builder
+ */
+ Builder image(Path path, ResourceLocation uniqueLocation);
+
+ /**
+ * Sets a static OR ANIMATED webP image to display with the description. This is backed by a regular minecraft resource
+ * in your mod's /assets folder.
+ *
+ * @param image the location of the image to display from the resource manager
+ * @return this builder
+ */
+ Builder webpImage(ResourceLocation image);
+
+ /**
+ * Sets a static OR ANIMATED webP image to display with the description. This is backed by a file on disk.
+ * The width and height is automatically determined from the image processing.
+ *
+ * @param path the absolute path to the image file
+ * @param uniqueLocation the unique identifier for the image, used for caching and resource manager registrar
+ * @return this builder
+ */
+ Builder webpImage(Path path, ResourceLocation uniqueLocation);
+
+ /**
+ * Sets a custom image renderer to display with the description.
+ * This is useful for rendering other abstract things relevant to your mod.
+ * <p>
+ * However, <strong>THIS IS NOT API SAFE!</strong> As part of the gui package, things
+ * may change that could break compatibility with future versions of YACL.
+ * A helpful utility (that is also not API safe) is {@link ImageRenderer#getOrMakeAsync(ResourceLocation, Supplier)}
+ * which will cache the image renderer for the whole game lifecycle and construct it asynchronously to the render thread.
+ * @param image the image renderer to display
+ * @return this builder
+ */
+ Builder customImage(CompletableFuture<Optional<ImageRenderer>> image);
+
+ /**
+ * Sets an animated GIF image to display with the description. This is backed by a regular minecraft resource
+ * in your mod's /assets folder.
+ *
+ * @param image the location of the image to display from the resource manager
+ * @return this builder
+ */
+ @Deprecated
+ Builder gifImage(ResourceLocation image);
+
+ /**
+ * Sets an animated GIF image to display with the description. This is backed by a file on disk.
+ * The width and height is automatically determined from the image processing.
+ *
+ * @param path the absolute path to the image file
+ * @param uniqueLocation the unique identifier for the image, used for caching and resource manager registrar
+ * @return this builder
+ */
+ @Deprecated
+ Builder gifImage(Path path, ResourceLocation uniqueLocation);
+
+ OptionDescription build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/OptionFlag.java b/src/main/java/dev/isxander/yacl3/api/OptionFlag.java
new file mode 100644
index 0000000..6f35495
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/OptionFlag.java
@@ -0,0 +1,23 @@
+package dev.isxander.yacl3.api;
+
+import dev.isxander.yacl3.gui.RequireRestartScreen;
+import net.minecraft.client.Minecraft;
+
+import java.util.function.Consumer;
+
+/**
+ * Code that is executed upon certain options being applied.
+ * Each flag is executed only once per save, no matter the amount of options with the flag.
+ */
+@FunctionalInterface
+public interface OptionFlag extends Consumer<Minecraft> {
+ /** Warns the user that a game restart is required for the changes to take effect */
+ OptionFlag GAME_RESTART = client -> client.setScreen(new RequireRestartScreen(client.screen));
+
+ /** Reloads chunks upon applying (F3+A) */
+ OptionFlag RELOAD_CHUNKS = client -> client.levelRenderer.allChanged();
+
+ OptionFlag WORLD_RENDER_UPDATE = client -> client.levelRenderer.needsUpdate();
+
+ OptionFlag ASSET_RELOAD = Minecraft::delayTextureReload;
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/OptionGroup.java b/src/main/java/dev/isxander/yacl3/api/OptionGroup.java
new file mode 100644
index 0000000..8f183b9
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/OptionGroup.java
@@ -0,0 +1,131 @@
+package dev.isxander.yacl3.api;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.impl.OptionGroupImpl;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collection;
+import java.util.function.Supplier;
+
+/**
+ * Serves as a separator between multiple chunks of options
+ * that may be too similar or too few to be placed in a separate {@link ConfigCategory}.
+ * Or maybe you just want your config to feel less dense.
+ */
+public interface OptionGroup {
+ /**
+ * Name of the option group, displayed as a separator in the option lists.
+ * Can be empty.
+ */
+ Component name();
+
+ OptionDescription description();
+
+ /**
+ * Tooltip displayed on hover.
+ */
+ @Deprecated
+ Component tooltip();
+
+ /**
+ * List of all options in the group
+ */
+ @NotNull ImmutableList<? extends Option<?>> options();
+
+ /**
+ * Dictates if the group should be collapsed by default.
+ */
+ boolean collapsed();
+
+ /**
+ * Always false when using the {@link Builder}
+ * used to not render the separator if true
+ */
+ boolean isRoot();
+
+ /**
+ * Creates a builder to construct a {@link OptionGroup}
+ */
+ static Builder createBuilder() {
+ return new OptionGroupImpl.BuilderImpl();
+ }
+
+ interface Builder extends OptionAddable {
+ /**
+ * Sets name of the group, can be {@link Component#empty()} to just separate options, like sodium.
+ *
+ * @see OptionGroup#name()
+ */
+ Builder name(@NotNull Component name);
+
+ Builder description(@NotNull OptionDescription description);
+
+ /**
+ * Adds an option to group.
+ * To construct an option, use {@link Option#createBuilder(Class)}
+ *
+ * @see OptionGroup#options()
+ */
+ @Override
+ Builder option(@NotNull Option<?> option);
+
+ /**
+ * Adds an option to this group.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @param optionSupplier to be called to initialise the option. called immediately
+ * @return this
+ */
+ @Override
+ default Builder option(@NotNull Supplier<@NotNull Option<?>> optionSupplier) {
+ OptionAddable.super.option(optionSupplier);
+ return this;
+ }
+
+ /**
+ * Adds an option to this group if a condition is met.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @param condition only if true is the option added
+ * @return this
+ */
+ @Override
+ default Builder optionIf(boolean condition, @NotNull Option<?> option) {
+ OptionAddable.super.optionIf(condition, option);
+ return this;
+ }
+
+ /**
+ * Adds an option to this group if a condition is met.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @param condition only if true is the option added
+ * @param optionSupplier to be called to initialise the option. called immediately only if condition is true
+ * @return this
+ */
+ @Override
+ default Builder optionIf(boolean condition, @NotNull Supplier<@NotNull Option<?>> optionSupplier) {
+ OptionAddable.super.optionIf(condition, optionSupplier);
+ return this;
+ }
+
+ /**
+ * Adds multiple options to group.
+ * To construct an option, use {@link Option#createBuilder()}
+ *
+ * @see OptionGroup#options()
+ */
+ @Override
+ Builder options(@NotNull Collection<? extends Option<?>> options);
+
+ /**
+ * Dictates if the group should be collapsed by default
+ *
+ * @see OptionGroup#collapsed()
+ */
+ Builder collapsed(boolean collapsible);
+
+ OptionGroup build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/PlaceholderCategory.java b/src/main/java/dev/isxander/yacl3/api/PlaceholderCategory.java
new file mode 100644
index 0000000..6eb82c5
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/PlaceholderCategory.java
@@ -0,0 +1,55 @@
+package dev.isxander.yacl3.api;
+
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.impl.PlaceholderCategoryImpl;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.function.BiFunction;
+
+/**
+ * A placeholder category that actually just opens another screen,
+ * instead of displaying options.
+ * <p>
+ * Use of this is discouraged, as it is not very user-friendly and navigating to a placeholder
+ * tab that opens another screen is not very intuitive, making keyboard navigation impossible.
+ */
+public interface PlaceholderCategory extends ConfigCategory {
+ /**
+ * Function to create a screen to open upon changing to this category
+ */
+ BiFunction<Minecraft, YACLScreen, Screen> screen();
+
+ static Builder createBuilder() {
+ return new PlaceholderCategoryImpl.BuilderImpl();
+ }
+
+ interface Builder {
+ /**
+ * Sets name of the category
+ *
+ * @see ConfigCategory#name()
+ */
+ Builder name(@NotNull Component name);
+
+ /**
+ * Sets the tooltip to be used by the category.
+ * Can be invoked twice to append more lines.
+ * No need to wrap the Component yourself, the gui does this itself.
+ *
+ * @param tooltips Component lines - merged with a new-line on {@link dev.isxander.yacl3.api.PlaceholderCategory.Builder#build()}.
+ */
+ Builder tooltip(@NotNull Component... tooltips);
+
+ /**
+ * Screen to open upon selecting this category
+ *
+ * @see PlaceholderCategory#screen()
+ */
+ Builder screen(@NotNull BiFunction<Minecraft, YACLScreen, Screen> screenFunction);
+
+ PlaceholderCategory build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/YetAnotherConfigLib.java b/src/main/java/dev/isxander/yacl3/api/YetAnotherConfigLib.java
new file mode 100644
index 0000000..68e9986
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/YetAnotherConfigLib.java
@@ -0,0 +1,113 @@
+package dev.isxander.yacl3.api;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.config.ConfigInstance;
+import dev.isxander.yacl3.config.v2.api.ConfigClassHandler;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.impl.YetAnotherConfigLibImpl;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Collection;
+import java.util.function.Consumer;
+
+/**
+ * Main class of the mod.
+ * Contains all data and used to provide a {@link Screen}
+ */
+public interface YetAnotherConfigLib {
+ /**
+ * Title of the GUI. Only used for Minecraft narration.
+ */
+ Component title();
+
+ /**
+ * Gets all config categories.
+ */
+ ImmutableList<ConfigCategory> categories();
+
+ /**
+ * Ran when changes are saved. Can be used to save config to a file etc.
+ */
+ Runnable saveFunction();
+
+ /**
+ * Ran every time the YACL screen initialises. Can be paired with FAPI to add custom widgets.
+ */
+ Consumer<YACLScreen> initConsumer();
+
+ /**
+ * Generates a Screen to display based on this instance.
+ *
+ * @param parent parent screen to open once closed
+ */
+ Screen generateScreen(@Nullable Screen parent);
+
+ /**
+ * Creates a builder to construct YACL
+ */
+ static Builder createBuilder() {
+ return new YetAnotherConfigLibImpl.BuilderImpl();
+ }
+
+ static <T> YetAnotherConfigLib create(ConfigClassHandler<T> configHandler, ConfigBackedBuilder<T> builder) {
+ return builder.build(configHandler.defaults(), configHandler.instance(), createBuilder().save(configHandler::save)).build();
+ }
+
+ /**
+ * Creates an instance using a {@link ConfigInstance} which autofills the save() builder method.
+ * This also takes an easy functional interface that provides defaults and config to help build YACL bindings.
+ */
+ @Deprecated
+ static <T> YetAnotherConfigLib create(ConfigInstance<T> configInstance, ConfigBackedBuilder<T> builder) {
+ return builder.build(configInstance.getDefaults(), configInstance.getConfig(), createBuilder().save(configInstance::save)).build();
+ }
+
+ interface Builder {
+ /**
+ * Sets title of GUI for Minecraft narration
+ *
+ * @see YetAnotherConfigLib#title()
+ */
+ Builder title(@NotNull Component title);
+
+ /**
+ * Adds a new category.
+ * To create a category you need to use {@link ConfigCategory#createBuilder()}
+ *
+ * @see YetAnotherConfigLib#categories()
+ */
+ Builder category(@NotNull ConfigCategory category);
+
+ /**
+ * Adds multiple categories at once.
+ * To create a category you need to use {@link ConfigCategory#createBuilder()}
+ *
+ * @see YetAnotherConfigLib#categories()
+ */
+ Builder categories(@NotNull Collection<? extends ConfigCategory> categories);
+
+ /**
+ * Used to define a save function for when user clicks the Save Changes button
+ *
+ * @see YetAnotherConfigLib#saveFunction()
+ */
+ Builder save(@NotNull Runnable saveFunction);
+
+ /**
+ * Defines a consumer that is accepted every time the YACL screen initialises
+ *
+ * @see YetAnotherConfigLib#initConsumer()
+ */
+ Builder screenInit(@NotNull Consumer<YACLScreen> initConsumer);
+
+ YetAnotherConfigLib build();
+ }
+
+ @FunctionalInterface
+ interface ConfigBackedBuilder<T> {
+ YetAnotherConfigLib.Builder build(T defaults, T config, YetAnotherConfigLib.Builder builder);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/BooleanControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/BooleanControllerBuilder.java
new file mode 100644
index 0000000..88f9a77
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/BooleanControllerBuilder.java
@@ -0,0 +1,16 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.BooleanControllerBuilderImpl;
+
+public interface BooleanControllerBuilder extends ValueFormattableController<Boolean, BooleanControllerBuilder> {
+ BooleanControllerBuilder coloured(boolean coloured);
+
+ BooleanControllerBuilder onOffFormatter();
+ BooleanControllerBuilder yesNoFormatter();
+ BooleanControllerBuilder trueFalseFormatter();
+
+ static BooleanControllerBuilder create(Option<Boolean> option) {
+ return new BooleanControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/ColorControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/ColorControllerBuilder.java
new file mode 100644
index 0000000..8e442ff
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/ColorControllerBuilder.java
@@ -0,0 +1,14 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.ColorControllerBuilderImpl;
+
+import java.awt.Color;
+
+public interface ColorControllerBuilder extends ControllerBuilder<Color> {
+ ColorControllerBuilder allowAlpha(boolean allowAlpha);
+
+ static ColorControllerBuilder create(Option<Color> option) {
+ return new ColorControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/ControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/ControllerBuilder.java
new file mode 100644
index 0000000..bbd49a7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/ControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import org.jetbrains.annotations.ApiStatus;
+
+@FunctionalInterface
+public interface ControllerBuilder<T> {
+ @ApiStatus.Internal
+ Controller<T> build();
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/CyclingListControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/CyclingListControllerBuilder.java
new file mode 100644
index 0000000..8c9ea91
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/CyclingListControllerBuilder.java
@@ -0,0 +1,15 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.CyclingListControllerBuilderImpl;
+
+public interface CyclingListControllerBuilder<T> extends ValueFormattableController<T, CyclingListControllerBuilder<T>> {
+ @SuppressWarnings("unchecked")
+ CyclingListControllerBuilder<T> values(T... values);
+
+ CyclingListControllerBuilder<T> values(Iterable<? extends T> values);
+
+ static <T> CyclingListControllerBuilder<T> create(Option<T> option) {
+ return new CyclingListControllerBuilderImpl<>(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/DoubleFieldControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/DoubleFieldControllerBuilder.java
new file mode 100644
index 0000000..db4af94
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/DoubleFieldControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.DoubleFieldControllerBuilderImpl;
+
+public interface DoubleFieldControllerBuilder extends NumberFieldControllerBuilder<Double, DoubleFieldControllerBuilder> {
+ static DoubleFieldControllerBuilder create(Option<Double> option) {
+ return new DoubleFieldControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/DoubleSliderControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/DoubleSliderControllerBuilder.java
new file mode 100644
index 0000000..7e4b6f9
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/DoubleSliderControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.DoubleSliderControllerBuilderImpl;
+
+public interface DoubleSliderControllerBuilder extends SliderControllerBuilder<Double, DoubleSliderControllerBuilder> {
+ static DoubleSliderControllerBuilder create(Option<Double> option) {
+ return new DoubleSliderControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/DropdownStringControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/DropdownStringControllerBuilder.java
new file mode 100644
index 0000000..3f5fb33
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/DropdownStringControllerBuilder.java
@@ -0,0 +1,18 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.DropdownStringControllerBuilderImpl;
+
+import java.util.List;
+
+public interface DropdownStringControllerBuilder extends StringControllerBuilder {
+ DropdownStringControllerBuilder values(List<String> values);
+ DropdownStringControllerBuilder values(String... values);
+ DropdownStringControllerBuilder allowEmptyValue(boolean allowEmptyValue);
+ DropdownStringControllerBuilder allowAnyValue(boolean allowAnyValue);
+
+
+ static DropdownStringControllerBuilder create(Option<String> option) {
+ return new DropdownStringControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/EnumControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/EnumControllerBuilder.java
new file mode 100644
index 0000000..decb8f9
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/EnumControllerBuilder.java
@@ -0,0 +1,12 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.EnumControllerBuilderImpl;
+
+public interface EnumControllerBuilder<T extends Enum<T>> extends ValueFormattableController<T, EnumControllerBuilder<T>> {
+ EnumControllerBuilder<T> enumClass(Class<T> enumClass);
+
+ static <T extends Enum<T>> EnumControllerBuilder<T> create(Option<T> option) {
+ return new EnumControllerBuilderImpl<>(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/EnumDropdownControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/EnumDropdownControllerBuilder.java
new file mode 100644
index 0000000..0814cc6
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/EnumDropdownControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.EnumDropdownControllerBuilderImpl;
+
+public interface EnumDropdownControllerBuilder<E extends Enum<E>> extends ValueFormattableController<E, EnumDropdownControllerBuilder<E>> {
+ static <E extends Enum<E>> EnumDropdownControllerBuilder<E> create(Option<E> option) {
+ return new EnumDropdownControllerBuilderImpl<>(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/FloatFieldControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/FloatFieldControllerBuilder.java
new file mode 100644
index 0000000..de81837
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/FloatFieldControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.FloatFieldControllerBuilderImpl;
+
+public interface FloatFieldControllerBuilder extends NumberFieldControllerBuilder<Float, FloatFieldControllerBuilder> {
+ static FloatFieldControllerBuilder create(Option<Float> option) {
+ return new FloatFieldControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/FloatSliderControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/FloatSliderControllerBuilder.java
new file mode 100644
index 0000000..2a04dde
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/FloatSliderControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.FloatSliderControllerBuilderImpl;
+
+public interface FloatSliderControllerBuilder extends SliderControllerBuilder<Float, FloatSliderControllerBuilder> {
+ static FloatSliderControllerBuilder create(Option<Float> option) {
+ return new FloatSliderControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/IntegerFieldControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/IntegerFieldControllerBuilder.java
new file mode 100644
index 0000000..1e31fac
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/IntegerFieldControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.IntegerFieldControllerBuilderImpl;
+
+public interface IntegerFieldControllerBuilder extends NumberFieldControllerBuilder<Integer, IntegerFieldControllerBuilder> {
+ static IntegerFieldControllerBuilder create(Option<Integer> option) {
+ return new IntegerFieldControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/IntegerSliderControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/IntegerSliderControllerBuilder.java
new file mode 100644
index 0000000..11e089a
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/IntegerSliderControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.IntegerSliderControllerBuilderImpl;
+
+public interface IntegerSliderControllerBuilder extends SliderControllerBuilder<Integer, IntegerSliderControllerBuilder> {
+ static IntegerSliderControllerBuilder create(Option<Integer> option) {
+ return new IntegerSliderControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/ItemControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/ItemControllerBuilder.java
new file mode 100644
index 0000000..5a1f5fa
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/ItemControllerBuilder.java
@@ -0,0 +1,11 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.ItemControllerBuilderImpl;
+import net.minecraft.world.item.Item;
+
+public interface ItemControllerBuilder extends ControllerBuilder<Item> {
+ static ItemControllerBuilder create(Option<Item> option) {
+ return new ItemControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/LongFieldControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/LongFieldControllerBuilder.java
new file mode 100644
index 0000000..c53b464
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/LongFieldControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.LongFieldControllerBuilderImpl;
+
+public interface LongFieldControllerBuilder extends NumberFieldControllerBuilder<Long, LongFieldControllerBuilder> {
+ static LongFieldControllerBuilder create(Option<Long> option) {
+ return new LongFieldControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/LongSliderControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/LongSliderControllerBuilder.java
new file mode 100644
index 0000000..fc09423
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/LongSliderControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.LongSliderControllerBuilderImpl;
+
+public interface LongSliderControllerBuilder extends SliderControllerBuilder<Long, LongSliderControllerBuilder> {
+ static LongSliderControllerBuilder create(Option<Long> option) {
+ return new LongSliderControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/NumberFieldControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/NumberFieldControllerBuilder.java
new file mode 100644
index 0000000..b5cfa1f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/NumberFieldControllerBuilder.java
@@ -0,0 +1,7 @@
+package dev.isxander.yacl3.api.controller;
+
+public interface NumberFieldControllerBuilder<T extends Number, B extends NumberFieldControllerBuilder<T, B>> extends ValueFormattableController<T, B> {
+ B min(T min);
+ B max(T max);
+ B range(T min, T max);
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/SliderControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/SliderControllerBuilder.java
new file mode 100644
index 0000000..2fb3fec
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/SliderControllerBuilder.java
@@ -0,0 +1,6 @@
+package dev.isxander.yacl3.api.controller;
+
+public interface SliderControllerBuilder<T extends Number, B extends SliderControllerBuilder<T, B>> extends ValueFormattableController<T, B> {
+ B range(T min, T max);
+ B step(T step);
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/StringControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/StringControllerBuilder.java
new file mode 100644
index 0000000..5e2f8c6
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/StringControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.StringControllerBuilderImpl;
+
+public interface StringControllerBuilder extends ControllerBuilder<String> {
+ static StringControllerBuilder create(Option<String> option) {
+ return new StringControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/TickBoxControllerBuilder.java b/src/main/java/dev/isxander/yacl3/api/controller/TickBoxControllerBuilder.java
new file mode 100644
index 0000000..71a2762
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/TickBoxControllerBuilder.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.api.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.impl.controller.TickBoxControllerBuilderImpl;
+
+public interface TickBoxControllerBuilder extends ControllerBuilder<Boolean> {
+ static TickBoxControllerBuilder create(Option<Boolean> option) {
+ return new TickBoxControllerBuilderImpl(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/ValueFormattableController.java b/src/main/java/dev/isxander/yacl3/api/controller/ValueFormattableController.java
new file mode 100644
index 0000000..b886318
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/ValueFormattableController.java
@@ -0,0 +1,14 @@
+package dev.isxander.yacl3.api.controller;
+
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public interface ValueFormattableController<T, B extends ValueFormattableController<T, B>> extends ControllerBuilder<T> {
+ B formatValue(ValueFormatter<T> formatter);
+
+ @Deprecated
+ default B valueFormatter(Function<T, Component> formatter) {
+ return formatValue(formatter::apply);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/controller/ValueFormatter.java b/src/main/java/dev/isxander/yacl3/api/controller/ValueFormatter.java
new file mode 100644
index 0000000..aecaf65
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/controller/ValueFormatter.java
@@ -0,0 +1,7 @@
+package dev.isxander.yacl3.api.controller;
+
+import net.minecraft.network.chat.Component;
+
+public interface ValueFormatter<T> {
+ Component format(T value);
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/utils/Dimension.java b/src/main/java/dev/isxander/yacl3/api/utils/Dimension.java
new file mode 100644
index 0000000..ec09238
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/utils/Dimension.java
@@ -0,0 +1,33 @@
+package dev.isxander.yacl3.api.utils;
+
+import dev.isxander.yacl3.impl.utils.DimensionIntegerImpl;
+
+public interface Dimension<T extends Number> {
+ T x();
+ T y();
+
+ T width();
+ T height();
+
+ T xLimit();
+ T yLimit();
+
+ T centerX();
+ T centerY();
+
+ boolean isPointInside(T x, T y);
+
+ MutableDimension<T> clone();
+
+ Dimension<T> withX(T x);
+ Dimension<T> withY(T y);
+ Dimension<T> withWidth(T width);
+ Dimension<T> withHeight(T height);
+
+ Dimension<T> moved(T x, T y);
+ Dimension<T> expanded(T width, T height);
+
+ static MutableDimension<Integer> ofInt(int x, int y, int width, int height) {
+ return new DimensionIntegerImpl(x, y, width, height);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/utils/MutableDimension.java b/src/main/java/dev/isxander/yacl3/api/utils/MutableDimension.java
new file mode 100644
index 0000000..f551232
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/utils/MutableDimension.java
@@ -0,0 +1,11 @@
+package dev.isxander.yacl3.api.utils;
+
+public interface MutableDimension<T extends Number> extends Dimension<T> {
+ MutableDimension<T> setX(T x);
+ MutableDimension<T> setY(T y);
+ MutableDimension<T> setWidth(T width);
+ MutableDimension<T> setHeight(T height);
+
+ MutableDimension<T> move(T x, T y);
+ MutableDimension<T> expand(T width, T height);
+}
diff --git a/src/main/java/dev/isxander/yacl3/api/utils/OptionUtils.java b/src/main/java/dev/isxander/yacl3/api/utils/OptionUtils.java
new file mode 100644
index 0000000..cf33f0f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/api/utils/OptionUtils.java
@@ -0,0 +1,39 @@
+package dev.isxander.yacl3.api.utils;
+
+import dev.isxander.yacl3.api.*;
+
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+public class OptionUtils {
+ /**
+ * Consumes all options, ignoring groups and categories.
+ * When consumer returns true, this function stops iterating.
+ */
+ public static void consumeOptions(YetAnotherConfigLib yacl, Function<Option<?>, Boolean> consumer) {
+ for (ConfigCategory category : yacl.categories()) {
+ for (OptionGroup group : category.groups()) {
+ if (group instanceof ListOption<?> list) {
+ if (consumer.apply(list)) return;
+ } else {
+ for (Option<?> option : group.options()) {
+ if (consumer.apply(option)) return;
+ }
+ }
+
+ }
+ }
+ }
+
+ /**
+ * Consumes all options, ignoring groups and categories.
+ *
+ * @see OptionUtils#consumeOptions(YetAnotherConfigLib, Function)
+ */
+ public static void forEachOptions(YetAnotherConfigLib yacl, Consumer<Option<?>> consumer) {
+ consumeOptions(yacl, (opt) -> {
+ consumer.accept(opt);
+ return false;
+ });
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/ConfigEntry.java b/src/main/java/dev/isxander/yacl3/config/ConfigEntry.java
new file mode 100644
index 0000000..066cf42
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/ConfigEntry.java
@@ -0,0 +1,15 @@
+package dev.isxander.yacl3.config;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * @deprecated Use {@link dev.isxander.yacl3.config.v2.api.SerialEntry} instead.
+ */
+@Deprecated
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface ConfigEntry {
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/ConfigInstance.java b/src/main/java/dev/isxander/yacl3/config/ConfigInstance.java
new file mode 100644
index 0000000..31d4ca2
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/ConfigInstance.java
@@ -0,0 +1,50 @@
+package dev.isxander.yacl3.config;
+
+import java.lang.reflect.InvocationTargetException;
+
+/**
+ * Responsible for handing the actual config data type.
+ * Holds the instance along with a final default instance
+ * to reference default values for options and should not be changed.
+ *
+ * Abstract methods to save and load the class, implementations are responsible for
+ * how it saves and load.
+ *
+ * @param <T> config data type
+ * @deprecated upgrade to config v2 {@link dev.isxander.yacl3.config.v2.api.ConfigClassHandler}
+ */
+@Deprecated
+public abstract class ConfigInstance<T> {
+ private final Class<T> configClass;
+ private final T defaultInstance;
+ private T instance;
+
+ public ConfigInstance(Class<T> configClass) {
+ this.configClass = configClass;
+
+ try {
+ this.defaultInstance = this.instance = configClass.getConstructor().newInstance();
+ } catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
+ throw new IllegalStateException(String.format("Could not create default instance of config for %s. Make sure there is a default constructor!", this.configClass.getSimpleName()));
+ }
+ }
+
+ public abstract void save();
+ public abstract void load();
+
+ public T getConfig() {
+ return this.instance;
+ }
+
+ protected void setConfig(T instance) {
+ this.instance = instance;
+ }
+
+ public T getDefaults() {
+ return this.defaultInstance;
+ }
+
+ public Class<T> getConfigClass() {
+ return this.configClass;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java b/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java
new file mode 100644
index 0000000..c47afe2
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java
@@ -0,0 +1,259 @@
+package dev.isxander.yacl3.config;
+
+import com.google.gson.*;
+import dev.isxander.yacl3.config.v2.impl.serializer.GsonConfigSerializer;
+import dev.isxander.yacl3.gui.utils.ItemRegistryHelper;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.Style;
+import net.minecraft.world.item.Item;
+
+import java.awt.*;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.function.UnaryOperator;
+
+/**
+ * Uses GSON to serialize and deserialize config data from JSON to a file.
+ * <p>
+ * Only fields annotated with {@link ConfigEntry} are included in the JSON.
+ * {@link Component}, {@link Style} and {@link Color} have default type adapters, so there is no need to provide them in your GSON instance.
+ * GSON is automatically configured to format fields as {@code lower_camel_case}.
+ *
+ * @param <T> config data type
+ * @deprecated upgrade to config v2 {@link dev.isxander.yacl3.config.v2.api.ConfigClassHandler} with {@link dev.isxander.yacl3.config.v2.api.serializer.GsonConfigSerializerBuilder}
+ * <pre>
+ * {@code
+ * public class MyConfig {
+ * public static ConfigClassHandler<MyConfig> HANDLER = ConfigClassHandler.createBuilder(MyConfig.class)
+ * .id(new ResourceLocation("modid", "config"))
+ * .serializer(config -> GsonConfigSerializerBuilder.create(config)
+ * .setPath(FabricLoader.getInstance().getConfigDir().resolve("my_mod.json")
+ * .build())
+ * .build();
+ *
+ * @SerialEntry public boolean myBoolean = true;
+ * }
+ * }
+ * </pre>
+ */
+@Deprecated
+public class GsonConfigInstance<T> extends ConfigInstance<T> {
+ private final Gson gson;
+ private final Path path;
+
+ @Deprecated
+ public GsonConfigInstance(Class<T> configClass, Path path) {
+ this(configClass, path, new GsonBuilder());
+ }
+
+ @Deprecated
+ public GsonConfigInstance(Class<T> configClass, Path path, Gson gson) {
+ this(configClass, path, gson.newBuilder());
+ }
+
+ @Deprecated
+ public GsonConfigInstance(Class<T> configClass, Path path, UnaryOperator<GsonBuilder> builder) {
+ this(configClass, path, builder.apply(new GsonBuilder()));
+ }
+
+ @Deprecated
+ public GsonConfigInstance(Class<T> configClass, Path path, GsonBuilder builder) {
+ super(configClass);
+ this.path = path;
+ this.gson = builder
+ .setExclusionStrategies(new ConfigExclusionStrategy())
+ /*? if >1.20.4 { *//*
+ .registerTypeHierarchyAdapter(Component.class, new Component.SerializerAdapter(RegistryAccess.EMPTY))
+ *//*? } elif =1.20.4 {*/
+ .registerTypeHierarchyAdapter(Component.class, new Component.SerializerAdapter())
+ /*? } else {*//*
+ .registerTypeHierarchyAdapter(Component.class, new Component.Serializer())
+ *//*?}*/
+ .registerTypeHierarchyAdapter(Style.class, /*? if >=1.20.4 {*/new GsonConfigSerializer.StyleTypeAdapter()/*?} else {*//*new Style.Serializer()*//*?}*/)
+ .registerTypeHierarchyAdapter(Color.class, new ColorTypeAdapter())
+ .registerTypeHierarchyAdapter(Item.class, new ItemTypeAdapter())
+ .serializeNulls()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create();
+ }
+
+ private GsonConfigInstance(Class<T> configClass, Path path, Gson gson, boolean fromBuilder) {
+ super(configClass);
+ this.path = path;
+ this.gson = gson;
+ }
+
+ @Override
+ public void save() {
+ try {
+ YACLConstants.LOGGER.info("Saving {}...", getConfigClass().getSimpleName());
+ Files.writeString(path, gson.toJson(getConfig()), StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ @Override
+ public void load() {
+ try {
+ if (Files.notExists(path)) {
+ save();
+ return;
+ }
+
+ YACLConstants.LOGGER.info("Loading {}...", getConfigClass().getSimpleName());
+ setConfig(gson.fromJson(Files.readString(path), getConfigClass()));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public Path getPath() {
+ return this.path;
+ }
+
+ private static class ConfigExclusionStrategy implements ExclusionStrategy {
+ @Override
+ public boolean shouldSkipField(FieldAttributes fieldAttributes) {
+ return fieldAttributes.getAnnotation(ConfigEntry.class) == null;
+ }
+
+ @Override
+ public boolean shouldSkipClass(Class<?> aClass) {
+ return false;
+ }
+ }
+
+ public static class ColorTypeAdapter implements JsonSerializer<Color>, JsonDeserializer<Color> {
+ @Override
+ public Color deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext) throws JsonParseException {
+ return new Color(jsonElement.getAsInt(), true);
+ }
+
+ @Override
+ public JsonElement serialize(Color color, Type type, JsonSerializationContext jsonSerializationContext) {
+ return new JsonPrimitive(color.getRGB());
+ }
+ }
+ public static class ItemTypeAdapter implements JsonSerializer<Item>, JsonDeserializer<Item> {
+ @Override
+ public Item deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext) throws JsonParseException {
+ return ItemRegistryHelper.getItemFromName(jsonElement.getAsString());
+ }
+
+ @Override
+ public JsonElement serialize(Item item, Type type, JsonSerializationContext jsonSerializationContext) {
+ return new JsonPrimitive(BuiltInRegistries.ITEM.getKey(item).toString());
+ }
+ }
+
+ /**
+ * Creates a builder for a GSON config instance.
+ * @param configClass the config class
+ * @return a new builder
+ * @param <T> the config type
+ */
+ public static <T> Builder<T> createBuilder(Class<T> configClass) {
+ return new Builder<>(configClass);
+ }
+
+ public static class Builder<T> {
+ private final Class<T> configClass;
+ private Path path;
+ private UnaryOperator<GsonBuilder> gsonBuilder = builder -> builder
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .serializeNulls()
+ /*? if >1.20.4 { *//*
+ .registerTypeHierarchyAdapter(Component.class, new Component.SerializerAdapter(RegistryAccess.EMPTY))
+ *//*? } elif =1.20.4 {*/
+ .registerTypeHierarchyAdapter(Component.class, new Component.SerializerAdapter())
+ /*? } else {*//*
+ .registerTypeHierarchyAdapter(Component.class, new Component.Serializer())
+ *//*?}*/
+ .registerTypeHierarchyAdapter(Style.class, /*? if >=1.20.4 {*/new GsonConfigSerializer.StyleTypeAdapter()/*?} else {*//*new Style.Serializer()*//*?}*/)
+ .registerTypeHierarchyAdapter(Color.class, new ColorTypeAdapter())
+ .registerTypeHierarchyAdapter(Item.class, new ItemTypeAdapter());
+
+ private Builder(Class<T> configClass) {
+ this.configClass = configClass;
+ }
+
+ /**
+ * Sets the file path to save and load the config from.
+ */
+ public Builder<T> setPath(Path path) {
+ this.path = path;
+ return this;
+ }
+
+ /**
+ * Sets the GSON instance to use. Overrides all YACL defaults such as:
+ * <ul>
+ * <li>lower_camel_case field naming policy</li>
+ * <li>null serialization</li>
+ * <li>{@link Component}, {@link Style} and {@link Color} type adapters</li>
+ * </ul>
+ * Still respects the exclusion strategy to only serialize {@link ConfigEntry}
+ * but these can be added to with setExclusionStrategies.
+ *
+ * @param gsonBuilder gson builder to use
+ */
+ public Builder<T> overrideGsonBuilder(GsonBuilder gsonBuilder) {
+ this.gsonBuilder = builder -> gsonBuilder;
+ return this;
+ }
+
+ /**
+ * Sets the GSON instance to use. Overrides all YACL defaults such as:
+ * <ul>
+ * <li>lower_camel_case field naming policy</li>
+ * <li>null serialization</li>
+ * <li>{@link Component}, {@link Style} and {@link Color} type adapters</li>
+ * </ul>
+ * Still respects the exclusion strategy to only serialize {@link ConfigEntry}
+ * but these can be added to with setExclusionStrategies.
+ *
+ * @param gson gson instance to be converted to a builder
+ */
+ public Builder<T> overrideGsonBuilder(Gson gson) {
+ return this.overrideGsonBuilder(gson.newBuilder());
+ }
+
+ /**
+ * Appends extra configuration to a GSON builder.
+ * This is the intended way to add functionality to the GSON instance.
+ * <p>
+ * By default, YACL sets the GSON with the following options:
+ * <ul>
+ * <li>lower_camel_case field naming policy</li>
+ * <li>null serialization</li>
+ * <li>{@link Component}, {@link Style} and {@link Color} type adapters</li>
+ * </ul>
+ *
+ * @param gsonBuilder the function to apply to the builder
+ */
+ public Builder<T> appendGsonBuilder(UnaryOperator<GsonBuilder> gsonBuilder) {
+ UnaryOperator<GsonBuilder> prev = this.gsonBuilder;
+ this.gsonBuilder = builder -> gsonBuilder.apply(prev.apply(builder));
+ return this;
+ }
+
+ /**
+ * Builds the config instance.
+ * @return the built config instance
+ */
+ public GsonConfigInstance<T> build() {
+ UnaryOperator<GsonBuilder> gsonBuilder = builder -> this.gsonBuilder.apply(builder)
+ .addSerializationExclusionStrategy(new ConfigExclusionStrategy())
+ .addDeserializationExclusionStrategy(new ConfigExclusionStrategy());
+
+ return new GsonConfigInstance<>(configClass, path, gsonBuilder.apply(new GsonBuilder()).create(), true);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigClassHandler.java b/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigClassHandler.java
new file mode 100644
index 0000000..d94280f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigClassHandler.java
@@ -0,0 +1,107 @@
+package dev.isxander.yacl3.config.v2.api;
+
+import dev.isxander.yacl3.api.YetAnotherConfigLib;
+import dev.isxander.yacl3.config.v2.impl.ConfigClassHandlerImpl;
+import net.minecraft.resources.ResourceLocation;
+
+import java.util.function.Function;
+
+/**
+ * Represents a handled config class.
+ *
+ * @param <T> the backing config class to be managed
+ */
+public interface ConfigClassHandler<T> {
+ /**
+ * Gets the working instance of the config class.
+ * This should be used to get and set fields like usual.
+ */
+ T instance();
+
+ /**
+ * Gets a second instance of the config class that
+ * should be used to get default values only. No fields
+ * should be modified in this instance.
+ */
+ T defaults();
+
+ /**
+ * Gets the class of the config.
+ */
+ Class<T> configClass();
+
+ /**
+ * Get all eligible fields in the config class.
+ * They could either be annotated with {@link dev.isxander.yacl3.config.v2.api.autogen.AutoGen}
+ * or {@link SerialEntry}, do not assume that a field has both of these.
+ */
+ ConfigField<?>[] fields();
+
+ /**
+ * The unique identifier of this config handler.
+ */
+ ResourceLocation id();
+
+ /**
+ * Auto-generates a GUI for this config class.
+ * This throws an exception if auto-gen is not supported.
+ */
+ YetAnotherConfigLib generateGui();
+
+ /**
+ * Whether this config class supports auto-gen.
+ * If on a dedicated server, this returns false.
+ */
+ boolean supportsAutoGen();
+
+ /**
+ * Safely loads the config class using the provided serializer.
+ * @return if the config was loaded successfully
+ */
+ boolean load();
+
+ /**
+ * Safely saves the config class using the provided serializer.
+ */
+ void save();
+
+ /**
+ * The serializer for this config class.
+ * Manages saving and loading of the config with fields
+ * annotated with {@link SerialEntry}.
+ *
+ * @deprecated use {@link #load()} and {@link #save()} instead.
+ */
+ @Deprecated
+ ConfigSerializer<T> serializer();
+
+ /**
+ * Creates a builder for a config class.
+ *
+ * @param configClass the config class to build
+ * @param <T> the type of the config class
+ * @return the builder
+ */
+ static <T> Builder<T> createBuilder(Class<T> configClass) {
+ return new ConfigClassHandlerImpl.BuilderImpl<>(configClass);
+ }
+
+ interface Builder<T> {
+ /**
+ * The unique identifier of this config handler.
+ * The namespace should be your modid.
+ *
+ * @return this builder
+ */
+ Builder<T> id(ResourceLocation id);
+
+ /**
+ * The function to create the serializer for this config class.
+ *
+ * @return this builder
+ */
+ Builder<T> serializer(Function<ConfigClassHandler<T>, ConfigSerializer<T>> serializerFactory);
+
+ ConfigClassHandler<T> build();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigField.java
new file mode 100644
index 0000000..181a4d4
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigField.java
@@ -0,0 +1,40 @@
+package dev.isxander.yacl3.config.v2.api;
+
+import dev.isxander.yacl3.config.v2.api.autogen.AutoGenField;
+
+import java.util.Optional;
+
+/**
+ * Represents a field in a config class.
+ * This is used to get all metadata on a field,
+ * and access the field and its default value.
+ *
+ * @param <T> the field's type
+ */
+public interface ConfigField<T> {
+ /**
+ * Gets the accessor for the field on the main instance.
+ * (Accessed through {@link ConfigClassHandler#instance()})
+ */
+ FieldAccess<T> access();
+
+ /**
+ * Gets the accessor for the field on the default instance.
+ */
+ ReadOnlyFieldAccess<T> defaultAccess();
+
+ /**
+ * @return the parent config class handler that manages this field.
+ */
+ ConfigClassHandler<?> parent();
+
+ /**
+ * The serial entry metadata for this field, if it exists.
+ */
+ Optional<SerialField> serial();
+
+ /**
+ * The auto-gen metadata for this field, if it exists.
+ */
+ Optional<AutoGenField> autoGen();
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigSerializer.java b/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigSerializer.java
new file mode 100644
index 0000000..4ac988c
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigSerializer.java
@@ -0,0 +1,64 @@
+package dev.isxander.yacl3.config.v2.api;
+
+import java.util.Map;
+
+/**
+ * The base class for config serializers,
+ * offering a method to save and load.
+ * @param <T> the config class to be (de)serialized
+ */
+public abstract class ConfigSerializer<T> {
+ protected final ConfigClassHandler<T> config;
+
+ public ConfigSerializer(ConfigClassHandler<T> config) {
+ this.config = config;
+ }
+
+ /**
+ * Saves all fields in the config class.
+ * This can be done any way as it's abstract, but most
+ * commonly it is saved to a file.
+ */
+ public abstract void save();
+
+ /**
+ * Loads all fields into the config class.
+ * @param bufferAccessMap a map of the field accesses. instead of directly setting the field with
+ * {@link ConfigField#access()}, use this parameter. This loads into a temporary object,
+ * and the class handler handles pushing these changes to the instance.
+ * @return the result of the load
+ */
+ public LoadResult loadSafely(Map<ConfigField<?>, FieldAccess<?>> bufferAccessMap) {
+ this.load();
+ return LoadResult.NO_CHANGE;
+ }
+
+ /**
+ * Loads all fields in the config class.
+ *
+ * @deprecated use {@link #loadSafely(Map)} instead.
+ */
+ @Deprecated
+ public void load() {
+ throw new IllegalArgumentException("load() is deprecated, use loadSafely() instead.");
+ }
+
+ public enum LoadResult {
+ /**
+ * Indicates that the config was loaded successfully and the temporary object should be applied.
+ */
+ SUCCESS,
+ /**
+ * Indicates that the config was not loaded successfully and the load should be abandoned.
+ */
+ FAILURE,
+ /**
+ * Indicates that the config has not changed after a load and the temporary object should be ignored.
+ */
+ NO_CHANGE,
+ /**
+ * Indicates the config was loaded successfully, but the config should be re-saved straight away.
+ */
+ DIRTY
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/FieldAccess.java b/src/main/java/dev/isxander/yacl3/config/v2/api/FieldAccess.java
new file mode 100644
index 0000000..ea30cd8
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/FieldAccess.java
@@ -0,0 +1,14 @@
+package dev.isxander.yacl3.config.v2.api;
+
+/**
+ * A writable field instance access.
+ *
+ * @param <T> the type of the field
+ */
+public interface FieldAccess<T> extends ReadOnlyFieldAccess<T> {
+ /**
+ * Sets the value of the field.
+ * @param value the value to set
+ */
+ void set(T value);
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/ReadOnlyFieldAccess.java b/src/main/java/dev/isxander/yacl3/config/v2/api/ReadOnlyFieldAccess.java
new file mode 100644
index 0000000..566d60d
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/ReadOnlyFieldAccess.java
@@ -0,0 +1,36 @@
+package dev.isxander.yacl3.config.v2.api;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Type;
+import java.util.Optional;
+
+/**
+ * An abstract interface for accessing properties of an instance of a field.
+ * You do not need to worry about exceptions as the implementation
+ * will handle them.
+ *
+ * @param <T> the type of the field
+ */
+public interface ReadOnlyFieldAccess<T> {
+ /**
+ * @return the current value of the field.
+ */
+ T get();
+
+ /**
+ * @return the name of the field.
+ */
+ String name();
+
+ /**
+ * @return the type of the field.
+ */
+ Type type();
+
+ /**
+ * @return the class of the field.
+ */
+ Class<T> typeClass();
+
+ <A extends Annotation> Optional<A> getAnnotation(Class<A> annotationClass);
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/SerialEntry.java b/src/main/java/dev/isxander/yacl3/config/v2/api/SerialEntry.java
new file mode 100644
index 0000000..94bf785
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/SerialEntry.java
@@ -0,0 +1,39 @@
+package dev.isxander.yacl3.config.v2.api;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a field as serializable, so it can be used in a {@link ConfigSerializer}.
+ * Any field without this annotation will not be saved or loaded, but can still be turned
+ * into an auto-generated option.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface SerialEntry {
+ /**
+ * The serial name of the field.
+ * If empty, the serializer will decide the name.
+ */
+ String value() default "";
+
+ /**
+ * The comment to add to the field.
+ * Some serializers may not support this.
+ * If empty, the serializer will not add a comment.
+ */
+ String comment() default "";
+
+ /**
+ * Whether the field is required in the loaded config to be valid.
+ * If it's not, the config will be marked as dirty and re-saved with the default value.
+ */
+ boolean required() default true;
+
+ /**
+ * Whether the field can be null.
+ */
+ boolean nullable() default false;
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/SerialField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/SerialField.java
new file mode 100644
index 0000000..cf6abfc
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/SerialField.java
@@ -0,0 +1,16 @@
+package dev.isxander.yacl3.config.v2.api;
+
+import java.util.Optional;
+
+/**
+ * The backing interface for the {@link SerialEntry} annotation.
+ */
+public interface SerialField {
+ String serialName();
+
+ Optional<String> comment();
+
+ boolean required();
+
+ boolean nullable();
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/AutoGen.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/AutoGen.java
new file mode 100644
index 0000000..4187caf
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/AutoGen.java
@@ -0,0 +1,32 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Any field that is annotated with this will generate a config option
+ * in the auto-generated config GUI. This should be paired with an
+ * {@link OptionFactory} annotation to define how to create the option.
+ * Some examples of this are {@link TickBox}, {@link FloatSlider}, {@link Label} or {@link StringField}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface AutoGen {
+ /**
+ * Should be the id of the category. This is used to group options.
+ * The translation keys also use this. Category IDs can be set as a
+ * {@code private static final String} and used in the annotation to prevent
+ * repeating yourself.
+ */
+ String category();
+
+ /**
+ * If left blank, the option will go in the root group, where it is
+ * listed at the top of the category with no group header. If set,
+ * this also appends to the translation key. Group IDs can be reused
+ * between multiple categories.
+ */
+ String group() default "";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/AutoGenField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/AutoGenField.java
new file mode 100644
index 0000000..7f751fb
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/AutoGenField.java
@@ -0,0 +1,12 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.util.Optional;
+
+/**
+ * Backing interface for the {@link AutoGen} annotation.
+ */
+public interface AutoGenField {
+ String category();
+
+ Optional<String> group();
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Boolean.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Boolean.java
new file mode 100644
index 0000000..5598389
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Boolean.java
@@ -0,0 +1,41 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.BooleanControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface Boolean {
+ enum Formatter {
+ YES_NO,
+ TRUE_FALSE,
+ ON_OFF,
+ /**
+ * Uses the translation keys:
+ * <ul>
+ * <li>true: {@code yacl3.config.$configId.$fieldName.fmt.true}</li>
+ * <li>false: {@code yacl3.config.$configId.$fieldName.fmt.false}</li>
+ * </ul>
+ */
+ CUSTOM,
+ }
+
+ /**
+ * The format used to display the boolean.
+ */
+ Formatter formatter() default Formatter.TRUE_FALSE;
+
+ /**
+ * Whether to color the formatted text green and red
+ * depending on the value: true or false respectively.
+ */
+ boolean colored() default false;
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ColorField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ColorField.java
new file mode 100644
index 0000000..74937b4
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ColorField.java
@@ -0,0 +1,21 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.ColorControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface ColorField {
+ /**
+ * Whether to show/allow the alpha channel in the color field.
+ */
+ boolean allowAlpha() default false;
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomDescription.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomDescription.java
new file mode 100644
index 0000000..08624b4
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomDescription.java
@@ -0,0 +1,12 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface CustomDescription {
+ String[] value() default "";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomFormat.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomFormat.java
new file mode 100644
index 0000000..15f6336
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomFormat.java
@@ -0,0 +1,17 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Allows you to specify a custom {@link ValueFormatter} for a field.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface CustomFormat {
+ Class<? extends ValueFormatter<?>> value();
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomImage.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomImage.java
new file mode 100644
index 0000000..d193f42
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomImage.java
@@ -0,0 +1,69 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.impl.autogen.EmptyCustomImageFactory;
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Defines a custom image for an option.
+ * Without this annotation, the option factory will look
+ * for the resource {@code modid:textures/yacl3/$config_id_path/$fieldName.webp}.
+ * WEBP was chosen as the default format because file sizes are greatly reduced,
+ * which is important to keep your JAR size down, if you're so bothered.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface CustomImage {
+ /**
+ * The resource path to the image, a {@link net.minecraft.resources.ResourceLocation}
+ * is constructed with the namespace being the modid of the config, and the path being
+ * this value.
+ * <p>
+ * The following file formats are supported:
+ * <ul>
+ * <li>{@code .png}</li>
+ * <li>{@code .webp}</li>
+ * <li>{@code .jpg}, {@code .jpeg}</li>
+ * <li>{@code .gif} - <strong>HIGHLY DISCOURAGED DUE TO LARGE FILE SIZE</strong></li>
+ * </ul>
+ * <p>
+ * If left blank, then {@link CustomImage#factory()} is used.
+ */
+ String value() default "";
+
+ /**
+ * The width of the image, in pixels.
+ * <strong>This is only required when using a PNG with {@link CustomImage#value()}</strong>
+ */
+ int width() default 0;
+
+ /**
+ * The width of the image, in pixels.
+ * <strong>This is only required when using a PNG with {@link CustomImage#value()}</strong>
+ */
+ int height() default 0;
+
+ /**
+ * The factory to create the image with.
+ * For the average user, this should not be used as it breaks out of the
+ * API-safe environment where things could change at any time, but required
+ * when creating anything advanced with the {@link ImageRenderer}.
+ * <p>
+ * The factory should contain a public, no-args constructor that will be
+ * invoked via reflection.
+ *
+ * @return the class of the factory
+ */
+ Class<? extends CustomImageFactory<?>> factory() default EmptyCustomImageFactory.class;
+
+ interface CustomImageFactory<T> {
+ CompletableFuture<ImageRenderer> createImage(T value, ConfigField<T> field, OptionAccess access);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomName.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomName.java
new file mode 100644
index 0000000..aa235bb
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/CustomName.java
@@ -0,0 +1,18 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Overrides the name of an auto-generated option.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface CustomName {
+ /**
+ * The translation key to use for the option's name.
+ */
+ String value() default "";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/DoubleField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/DoubleField.java
new file mode 100644
index 0000000..963cefd
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/DoubleField.java
@@ -0,0 +1,46 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.DoubleFieldControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface DoubleField {
+ /**
+ * The minimum value of the field. If a user enters a value less
+ * than this, it will be clamped to this value.
+ * <p>
+ * If this is set to {@code -Double.MAX_VALUE}, there will be no minimum.
+ * <p>
+ * If the current value is at this minimum, if available,
+ * the translation key {@code yacl3.config.$configId.$fieldName.fmt.min}
+ * will be used.
+ */
+ double min() default -Double.MAX_VALUE;
+
+ /**
+ * The maximum value of the field. If a user enters a value more
+ * than this, it will be clamped to this value.
+ * <p>
+ * If this is set to {@code Double.MAX_VALUE}, there will be no minimum.
+ * <p>
+ * If the current value is at this maximum, if available,
+ * the translation key {@code yacl3.config.$configId.$fieldName.fmt.max}
+ * will be used.
+ */
+ double max() default Double.MAX_VALUE;
+
+ /**
+ * The format used to display the double.
+ * This is the syntax used in {@link String#format(String, Object...)}.
+ */
+ String format() default "%.2f";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/DoubleSlider.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/DoubleSlider.java
new file mode 100644
index 0000000..268f6a4
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/DoubleSlider.java
@@ -0,0 +1,48 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.DoubleSliderControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface DoubleSlider {
+ /**
+ * The minimum value of the slider.
+ * <p>
+ * If the current value is at this minimum, if available,
+ * the translation key {@code yacl3.config.$configId.$fieldName.fmt.min}
+ * will be used.
+ */
+ double min();
+
+ /**
+ * The maximum value of the slider.
+ * <p>
+ * If the current value is at this maximum, if available,
+ * the translation key {@code yacl3.config.$configId.$fieldName.fmt.max}
+ * will be used.
+ */
+ double max();
+
+ /**
+ * The step size of this slider.
+ * For example, if this is set to 0.1, the slider will
+ * increment/decrement by 0.1 when dragging, no less, no more and
+ * will always be a multiple of 0.1.
+ */
+ double step();
+
+ /**
+ * The format used to display the double.
+ * This is the syntax used in {@link String#format(String, Object...)}.
+ */
+ String format() default "%.2f";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Dropdown.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Dropdown.java
new file mode 100644
index 0000000..44239d5
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Dropdown.java
@@ -0,0 +1,43 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.DropdownStringControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface Dropdown {
+ /**
+ * The allowed values for the field. These will be shown in a dropdown
+ * that the user can filter and select from.
+ * <p>
+ * Only values in this list will be accepted and written to the config
+ * file, unless {@link #allow()} is set to ${@code ALLOW_ANY}.
+ * <p>
+ * Empty string is a valid value only if it appears in this list, or if
+ * {@link #allow()} is set to {@code ALLOW_EMPTY} or {@code ALLOW_ANY}.
+ */
+ String[] values();
+
+ /**
+ * Whether to accept the empty string as a valid value if it does not
+ * already appear in {@link #values()}. If it already appears there,
+ * the value of this does not apply.
+ */
+ boolean allowEmptyValue() default false;
+
+ /**
+ * Whether to accept any string as a valid value. The list of strings
+ * supplied in {@link #values()} are only used as dropdown suggestions.
+ * Empty strings are still prohibited unless the empty string appears in
+ * {@link #values()} or {@link #allowEmptyValue()}.
+ */
+ boolean allowAnyValue() default false;
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/EnumCycler.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/EnumCycler.java
new file mode 100644
index 0000000..98d94f9
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/EnumCycler.java
@@ -0,0 +1,35 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import dev.isxander.yacl3.api.NameableEnum;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An option factory.
+ * <p>
+ * This creates a regular option with a {@link dev.isxander.yacl3.api.controller.CyclingListControllerBuilder}
+ * controller. If the enum implements {@link CyclableEnum}, the allowed values will be used from that,
+ * rather than every single enum constant in the class. If not, {@link EnumCycler#allowedOrdinals()} is used.
+ * <p>
+ * There are two methods of formatting for enum values. First, if the enum implements
+ * {@link dev.isxander.yacl3.api.NameableEnum}, {@link NameableEnum#getDisplayName()} is used.
+ * Otherwise, the translation key {@code yacl3.config.enum.$enumClassName.$enumName} where
+ * {@code $enumClassName} is the exact name of the class and {@code $enumName} is equal to the lower
+ * case of {@link Enum#name()}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface EnumCycler {
+ /**
+ * The allowed ordinals of the enum class. If empty, all ordinals are allowed.
+ * This is only used if the enum does not implement {@link CyclableEnum}.
+ */
+ int[] allowedOrdinals() default {};
+
+ interface CyclableEnum<T extends Enum<T>> {
+ T[] allowedValues();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FloatField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FloatField.java
new file mode 100644
index 0000000..1e7e71e
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FloatField.java
@@ -0,0 +1,46 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.FloatFieldControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface FloatField {
+ /**
+ * The minimum value of the field. If a user enters a value less
+ * than this, it will be clamped to this value.
+ * <p>
+ * If this is set to {@code -Float.MAX_VALUE}, there will be no minimum.
+ * <p>
+ * If the current value is at this minimum, if available,
+ * the translation key {@code yacl3.config.$configId.$fieldName.fmt.min}
+ * will be used.
+ */
+ float min() default -Float.MAX_VALUE;
+
+ /**
+ * The maximum value of the field. If a user enters a value more
+ * than this, it will be clamped to this value.
+ * <p>
+ * If this is set to {@code Float.MAX_VALUE}, there will be no minimum.
+ * <p>
+ * If the current value is at this maximum, if available,
+ * the translation key {@code yacl3.config.$configId.$fieldName.fmt.max}
+ * will be used.
+ */
+ float max() default Float.MAX_VALUE;
+
+ /**
+ * The format used to display the float.
+ * This is the syntax used in {@link String#format(String, Object...)}.
+ */
+ String format() default "%.1f";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FloatSlider.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FloatSlider.java
new file mode 100644
index 0000000..19ae9db
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FloatSlider.java
@@ -0,0 +1,48 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.FloatSliderControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface FloatSlider {
+ /**
+ * The minimum value of the slider.
+ * <p>
+ * If the current value is at this minimum, if available,
+ * the translation key {@code yacl3.config.$configId.$fieldName.fmt.min}
+ * will be used.
+ */
+ float min();
+
+ /**
+ * The maximum value of the slider.
+ * <p>
+ * If the current value is at this maximum, if available,
+ * the translation key {@code yacl3.config.$configId.$fieldName.fmt.max}
+ * will be used.
+ */
+ float max();
+
+ /**
+ * The step size of this slider.
+ * For example, if this is set to 0.1, the slider will
+ * increment/decrement by 0.1 when dragging, no less, no more and
+ * will always be a multiple of 0.1.
+ */
+ float step();
+
+ /**
+ * The format used to display the float.
+ * This is the syntax used in {@link String#format(String, Object...)}.
+ */
+ String format() default "%.1f";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FormatTranslation.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FormatTranslation.java
new file mode 100644
index 0000000..7cc4ded
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/FormatTranslation.java
@@ -0,0 +1,25 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Allows you to specify a custom value formatter
+ * in the form of a translation key.
+ * <p>
+ * Without this annotation, the value will be formatted
+ * according to the option factory, implementation details
+ * for that should be found in the javadoc for the factory.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface FormatTranslation {
+ /**
+ * The translation key for the value formatter.
+ * One parameter is passed to this key: the option's value,
+ * using {@link Object#toString()}.
+ */
+ String value() default "";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/IntField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/IntField.java
new file mode 100644
index 0000000..9945d01
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/IntField.java
@@ -0,0 +1,41 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.IntegerFieldControllerBuilder} controller.
+ * <p>
+ * If available, the translation key {@code yacl3.config.$configId.$fieldName.fmt.$value}
+ * is used where {@code $value} is the current value of the option, for example, {@code 5}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface IntField {
+ /**
+ * The minimum value of the field. If a user enters a value less
+ * than this, it will be clamped to this value.
+ * <p>
+ * If this is set to {@code Integer.MIN_VALUE}, there will be no minimum.
+ */
+ int min() default Integer.MIN_VALUE;
+
+ /**
+ * The minimum value of the field. If a user enters a value more
+ * than this, it will be clamped to this value.
+ * <p>
+ * If this is set to {@code Integer.MAX_VALUE}, there will be no minimum.
+ */
+ int max() default Integer.MAX_VALUE;
+
+ /**
+ * The format used to display the integer.
+ * This is the syntax used in {@link String#format(String, Object...)}.
+ */
+ String format() default "%.0f";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/IntSlider.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/IntSlider.java
new file mode 100644
index 0000000..7fd2282
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/IntSlider.java
@@ -0,0 +1,35 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.IntegerSliderControllerBuilder} controller.
+ * <p>
+ * If available, the translation key {@code yacl3.config.$configId.$fieldName.fmt.$value}
+ * is used where {@code $value} is the current value of the option, for example, {@code 5}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface IntSlider {
+ /**
+ * The minimum value of the slider.
+ */
+ int min();
+
+ /**
+ * The maximum value of the slider.
+ */
+ int max();
+
+ /**
+ * The format used to display the integer.
+ * This is the syntax used in {@link String#format(String, Object...)}.
+ */
+ int step();
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ItemField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ItemField.java
new file mode 100644
index 0000000..84d2c7a
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ItemField.java
@@ -0,0 +1,17 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.ItemControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface ItemField {
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Label.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Label.java
new file mode 100644
index 0000000..41e026f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Label.java
@@ -0,0 +1,18 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An option factory that creates an instance
+ * of a {@link dev.isxander.yacl3.api.LabelOption}.
+ * <p>
+ * The backing field can be private and final and
+ * must be of type {@link net.minecraft.network.chat.Component}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface Label {
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ListGroup.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ListGroup.java
new file mode 100644
index 0000000..c664f71
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ListGroup.java
@@ -0,0 +1,60 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.List;
+
+/**
+ * An option factory.
+ * <p>
+ * This creates a List option with a custom controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface ListGroup {
+ /**
+ * The {@link Class} representing a class that implements {@link ValueFactory}.
+ * To create a new instance for the list when the user adds a new entry to the list.
+ * Remember this class can be shared with {@link ControllerFactory} as well.
+ */
+ Class<? extends ValueFactory<?>> valueFactory();
+
+ /**
+ * The {@link Class} representing a class that implements {@link ControllerBuilder}
+ * to add a controller to every entry in the list.
+ * Remember this class can be shared with {@link ValueFactory} as well.
+ */
+ Class<? extends ControllerFactory<?>> controllerFactory();
+
+ /**
+ * The maximum number of entries that can be added to the list.
+ * Once at this limit, the add button is disabled.
+ * If this is equal to {@code 0}, there is no limit.
+ */
+ int maxEntries() default 0;
+
+ /**
+ * The minimum number of entries that must be in the list.
+ * When at this limit, the remove button of the entries is disabled.
+ */
+ int minEntries() default 0;
+
+ /**
+ * Whether to add new entries at the bottom of the list rather than the top.
+ */
+ boolean addEntriesToBottom() default false;
+
+ interface ValueFactory<T> {
+ T provideNewValue();
+ }
+
+ interface ControllerFactory<T> {
+ ControllerBuilder<T> createController(ListGroup annotation, ConfigField<List<T>> field, OptionAccess storage, Option<T> option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/LongField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/LongField.java
new file mode 100644
index 0000000..01c3a7e
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/LongField.java
@@ -0,0 +1,41 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.LongFieldControllerBuilder} controller.
+ * <p>
+ * If available, the translation key {@code yacl3.config.$configId.$fieldName.fmt.$value}
+ * is used where {@code $value} is the current value of the option, for example, {@code 5}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface LongField {
+ /**
+ * The minimum value of the field. If a user enters a value less
+ * than this, it will be clamped to this value.
+ * <p>
+ * If this is set to {@code Long.MIN_VALUE}, there will be no minimum.
+ */
+ long min() default Long.MIN_VALUE;
+
+ /**
+ * The maximum value of the field. If a user enters a value more
+ * than this, it will be clamped to this value.
+ * <p>
+ * If this is set to {@code Long.MAX_VALUE}, there will be no minimum.
+ */
+ long max() default Long.MAX_VALUE;
+
+ /**
+ * The format used to display the long.
+ * This is the syntax used in {@link String#format(String, Object...)}.
+ */
+ String format() default "%.0f";
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/LongSlider.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/LongSlider.java
new file mode 100644
index 0000000..5563bd0
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/LongSlider.java
@@ -0,0 +1,35 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.LongSliderControllerBuilder} controller.
+ * <p>
+ * If available, the translation key {@code yacl3.config.$configId.$fieldName.fmt.$value}
+ * is used where {@code $value} is the current value of the option, for example, {@code 5}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface LongSlider {
+ /**
+ * The minimum value of the slider.
+ */
+ long min();
+
+ /**
+ * The maximum value of the slider.
+ */
+ long max();
+
+ /**
+ * The format used to display the integer.
+ * This is the syntax used in {@link String#format(String, Object...)}.
+ */
+ long step();
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/MasterTickBox.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/MasterTickBox.java
new file mode 100644
index 0000000..70dee1a
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/MasterTickBox.java
@@ -0,0 +1,26 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An option factory like {@link TickBox} but controls
+ * other options' availability based on its state.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface MasterTickBox {
+ /**
+ * The exact names of the fields with {@link AutoGen} annotation
+ * to control the availability of.
+ */
+ String[] value();
+
+ /**
+ * Whether having the tickbox disabled should enable the options
+ * rather than disable.
+ */
+ boolean invert() default false;
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OptionAccess.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OptionAccess.java
new file mode 100644
index 0000000..c55afe4
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OptionAccess.java
@@ -0,0 +1,35 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.function.Consumer;
+
+/**
+ * An accessor to all options that are auto-generated
+ * by the config system.
+ */
+public interface OptionAccess {
+ /**
+ * Gets an option by its field name.
+ * This could be null if the option hasn't been created yet. It is created
+ * in order of the fields in the class, so if you are trying to get an option
+ * lower-down in the class, this will return null.
+ *
+ * @param fieldName the exact, case-sensitive name of the field.
+ * @return the created option, or {@code null} if it hasn't been created yet.
+ */
+ @Nullable Option<?> getOption(String fieldName);
+
+ /**
+ * Schedules an operation to be performed on an option.
+ * If the option has already been created, the consumer will be
+ * accepted immediately upon calling this method, if not, it will
+ * be added to the queue of operations to be performed on the option
+ * once it does get created.
+ *
+ * @param fieldName the exact, case-sensitive name of the field.
+ * @param optionConsumer the operation to perform on the option.
+ */
+ void scheduleOptionOperation(String fieldName, Consumer<Option<?>> optionConsumer);
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OptionFactory.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OptionFactory.java
new file mode 100644
index 0000000..515a40b
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/OptionFactory.java
@@ -0,0 +1,40 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.impl.autogen.OptionFactoryRegistry;
+
+import java.lang.annotation.Annotation;
+
+/**
+ * The backing builder for option factories' annotations.
+ * <p>
+ * If you want to make a basic option with a controller, it's recommended
+ * to use {@link SimpleOptionFactory} instead which is a subclass of this.
+ *
+ * @param <A> the annotation type
+ * @param <T> the option's binding type
+ */
+public interface OptionFactory<A extends Annotation, T> {
+ /**
+ * Creates an option from the given annotation, backing field, and storage.
+ *
+ * @param annotation the annotation that fields are annotated with to use this factory
+ * @param field the backing field
+ * @param optionAccess the option access to access other options in the GUI
+ * @return the built option to be added to the group/category
+ */
+ Option<T> createOption(A annotation, ConfigField<T> field, OptionAccess optionAccess);
+
+ /**
+ * Registers an option factory to be used by configs.
+ *
+ * @param annotationClass the class of the annotation to use a factory
+ * @param factory an instance of the factory
+ * @param <A> the type of the annotation
+ * @param <T> the type of the option's binding
+ */
+ static <A extends Annotation, T> void register(Class<A> annotationClass, OptionFactory<A, T> factory) {
+ OptionFactoryRegistry.registerOptionFactory(annotationClass, factory);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/SimpleOptionFactory.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/SimpleOptionFactory.java
new file mode 100644
index 0000000..f7d807f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/SimpleOptionFactory.java
@@ -0,0 +1,138 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.OptionDescription;
+import dev.isxander.yacl3.api.OptionFlag;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.impl.FieldBackedBinding;
+import dev.isxander.yacl3.config.v2.impl.autogen.AutoGenUtils;
+import dev.isxander.yacl3.config.v2.impl.autogen.EmptyCustomImageFactory;
+import dev.isxander.yacl3.config.v2.impl.autogen.YACLAutoGenException;
+import net.minecraft.client.Minecraft;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.resources.ResourceLocation;
+import org.jetbrains.annotations.Nullable;
+
+import java.lang.annotation.Annotation;
+import java.util.Optional;
+import java.util.Set;
+
+public abstract class SimpleOptionFactory<A extends Annotation, T> implements OptionFactory<A, T> {
+ @Override
+ public Option<T> createOption(A annotation, ConfigField<T> field, OptionAccess optionAccess) {
+ Option<T> option = Option.<T>createBuilder()
+ .name(this.name(annotation, field, optionAccess))
+ .description(v -> this.description(v, annotation, field, optionAccess).build())
+ .binding(new FieldBackedBinding<>(field.access(), field.defaultAccess()))
+ .controller(opt -> {
+ ControllerBuilder<T> builder = this.createController(annotation, field, optionAccess, opt);
+
+ AutoGenUtils.addCustomFormatterToController(builder, field.access());
+
+ return builder;
+ })
+ .available(this.available(annotation, field, optionAccess))
+ .flags(this.flags(annotation, field, optionAccess))
+ .listener((opt, v) -> this.listener(annotation, field, optionAccess, opt, v))
+ .build();
+
+ postInit(annotation, field, optionAccess, option);
+ return option;
+ }
+
+ protected abstract ControllerBuilder<T> createController(A annotation, ConfigField<T> field, OptionAccess storage, Option<T> option);
+
+ protected MutableComponent name(A annotation, ConfigField<T> field, OptionAccess storage) {
+ Optional<CustomName> customName = field.access().getAnnotation(CustomName.class);
+ return Component.translatable(customName.map(CustomName::value).orElse(this.getTranslationKey(field, null)));
+ }
+
+ protected OptionDescription.Builder description(T value, A annotation, ConfigField<T> field, OptionAccess storage) {
+ OptionDescription.Builder builder = OptionDescription.createBuilder();
+
+ String key = this.getTranslationKey(field, "desc");
+ if (Language.getInstance().has(key)) {
+ builder.text(Component.translatable(key));
+ } else {
+ key += ".";
+ int i = 1;
+ while (Language.getInstance().has(key + i)) {
+ builder.text(Component.translatable(key + i));
+ i++;
+ }
+ }
+
+ field.access().getAnnotation(CustomDescription.class).ifPresent(customDescription -> {
+ for (String line : customDescription.value()) {
+ builder.text(Component.translatable(line));
+ }
+ });
+
+ Optional<CustomImage> imageOverrideOpt = field.access().getAnnotation(CustomImage.class);
+ if (imageOverrideOpt.isPresent()) {
+ CustomImage imageOverride = imageOverrideOpt.get();
+
+ if (!imageOverride.factory().equals(EmptyCustomImageFactory.class)) {
+ CustomImage.CustomImageFactory<T> imageFactory;
+ try {
+ imageFactory = (CustomImage.CustomImageFactory<T>) AutoGenUtils.constructNoArgsClass(
+ imageOverride.factory(),
+ () -> "'%s': The factory class on @OverrideImage has no no-args constructor.".formatted(field.access().name()),
+ () -> "'%s': Failed to instantiate factory class %s.".formatted(field.access().name(), imageOverride.factory().getName())
+ );
+ } catch (ClassCastException e) {
+ throw new YACLAutoGenException("'%s': The factory class on @OverrideImage is of incorrect type. Expected %s, got %s.".formatted(field.access().name(), field.access().type().getTypeName(), imageOverride.factory().getTypeParameters()[0].getName()));
+ }
+
+ builder.customImage(imageFactory.createImage(value, field, storage).thenApply(Optional::of));
+ } else if (!imageOverride.value().isEmpty()) {
+ String path = imageOverride.value();
+ ResourceLocation imageLocation = new ResourceLocation(field.parent().id().getNamespace(), path);
+ String extension = path.substring(path.lastIndexOf('.') + 1);
+
+ switch (extension) {
+ case "png", "jpg", "jpeg" -> builder.image(imageLocation, imageOverride.width(), imageOverride.height());
+ case "webp" -> builder.webpImage(imageLocation);
+ case "gif" -> builder.gifImage(imageLocation);
+ default -> throw new YACLAutoGenException("'%s': Invalid image extension '%s' on @OverrideImage. Expected: ('png','jpg','webp','gif')".formatted(field.access().name(), extension));
+ }
+ } else {
+ throw new YACLAutoGenException("'%s': @OverrideImage has no value or factory class.".formatted(field.access().name()));
+ }
+ } else {
+ String imagePath = "textures/yacl3/" + field.parent().id().getPath() + "/" + field.access().name() + ".webp";
+ imagePath = imagePath.toLowerCase().replaceAll("[^a-z0-9/._:-]", "_");
+ ResourceLocation imageLocation = new ResourceLocation(field.parent().id().getNamespace(), imagePath);
+ if (Minecraft.getInstance().getResourceManager().getResource(imageLocation).isPresent()) {
+ builder.webpImage(imageLocation);
+ }
+ }
+
+ return builder;
+ }
+
+ protected boolean available(A annotation, ConfigField<T> field, OptionAccess storage) {
+ return true;
+ }
+
+ protected Set<OptionFlag> flags(A annotation, ConfigField<T> field, OptionAccess storage) {
+ return Set.of();
+ }
+
+ protected void listener(A annotation, ConfigField<T> field, OptionAccess storage, Option<T> option, T value) {
+
+ }
+
+ protected void postInit(A annotation, ConfigField<T> field, OptionAccess storage, Option<T> option) {
+
+ }
+
+ protected String getTranslationKey(ConfigField<T> field, @Nullable String suffix) {
+ String key = "yacl3.config.%s.%s".formatted(field.parent().id().toString(), field.access().name());
+ if (suffix != null) key += "." + suffix;
+ return key;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/StringField.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/StringField.java
new file mode 100644
index 0000000..50d638e
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/StringField.java
@@ -0,0 +1,17 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * A regular option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.StringControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface StringField {
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/TickBox.java b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/TickBox.java
new file mode 100644
index 0000000..0a88c14
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/TickBox.java
@@ -0,0 +1,17 @@
+package dev.isxander.yacl3.config.v2.api.autogen;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * An option factory.
+ * <p>
+ * This creates a regular option with a
+ * {@link dev.isxander.yacl3.api.controller.TickBoxControllerBuilder} controller.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface TickBox {
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/api/serializer/GsonConfigSerializerBuilder.java b/src/main/java/dev/isxander/yacl3/config/v2/api/serializer/GsonConfigSerializerBuilder.java
new file mode 100644
index 0000000..33003d7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/api/serializer/GsonConfigSerializerBuilder.java
@@ -0,0 +1,98 @@
+package dev.isxander.yacl3.config.v2.api.serializer;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import dev.isxander.yacl3.config.ConfigEntry;
+import dev.isxander.yacl3.config.v2.api.ConfigClassHandler;
+import dev.isxander.yacl3.config.v2.api.ConfigSerializer;
+import dev.isxander.yacl3.config.v2.api.SerialEntry;
+import dev.isxander.yacl3.config.v2.impl.serializer.GsonConfigSerializer;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.Style;
+
+import java.awt.*;
+import java.nio.file.Path;
+import java.util.function.UnaryOperator;
+
+/**
+ * Uses GSON to serialize and deserialize config data from JSON to a file.
+ * <p>
+ * Only fields annotated with {@link dev.isxander.yacl3.config.v2.api.SerialEntry} are included in the JSON.
+ * {@link Component}, {@link Style} and {@link Color} have default type adapters, so there is no need to provide them in your GSON instance.
+ * GSON is automatically configured to format fields as {@code lower_camel_case}.
+ * <p>
+ * Optionally, this can also be written under JSON5 spec, allowing comments.
+ *
+ * @param <T> config data type
+ */
+public interface GsonConfigSerializerBuilder<T> {
+ static <T> GsonConfigSerializerBuilder<T> create(ConfigClassHandler<T> config) {
+ return new GsonConfigSerializer.Builder<>(config);
+ }
+
+ /**
+ * Sets the file path to save and load the config from.
+ */
+ GsonConfigSerializerBuilder<T> setPath(Path path);
+
+ /**
+ * Sets the GSON instance to use. Overrides all YACL defaults such as:
+ * <ul>
+ * <li>lower_camel_case field naming policy</li>
+ * <li>null serialization</li>
+ * <li>{@link Component}, {@link Style} and {@link Color} type adapters</li>
+ * </ul>
+ * Still respects the exclusion strategy to only serialize {@link ConfigEntry}
+ * but these can be added to with setExclusionStrategies.
+ *
+ * @param gsonBuilder gson builder to use
+ */
+ GsonConfigSerializerBuilder<T> overrideGsonBuilder(GsonBuilder gsonBuilder);
+
+ /**
+ * Sets the GSON instance to use. Overrides all YACL defaults such as:
+ * <ul>
+ * <li>lower_camel_case field naming policy</li>
+ * <li>null serialization</li>
+ * <li>{@link Component}, {@link Style} and {@link Color} type adapters</li>
+ * </ul>
+ * but these can be added to with setExclusionStrategies.
+ *
+ * @param gson gson instance to be converted to a builder
+ */
+ GsonConfigSerializerBuilder<T> overrideGsonBuilder(Gson gson);
+
+ /**
+ * Appends extra configuration to a GSON builder.
+ * This is the intended way to add functionality to the GSON instance.
+ * <p>
+ * By default, YACL sets the GSON with the following options:
+ * <ul>
+ * <li>lower_camel_case field naming policy</li>
+ * <li>null serialization</li>
+ * <li>{@link Component}, {@link Style} and {@link Color} type adapters</li>
+ * </ul>
+ * For example, if you wanted to revert YACL's lower_camel_case naming policy,
+ * you could do the following:
+ * <pre>
+ * {@code
+ * GsonConfigSerializerBuilder.create(config)
+ * .appendGsonBuilder(builder -> builder.setFieldNamingPolicy(FieldNamingPolicy.IDENTITY))
+ * }
+ * </pre>
+ *
+ * @param gsonBuilder the function to apply to the builder
+ */
+ GsonConfigSerializerBuilder<T> appendGsonBuilder(UnaryOperator<GsonBuilder> gsonBuilder);
+
+ /**
+ * Writes the json under JSON5 spec, allowing the use of {@link SerialEntry#comment()}.
+ * If enabling this option it's recommended to use the file extension {@code .json5}.
+ *
+ * @param json5 whether to write under JSON5 spec
+ * @return this builder
+ */
+ GsonConfigSerializerBuilder<T> setJson5(boolean json5);
+
+ ConfigSerializer<T> build();
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigClassHandlerImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigClassHandlerImpl.java
new file mode 100644
index 0000000..813b3ab
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigClassHandlerImpl.java
@@ -0,0 +1,274 @@
+package dev.isxander.yacl3.config.v2.impl;
+
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.config.ConfigEntry;
+import dev.isxander.yacl3.config.v2.api.*;
+import dev.isxander.yacl3.config.v2.api.autogen.AutoGen;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.impl.autogen.OptionFactoryRegistry;
+import dev.isxander.yacl3.config.v2.impl.autogen.OptionAccessImpl;
+import dev.isxander.yacl3.config.v2.impl.autogen.YACLAutoGenException;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import dev.isxander.yacl3.platform.YACLPlatform;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import org.apache.commons.lang3.Validate;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.util.AbstractMap;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public class ConfigClassHandlerImpl<T> implements ConfigClassHandler<T> {
+ private final Class<T> configClass;
+ private final ResourceLocation id;
+ private final boolean supportsAutoGen;
+ private final ConfigSerializer<T> serializer;
+ private final ConfigFieldImpl<?>[] fields;
+
+ private T instance;
+ private final T defaults;
+ private final Constructor<T> noArgsConstructor;
+
+ public ConfigClassHandlerImpl(Class<T> configClass, ResourceLocation id, Function<ConfigClassHandler<T>, ConfigSerializer<T>> serializerFactory) {
+ this.configClass = configClass;
+ this.id = id;
+ this.supportsAutoGen = id != null && YACLPlatform.getEnvironment().isClient();
+
+ try {
+ noArgsConstructor = configClass.getDeclaredConstructor();
+ } catch (NoSuchMethodException e) {
+ throw new YACLAutoGenException("Failed to find no-args constructor for config class %s.".formatted(configClass.getName()), e);
+ }
+ this.instance = createNewObject();
+ this.defaults = createNewObject();
+
+ detectOldAnnotation(configClass.getDeclaredFields());
+
+ this.fields = discoverFields();
+ this.serializer = serializerFactory.apply(this);
+ }
+
+ private ConfigFieldImpl<?>[] discoverFields() {
+ return Arrays.stream(configClass.getDeclaredFields())
+ .peek(field -> field.setAccessible(true))
+ .filter(field -> field.isAnnotationPresent(SerialEntry.class) || field.isAnnotationPresent(AutoGen.class))
+ .map(field -> new ConfigFieldImpl<>(
+ new ReflectionFieldAccess<>(field, instance),
+ new ReflectionFieldAccess<>(field, defaults),
+ this,
+ field.getAnnotation(SerialEntry.class),
+ field.getAnnotation(AutoGen.class)
+ ))
+ .toArray(ConfigFieldImpl[]::new);
+ }
+
+ @Override
+ public T instance() {
+ return this.instance;
+ }
+
+ @Override
+ public T defaults() {
+ return this.defaults;
+ }
+
+ @Override
+ public Class<T> configClass() {
+ return this.configClass;
+ }
+
+ @Override
+ public ConfigFieldImpl<?>[] fields() {
+ return this.fields;
+ }
+
+ @Override
+ public ResourceLocation id() {
+ return this.id;
+ }
+
+ @Override
+ public boolean supportsAutoGen() {
+ return this.supportsAutoGen;
+ }
+
+ @Override
+ public YetAnotherConfigLib generateGui() {
+ if (!supportsAutoGen()) {
+ throw new YACLAutoGenException("Auto GUI generation is not supported for this config class. You either need to enable it in the builder or you are attempting to create a GUI in a dedicated server environment.");
+ }
+
+ boolean hasAutoGenFields = Arrays.stream(fields()).anyMatch(field -> field.autoGen().isPresent());
+
+ if (!hasAutoGenFields) {
+ throw new YACLAutoGenException("No fields in this config class are annotated with @AutoGen. You must annotate at least one field with @AutoGen to generate a GUI.");
+ }
+
+ OptionAccessImpl storage = new OptionAccessImpl();
+ Map<String, CategoryAndGroups> categories = new LinkedHashMap<>();
+ for (ConfigField<?> configField : fields()) {
+ configField.autoGen().ifPresent(autoGen -> {
+ CategoryAndGroups groups = categories.computeIfAbsent(
+ autoGen.category(),
+ k -> new CategoryAndGroups(
+ ConfigCategory.createBuilder()
+ .name(Component.translatable("yacl3.config.%s.category.%s".formatted(id().toString(), k))),
+ new LinkedHashMap<>()
+ )
+ );
+ OptionAddable group = groups.groups().computeIfAbsent(autoGen.group().orElse(""), k -> {
+ if (k.isEmpty())
+ return groups.category();
+ return OptionGroup.createBuilder()
+ .name(Component.translatable("yacl3.config.%s.category.%s.group.%s".formatted(id().toString(), autoGen.category(), k)));
+ });
+
+ Option<?> option;
+ try {
+ option = createOption(configField, storage);
+ } catch (Exception e) {
+ throw new YACLAutoGenException("Failed to create option for field '%s'".formatted(configField.access().name()), e);
+ }
+
+ storage.putOption(configField.access().name(), option);
+ group.option(option);
+ });
+ }
+ storage.checkBadOperations();
+ categories.values().forEach(CategoryAndGroups::finaliseGroups);
+
+ YetAnotherConfigLib.Builder yaclBuilder = YetAnotherConfigLib.createBuilder()
+ .save(this.serializer()::save)
+ .title(Component.translatable("yacl3.config.%s.title".formatted(this.id().toString())));
+ categories.values().forEach(category -> yaclBuilder.category(category.category().build()));
+
+ return yaclBuilder.build();
+ }
+
+ private <U> Option<U> createOption(ConfigField<U> configField, OptionAccess storage) {
+ return OptionFactoryRegistry.createOption(((ReflectionFieldAccess<?>) configField.access()).field(), configField, storage)
+ .orElseThrow(() -> new YACLAutoGenException("Failed to create option for field %s".formatted(configField.access().name())));
+ }
+
+ @Override
+ public ConfigSerializer<T> serializer() {
+ return this.serializer;
+ }
+
+ @Override
+ public boolean load() {
+ // create a new instance to load into
+ T newInstance = createNewObject();
+
+ // create field accesses for the new object
+ Map<ConfigFieldImpl<?>, ReflectionFieldAccess<?>> accessBufferImpl = Arrays.stream(fields())
+ .map(field -> new AbstractMap.SimpleImmutableEntry<>(
+ field,
+ new ReflectionFieldAccess<>(field.access().field(), newInstance)
+ ))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ // convert the map into API safe field accesses
+ Map<ConfigField<?>, FieldAccess<?>> accessBuffer = accessBufferImpl.entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+ // attempt to load the config
+ ConfigSerializer.LoadResult loadResult = ConfigSerializer.LoadResult.FAILURE;
+ Throwable error = null;
+ try {
+ loadResult = this.serializer().loadSafely(accessBuffer);
+ } catch (Throwable e) {
+ // handle any errors later in the loadResult switch case
+ error = e;
+ }
+
+ switch (loadResult) {
+ case DIRTY:
+ case SUCCESS:
+ // replace the instance with the newly created one
+ this.instance = newInstance;
+ for (ConfigFieldImpl<?> field : fields()) {
+ // update the field accesses to point to the correct object
+ ((ConfigFieldImpl<Object>) field).setFieldAccess((ReflectionFieldAccess<Object>) accessBufferImpl.get(field));
+ }
+
+ if (loadResult == ConfigSerializer.LoadResult.DIRTY) {
+ // if the load result is dirty, we need to save the config again
+ this.save();
+ }
+ case NO_CHANGE:
+ return true;
+ case FAILURE:
+ YACLConstants.LOGGER.error(
+ "Unsuccessful load of config class '{}'. The load will be abandoned and config remains unchanged.",
+ configClass.getSimpleName(), error
+ );
+ }
+
+ return false;
+ }
+
+ @Override
+ public void save() {
+ serializer().save();
+ }
+
+ private T createNewObject() {
+ try {
+ return noArgsConstructor.newInstance();
+ } catch (Exception e) {
+ throw new YACLAutoGenException("Failed to create instance of config class '%s' with no-args constructor.".formatted(configClass.getName()), e);
+ }
+ }
+
+ private void detectOldAnnotation(Field[] fields) {
+ boolean hasOldConfigEntry = Arrays.stream(fields)
+ .anyMatch(field -> field.isAnnotationPresent(ConfigEntry.class));
+
+ Validate.isTrue(!hasOldConfigEntry, "At least one field in %s is still annotated with the deprecated @ConfigEntry annotation. This is incorrect. Use @SerialEntry.".formatted(configClass.getName()));
+ }
+
+ public static class BuilderImpl<T> implements Builder<T> {
+ private final Class<T> configClass;
+ private ResourceLocation id;
+ private Function<ConfigClassHandler<T>, ConfigSerializer<T>> serializerFactory;
+
+ public BuilderImpl(Class<T> configClass) {
+ this.configClass = configClass;
+ }
+
+ @Override
+ public Builder<T> id(ResourceLocation id) {
+ this.id = id;
+ return this;
+ }
+
+ @Override
+ public Builder<T> serializer(Function<ConfigClassHandler<T>, ConfigSerializer<T>> serializerFactory) {
+ this.serializerFactory = serializerFactory;
+ return this;
+ }
+
+ @Override
+ public ConfigClassHandler<T> build() {
+ Validate.notNull(serializerFactory, "serializerFactory must not be null");
+ Validate.notNull(configClass, "configClass must not be null");
+
+ return new ConfigClassHandlerImpl<>(configClass, id, serializerFactory);
+ }
+ }
+
+ private record CategoryAndGroups(ConfigCategory.Builder category, Map<String, OptionAddable> groups) {
+ private void finaliseGroups() {
+ groups.forEach((name, group) -> {
+ if (group instanceof OptionGroup.Builder groupBuilder) {
+ category.group(groupBuilder.build());
+ }
+ });
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigFieldImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigFieldImpl.java
new file mode 100644
index 0000000..aeed5ac
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigFieldImpl.java
@@ -0,0 +1,75 @@
+package dev.isxander.yacl3.config.v2.impl;
+
+import dev.isxander.yacl3.config.v2.api.*;
+import dev.isxander.yacl3.config.v2.api.autogen.AutoGen;
+import dev.isxander.yacl3.config.v2.api.autogen.AutoGenField;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Optional;
+
+public class ConfigFieldImpl<T> implements ConfigField<T> {
+ private ReflectionFieldAccess<T> field;
+ private final ReflectionFieldAccess<T> defaultField;
+ private final ConfigClassHandler<?> parent;
+ private final Optional<SerialField> serial;
+ private final Optional<AutoGenField> autoGen;
+
+ public ConfigFieldImpl(ReflectionFieldAccess<T> field, ReflectionFieldAccess<T> defaultField, ConfigClassHandler<?> parent, @Nullable SerialEntry config, @Nullable AutoGen autoGen) {
+ this.field = field;
+ this.defaultField = defaultField;
+ this.parent = parent;
+
+ this.serial = config != null
+ ? Optional.of(
+ new SerialFieldImpl(
+ "".equals(config.value()) ? field.name() : config.value(),
+ "".equals(config.comment()) ? Optional.empty() : Optional.of(config.comment()),
+ config.required(),
+ config.nullable()
+ )
+ )
+ : Optional.empty();
+ this.autoGen = autoGen != null
+ ? Optional.of(
+ new AutoGenFieldImpl<>(
+ autoGen.category(),
+ "".equals(autoGen.group()) ? Optional.empty() : Optional.of(autoGen.group())
+ )
+ )
+ : Optional.empty();
+ }
+
+ @Override
+ public ReflectionFieldAccess<T> access() {
+ return field;
+ }
+
+ public void setFieldAccess(ReflectionFieldAccess<T> field) {
+ this.field = field;
+ }
+
+ @Override
+ public ReflectionFieldAccess<T> defaultAccess() {
+ return defaultField;
+ }
+
+ @Override
+ public ConfigClassHandler<?> parent() {
+ return parent;
+ }
+
+ @Override
+ public Optional<SerialField> serial() {
+ return this.serial;
+ }
+
+ @Override
+ public Optional<AutoGenField> autoGen() {
+ return this.autoGen;
+ }
+
+ private record SerialFieldImpl(String serialName, Optional<String> comment, boolean required, boolean nullable) implements SerialField {
+ }
+ private record AutoGenFieldImpl<T>(String category, Optional<String> group) implements AutoGenField {
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/FieldBackedBinding.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/FieldBackedBinding.java
new file mode 100644
index 0000000..f2f36e7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/FieldBackedBinding.java
@@ -0,0 +1,22 @@
+package dev.isxander.yacl3.config.v2.impl;
+
+import dev.isxander.yacl3.api.Binding;
+import dev.isxander.yacl3.config.v2.api.FieldAccess;
+import dev.isxander.yacl3.config.v2.api.ReadOnlyFieldAccess;
+
+public record FieldBackedBinding<T>(FieldAccess<T> field, ReadOnlyFieldAccess<T> defaultField) implements Binding<T> {
+ @Override
+ public T getValue() {
+ return field.get();
+ }
+
+ @Override
+ public void setValue(T value) {
+ field.set(value);
+ }
+
+ @Override
+ public T defaultValue() {
+ return defaultField.get();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/ReflectionFieldAccess.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/ReflectionFieldAccess.java
new file mode 100644
index 0000000..e102344
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/ReflectionFieldAccess.java
@@ -0,0 +1,49 @@
+package dev.isxander.yacl3.config.v2.impl;
+
+import dev.isxander.yacl3.config.v2.api.FieldAccess;
+import dev.isxander.yacl3.config.v2.impl.autogen.YACLAutoGenException;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Type;
+import java.util.Optional;
+
+public record ReflectionFieldAccess<T>(Field field, Object instance) implements FieldAccess<T> {
+ @Override
+ public T get() {
+ try {
+ return (T) field.get(instance);
+ } catch (IllegalAccessException e) {
+ throw new YACLAutoGenException("Failed to access field '%s'".formatted(name()), e);
+ }
+ }
+
+ @Override
+ public void set(T value) {
+ try {
+ field.set(instance, value);
+ } catch (IllegalAccessException e) {
+ throw new YACLAutoGenException("Failed to set field '%s'".formatted(name()), e);
+ }
+ }
+
+ @Override
+ public String name() {
+ return field.getName();
+ }
+
+ @Override
+ public Type type() {
+ return field.getGenericType();
+ }
+
+ @Override
+ public Class<T> typeClass() {
+ return (Class<T>) field.getType();
+ }
+
+ @Override
+ public <A extends Annotation> Optional<A> getAnnotation(Class<A> annotationClass) {
+ return Optional.ofNullable(field.getAnnotation(annotationClass));
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/AutoGenUtils.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/AutoGenUtils.java
new file mode 100644
index 0000000..6f614c1
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/AutoGenUtils.java
@@ -0,0 +1,54 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormattableController;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.config.v2.api.ReadOnlyFieldAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.CustomFormat;
+import dev.isxander.yacl3.config.v2.api.autogen.FormatTranslation;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.Optional;
+import java.util.function.Supplier;
+
+@ApiStatus.Internal
+public final class AutoGenUtils {
+ public static <T> void addCustomFormatterToController(ControllerBuilder<T> controller, ReadOnlyFieldAccess<T> field) {
+ Optional<CustomFormat> formatter = field.getAnnotation(CustomFormat.class);
+ Optional<FormatTranslation> translation = field.getAnnotation(FormatTranslation.class);
+
+ if (formatter.isPresent() && translation.isPresent()) {
+ throw new YACLAutoGenException("'%s': Cannot use both @CustomFormatter and @FormatTranslation on the same field.".formatted(field.name()));
+ } else if (formatter.isEmpty() && translation.isEmpty()) {
+ return;
+ }
+
+ if (!(controller instanceof ValueFormattableController<?, ?>)) {
+ throw new YACLAutoGenException("Attempted to use @CustomFormatter or @FormatTranslation on an option factory for field '%s' that uses a controller that does not support this.".formatted(field.name()));
+ }
+
+ ValueFormattableController<T, ?> typedBuilder = (ValueFormattableController<T, ?>) controller;
+
+ formatter.ifPresent(formatterClass -> {
+ try {
+ typedBuilder.formatValue((ValueFormatter<T>) formatterClass.value().getConstructor().newInstance());
+ } catch (Exception e) {
+ throw new YACLAutoGenException("'%s': Failed to instantiate formatter class %s.".formatted(field.name(), formatterClass.value().getName()), e);
+ }
+ });
+
+ translation.ifPresent(annotation ->
+ typedBuilder.formatValue(v -> Component.translatable(annotation.value(), v)));
+ }
+
+ public static <T> T constructNoArgsClass(Class<T> clazz, Supplier<String> constructorNotFoundConsumer, Supplier<String> constructorFailedConsumer) {
+ try {
+ return clazz.getConstructor().newInstance();
+ } catch (NoSuchMethodException e) {
+ throw new YACLAutoGenException(constructorNotFoundConsumer.get(), e);
+ } catch (Exception e) {
+ throw new YACLAutoGenException(constructorFailedConsumer.get(), e);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/BooleanImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/BooleanImpl.java
new file mode 100644
index 0000000..b41836a
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/BooleanImpl.java
@@ -0,0 +1,25 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.BooleanControllerBuilder;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.Boolean;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import net.minecraft.network.chat.Component;
+
+public class BooleanImpl extends SimpleOptionFactory<Boolean, java.lang.Boolean> {
+ @Override
+ protected ControllerBuilder<java.lang.Boolean> createController(Boolean annotation, ConfigField<java.lang.Boolean> field, OptionAccess storage, Option<java.lang.Boolean> option) {
+ var builder = BooleanControllerBuilder.create(option)
+ .coloured(annotation.colored());
+ switch (annotation.formatter()) {
+ case ON_OFF -> builder.onOffFormatter();
+ case YES_NO -> builder.yesNoFormatter();
+ case TRUE_FALSE -> builder.trueFalseFormatter();
+ case CUSTOM -> builder.formatValue(v -> Component.translatable(getTranslationKey(field, "fmt." + v)));
+ }
+ return builder;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ColorFieldImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ColorFieldImpl.java
new file mode 100644
index 0000000..7910c59
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ColorFieldImpl.java
@@ -0,0 +1,19 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ColorControllerBuilder;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.ColorField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+
+import java.awt.Color;
+
+public class ColorFieldImpl extends SimpleOptionFactory<ColorField, Color> {
+ @Override
+ protected ControllerBuilder<Color> createController(ColorField annotation, ConfigField<Color> field, OptionAccess storage, Option<Color> option) {
+ return ColorControllerBuilder.create(option)
+ .allowAlpha(annotation.allowAlpha());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DoubleFieldImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DoubleFieldImpl.java
new file mode 100644
index 0000000..6445141
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DoubleFieldImpl.java
@@ -0,0 +1,32 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.DoubleFieldControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.DoubleField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+
+public class DoubleFieldImpl extends SimpleOptionFactory<DoubleField, Double> {
+ @Override
+ protected ControllerBuilder<Double> createController(DoubleField annotation, ConfigField<Double> field, OptionAccess storage, Option<Double> option) {
+ return DoubleFieldControllerBuilder.create(option)
+ .formatValue(v -> {
+ String key = null;
+ if (v == annotation.min())
+ key = getTranslationKey(field, "fmt.min");
+ else if (v == annotation.max())
+ key = getTranslationKey(field, "fmt.max");
+ if (key != null && Language.getInstance().has(key))
+ return Component.translatable(key);
+ key = getTranslationKey(field, "fmt");
+ if (Language.getInstance().has(key))
+ return Component.translatable(key, v);
+ return Component.translatable(String.format(annotation.format(), v));
+ })
+ .range(annotation.min(), annotation.max());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DoubleSliderImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DoubleSliderImpl.java
new file mode 100644
index 0000000..e6dd05d
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DoubleSliderImpl.java
@@ -0,0 +1,33 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.DoubleSliderControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.DoubleSlider;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+
+public class DoubleSliderImpl extends SimpleOptionFactory<DoubleSlider, Double> {
+ @Override
+ protected ControllerBuilder<Double> createController(DoubleSlider annotation, ConfigField<Double> field, OptionAccess storage, Option<Double> option) {
+ return DoubleSliderControllerBuilder.create(option)
+ .formatValue(v -> {
+ String key = null;
+ if (v == annotation.min())
+ key = getTranslationKey(field, "fmt.min");
+ else if (v == annotation.max())
+ key = getTranslationKey(field, "fmt.max");
+ if (key != null && Language.getInstance().has(key))
+ return Component.translatable(key);
+ key = getTranslationKey(field, "fmt");
+ if (Language.getInstance().has(key))
+ return Component.translatable(key, v);
+ return Component.translatable(String.format(annotation.format(), v));
+ })
+ .range(annotation.min(), annotation.max())
+ .step(annotation.step());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DropdownImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DropdownImpl.java
new file mode 100644
index 0000000..c487aab
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DropdownImpl.java
@@ -0,0 +1,19 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.DropdownStringControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.Dropdown;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+
+public class DropdownImpl extends SimpleOptionFactory<Dropdown, String> {
+ @Override
+ protected ControllerBuilder<String> createController(Dropdown annotation, ConfigField<String> field, OptionAccess storage, Option<String> option) {
+ return DropdownStringControllerBuilder.create(option)
+ .values(annotation.values())
+ .allowEmptyValue(annotation.allowEmptyValue())
+ .allowAnyValue(annotation.allowAnyValue());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EmptyCustomImageFactory.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EmptyCustomImageFactory.java
new file mode 100644
index 0000000..1500864
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EmptyCustomImageFactory.java
@@ -0,0 +1,17 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.CustomImage;
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+public class EmptyCustomImageFactory implements CustomImage.CustomImageFactory<Object> {
+
+ @Override
+ public CompletableFuture<ImageRenderer> createImage(Object value, ConfigField<Object> field, OptionAccess access) {
+ throw new IllegalStateException();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EnumCyclerImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EnumCyclerImpl.java
new file mode 100644
index 0000000..f15d862
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/EnumCyclerImpl.java
@@ -0,0 +1,42 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.NameableEnum;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.CyclingListControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.EnumCycler;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import net.minecraft.network.chat.Component;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.IntStream;
+
+public class EnumCyclerImpl extends SimpleOptionFactory<EnumCycler, Enum<?>> {
+ @Override
+ protected ControllerBuilder<Enum<?>> createController(EnumCycler annotation, ConfigField<Enum<?>> field, OptionAccess storage, Option<Enum<?>> option) {
+ List<? extends Enum<?>> values;
+
+ if (option.pendingValue() instanceof EnumCycler.CyclableEnum<?> cyclableEnum) {
+ values = Arrays.asList(cyclableEnum.allowedValues());
+ } else {
+ Enum<?>[] constants = field.access().typeClass().getEnumConstants();
+ values = IntStream.range(0, constants.length)
+ .filter(ordinal -> annotation.allowedOrdinals().length == 0 || Arrays.stream(annotation.allowedOrdinals()).noneMatch(allowed -> allowed == ordinal))
+ .mapToObj(ordinal -> constants[ordinal])
+ .toList();
+ }
+
+ // EnumController doesn't support filtering
+ var builder = CyclingListControllerBuilder.create(option)
+ .values(values);
+ if (NameableEnum.class.isAssignableFrom(field.access().typeClass())) {
+ builder.formatValue(v -> ((NameableEnum) v).getDisplayName());
+ } else {
+ builder.formatValue(v -> Component.translatable("yacl3.config.enum.%s.%s".formatted(field.access().typeClass().getSimpleName(), v.name().toLowerCase())));
+ }
+ return builder;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/FloatFieldImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/FloatFieldImpl.java
new file mode 100644
index 0000000..acdabd6
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/FloatFieldImpl.java
@@ -0,0 +1,32 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.FloatFieldControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.FloatField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+
+public class FloatFieldImpl extends SimpleOptionFactory<FloatField, Float> {
+ @Override
+ protected ControllerBuilder<Float> createController(FloatField annotation, ConfigField<Float> field, OptionAccess storage, Option<Float> option) {
+ return FloatFieldControllerBuilder.create(option)
+ .formatValue(v -> {
+ String key = null;
+ if (v == annotation.min())
+ key = getTranslationKey(field, "fmt.min");
+ else if (v == annotation.max())
+ key = getTranslationKey(field, "fmt.max");
+ if (key != null && Language.getInstance().has(key))
+ return Component.translatable(key);
+ key = getTranslationKey(field, "fmt");
+ if (Language.getInstance().has(key))
+ return Component.translatable(key, v);
+ return Component.translatable(String.format(annotation.format(), v));
+ })
+ .range(annotation.min(), annotation.max());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/FloatSliderImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/FloatSliderImpl.java
new file mode 100644
index 0000000..f22302f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/FloatSliderImpl.java
@@ -0,0 +1,33 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.FloatSliderControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.FloatSlider;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+
+public class FloatSliderImpl extends SimpleOptionFactory<FloatSlider, Float> {
+ @Override
+ protected ControllerBuilder<Float> createController(FloatSlider annotation, ConfigField<Float> field, OptionAccess storage, Option<Float> option) {
+ return FloatSliderControllerBuilder.create(option)
+ .formatValue(v -> {
+ String key = null;
+ if (v == annotation.min())
+ key = getTranslationKey(field, "fmt.min");
+ else if (v == annotation.max())
+ key = getTranslationKey(field, "fmt.max");
+ if (key != null && Language.getInstance().has(key))
+ return Component.translatable(key);
+ key = getTranslationKey(field, "fmt");
+ if (Language.getInstance().has(key))
+ return Component.translatable(key, v);
+ return Component.translatable(String.format(annotation.format(), v));
+ })
+ .range(annotation.min(), annotation.max())
+ .step(annotation.step());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/IntFieldImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/IntFieldImpl.java
new file mode 100644
index 0000000..a3b759a
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/IntFieldImpl.java
@@ -0,0 +1,28 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.IntegerFieldControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.IntField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+
+public class IntFieldImpl extends SimpleOptionFactory<IntField, Integer> {
+ @Override
+ protected ControllerBuilder<Integer> createController(IntField annotation, ConfigField<Integer> field, OptionAccess storage, Option<Integer> option) {
+ return IntegerFieldControllerBuilder.create(option)
+ .formatValue(v -> {
+ String key = getTranslationKey(field, "fmt." + v);
+ if (Language.getInstance().has(key))
+ return Component.translatable(key);
+ key = getTranslationKey(field, "fmt");
+ if (Language.getInstance().has(key))
+ return Component.translatable(key, v);
+ return Component.literal(Integer.toString(v));
+ })
+ .range(annotation.min(), annotation.max());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/IntSliderImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/IntSliderImpl.java
new file mode 100644
index 0000000..b570b44
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/IntSliderImpl.java
@@ -0,0 +1,29 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.IntegerSliderControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.IntSlider;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+
+public class IntSliderImpl extends SimpleOptionFactory<IntSlider, Integer> {
+ @Override
+ protected ControllerBuilder<Integer> createController(IntSlider annotation, ConfigField<Integer> field, OptionAccess storage, Option<Integer> option) {
+ return IntegerSliderControllerBuilder.create(option)
+ .formatValue(v -> {
+ String key = getTranslationKey(field, "fmt." + v);
+ if (Language.getInstance().has(key))
+ return Component.translatable(key);
+ key = getTranslationKey(field, "fmt");
+ if (Language.getInstance().has(key))
+ return Component.translatable(key, v);
+ return Component.literal(Integer.toString(v));
+ })
+ .range(annotation.min(), annotation.max())
+ .step(annotation.step());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ItemFieldImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ItemFieldImpl.java
new file mode 100644
index 0000000..2802f5c
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ItemFieldImpl.java
@@ -0,0 +1,17 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.ItemControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.ItemField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import net.minecraft.world.item.Item;
+
+public class ItemFieldImpl extends SimpleOptionFactory<ItemField, Item> {
+ @Override
+ protected ControllerBuilder<Item> createController(ItemField annotation, ConfigField<Item> field, OptionAccess storage, Option<Item> option) {
+ return ItemControllerBuilder.create(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LabelImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LabelImpl.java
new file mode 100644
index 0000000..6f9b368
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LabelImpl.java
@@ -0,0 +1,16 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.LabelOption;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.Label;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import net.minecraft.network.chat.Component;
+
+public class LabelImpl implements OptionFactory<Label, Component> {
+ @Override
+ public Option<Component> createOption(Label annotation, ConfigField<Component> field, OptionAccess optionAccess) {
+ return LabelOption.create(field.access().get());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ListGroupImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ListGroupImpl.java
new file mode 100644
index 0000000..f78d4ba
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ListGroupImpl.java
@@ -0,0 +1,102 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.ListOption;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.OptionDescription;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.ListGroup;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.impl.FieldBackedBinding;
+import net.minecraft.client.Minecraft;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import org.jetbrains.annotations.Nullable;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.List;
+
+public class ListGroupImpl<T> implements OptionFactory<ListGroup, List<T>> {
+ @Override
+ public Option<List<T>> createOption(ListGroup annotation, ConfigField<List<T>> field, OptionAccess optionAccess) {
+ if (field.autoGen().orElseThrow().group().isPresent()) {
+ throw new YACLAutoGenException("@ListGroup fields ('%s') cannot be inside a group as lists act as groups.".formatted(field.access().name()));
+ }
+
+ ListGroup.ValueFactory<T> valueFactory = createValueFactory((Class<? extends ListGroup.ValueFactory<T>>) annotation.valueFactory());
+ ListGroup.ControllerFactory<T> controllerFactory = createControllerFactory((Class<? extends ListGroup.ControllerFactory<T>>) annotation.controllerFactory());
+
+ return ListOption.<T>createBuilder()
+ .name(Component.translatable(this.getTranslationKey(field, null)))
+ .description(this.description(field))
+ .initial(valueFactory::provideNewValue)
+ .controller(opt -> controllerFactory.createController(annotation, field, optionAccess, opt))
+ .binding(new FieldBackedBinding<>(field.access(), field.defaultAccess()))
+ .minimumNumberOfEntries(annotation.minEntries())
+ .maximumNumberOfEntries(annotation.maxEntries() == 0 ? Integer.MAX_VALUE : annotation.maxEntries())
+ .insertEntriesAtEnd(annotation.addEntriesToBottom())
+ .build();
+ }
+
+ private OptionDescription description(ConfigField<List<T>> field) {
+ OptionDescription.Builder builder = OptionDescription.createBuilder();
+
+ String key = this.getTranslationKey(field, "desc");
+ if (Language.getInstance().has(key)) {
+ builder.text(Component.translatable(key));
+ } else {
+ key += ".";
+ int i = 0;
+ while (Language.getInstance().has(key + i++)) {
+ builder.text(Component.translatable(key + i));
+ }
+ }
+
+ String imagePath = "textures/yacl3/" + field.parent().id().getPath() + "/" + field.access().name() + ".webp";
+ imagePath = imagePath.toLowerCase().replaceAll("[^a-z0-9/._:-]", "_");
+ ResourceLocation imageLocation = new ResourceLocation(field.parent().id().getNamespace(), imagePath);
+ if (Minecraft.getInstance().getResourceManager().getResource(imageLocation).isPresent()) {
+ builder.webpImage(imageLocation);
+ }
+
+ return builder.build();
+ }
+
+ private ListGroup.ValueFactory<T> createValueFactory(Class<? extends ListGroup.ValueFactory<T>> clazz) {
+ Constructor<? extends ListGroup.ValueFactory<T>> constructor;
+ try {
+ constructor = clazz.getConstructor();
+ } catch (NoSuchMethodException e) {
+ throw new YACLAutoGenException("Could not find no-args constructor for `valueFactory` on '%s' for @ListGroup field.".formatted(clazz.getName()), e);
+ }
+
+ try {
+ return constructor.newInstance();
+ } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) {
+ throw new YACLAutoGenException("Couldn't invoke no-args constructor for `valueFactory` on '%s' for @ListGroup field.".formatted(clazz.getName()), e);
+ }
+ }
+
+ private ListGroup.ControllerFactory<T> createControllerFactory(Class<? extends ListGroup.ControllerFactory<T>> clazz) {
+ Constructor<? extends ListGroup.ControllerFactory<T>> constructor;
+ try {
+ constructor = clazz.getConstructor();
+ } catch (NoSuchMethodException e) {
+ throw new YACLAutoGenException("Could not find no-args constructor on `controllerFactory`, '%s' for @ListGroup field.".formatted(clazz.getName()), e);
+ }
+
+ try {
+ return constructor.newInstance();
+ } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) {
+ throw new YACLAutoGenException("Couldn't invoke no-args constructor on `controllerFactory`, '%s' for @ListGroup field.".formatted(clazz.getName()), e);
+ }
+ }
+
+ private String getTranslationKey(ConfigField<List<T>> field, @Nullable String suffix) {
+ String key = "yacl3.config.%s.%s".formatted(field.parent().id().toString(), field.access().name());
+ if (suffix != null) key += "." + suffix;
+ return key;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LongFieldImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LongFieldImpl.java
new file mode 100644
index 0000000..5da7d20
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LongFieldImpl.java
@@ -0,0 +1,28 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.LongFieldControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.LongField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+
+public class LongFieldImpl extends SimpleOptionFactory<LongField, Long> {
+ @Override
+ protected ControllerBuilder<Long> createController(LongField annotation, ConfigField<Long> field, OptionAccess storage, Option<Long> option) {
+ return LongFieldControllerBuilder.create(option)
+ .formatValue(v -> {
+ String key = getTranslationKey(field, "fmt." + v);
+ if (Language.getInstance().has(key))
+ return Component.translatable(key);
+ key = getTranslationKey(field, "fmt");
+ if (Language.getInstance().has(key))
+ return Component.translatable(key, v);
+ return Component.literal(Long.toString(v));
+ })
+ .range(annotation.min(), annotation.max());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LongSliderImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LongSliderImpl.java
new file mode 100644
index 0000000..95c5254
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/LongSliderImpl.java
@@ -0,0 +1,29 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.LongSliderControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.LongSlider;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+
+public class LongSliderImpl extends SimpleOptionFactory<LongSlider, Long> {
+ @Override
+ protected ControllerBuilder<Long> createController(LongSlider annotation, ConfigField<Long> field, OptionAccess storage, Option<Long> option) {
+ return LongSliderControllerBuilder.create(option)
+ .formatValue(v -> {
+ String key = getTranslationKey(field, "fmt." + v);
+ if (Language.getInstance().has(key))
+ return Component.translatable(key);
+ key = getTranslationKey(field, "fmt");
+ if (Language.getInstance().has(key))
+ return Component.translatable(key, v);
+ return Component.literal(Long.toString(v));
+ })
+ .range(annotation.min(), annotation.max())
+ .step(annotation.step());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/MasterTickBoxImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/MasterTickBoxImpl.java
new file mode 100644
index 0000000..2d37f03
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/MasterTickBoxImpl.java
@@ -0,0 +1,25 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.TickBoxControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.MasterTickBox;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+
+public class MasterTickBoxImpl extends SimpleOptionFactory<MasterTickBox, Boolean> {
+ @Override
+ protected ControllerBuilder<Boolean> createController(MasterTickBox annotation, ConfigField<Boolean> field, OptionAccess storage, Option<Boolean> option) {
+ return TickBoxControllerBuilder.create(option);
+ }
+
+ @Override
+ protected void listener(MasterTickBox annotation, ConfigField<Boolean> field, OptionAccess storage, Option<Boolean> option, Boolean value) {
+ for (String child : annotation.value()) {
+ storage.scheduleOptionOperation(child, childOpt -> {
+ childOpt.setAvailable(annotation.invert() != value);
+ });
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionAccessImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionAccessImpl.java
new file mode 100644
index 0000000..579f776
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionAccessImpl.java
@@ -0,0 +1,44 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+public class OptionAccessImpl implements OptionAccess {
+ private final Map<String, Option<?>> storage = new HashMap<>();
+ private final Map<String, Consumer<Option<?>>> scheduledOperations = new HashMap<>();
+
+ @Override
+ public @Nullable Option<?> getOption(String fieldName) {
+ return storage.get(fieldName);
+ }
+
+ @Override
+ public void scheduleOptionOperation(String fieldName, Consumer<Option<?>> optionConsumer) {
+ if (storage.containsKey(fieldName)) {
+ optionConsumer.accept(storage.get(fieldName));
+ } else {
+ scheduledOperations.merge(fieldName, optionConsumer, Consumer::andThen);
+ }
+ }
+
+ public void putOption(String fieldName, Option<?> option) {
+ storage.put(fieldName, option);
+
+ Consumer<Option<?>> consumer = scheduledOperations.remove(fieldName);
+ if (consumer != null) {
+ consumer.accept(option);
+ }
+ }
+
+ public void checkBadOperations() {
+ if (!scheduledOperations.isEmpty()) {
+ YACLConstants.LOGGER.warn("There are scheduled operations on the `OptionAccess` that tried to reference fields that do not exist. The following have been referenced that do not exist: " + String.join(", ", scheduledOperations.keySet()));
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionFactoryRegistry.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionFactoryRegistry.java
new file mode 100644
index 0000000..4f6e3c7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionFactoryRegistry.java
@@ -0,0 +1,64 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.*;
+import dev.isxander.yacl3.config.v2.api.autogen.Boolean;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+public class OptionFactoryRegistry {
+ private static final Map<Class<?>, OptionFactory<?, ?>> factoryMap = new HashMap<>();
+
+ static {
+ registerOptionFactory(TickBox.class, new TickBoxImpl());
+ registerOptionFactory(Boolean.class, new BooleanImpl());
+ registerOptionFactory(IntSlider.class, new IntSliderImpl());
+ registerOptionFactory(LongSlider.class, new LongSliderImpl());
+ registerOptionFactory(FloatSlider.class, new FloatSliderImpl());
+ registerOptionFactory(DoubleSlider.class, new DoubleSliderImpl());
+ registerOptionFactory(IntField.class, new IntFieldImpl());
+ registerOptionFactory(LongField.class, new LongFieldImpl());
+ registerOptionFactory(FloatField.class, new FloatFieldImpl());
+ registerOptionFactory(DoubleField.class, new DoubleFieldImpl());
+ registerOptionFactory(EnumCycler.class, new EnumCyclerImpl());
+ registerOptionFactory(StringField.class, new StringFieldImpl());
+ registerOptionFactory(ColorField.class, new ColorFieldImpl());
+ registerOptionFactory(Dropdown.class, new DropdownImpl());
+ registerOptionFactory(ItemField.class, new ItemFieldImpl());
+ registerOptionFactory(Label.class, new LabelImpl());
+ registerOptionFactory(ListGroup.class, new ListGroupImpl<>());
+
+ registerOptionFactory(MasterTickBox.class, new MasterTickBoxImpl());
+ }
+
+ public static <A extends Annotation, T> void registerOptionFactory(Class<A> annotation, OptionFactory<A, T> factory) {
+ factoryMap.put(annotation, factory);
+ }
+
+ public static <T> Optional<Option<T>> createOption(Field field, ConfigField<T> configField, OptionAccess storage) {
+ Annotation[] annotations = Arrays.stream(field.getAnnotations())
+ .filter(annotation -> factoryMap.containsKey(annotation.annotationType()))
+ .toArray(Annotation[]::new);
+
+ if (annotations.length != 1) {
+ YACLConstants.LOGGER.warn("Found {} option factory annotations on field {}, expected 1", annotations.length, field);
+
+ if (annotations.length == 0) {
+ return Optional.empty();
+ }
+ }
+
+ Annotation annotation = annotations[0];
+ // noinspection unchecked
+ OptionFactory<Annotation, T> factory = (OptionFactory<Annotation, T>) factoryMap.get(annotation.annotationType());
+ return Optional.of(factory.createOption(annotation, configField, storage));
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/StringFieldImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/StringFieldImpl.java
new file mode 100644
index 0000000..96b63a7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/StringFieldImpl.java
@@ -0,0 +1,16 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.StringControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.StringField;
+
+public class StringFieldImpl extends SimpleOptionFactory<StringField, String> {
+ @Override
+ protected ControllerBuilder<String> createController(StringField annotation, ConfigField<String> field, OptionAccess storage, Option<String> option) {
+ return StringControllerBuilder.create(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/TickBoxImpl.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/TickBoxImpl.java
new file mode 100644
index 0000000..050257c
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/TickBoxImpl.java
@@ -0,0 +1,16 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.TickBoxControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.SimpleOptionFactory;
+import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess;
+import dev.isxander.yacl3.config.v2.api.autogen.TickBox;
+
+public class TickBoxImpl extends SimpleOptionFactory<TickBox, Boolean> {
+ @Override
+ protected ControllerBuilder<Boolean> createController(TickBox annotation, ConfigField<Boolean> field, OptionAccess storage, Option<Boolean> option) {
+ return TickBoxControllerBuilder.create(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/YACLAutoGenException.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/YACLAutoGenException.java
new file mode 100644
index 0000000..68b375d
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/YACLAutoGenException.java
@@ -0,0 +1,11 @@
+package dev.isxander.yacl3.config.v2.impl.autogen;
+
+public class YACLAutoGenException extends RuntimeException {
+ public YACLAutoGenException(String message) {
+ super(message);
+ }
+
+ public YACLAutoGenException(String message, Throwable e) {
+ super(message, e);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java b/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java
new file mode 100644
index 0000000..9f6d8c8
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java
@@ -0,0 +1,275 @@
+package dev.isxander.yacl3.config.v2.impl.serializer;
+
+import com.google.gson.*;
+import com.mojang.serialization.JsonOps;
+import dev.isxander.yacl3.config.v2.api.*;
+import dev.isxander.yacl3.config.v2.api.serializer.GsonConfigSerializerBuilder;
+import dev.isxander.yacl3.gui.utils.ItemRegistryHelper;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import dev.isxander.yacl3.platform.YACLPlatform;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.Style;
+import net.minecraft.world.item.Item;
+import org.jetbrains.annotations.ApiStatus;
+import org.quiltmc.parsers.json.JsonReader;
+import org.quiltmc.parsers.json.JsonWriter;
+import org.quiltmc.parsers.json.gson.GsonReader;
+import org.quiltmc.parsers.json.gson.GsonWriter;
+
+import java.awt.Color;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.lang.reflect.Type;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+
+public class GsonConfigSerializer<T> extends ConfigSerializer<T> {
+ private final Gson gson;
+ private final Path path;
+ private final boolean json5;
+
+ private GsonConfigSerializer(ConfigClassHandler<T> config, Path path, Gson gson, boolean json5) {
+ super(config);
+ this.gson = gson;
+ this.path = path;
+ this.json5 = json5;
+ }
+
+ @Override
+ public void save() {
+ YACLConstants.LOGGER.info("Serializing {} to '{}'", config.configClass(), path);
+
+ try (StringWriter stringWriter = new StringWriter()) {
+ JsonWriter jsonWriter = json5 ? JsonWriter.json5(stringWriter) : JsonWriter.json(stringWriter);
+ GsonWriter gsonWriter = new GsonWriter(jsonWriter);
+
+ jsonWriter.beginObject();
+
+ for (ConfigField<?> field : config.fields()) {
+ SerialField serial = field.serial().orElse(null);
+ if (serial == null) continue;
+
+ if (!json5 && serial.comment().isPresent() && YACLPlatform.isDevelopmentEnv()) {
+ YACLConstants.LOGGER.warn("Found comment in config field '{}', but json5 is not enabled. Enable it with `.setJson5(true)` on the `GsonConfigSerializerBuilder`. Comments will not be serialized. This warning is only visible in development environments.", serial.serialName());
+ }
+ jsonWriter.comment(serial.comment().orElse(null));
+
+ jsonWriter.name(serial.serialName());
+
+ JsonElement element;
+ try {
+ element = gson.toJsonTree(field.access().get(), field.access().type());
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to serialize config field '{}'. Serializing as null.", serial.serialName(), e);
+ jsonWriter.nullValue();
+ continue;
+ }
+
+ try {
+ gson.toJson(element, gsonWriter);
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to serialize config field '{}'. Due to the error state this JSON writer cannot continue safely and the save will be abandoned.", serial.serialName(), e);
+ return;
+ }
+ }
+
+ jsonWriter.endObject();
+ jsonWriter.flush();
+
+ Files.createDirectories(path.getParent());
+ Files.writeString(path, stringWriter.toString(), StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE);
+ } catch (IOException e) {
+ YACLConstants.LOGGER.error("Failed to serialize config class '{}'.", config.configClass().getSimpleName(), e);
+ }
+ }
+
+ @Override
+ public LoadResult loadSafely(Map<ConfigField<?>, FieldAccess<?>> bufferAccessMap) {
+ if (!Files.exists(path)) {
+ YACLConstants.LOGGER.info("Config file '{}' does not exist. Creating it with default values.", path);
+ save();
+ return LoadResult.NO_CHANGE;
+ }
+
+ YACLConstants.LOGGER.info("Deserializing {} from '{}'", config.configClass().getSimpleName(), path);
+
+ Map<String, ConfigField<?>> fieldMap = Arrays.stream(config.fields())
+ .filter(field -> field.serial().isPresent())
+ .collect(Collectors.toMap(f -> f.serial().orElseThrow().serialName(), Function.identity()));
+ Set<String> missingFields = fieldMap.keySet();
+ boolean dirty = false;
+
+ try (JsonReader jsonReader = json5 ? JsonReader.json5(path) : JsonReader.json(path)) {
+ GsonReader gsonReader = new GsonReader(jsonReader);
+
+ jsonReader.beginObject();
+
+ while (jsonReader.hasNext()) {
+ String name = jsonReader.nextName();
+ ConfigField<?> field = fieldMap.get(name);
+ missingFields.remove(name);
+
+ if (field == null) {
+ YACLConstants.LOGGER.warn("Found unknown config field '{}'.", name);
+ jsonReader.skipValue();
+ continue;
+ }
+
+ FieldAccess<?> bufferAccess = bufferAccessMap.get(field);
+ SerialField serial = field.serial().orElse(null);
+ if (serial == null) continue;
+
+ JsonElement element;
+ try {
+ element = gson.fromJson(gsonReader, JsonElement.class);
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to deserialize config field '{}'. Due to the error state this JSON reader cannot be re-used and loading will be aborted.", name, e);
+ return LoadResult.FAILURE;
+ }
+
+ if (element.isJsonNull() && !serial.nullable()) {
+ YACLConstants.LOGGER.warn("Found null value in non-nullable config field '{}'. Leaving field as default and marking as dirty.", name);
+ dirty = true;
+ continue;
+ }
+
+ try {
+ bufferAccess.set(gson.fromJson(element, bufferAccess.type()));
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to deserialize config field '{}'. Leaving as default.", name, e);
+ }
+ }
+
+ jsonReader.endObject();
+ } catch (IOException e) {
+ YACLConstants.LOGGER.error("Failed to deserialize config class.", e);
+ return LoadResult.FAILURE;
+ }
+
+ if (!missingFields.isEmpty()) {
+ for (String missingField : missingFields) {
+ if (fieldMap.get(missingField).serial().orElseThrow().required()) {
+ dirty = true;
+ YACLConstants.LOGGER.warn("Missing required config field '{}''. Re-saving as default.", missingField);
+ }
+ }
+ }
+
+ return dirty ? LoadResult.DIRTY : LoadResult.SUCCESS;
+ }
+
+ @Override
+ @Deprecated
+ @SuppressWarnings("deprecation")
+ public void load() {
+ YACLConstants.LOGGER.warn("Calling ConfigSerializer#load() directly is deprecated. Please use ConfigClassHandler#load() instead.");
+ config.load();
+ }
+
+ /*? if >=1.20.4 {*/
+ public static class StyleTypeAdapter implements JsonSerializer<Style>, JsonDeserializer<Style> {
+ @Override
+ public Style deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
+ return Style.Serializer.CODEC.parse(JsonOps.INSTANCE, json).result().orElse(Style.EMPTY);
+ }
+
+ @Override
+ public JsonElement serialize(Style src, Type typeOfSrc, JsonSerializationContext context) {
+ return Style.Serializer.CODEC.encodeStart(JsonOps.INSTANCE, src).result().orElse(JsonNull.INSTANCE);
+ }
+ }
+ /*?}*/
+
+ public static class ColorTypeAdapter implements JsonSerializer<Color>, JsonDeserializer<Color> {
+ @Override
+ public Color deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext) throws JsonParseException {
+ return new Color(jsonElement.getAsInt(), true);
+ }
+
+ @Override
+ public JsonElement serialize(Color color, Type type, JsonSerializationContext jsonSerializationContext) {
+ return new JsonPrimitive(color.getRGB());
+ }
+ }
+
+ public static class ItemTypeAdapter implements JsonSerializer<Item>, JsonDeserializer<Item> {
+ @Override
+ public Item deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext) throws JsonParseException {
+ return ItemRegistryHelper.getItemFromName(jsonElement.getAsString());
+ }
+
+ @Override
+ public JsonElement serialize(Item item, Type type, JsonSerializationContext jsonSerializationContext) {
+ return new JsonPrimitive(BuiltInRegistries.ITEM.getKey(item).toString());
+ }
+ }
+
+ @ApiStatus.Internal
+ public static class Builder<T> implements GsonConfigSerializerBuilder<T> {
+ private final ConfigClassHandler<T> config;
+ private Path path;
+ private boolean json5;
+ private UnaryOperator<GsonBuilder> gsonBuilder = builder -> builder
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .serializeNulls()
+ /*? if >1.20.4 { *//*
+ .registerTypeHierarchyAdapter(Component.class, new Component.SerializerAdapter(RegistryAccess.EMPTY))
+ *//*? } elif =1.20.4 {*/
+ .registerTypeHierarchyAdapter(Component.class, new Component.SerializerAdapter())
+ /*? } else {*//*
+ .registerTypeHierarchyAdapter(Component.class, new Component.Serializer())
+ *//*?}*/
+ .registerTypeHierarchyAdapter(Style.class, /*? if >=1.20.4 {*/new StyleTypeAdapter()/*?} else {*//*new Style.Serializer()*//*?}*/)
+ .registerTypeHierarchyAdapter(Color.class, new ColorTypeAdapter())
+ .registerTypeHierarchyAdapter(Item.class, new ItemTypeAdapter())
+ .setPrettyPrinting();
+
+ public Builder(ConfigClassHandler<T> config) {
+ this.config = config;
+ }
+
+ @Override
+ public Builder<T> setPath(Path path) {
+ this.path = path;
+ return this;
+ }
+
+ @Override
+ public Builder<T> overrideGsonBuilder(GsonBuilder gsonBuilder) {
+ this.gsonBuilder = builder -> gsonBuilder;
+ return this;
+ }
+
+ @Override
+ public Builder<T> overrideGsonBuilder(Gson gson) {
+ return this.overrideGsonBuilder(gson.newBuilder());
+ }
+
+ @Override
+ public Builder<T> appendGsonBuilder(UnaryOperator<GsonBuilder> gsonBuilder) {
+ UnaryOperator<GsonBuilder> prev = this.gsonBuilder;
+ this.gsonBuilder = builder -> gsonBuilder.apply(prev.apply(builder));
+ return this;
+ }
+
+ @Override
+ public Builder<T> setJson5(boolean json5) {
+ this.json5 = json5;
+ return this;
+ }
+
+ @Override
+ public GsonConfigSerializer<T> build() {
+ return new GsonConfigSerializer<>(config, path, gsonBuilder.apply(new GsonBuilder()).create(), json5);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/debug/DebugProperties.java b/src/main/java/dev/isxander/yacl3/debug/DebugProperties.java
new file mode 100644
index 0000000..8d93bcd
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/debug/DebugProperties.java
@@ -0,0 +1,13 @@
+package dev.isxander.yacl3.debug;
+
+import dev.isxander.yacl3.platform.YACLPlatform;
+
+public final class DebugProperties {
+ /** Applies GL filtering to rendering images. */
+ public static final boolean IMAGE_FILTERING = boolProp("imageFiltering", false, false);
+
+ private static boolean boolProp(String name, boolean defProd, boolean defDebug) {
+ boolean defaultValue = YACLPlatform.isDevelopmentEnv() ? defDebug : defProd;
+ return Boolean.parseBoolean(System.getProperty("yacl3.debug." + name, Boolean.toString(defaultValue)));
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/AbstractWidget.java b/src/main/java/dev/isxander/yacl3/gui/AbstractWidget.java
new file mode 100644
index 0000000..6f92749
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/AbstractWidget.java
@@ -0,0 +1,100 @@
+package dev.isxander.yacl3.gui;
+
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.utils.ButtonTextureRenderer;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.Renderable;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.narration.NarratableEntry;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.resources.sounds.SimpleSoundInstance;
+import net.minecraft.sounds.SoundEvents;
+
+import java.awt.Color;
+
+public abstract class AbstractWidget implements GuiEventListener, Renderable, NarratableEntry {
+ protected final Minecraft client = Minecraft.getInstance();
+ protected final Font textRenderer = client.font;
+ protected final int inactiveColor = 0xFFA0A0A0;
+
+ private Dimension<Integer> dim;
+
+ public AbstractWidget(Dimension<Integer> dim) {
+ this.dim = dim;
+ }
+
+ public boolean canReset() {
+ return false;
+ }
+
+ @Override
+ public boolean isMouseOver(double mouseX, double mouseY) {
+ if (dim == null) return false;
+ return this.dim.isPointInside((int) mouseX, (int) mouseY);
+ }
+
+ public void setDimension(Dimension<Integer> dim) {
+ this.dim = dim;
+ }
+
+ public Dimension<Integer> getDimension() {
+ return dim;
+ }
+
+ @Override
+ public NarrationPriority narrationPriority() {
+ return NarrationPriority.NONE;
+ }
+
+ public void unfocus() {
+
+ }
+
+ public boolean matchesSearch(String query) {
+ return true;
+ }
+
+ @Override
+ public void updateNarration(NarrationElementOutput builder) {
+
+ }
+
+ protected void drawButtonRect(GuiGraphics graphics, int x1, int y1, int x2, int y2, boolean hovered, boolean enabled) {
+ if (x1 > x2) {
+ int xx1 = x1;
+ x1 = x2;
+ x2 = xx1;
+ }
+ if (y1 > y2) {
+ int yy1 = y1;
+ y1 = y2;
+ y2 = yy1;
+ }
+ int width = x2 - x1;
+ int height = y2 - y1;
+
+ ButtonTextureRenderer.render(graphics, x1, y1, width, height, enabled, hovered);
+ }
+
+ protected void drawOutline(GuiGraphics graphics, int x1, int y1, int x2, int y2, int width, int color) {
+ graphics.fill(x1, y1, x2, y1 + width, color);
+ graphics.fill(x2, y1, x2 - width, y2, color);
+ graphics.fill(x1, y2, x2, y2 - width, color);
+ graphics.fill(x1, y1, x1 + width, y2, color);
+ }
+
+ protected int multiplyColor(int hex, float amount) {
+ Color color = new Color(hex, true);
+
+ return new Color(Math.max((int)(color.getRed() * amount), 0),
+ Math.max((int)(color.getGreen() * amount), 0),
+ Math.max((int)(color.getBlue() * amount), 0),
+ color.getAlpha()).getRGB();
+ }
+
+ public void playDownSound() {
+ Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0F));
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/DescriptionWithName.java b/src/main/java/dev/isxander/yacl3/gui/DescriptionWithName.java
new file mode 100644
index 0000000..6ad72e8
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/DescriptionWithName.java
@@ -0,0 +1,11 @@
+package dev.isxander.yacl3.gui;
+
+import dev.isxander.yacl3.api.OptionDescription;
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+
+public record DescriptionWithName(Component name, OptionDescription description) {
+ public static DescriptionWithName of(Component name, OptionDescription description) {
+ return new DescriptionWithName(name.copy().withStyle(ChatFormatting.BOLD), description);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/ElementListWidgetExt.java b/src/main/java/dev/isxander/yacl3/gui/ElementListWidgetExt.java
new file mode 100644
index 0000000..742125b
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/ElementListWidgetExt.java
@@ -0,0 +1,274 @@
+package dev.isxander.yacl3.gui;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.ContainerObjectSelectionList;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.layouts.LayoutElement;
+import net.minecraft.client.gui.navigation.ScreenRectangle;
+import net.minecraft.util.Mth;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.function.Consumer;
+
+public class ElementListWidgetExt<E extends ElementListWidgetExt.Entry<E>> extends ContainerObjectSelectionList<E> implements LayoutElement {
+ protected static final int SCROLLBAR_WIDTH = 6;
+
+ private double smoothScrollAmount = getScrollAmount();
+ private boolean returnSmoothAmount = false;
+ private final boolean doSmoothScrolling;
+ private boolean usingScrollbar;
+
+ public ElementListWidgetExt(Minecraft client, int x, int y, int width, int height, boolean smoothScrolling) {
+ /*? if >1.20.2 {*/
+ super(client, width, x, y, height);
+ /*? } else {*//*
+ super(client, width, height, y, y + height, 22);
+ this.x0 = x;
+ this.x1 = x + width;
+ *//*?}*/
+ this.doSmoothScrolling = smoothScrolling;
+ setRenderHeader(false, 0);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, /*? if >1.20.2 {*/ double horizontal, /*?}*/ double vertical) {
+ double scroll = vertical;
+ /*? if >1.20.2 {*/
+ scroll += horizontal;
+ /*?}*/
+
+ // default implementation bases scroll step from total height of entries, this is constant
+ this.setScrollAmount(this.getScrollAmount() - scroll * 20);
+ return true;
+ }
+
+ @Override
+ protected int getScrollbarPosition() {
+ // default implementation does not respect left/right
+ return this.getX() + this.getWidth() - SCROLLBAR_WIDTH;
+ }
+
+ @Override
+ /*? if >1.20.2 { */
+ public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta)
+ /*?} else { *//*
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta)
+ *//*?}*/
+ {
+ if (usingScrollbar) {
+ resetSmoothScrolling();
+ }
+
+ smoothScrollAmount = Mth.lerp(Minecraft.getInstance().getDeltaFrameTime() * 0.5, smoothScrollAmount, getScrollAmount());
+ returnSmoothAmount = true;
+
+
+ graphics.enableScissor(this.getX(), this.getY(), this.getX() + this.getWidth(), this.getY() + this.getHeight());
+
+ /*? if >1.20.2 { */
+ super.renderWidget(graphics, mouseX, mouseY, delta);
+ /*?} else { *//*
+ super.render(graphics, mouseX, mouseY, delta);
+ *//*?}*/
+
+ graphics.disableScissor();
+
+ returnSmoothAmount = false;
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (button == 0 && mouseX >= getScrollbarPosition() && mouseX < getScrollbarPosition() + SCROLLBAR_WIDTH) {
+ usingScrollbar = true;
+ }
+
+ return super.mouseClicked(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean mouseReleased(double mouseX, double mouseY, int button) {
+ if (button == 0) {
+ usingScrollbar = false;
+ }
+
+ return super.mouseReleased(mouseX, mouseY, button);
+ }
+
+ public void updateDimensions(ScreenRectangle rectangle) {
+ this.setX(rectangle.left());
+ this.setY(rectangle.top());
+ this.setWidth(rectangle.width());
+ this.setHeight(rectangle.height());
+ }
+
+ /**
+ * awful code to only use smooth scroll state when rendering,
+ * not other code that needs target scroll amount
+ */
+ @Override
+ public double getScrollAmount() {
+ if (returnSmoothAmount && doSmoothScrolling)
+ return smoothScrollAmount;
+
+ return super.getScrollAmount();
+ }
+
+ protected void resetSmoothScrolling() {
+ this.smoothScrollAmount = super.getScrollAmount();
+ }
+
+ @Nullable
+ @Override
+ protected E getEntryAtPosition(double x, double y) {
+ y += getScrollAmount();
+
+ if (x < this.getX() || x > this.getX() + this.getWidth())
+ return null;
+
+ int currentY = this.getY() - headerHeight + 4;
+ for (E entry : children()) {
+ if (y >= currentY && y <= currentY + entry.getItemHeight()) {
+ return entry;
+ }
+
+ currentY += entry.getItemHeight();
+ }
+
+ return null;
+ }
+
+ /*
+ below code is licensed from cloth-config under LGPL3
+ modified to inherit vanilla's EntryListWidget and use yarn mappings
+
+ code is responsible for having dynamic item heights
+ */
+
+ @Override
+ protected int getMaxPosition() {
+ return children().stream().map(E::getItemHeight).reduce(0, Integer::sum) + headerHeight;
+ }
+
+ @Override
+ protected void centerScrollOn(E entry) {
+ double d = (this.height) / -2d;
+ for (int i = 0; i < this.children().indexOf(entry) && i < this.getItemCount(); i++)
+ d += children().get(i).getItemHeight();
+ this.setScrollAmount(d);
+ }
+
+ @Override
+ protected int getRowTop(int index) {
+ int integer = getY() + 4 - (int) this.getScrollAmount() + headerHeight;
+ for (int i = 0; i < children().size() && i < index; i++)
+ integer += children().get(i).getItemHeight();
+ return integer;
+ }
+
+ @Override
+ /*? if >1.20.4 {*//*
+ protected void renderListItems(GuiGraphics graphics, int mouseX, int mouseY, float delta)
+ *//*? } else {*/
+ protected void renderList(GuiGraphics graphics, int mouseX, int mouseY, float delta)
+ /*?}*/
+ {
+ int left = this.getRowLeft();
+ int right = this.getRowWidth();
+ int count = this.getItemCount();
+
+ for(int i = 0; i < count; ++i) {
+ E entry = children().get(i);
+ int top = this.getRowTop(i);
+ int bottom = top + entry.getItemHeight();
+ int entryHeight = entry.getItemHeight() - 4;
+ if (bottom >= this.getY() && top <= this.getY() + this.getHeight()) {
+ this.renderItem(graphics, mouseX, mouseY, delta, i, left, top, right, entryHeight);
+ }
+ }
+ }
+
+ /* END cloth config code */
+
+ @Override
+ public void visitWidgets(Consumer<AbstractWidget> consumer) {
+ }
+
+ public abstract static class Entry<E extends Entry<E>> extends ContainerObjectSelectionList.Entry<E> {
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ for (GuiEventListener child : this.children()) {
+ if (child.mouseClicked(mouseX, mouseY, button)) {
+ if (button == InputConstants.MOUSE_BUTTON_LEFT)
+ this.setDragging(true);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
+ if (isDragging() && button == InputConstants.MOUSE_BUTTON_LEFT) {
+ for (GuiEventListener child : this.children()) {
+ if (child.mouseDragged(mouseX, mouseY, button, deltaX, deltaY))
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public int getItemHeight() {
+ return 22;
+ }
+ }
+
+ /*? if <1.20.3 {*//*
+ @Override
+ public int getX() {
+ return x0;
+ }
+
+ @Override
+ public int getY() {
+ return y0;
+ }
+
+ @Override
+ public void setX(int x) {
+ int width = this.getWidth();
+ x0 = x;
+ x1 = x + width;
+ }
+
+ @Override
+ public void setY(int y) {
+ int height = this.getHeight();
+ y0 = y;
+ y1 = y + height;
+ }
+
+ public void setWidth(int width) {
+ x1 = x0 + width;
+ this.width = width;
+ }
+
+ public void setHeight(int height) {
+ y1 = y0 + height;
+ this.height = height;
+ }
+
+ @Override
+ public int getWidth() {
+ return width;
+ }
+
+ @Override
+ public int getHeight() {
+ return height;
+ }
+ *//*?}*/
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/LowProfileButtonWidget.java b/src/main/java/dev/isxander/yacl3/gui/LowProfileButtonWidget.java
new file mode 100644
index 0000000..3f5822f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/LowProfileButtonWidget.java
@@ -0,0 +1,28 @@
+package dev.isxander.yacl3.gui;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.Tooltip;
+import net.minecraft.network.chat.Component;
+
+public class LowProfileButtonWidget extends Button {
+ public LowProfileButtonWidget(int x, int y, int width, int height, Component message, OnPress onPress) {
+ super(x, y, width, height, message, onPress, DEFAULT_NARRATION);
+ }
+
+ public LowProfileButtonWidget(int x, int y, int width, int height, Component message, OnPress onPress, Tooltip tooltip) {
+ this(x, y, width, height, message, onPress);
+ setTooltip(tooltip);
+ }
+
+ @Override
+ public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float deltaTicks) {
+ if (!isHoveredOrFocused() || !active) {
+ int j = this.active ? 0xFFFFFF : 0xA0A0A0;
+ this.renderString(graphics, Minecraft.getInstance().font, j);
+ } else {
+ super.renderWidget(graphics, mouseX, mouseY, deltaTicks);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/OptionDescriptionWidget.java b/src/main/java/dev/isxander/yacl3/gui/OptionDescriptionWidget.java
new file mode 100644
index 0000000..4ca3ad3
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/OptionDescriptionWidget.java
@@ -0,0 +1,222 @@
+package dev.isxander.yacl3.gui;
+
+import com.mojang.blaze3d.Blaze3D;
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.ComponentPath;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.narration.NarratedElementType;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.gui.navigation.FocusNavigationEvent;
+import net.minecraft.client.gui.navigation.ScreenRectangle;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.Style;
+import net.minecraft.util.FormattedCharSequence;
+import net.minecraft.util.Mth;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+public class OptionDescriptionWidget extends AbstractWidget {
+ private static final int AUTO_SCROLL_TIMER = 1500;
+ private static final float AUTO_SCROLL_SPEED = 1; // lines per second
+
+ private @Nullable DescriptionWithName description;
+ private List<FormattedCharSequence> wrappedText;
+
+ private static final Minecraft minecraft = Minecraft.getInstance();
+ private static final Font font = minecraft.font;
+
+ private Supplier<ScreenRectangle> dimensions;
+
+ private float targetScrollAmount, currentScrollAmount;
+ private int maxScrollAmount;
+ private int descriptionY;
+
+ private int lastInteractionTime;
+ private boolean scrollingBackward;
+
+ public OptionDescriptionWidget(Supplier<ScreenRectangle> dimensions, @Nullable DescriptionWithName description) {
+ super(0, 0, 0, 0, description == null ? Component.empty() : description.name());
+ this.dimensions = dimensions;
+ this.setOptionDescription(description);
+ }
+
+ @Override
+ public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ if (description == null) return;
+
+ currentScrollAmount = Mth.lerp(delta * 0.5f, currentScrollAmount, targetScrollAmount);
+
+ ScreenRectangle dimensions = this.dimensions.get();
+ this.setX(dimensions.left());
+ this.setY(dimensions.top());
+ this.width = dimensions.width();
+ this.height = dimensions.height();
+
+ int y = getY();
+
+ int nameWidth = font.width(description.name());
+ if (nameWidth > getWidth()) {
+ renderScrollingString(graphics, font, description.name(), getX(), y, getX() + getWidth(), y + font.lineHeight, -1);
+ } else {
+ graphics.drawString(font, description.name(), getX(), y, 0xFFFFFF);
+ }
+
+ y += 5 + font.lineHeight;
+
+ graphics.enableScissor(getX(), y, getX() + getWidth(), getY() + getHeight());
+
+ y -= (int)currentScrollAmount;
+
+ if (description.description().image().isDone()) {
+ var image = description.description().image().join();
+ if (image.isPresent()) {
+ y += image.get().render(graphics, getX(), y, getWidth(), delta) + 5;
+ }
+ }
+
+ if (wrappedText == null)
+ wrappedText = font.split(description.description().text(), getWidth());
+
+ descriptionY = y;
+ for (var line : wrappedText) {
+ graphics.drawString(font, line, getX(), y, 0xFFFFFF);
+ y += font.lineHeight;
+ }
+
+ graphics.disableScissor();
+
+ maxScrollAmount = Math.max(0, y + (int)currentScrollAmount - getY() - getHeight());
+
+ if (isHoveredOrFocused()) {
+ lastInteractionTime = currentTimeMS();
+ }
+ Style hoveredStyle = getDescStyle(mouseX, mouseY);
+ if (hoveredStyle != null && hoveredStyle.getHoverEvent() != null) {
+ graphics.renderComponentHoverEffect(font, hoveredStyle, mouseX, mouseY);
+ }
+
+ if (isFocused()) {
+ graphics.renderOutline(getX(), getY(), getWidth(), getHeight(), -1);
+ }
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ Style clickedStyle = getDescStyle((int) mouseX, (int) mouseY);
+ if (clickedStyle != null && clickedStyle.getClickEvent() != null) {
+ if (minecraft.screen.handleComponentClicked(clickedStyle)) {
+ playDownSound(minecraft.getSoundManager());
+ return true;
+ }
+ return false;
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, /*? if >1.20.2 {*/ double horizontal, /*?}*/ double vertical) {
+ if (isMouseOver(mouseX, mouseY)) {
+ targetScrollAmount = Mth.clamp(targetScrollAmount - (int) vertical * 10, 0, maxScrollAmount);
+ lastInteractionTime = currentTimeMS();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (isFocused()) {
+ switch (keyCode) {
+ case InputConstants.KEY_UP ->
+ targetScrollAmount = Mth.clamp(targetScrollAmount - 10, 0, maxScrollAmount);
+ case InputConstants.KEY_DOWN ->
+ targetScrollAmount = Mth.clamp(targetScrollAmount + 10, 0, maxScrollAmount);
+ default -> {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public void tick() {
+ if (description != null) {
+ description.description().image()
+ .getNow(Optional.empty())
+ .ifPresent(ImageRenderer::tick);
+ }
+
+ float pxPerTick = AUTO_SCROLL_SPEED / 20f * font.lineHeight;
+ if (maxScrollAmount > 0 && currentTimeMS() - lastInteractionTime > AUTO_SCROLL_TIMER) {
+ if (scrollingBackward) {
+ pxPerTick *= -1;
+ if (targetScrollAmount + pxPerTick < 0) {
+ scrollingBackward = false;
+ lastInteractionTime = currentTimeMS();
+ }
+ } else {
+ if (targetScrollAmount + pxPerTick > maxScrollAmount) {
+ scrollingBackward = true;
+ lastInteractionTime = currentTimeMS();
+ }
+ }
+
+ targetScrollAmount = Mth.clamp(targetScrollAmount + pxPerTick, 0, maxScrollAmount);
+ }
+ }
+
+ private Style getDescStyle(int mouseX, int mouseY) {
+ if (!clicked(mouseX, mouseY))
+ return null;
+
+ int x = mouseX - getX();
+ int y = mouseY - descriptionY;
+
+ if (x < 0 || x > getX() + getWidth()) return null;
+ if (y < 0 || y > getY() + getHeight()) return null;
+
+ int line = y / font.lineHeight;
+
+ if (line >= wrappedText.size()) return null;
+
+ return font.getSplitter().componentStyleAtWidth(wrappedText.get(line), x);
+ }
+
+ @Override
+ protected void updateWidgetNarration(NarrationElementOutput builder) {
+ if (description != null) {
+ builder.add(NarratedElementType.TITLE, description.name());
+ builder.add(NarratedElementType.HINT, description.description().text());
+ }
+
+ }
+
+ public void setOptionDescription(DescriptionWithName description) {
+ this.description = description;
+ this.wrappedText = null;
+ this.targetScrollAmount = 0;
+ this.currentScrollAmount = 0;
+ this.lastInteractionTime = currentTimeMS();
+ }
+
+ private int currentTimeMS() {
+ return (int)(Blaze3D.getTime() * 1000);
+ }
+
+ @Nullable
+ @Override
+ public ComponentPath nextFocusPath(FocusNavigationEvent event) {
+ // prevents focusing on this widget
+ return null;
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/OptionListWidget.java b/src/main/java/dev/isxander/yacl3/gui/OptionListWidget.java
new file mode 100644
index 0000000..f699f0c
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/OptionListWidget.java
@@ -0,0 +1,578 @@
+package dev.isxander.yacl3.gui;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.MultiLineLabel;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.narration.NarratableEntry;
+import net.minecraft.client.gui.narration.NarratedElementType;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+public class OptionListWidget extends ElementListWidgetExt<OptionListWidget.Entry> {
+ private final YACLScreen yaclScreen;
+ private final ConfigCategory category;
+ private ImmutableList<Entry> viewableChildren;
+ private String searchQuery = "";
+ private final Consumer<DescriptionWithName> hoverEvent;
+ private DescriptionWithName lastHoveredOption;
+
+ public OptionListWidget(YACLScreen screen, ConfigCategory category, Minecraft client, int x, int y, int width, int height, Consumer<DescriptionWithName> hoverEvent) {
+ super(client, x, y, width, height, true);
+ this.yaclScreen = screen;
+ this.category = category;
+ this.hoverEvent = hoverEvent;
+
+ refreshOptions();
+
+ for (OptionGroup group : category.groups()) {
+ if (group instanceof ListOption<?> listOption) {
+ listOption.addRefreshListener(() -> refreshListEntries(listOption, category));
+ }
+ }
+ }
+
+ public void refreshOptions() {
+ clearEntries();
+
+ for (OptionGroup group : category.groups()) {
+ GroupSeparatorEntry groupSeparatorEntry;
+ if (!group.isRoot()) {
+ groupSeparatorEntry = group instanceof ListOption<?> listOption
+ ? new ListGroupSeparatorEntry(listOption, yaclScreen)
+ : new GroupSeparatorEntry(group, yaclScreen);
+ addEntry(groupSeparatorEntry);
+ } else {
+ groupSeparatorEntry = null;
+ }
+
+ List<Entry> optionEntries = new ArrayList<>();
+
+ // add empty entry to make sure users know it's empty not just bugging out
+ if (groupSeparatorEntry instanceof ListGroupSeparatorEntry listGroupSeparatorEntry) {
+ if (listGroupSeparatorEntry.listOption.options().isEmpty()) {
+ EmptyListLabel emptyListLabel = new EmptyListLabel(listGroupSeparatorEntry, category);
+ addEntry(emptyListLabel);
+ optionEntries.add(emptyListLabel);
+ }
+ }
+
+ for (Option<?> option : group.options()) {
+ OptionEntry entry = new OptionEntry(option, category, group, groupSeparatorEntry, option.controller().provideWidget(yaclScreen, getDefaultEntryDimension()));
+ addEntry(entry);
+ optionEntries.add(entry);
+ }
+
+ if (groupSeparatorEntry != null) {
+ groupSeparatorEntry.setChildEntries(optionEntries);
+ }
+ }
+
+ recacheViewableChildren();
+ setScrollAmount(0);
+ resetSmoothScrolling();
+ }
+
+ private void refreshListEntries(ListOption<?> listOption, ConfigCategory category) {
+ // find group separator for group
+ ListGroupSeparatorEntry groupSeparator = super.children().stream().filter(e -> e instanceof ListGroupSeparatorEntry gs && gs.group == listOption).map(ListGroupSeparatorEntry.class::cast).findAny().orElse(null);
+
+ if (groupSeparator == null) {
+ YACLConstants.LOGGER.warn("Can't find group seperator to refresh list option entries for list option " + listOption.name());
+ return;
+ }
+
+ for (Entry entry : groupSeparator.childEntries)
+ super.removeEntry(entry);
+ groupSeparator.childEntries.clear();
+
+ // if no entries, below loop won't run where addEntryBelow() recaches viewable children
+ if (listOption.options().isEmpty()) {
+ EmptyListLabel emptyListLabel;
+ addEntryBelow(groupSeparator, emptyListLabel = new EmptyListLabel(groupSeparator, category));
+ groupSeparator.childEntries.add(emptyListLabel);
+ return;
+ }
+
+ Entry lastEntry = groupSeparator;
+ for (ListOptionEntry<?> listOptionEntry : listOption.options()) {
+ OptionEntry optionEntry = new OptionEntry(listOptionEntry, category, listOption, groupSeparator, listOptionEntry.controller().provideWidget(yaclScreen, getDefaultEntryDimension()));
+ addEntryBelow(lastEntry, optionEntry);
+ groupSeparator.childEntries.add(optionEntry);
+ lastEntry = optionEntry;
+ }
+ }
+
+ public Dimension<Integer> getDefaultEntryDimension() {
+ return Dimension.ofInt(getRowLeft(), 0, getRowWidth(), 20);
+ }
+
+ public void expandAllGroups() {
+ for (Entry entry : super.children()) {
+ if (entry instanceof GroupSeparatorEntry groupSeparatorEntry) {
+ groupSeparatorEntry.setExpanded(true);
+ }
+ }
+ }
+
+ @Override
+ public int getRowLeft() {
+ return super.getRowLeft() - SCROLLBAR_WIDTH;
+ }
+
+ @Override
+ public int getRowWidth() {
+ return getWidth() - SCROLLBAR_WIDTH - 20; // 10 padding each side
+ }
+
+ public void updateSearchQuery(String query) {
+ this.searchQuery = query;
+ expandAllGroups();
+ recacheViewableChildren();
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ for (Entry child : children()) {
+ if (child != getEntryAtPosition(mouseX, mouseY) && child instanceof OptionEntry optionEntry)
+ optionEntry.widget.unfocus();
+ }
+
+ return super.mouseClicked(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, /*? if >1.20.2 {*/ double horizontal, /*?}*/ double vertical) {
+ super.mouseScrolled(mouseX, mouseY, /*? if >1.20.2 {*/ horizontal, /*?}*/ vertical);
+
+ for (Entry child : children()) {
+ if (child.mouseScrolled(mouseX, mouseY, /*? if >1.20.2 {*/ horizontal, /*?}*/ vertical))
+ break;
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ for (Entry child : children()) {
+ if (child.keyPressed(keyCode, scanCode, modifiers))
+ return true;
+ }
+
+ return super.keyPressed(keyCode, scanCode, modifiers);
+ }
+
+ @Override
+ public boolean charTyped(char chr, int modifiers) {
+ for (Entry child : children()) {
+ if (child.charTyped(chr, modifiers))
+ return true;
+ }
+
+ return super.charTyped(chr, modifiers);
+ }
+
+ public void recacheViewableChildren() {
+ this.viewableChildren = ImmutableList.copyOf(super.children().stream().filter(Entry::isViewable).toList());
+
+ // update y positions before they need to be rendered are rendered
+ int i = 0;
+ for (Entry entry : viewableChildren) {
+ if (entry instanceof OptionEntry optionEntry)
+ optionEntry.widget.setDimension(optionEntry.widget.getDimension().withY(getRowTop(i)));
+ i++;
+ }
+ }
+
+ @Override
+ public List<Entry> children() {
+ return viewableChildren;
+ }
+
+ public void addEntry(int index, Entry entry) {
+ super.children().add(index, entry);
+ recacheViewableChildren();
+ }
+
+ public void addEntryBelow(Entry below, Entry entry) {
+ int idx = super.children().indexOf(below) + 1;
+
+ if (idx == 0)
+ throw new IllegalStateException("The entry to insert below does not exist!");
+
+ addEntry(idx, entry);
+ }
+
+ public void addEntryBelowWithoutScroll(Entry below, Entry entry) {
+ double d = (double)this.getMaxScroll() - this.getScrollAmount();
+ addEntryBelow(below, entry);
+ setScrollAmount(getMaxScroll() - d);
+ }
+
+ @Override
+ public boolean removeEntryFromTop(Entry entry) {
+ boolean ret = super.removeEntryFromTop(entry);
+ recacheViewableChildren();
+ return ret;
+ }
+
+ @Override
+ public boolean removeEntry(Entry entry) {
+ boolean ret = super.removeEntry(entry);
+ recacheViewableChildren();
+ return ret;
+ }
+
+ private void setHoverDescription(DescriptionWithName description) {
+ if (description != lastHoveredOption) {
+ lastHoveredOption = description;
+ hoverEvent.accept(description);
+ }
+ }
+
+ /*? if >1.20.4 {*//*
+ @Override
+ protected void renderListBackground(GuiGraphics guiGraphics) {
+ }
+ *//*?}*/
+
+ public abstract class Entry extends ElementListWidgetExt.Entry<Entry> {
+ public boolean isViewable() {
+ return true;
+ }
+
+ protected boolean isHovered() {
+ return Objects.equals(getHovered(), this);
+ }
+ }
+
+ public class OptionEntry extends Entry {
+ public final Option<?> option;
+ public final ConfigCategory category;
+ public final OptionGroup group;
+
+ public final @Nullable GroupSeparatorEntry groupSeparatorEntry;
+
+ public final AbstractWidget widget;
+
+ private final TextScaledButtonWidget resetButton;
+
+ private final String categoryName;
+ private final String groupName;
+
+ public OptionEntry(Option<?> option, ConfigCategory category, OptionGroup group, @Nullable GroupSeparatorEntry groupSeparatorEntry, AbstractWidget widget) {
+ this.option = option;
+ this.category = category;
+ this.group = group;
+ this.groupSeparatorEntry = groupSeparatorEntry;
+ this.widget = widget;
+ this.categoryName = category.name().getString().toLowerCase();
+ this.groupName = group.name().getString().toLowerCase();
+ if (option.canResetToDefault() && this.widget.canReset()) {
+ this.widget.setDimension(this.widget.getDimension().expanded(-20, 0));
+ this.resetButton = new TextScaledButtonWidget(yaclScreen, widget.getDimension().xLimit(), -50, 20, 20, 2f, Component.literal("\u21BB"), button -> {
+ option.requestSetDefault();
+ });
+ option.addListener((opt, val) -> this.resetButton.active = !opt.isPendingValueDefault() && opt.available());
+ this.resetButton.active = !option.isPendingValueDefault() && option.available();
+ } else {
+ this.resetButton = null;
+ }
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) {
+ widget.setDimension(widget.getDimension().withY(y));
+
+ widget.render(graphics, mouseX, mouseY, tickDelta);
+
+ if (resetButton != null) {
+ resetButton.setY(y);
+ resetButton.render(graphics, mouseX, mouseY, tickDelta);
+ }
+
+ if (isHovered()) {
+ setHoverDescription(DescriptionWithName.of(option.name(), option.description()));
+ }
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, /*? if >1.20.2 {*/ double horizontal, /*?}*/ double vertical) {
+ return widget.mouseScrolled(mouseX, mouseY, /*? if >1.20.2 {*/ horizontal, /*?}*/ vertical);
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ return widget.keyPressed(keyCode, scanCode, modifiers);
+ }
+
+ @Override
+ public boolean charTyped(char chr, int modifiers) {
+ return widget.charTyped(chr, modifiers);
+ }
+
+ @Override
+ public boolean isViewable() {
+ return (groupSeparatorEntry == null || groupSeparatorEntry.isExpanded())
+ && (searchQuery.isEmpty()
+ || groupName.contains(searchQuery)
+ || widget.matchesSearch(searchQuery));
+ }
+
+ @Override
+ public int getItemHeight() {
+ return Math.max(widget.getDimension().height(), resetButton != null ? resetButton.getHeight() : 0) + 2;
+ }
+
+ @Override
+ public void setFocused(boolean focused) {
+ super.setFocused(focused);
+ if (focused)
+ setHoverDescription(DescriptionWithName.of(option.name(), option.description()));
+ }
+
+ @Override
+ public List<? extends NarratableEntry> narratables() {
+ if (resetButton == null)
+ return ImmutableList.of(widget);
+
+ return ImmutableList.of(widget, resetButton);
+ }
+
+ @Override
+ public List<? extends GuiEventListener> children() {
+ if (resetButton == null)
+ return ImmutableList.of(widget);
+
+ return ImmutableList.of(widget, resetButton);
+ }
+ }
+
+ public class GroupSeparatorEntry extends Entry {
+ protected final OptionGroup group;
+ protected final MultiLineLabel wrappedName;
+ protected final MultiLineLabel wrappedTooltip;
+
+ protected final LowProfileButtonWidget expandMinimizeButton;
+
+ protected final Screen screen;
+ protected final Font font = Minecraft.getInstance().font;
+
+ protected boolean groupExpanded;
+
+ protected List<Entry> childEntries = new ArrayList<>();
+
+ private int y;
+
+ private GroupSeparatorEntry(OptionGroup group, Screen screen) {
+ this.group = group;
+ this.screen = screen;
+ this.wrappedName = MultiLineLabel.create(font, group.name(), getRowWidth() - 45);
+ this.wrappedTooltip = MultiLineLabel.create(font, group.tooltip(), screen.width / 3 * 2 - 10);
+ this.groupExpanded = !group.collapsed();
+ this.expandMinimizeButton = new LowProfileButtonWidget(0, 0, 20, 20, Component.empty(), btn -> onExpandButtonPress());
+ updateExpandMinimizeText();
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) {
+ this.y = y;
+
+ int buttonY = y + entryHeight / 2 - expandMinimizeButton.getHeight() / 2 + 1;
+
+ expandMinimizeButton.setY(buttonY);
+ expandMinimizeButton.setX(x);
+ expandMinimizeButton.render(graphics, mouseX, mouseY, tickDelta);
+
+ wrappedName.renderCentered(graphics, x + entryWidth / 2, y + getYPadding());
+
+ if (isHovered()) {
+ setHoverDescription(DescriptionWithName.of(group.name(), group.description()));
+ }
+ }
+
+ public boolean isExpanded() {
+ return groupExpanded;
+ }
+
+ public void setExpanded(boolean expanded) {
+ if (this.groupExpanded == expanded)
+ return;
+
+ this.groupExpanded = expanded;
+ updateExpandMinimizeText();
+ recacheViewableChildren();
+ }
+
+ protected void onExpandButtonPress() {
+ setExpanded(!isExpanded());
+ }
+
+ protected void updateExpandMinimizeText() {
+ expandMinimizeButton.setMessage(Component.literal(isExpanded() ? "▼" : "▶"));
+ }
+
+ public void setChildEntries(List<? extends Entry> childEntries) {
+ this.childEntries.clear();
+ this.childEntries.addAll(childEntries);
+ }
+
+ @Override
+ public boolean isViewable() {
+ return searchQuery.isEmpty() || childEntries.stream().anyMatch(Entry::isViewable);
+ }
+
+ @Override
+ public int getItemHeight() {
+ return Math.max(wrappedName.getLineCount(), 1) * font.lineHeight + getYPadding() * 2;
+ }
+
+ private int getYPadding() {
+ return 6;
+ }
+
+ @Override
+ public void setFocused(boolean focused) {
+ super.setFocused(focused);
+ if (focused)
+ setHoverDescription(DescriptionWithName.of(group.name(), group.description()));
+ }
+
+ @Override
+ public List<? extends NarratableEntry> narratables() {
+ return ImmutableList.of(new NarratableEntry() {
+ @Override
+ public NarrationPriority narrationPriority() {
+ return NarrationPriority.HOVERED;
+ }
+
+ @Override
+ public void updateNarration(NarrationElementOutput builder) {
+ builder.add(NarratedElementType.TITLE, group.name());
+ builder.add(NarratedElementType.HINT, group.tooltip());
+ }
+ });
+ }
+
+ @Override
+ public List<? extends GuiEventListener> children() {
+ return ImmutableList.of(expandMinimizeButton);
+ }
+ }
+
+ public class ListGroupSeparatorEntry extends GroupSeparatorEntry {
+ private final ListOption<?> listOption;
+ private final TextScaledButtonWidget resetListButton;
+ private final TooltipButtonWidget addListButton;
+
+ private ListGroupSeparatorEntry(ListOption<?> group, Screen screen) {
+ super(group, screen);
+ this.listOption = group;
+
+ this.resetListButton = new TextScaledButtonWidget(screen, getRowRight() - 20, -50, 20, 20, 2f, Component.literal("\u21BB"), button -> {
+ group.requestSetDefault();
+ });
+ group.addListener((opt, val) -> this.resetListButton.active = !opt.isPendingValueDefault() && opt.available());
+ this.resetListButton.active = !group.isPendingValueDefault() && group.available();
+
+
+ this.addListButton = new TooltipButtonWidget(yaclScreen, resetListButton.getX() - 20, -50, 20, 20, Component.literal("+"), Component.translatable("yacl.list.add_top"), btn -> {
+ group.insertNewEntry();
+ setExpanded(true);
+ });
+
+ updateExpandMinimizeText();
+ minimizeIfUnavailable();
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) {
+ updateExpandMinimizeText(); // update every render because option could become available/unavailable at any time
+
+ super.render(graphics, index, y, x, entryWidth, entryHeight, mouseX, mouseY, hovered, tickDelta);
+
+ int buttonY = expandMinimizeButton.getY();
+
+ resetListButton.setY(buttonY);
+ addListButton.setY(buttonY);
+
+ resetListButton.render(graphics, mouseX, mouseY, tickDelta);
+ addListButton.render(graphics, mouseX, mouseY, tickDelta);
+ }
+
+ private void minimizeIfUnavailable() {
+ if (!listOption.available() && isExpanded()) {
+ setExpanded(false);
+ }
+ }
+
+ @Override
+ protected void updateExpandMinimizeText() {
+ super.updateExpandMinimizeText();
+ expandMinimizeButton.active = listOption == null || listOption.available();
+ if (addListButton != null)
+ addListButton.active = expandMinimizeButton.active && listOption.numberOfEntries() < listOption.maximumNumberOfEntries();
+ }
+
+ @Override
+ public void setExpanded(boolean expanded) {
+ super.setExpanded(listOption.available() && expanded);
+ }
+
+ @Override
+ public List<? extends GuiEventListener> children() {
+ return ImmutableList.of(expandMinimizeButton, addListButton, resetListButton);
+ }
+ }
+
+ public class EmptyListLabel extends Entry {
+ private final ListGroupSeparatorEntry parent;
+ private final String groupName;
+ private final String categoryName;
+
+ public EmptyListLabel(ListGroupSeparatorEntry parent, ConfigCategory category) {
+ this.parent = parent;
+ this.groupName = parent.group.name().getString().toLowerCase();
+ this.categoryName = category.name().getString().toLowerCase();
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) {
+ graphics.drawCenteredString(Minecraft.getInstance().font, Component.translatable("yacl.list.empty").withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.ITALIC), x + entryWidth / 2, y, -1);
+ }
+
+ @Override
+ public boolean isViewable() {
+ return parent.isExpanded() && (searchQuery.isEmpty() || groupName.contains(searchQuery));
+ }
+
+ @Override
+ public int getItemHeight() {
+ return 11;
+ }
+
+ @Override
+ public List<? extends GuiEventListener> children() {
+ return ImmutableList.of();
+ }
+
+ @Override
+ public List<? extends NarratableEntry> narratables() {
+ return ImmutableList.of();
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/RequireRestartScreen.java b/src/main/java/dev/isxander/yacl3/gui/RequireRestartScreen.java
new file mode 100644
index 0000000..5ba4b03
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/RequireRestartScreen.java
@@ -0,0 +1,21 @@
+package dev.isxander.yacl3.gui;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.screens.ConfirmScreen;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+
+public class RequireRestartScreen extends ConfirmScreen {
+ public RequireRestartScreen(Screen parent) {
+ super(option -> {
+ if (option) Minecraft.getInstance().stop();
+ else Minecraft.getInstance().setScreen(parent);
+ },
+ Component.translatable("yacl.restart.title").withStyle(ChatFormatting.RED, ChatFormatting.BOLD),
+ Component.translatable("yacl.restart.message"),
+ Component.translatable("yacl.restart.yes"),
+ Component.translatable("yacl.restart.no")
+ );
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/SearchFieldWidget.java b/src/main/java/dev/isxander/yacl3/gui/SearchFieldWidget.java
new file mode 100644
index 0000000..a666886
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/SearchFieldWidget.java
@@ -0,0 +1,61 @@
+package dev.isxander.yacl3.gui;
+
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Consumer;
+
+public class SearchFieldWidget extends EditBox {
+ private Component emptyText;
+ private final YACLScreen yaclScreen;
+ private final Font font;
+ private final Consumer<String> updateConsumer;
+
+ private boolean isEmpty = true;
+
+ public SearchFieldWidget(YACLScreen yaclScreen, Font font, int x, int y, int width, int height, Component text, Component emptyText, Consumer<String> updateConsumer) {
+ super(font, x, y, width, height, text);
+ setResponder(this::update);
+ setFilter(string -> !string.endsWith(" ") && !string.startsWith(" "));
+ this.yaclScreen = yaclScreen;
+ this.font = font;
+ this.emptyText = emptyText;
+ this.updateConsumer = updateConsumer;
+ }
+
+ @Override
+ public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ super.renderWidget(graphics, mouseX, mouseY, delta);
+ if (isVisible() && isEmpty()) {
+ graphics.drawString(font, emptyText, getX() + 4, this.getY() + (this.height - 8) / 2, 0x707070, true);
+ }
+ }
+
+ private void update(String query) {
+ boolean wasEmpty = isEmpty;
+ isEmpty = query.isEmpty();
+
+ if (isEmpty && wasEmpty)
+ return;
+
+ updateConsumer.accept(query);
+ }
+
+ public String getQuery() {
+ return getValue().toLowerCase();
+ }
+
+ public boolean isEmpty() {
+ return isEmpty;
+ }
+
+ public Component getEmptyText() {
+ return emptyText;
+ }
+
+ public void setEmptyText(Component emptyText) {
+ this.emptyText = emptyText;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/TextScaledButtonWidget.java b/src/main/java/dev/isxander/yacl3/gui/TextScaledButtonWidget.java
new file mode 100644
index 0000000..6ad0d1c
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/TextScaledButtonWidget.java
@@ -0,0 +1,34 @@
+package dev.isxander.yacl3.gui;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import net.minecraft.util.Mth;
+
+public class TextScaledButtonWidget extends TooltipButtonWidget {
+ public float textScale;
+
+ public TextScaledButtonWidget(Screen screen, int x, int y, int width, int height, float textScale, Component message, Component tooltip, OnPress onPress) {
+ super(screen, x, y, width, height, message, tooltip, onPress);
+ this.textScale = textScale;
+ }
+
+ public TextScaledButtonWidget(Screen screen, int x, int y, int width, int height, float textScale, Component message, OnPress onPress) {
+ this(screen, x, y, width, height, textScale, message, null, onPress);
+ }
+
+ @Override
+ public void renderString(GuiGraphics graphics, Font textRenderer, int color) {
+ Font font = Minecraft.getInstance().font;
+ PoseStack pose = graphics.pose();
+
+ pose.pushPose();
+ pose.translate(((this.getX() + this.width / 2f) - font.width(getMessage()) * textScale / 2), (float)this.getY() + (this.height - 8 * textScale) / 2f / textScale, 0);
+ pose.scale(textScale, textScale, 1);
+ graphics.drawString(font, getMessage(), 0, 0, color | Mth.ceil(this.alpha * 255.0F) << 24, true);
+ pose.popPose();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/TooltipButtonWidget.java b/src/main/java/dev/isxander/yacl3/gui/TooltipButtonWidget.java
new file mode 100644
index 0000000..f439301
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/TooltipButtonWidget.java
@@ -0,0 +1,21 @@
+package dev.isxander.yacl3.gui;
+
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.Tooltip;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipPositioner;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public class TooltipButtonWidget extends Button {
+
+ protected final Screen screen;
+
+ public TooltipButtonWidget(Screen screen, int x, int y, int width, int height, Component message, Component tooltip, OnPress onPress) {
+ super(x, y, width, height, message, onPress, DEFAULT_NARRATION);
+ this.screen = screen;
+ if (tooltip != null)
+ setTooltip(new YACLTooltip(tooltip, this));
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/ValueFormatters.java b/src/main/java/dev/isxander/yacl3/gui/ValueFormatters.java
new file mode 100644
index 0000000..988b257
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/ValueFormatters.java
@@ -0,0 +1,21 @@
+package dev.isxander.yacl3.gui;
+
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import net.minecraft.network.chat.Component;
+
+public final class ValueFormatters {
+ public static ValueFormatter<Float> percent(int decimalPlaces) {
+ return new PercentFormatter(decimalPlaces);
+ }
+
+ public record PercentFormatter(int decimalPlaces) implements ValueFormatter<Float> {
+ public PercentFormatter() {
+ this(1);
+ }
+
+ @Override
+ public Component format(Float value) {
+ return Component.literal(String.format("%." + decimalPlaces + "f%%", value * 100));
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/YACLScreen.java b/src/main/java/dev/isxander/yacl3/gui/YACLScreen.java
new file mode 100644
index 0000000..e88c144
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/YACLScreen.java
@@ -0,0 +1,426 @@
+package dev.isxander.yacl3.gui;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+import com.mojang.blaze3d.vertex.*;
+import com.mojang.math.Axis;
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.api.utils.MutableDimension;
+import dev.isxander.yacl3.api.utils.OptionUtils;
+import dev.isxander.yacl3.gui.tab.ScrollableNavigationBar;
+import dev.isxander.yacl3.gui.tab.ListHolderWidget;
+import dev.isxander.yacl3.gui.tab.TabExt;
+import dev.isxander.yacl3.gui.utils.GuiUtils;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.MultiLineLabel;
+import net.minecraft.client.gui.components.Tooltip;
+import net.minecraft.client.gui.components.tabs.Tab;
+import net.minecraft.client.gui.components.tabs.TabManager;
+import net.minecraft.client.gui.components.tabs.TabNavigationBar;
+import net.minecraft.client.gui.navigation.ScreenRectangle;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.gui.screens.inventory.tooltip.TooltipRenderUtil;
+import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen;
+import net.minecraft.client.renderer.GameRenderer;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+public class YACLScreen extends Screen {
+ public final YetAnotherConfigLib config;
+
+ private final Screen parent;
+
+ public final TabManager tabManager = new TabManager(this::addRenderableWidget, this::removeWidget);
+ public TabNavigationBar tabNavigationBar;
+ public ScreenRectangle tabArea;
+
+ public Component saveButtonMessage;
+ public Tooltip saveButtonTooltipMessage;
+ private int saveButtonMessageTime;
+
+ private boolean pendingChanges;
+
+ public YACLScreen(YetAnotherConfigLib config, Screen parent) {
+ super(config.title());
+ this.config = config;
+ this.parent = parent;
+
+ OptionUtils.forEachOptions(config, option -> {
+ option.addListener((opt, val) -> onOptionChanged(opt));
+ });
+ }
+
+ @Override
+ protected void init() {
+ tabArea = new ScreenRectangle(0, 24 - 1, this.width, this.height - 24 + 1);
+
+ int currentTab = tabNavigationBar != null
+ ? tabNavigationBar.tabs.indexOf(tabManager.getCurrentTab())
+ : 0;
+ if (currentTab == -1)
+ currentTab = 0;
+
+ tabNavigationBar = new ScrollableNavigationBar(this.width, tabManager, config.categories()
+ .stream()
+ .map(category -> {
+ if (category instanceof PlaceholderCategory placeholder)
+ return new PlaceholderTab(placeholder);
+ return new CategoryTab(category);
+ }).toList());
+ tabNavigationBar.selectTab(currentTab, false);
+ tabNavigationBar.arrangeElements();
+ tabManager.setTabArea(tabArea);
+ addRenderableWidget(tabNavigationBar);
+
+ config.initConsumer().accept(this);
+ }
+
+ /*? if <=1.20.4 {*/
+ @Override
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ renderDirtBackground(graphics);
+ super.render(graphics, mouseX, mouseY, delta);
+ }
+ /*?}*/
+
+ @Override
+ public void renderBackground(GuiGraphics guiGraphics/*? if >1.20.1 {*/, int mouseX, int mouseY, float partialTick/*?}*/) {
+ super.renderBackground(guiGraphics/*? if >1.20.1 {*/, mouseX, mouseY, partialTick/*?}*/);
+
+ if (tabManager.getCurrentTab() instanceof TabExt tab) {
+ tab.renderBackground(guiGraphics);
+ }
+ }
+
+ protected void finishOrSave() {
+ saveButtonMessage = null;
+
+ if (pendingChanges()) {
+ Set<OptionFlag> flags = new HashSet<>();
+ OptionUtils.forEachOptions(config, option -> {
+ if (option.applyValue()) {
+ flags.addAll(option.flags());
+ }
+ });
+ OptionUtils.forEachOptions(config, option -> {
+ if (option.changed()) {
+ // if still changed after applying, reset to the current value from binding
+ // as something has gone wrong.
+ option.forgetPendingValue();
+ YACLConstants.LOGGER.error("Option '{}' value mismatch after applying! Reset to binding's getter.", option.name().getString());
+ }
+ });
+ config.saveFunction().run();
+
+ flags.forEach(flag -> flag.accept(minecraft));
+
+ pendingChanges = false;
+ if (tabManager.getCurrentTab() instanceof CategoryTab categoryTab) {
+ categoryTab.updateButtons();
+ }
+ } else onClose();
+ }
+
+ protected void cancelOrReset() {
+ if (pendingChanges()) { // if pending changes, button acts as a cancel button
+ OptionUtils.forEachOptions(config, Option::forgetPendingValue);
+ onClose();
+ } else { // if not, button acts as a reset button
+ OptionUtils.forEachOptions(config, Option::requestSetDefault);
+ }
+ }
+
+ protected void undo() {
+ OptionUtils.forEachOptions(config, Option::forgetPendingValue);
+ }
+
+ @Override
+ public void tick() {
+ if (tabManager.getCurrentTab() instanceof TabExt tabExt) {
+ tabExt.tick();
+ }
+
+ if (tabManager.getCurrentTab() instanceof CategoryTab categoryTab) {
+ if (saveButtonMessage != null) {
+ if (saveButtonMessageTime > 140) {
+ saveButtonMessage = null;
+ saveButtonTooltipMessage = null;
+ saveButtonMessageTime = 0;
+ } else {
+ saveButtonMessageTime++;
+ categoryTab.saveFinishedButton.setMessage(saveButtonMessage);
+ if (saveButtonTooltipMessage != null) {
+ categoryTab.saveFinishedButton.setTooltip(saveButtonTooltipMessage);
+ }
+ }
+ }
+ }
+ }
+
+ private void setSaveButtonMessage(Component message, Component tooltip) {
+ saveButtonMessage = message;
+ saveButtonTooltipMessage = Tooltip.create(tooltip);
+ saveButtonMessageTime = 0;
+ }
+
+ private boolean pendingChanges() {
+ return pendingChanges;
+ }
+
+ private void onOptionChanged(Option<?> option) {
+ pendingChanges = false;
+
+ OptionUtils.consumeOptions(config, opt -> {
+ pendingChanges |= opt.changed();
+ return pendingChanges;
+ });
+
+ if (tabManager.getCurrentTab() instanceof CategoryTab categoryTab) {
+ categoryTab.updateButtons();
+ }
+ }
+
+ @Override
+ public boolean shouldCloseOnEsc() {
+ if (pendingChanges()) {
+ setSaveButtonMessage(Component.translatable("yacl.gui.save_before_exit").withStyle(ChatFormatting.RED), Component.translatable("yacl.gui.save_before_exit.tooltip"));
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void onClose() {
+ minecraft.setScreen(parent);
+ }
+
+ public static void renderMultilineTooltip(GuiGraphics graphics, Font font, MultiLineLabel text, int centerX, int yAbove, int yBelow, int screenWidth, int screenHeight) {
+ if (text.getLineCount() > 0) {
+ int maxWidth = text.getWidth();
+ int lineHeight = font.lineHeight + 1;
+ int height = text.getLineCount() * lineHeight - 1;
+
+ int belowY = yBelow + 12;
+ int aboveY = yAbove - height + 12;
+ int maxBelow = screenHeight - (belowY + height);
+ int minAbove = aboveY - height;
+ int y = aboveY;
+ if (minAbove < 8)
+ y = maxBelow > minAbove ? belowY : aboveY;
+
+ int x = Math.max(centerX - text.getWidth() / 2 - 12, -6);
+
+ int drawX = x + 12;
+ int drawY = y - 12;
+
+ graphics.pose().pushPose();
+ Tesselator tesselator = Tesselator.getInstance();
+ BufferBuilder bufferBuilder = tesselator.getBuilder();
+ RenderSystem.setShader(GameRenderer::getPositionColorShader);
+ bufferBuilder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
+ TooltipRenderUtil.renderTooltipBackground(
+ graphics,
+ drawX,
+ drawY,
+ maxWidth,
+ height,
+ 400
+ );
+ RenderSystem.enableDepthTest();
+ RenderSystem.enableBlend();
+ RenderSystem.defaultBlendFunc();
+ BufferUploader.drawWithShader(bufferBuilder.end());
+ RenderSystem.disableBlend();
+ graphics.pose().translate(0.0, 0.0, 400.0);
+
+ text.renderLeftAligned(graphics, drawX, drawY, lineHeight, -1);
+
+ graphics.pose().popPose();
+ }
+ }
+
+ public class CategoryTab implements TabExt {
+ /*? if >1.20.4 {*//*
+ private static final ResourceLocation DARKER_BG = new ResourceLocation("textures/gui/menu_list_background.png");
+ *//*?}*/
+
+ private final ConfigCategory category;
+ private final Tooltip tooltip;
+
+ private ListHolderWidget<OptionListWidget> optionList;
+ public final Button saveFinishedButton;
+ private final Button cancelResetButton;
+ private final Button undoButton;
+ private final SearchFieldWidget searchField;
+ private OptionDescriptionWidget descriptionWidget;
+
+ private final ScreenRectangle rightPaneDim;
+
+ public CategoryTab(ConfigCategory category) {
+ this.category = category;
+ this.tooltip = Tooltip.create(category.tooltip());
+
+ int columnWidth = width / 3;
+ int padding = columnWidth / 20;
+ columnWidth = Math.min(columnWidth, 400);
+ int paddedWidth = columnWidth - padding * 2;
+ rightPaneDim = new ScreenRectangle(width / 3 * 2, tabArea.top() + 1, width / 3, tabArea.height());
+ MutableDimension<Integer> actionDim = Dimension.ofInt(width / 3 * 2 + width / 6, height - padding - 20, paddedWidth, 20);
+
+ saveFinishedButton = Button.builder(Component.literal("Done"), btn -> finishOrSave())
+ .pos(actionDim.x() - actionDim.width() / 2, actionDim.y())
+ .size(actionDim.width(), actionDim.height())
+ .build();
+
+ actionDim.expand(-actionDim.width() / 2 - 2, 0).move(-actionDim.width() / 2 - 2, -22);
+ cancelResetButton = Button.builder(Component.literal("Cancel"), btn -> cancelOrReset())
+ .pos(actionDim.x() - actionDim.width() / 2, actionDim.y())
+ .size(actionDim.width(), actionDim.height())
+ .build();
+
+ actionDim.move(actionDim.width() + 4, 0);
+ undoButton = Button.builder(Component.translatable("yacl.gui.undo"), btn -> undo())
+ .pos(actionDim.x() - actionDim.width() / 2, actionDim.y())
+ .size(actionDim.width(), actionDim.height())
+ .tooltip(Tooltip.create(Component.translatable("yacl.gui.undo.tooltip")))
+ .build();
+
+ searchField = new SearchFieldWidget(
+ YACLScreen.this,
+ font,
+ width / 3 * 2 + width / 6 - paddedWidth / 2 + 1,
+ undoButton.getY() - 22,
+ paddedWidth - 2, 18,
+ Component.translatable("gui.recipebook.search_hint"),
+ Component.translatable("gui.recipebook.search_hint"),
+ searchQuery -> optionList.getList().updateSearchQuery(searchQuery)
+ );
+
+ this.optionList = new ListHolderWidget<>(
+ () -> new ScreenRectangle(tabArea.position(), tabArea.width() / 3 * 2, tabArea.height()),
+ new OptionListWidget(YACLScreen.this, category, minecraft, 0, 0, width / 3 * 2 + 1, height, desc -> {
+ descriptionWidget.setOptionDescription(desc);
+ })
+ );
+
+ descriptionWidget = new OptionDescriptionWidget(
+ () -> new ScreenRectangle(
+ width / 3 * 2 + padding,
+ tabArea.top() + padding,
+ paddedWidth,
+ searchField.getY() - 1 - tabArea.top() - padding * 2
+ ),
+ null
+ );
+
+ updateButtons();
+ }
+
+ @Override
+ public Component getTabTitle() {
+ return category.name();
+ }
+
+ @Override
+ public void visitChildren(Consumer<AbstractWidget> consumer) {
+ consumer.accept(optionList);
+ consumer.accept(saveFinishedButton);
+ consumer.accept(cancelResetButton);
+ consumer.accept(undoButton);
+ consumer.accept(searchField);
+ consumer.accept(descriptionWidget);
+ }
+
+ /*? if >1.20.4 {*//*
+ @Override
+ public void renderBackground(GuiGraphics graphics) {
+ RenderSystem.enableBlend();
+ // right pane darker db
+ graphics.blit(DARKER_BG, rightPaneDim.left(), rightPaneDim.top(), rightPaneDim.right() + 2, rightPaneDim.bottom() + 2, rightPaneDim.width() + 2, rightPaneDim.height() + 2, 32, 32);
+
+ // top separator for right pane
+ graphics.pose().pushPose();
+ graphics.pose().translate(0, 0, 10);
+ graphics.blit(CreateWorldScreen.HEADER_SEPARATOR, rightPaneDim.left() - 1, rightPaneDim.top() - 2, 0.0F, 0.0F, rightPaneDim.width() + 1, 2, 32, 2);
+ graphics.pose().popPose();
+
+ // left separator for right pane
+ graphics.pose().pushPose();
+ graphics.pose().translate(rightPaneDim.left(), rightPaneDim.top() - 1, 0);
+ graphics.pose().rotateAround(Axis.ZP.rotationDegrees(90), 0, 0, 1);
+ graphics.blit(CreateWorldScreen.FOOTER_SEPARATOR, 0, 0, 0f, 0f, rightPaneDim.height() + 1, 2, 32, 2);
+ graphics.pose().popPose();
+
+ RenderSystem.disableBlend();
+ }
+ *//*?}*/
+
+ @Override
+ public void doLayout(ScreenRectangle screenRectangle) {
+
+ }
+
+ @Override
+ public void tick() {
+ descriptionWidget.tick();
+ }
+
+ @Nullable
+ @Override
+ public Tooltip getTooltip() {
+ return tooltip;
+ }
+
+ public void updateButtons() {
+ boolean pendingChanges = pendingChanges();
+
+ undoButton.active = pendingChanges;
+ saveFinishedButton.setMessage(pendingChanges ? Component.translatable("yacl.gui.save") : GuiUtils.translatableFallback("yacl.gui.done", CommonComponents.GUI_DONE));
+ saveFinishedButton.setTooltip(new YACLTooltip(pendingChanges ? Component.translatable("yacl.gui.save.tooltip") : Component.translatable("yacl.gui.finished.tooltip"), saveFinishedButton));
+ cancelResetButton.setMessage(pendingChanges ? GuiUtils.translatableFallback("yacl.gui.cancel", CommonComponents.GUI_CANCEL) : Component.translatable("controls.reset"));
+ cancelResetButton.setTooltip(new YACLTooltip(pendingChanges ? Component.translatable("yacl.gui.cancel.tooltip") : Component.translatable("yacl.gui.reset.tooltip"), cancelResetButton));
+ }
+ }
+
+ public class PlaceholderTab implements TabExt {
+ private final PlaceholderCategory category;
+ private final Tooltip tooltip;
+
+ public PlaceholderTab(PlaceholderCategory category) {
+ this.category = category;
+ this.tooltip = Tooltip.create(category.tooltip());
+ }
+
+ @Override
+ public Component getTabTitle() {
+ return category.name();
+ }
+
+ @Override
+ public void visitChildren(Consumer<AbstractWidget> consumer) {
+
+ }
+
+ @Override
+ public void doLayout(ScreenRectangle screenRectangle) {
+ minecraft.setScreen(category.screen().apply(minecraft, YACLScreen.this));
+ }
+
+ @Override
+ public @Nullable Tooltip getTooltip() {
+ return this.tooltip;
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/YACLTooltip.java b/src/main/java/dev/isxander/yacl3/gui/YACLTooltip.java
new file mode 100644
index 0000000..94b91a9
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/YACLTooltip.java
@@ -0,0 +1,23 @@
+package dev.isxander.yacl3.gui;
+
+import net.minecraft.client.gui.components.Tooltip;
+import net.minecraft.client.gui.navigation.ScreenRectangle;
+import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipPositioner;
+import net.minecraft.network.chat.Component;
+
+public class YACLTooltip extends Tooltip {
+ private final net.minecraft.client.gui.components.AbstractWidget widget;
+
+ public YACLTooltip(Component tooltip, net.minecraft.client.gui.components.AbstractWidget widget) {
+ super(tooltip, tooltip);
+ this.widget = widget;
+ }
+
+ /*? if >1.20.4 {*//* // stonecutter cannot handle AND expressions
+ *//*? } elif >1.20.1 {*/
+ @Override
+ protected ClientTooltipPositioner createTooltipPositioner(boolean bl, boolean bl2, ScreenRectangle screenRectangle) {
+ return new YACLTooltipPositioner(widget);
+ }
+ /*?}*/
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/YACLTooltipPositioner.java b/src/main/java/dev/isxander/yacl3/gui/YACLTooltipPositioner.java
new file mode 100644
index 0000000..bb87170
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/YACLTooltipPositioner.java
@@ -0,0 +1,48 @@
+package dev.isxander.yacl3.gui;
+
+import net.minecraft.client.gui.navigation.ScreenRectangle;
+import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipPositioner;
+import net.minecraft.util.Mth;
+import org.joml.Vector2i;
+import org.joml.Vector2ic;
+
+import java.util.function.Supplier;
+
+public class YACLTooltipPositioner implements ClientTooltipPositioner {
+ private final Supplier<ScreenRectangle> buttonDimensions;
+
+ public YACLTooltipPositioner(net.minecraft.client.gui.components.AbstractWidget widget) {
+ this.buttonDimensions = widget::getRectangle;
+ }
+
+ public YACLTooltipPositioner(dev.isxander.yacl3.gui.AbstractWidget widget) {
+ this.buttonDimensions = () -> {
+ var dim = widget.getDimension();
+ return new ScreenRectangle(dim.x(), dim.y(), dim.width(), dim.height());
+ };
+ }
+
+ public YACLTooltipPositioner(Supplier<ScreenRectangle> buttonDimensions) {
+ this.buttonDimensions = buttonDimensions;
+ }
+
+ @Override
+ public Vector2ic positionTooltip(int guiWidth, int guiHeight, int x, int y, int width, int height) {
+ ScreenRectangle buttonDimensions = this.buttonDimensions.get();
+
+ int centerX = buttonDimensions.left() + buttonDimensions.width() / 2;
+ int aboveY = buttonDimensions.top() - height - 4;
+ int belowY = buttonDimensions.top() + buttonDimensions.height() + 4;
+
+ int maxBelow = guiHeight - (belowY + height);
+ int minAbove = aboveY - height;
+
+ int yResult = aboveY;
+ if (minAbove < 8)
+ yResult = maxBelow > minAbove ? belowY : aboveY;
+
+ int xResult = Mth.clamp(centerX - width / 2, -4, guiWidth - width - 4);
+
+ return new Vector2i(xResult, yResult);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/ActionController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/ActionController.java
new file mode 100644
index 0000000..77938f6
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/ActionController.java
@@ -0,0 +1,120 @@
+package dev.isxander.yacl3.gui.controllers;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.isxander.yacl3.api.ButtonOption;
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.BiConsumer;
+
+/**
+ * Simple controller that simply runs the button action on press
+ * and renders a {@link} Text on the right.
+ */
+public class ActionController implements Controller<BiConsumer<YACLScreen, ButtonOption>> {
+ public static final Component DEFAULT_TEXT = Component.translatable("yacl.control.action.execute");
+
+ private final ButtonOption option;
+ private final Component text;
+
+ /**
+ * Constructs an action controller
+ * with the default formatter of {@link ActionController#DEFAULT_TEXT}
+ *
+ * @param option bound option
+ */
+ public ActionController(ButtonOption option) {
+ this(option, DEFAULT_TEXT);
+ }
+
+ /**
+ * Constructs an action controller
+ *
+ * @param option bound option
+ * @param text text to display
+ */
+ public ActionController(ButtonOption option, Component text) {
+ this.option = option;
+ this.text = text;
+
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ButtonOption option() {
+ return option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return text;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new ActionControllerElement(this, screen, widgetDimension);
+ }
+
+ public static class ActionControllerElement extends ControllerWidget<ActionController> {
+ private final String buttonString;
+
+ public ActionControllerElement(ActionController control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim);
+ buttonString = control.formatValue().getString().toLowerCase();
+ }
+
+ public void executeAction() {
+ playDownSound();
+ control.option().action().accept(screen, control.option());
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (isMouseOver(mouseX, mouseY) && isAvailable()) {
+ executeAction();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (!focused) {
+ return false;
+ }
+
+ if (keyCode == InputConstants.KEY_RETURN || keyCode == InputConstants.KEY_SPACE || keyCode == InputConstants.KEY_NUMPADENTER) {
+ executeAction();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected int getHoveredControlWidth() {
+ return getUnhoveredControlWidth();
+ }
+
+ @Override
+ public boolean canReset() {
+ return false;
+ }
+
+ @Override
+ public boolean matchesSearch(String query) {
+ return super.matchesSearch(query) || buttonString.contains(query);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/BooleanController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/BooleanController.java
new file mode 100644
index 0000000..cbd6ba5
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/BooleanController.java
@@ -0,0 +1,164 @@
+package dev.isxander.yacl3.gui.controllers;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.function.Function;
+
+/**
+ * This controller renders a simple formatted {@link Component}
+ */
+public class BooleanController implements Controller<Boolean> {
+
+ public static final Function<Boolean, Component> ON_OFF_FORMATTER = (state) ->
+ state
+ ? CommonComponents.OPTION_ON
+ : CommonComponents.OPTION_OFF;
+
+ public static final Function<Boolean, Component> TRUE_FALSE_FORMATTER = (state) ->
+ state
+ ? Component.translatable("yacl.control.boolean.true")
+ : Component.translatable("yacl.control.boolean.false");
+
+ public static final Function<Boolean, Component> YES_NO_FORMATTER = (state) ->
+ state
+ ? CommonComponents.GUI_YES
+ : CommonComponents.GUI_NO;
+
+ private final Option<Boolean> option;
+ private final ValueFormatter<Boolean> valueFormatter;
+ private final boolean coloured;
+
+ /**
+ * Constructs a tickbox controller
+ * with the default value formatter of {@link BooleanController#ON_OFF_FORMATTER}
+ *
+ * @param option bound option
+ */
+ public BooleanController(Option<Boolean> option) {
+ this(option, ON_OFF_FORMATTER, false);
+ }
+
+ /**
+ * Constructs a tickbox controller
+ * with the default value formatter of {@link BooleanController#ON_OFF_FORMATTER}
+ *
+ * @param option bound option
+ * @param coloured value format is green or red depending on the state
+ */
+ public BooleanController(Option<Boolean> option, boolean coloured) {
+ this(option, ON_OFF_FORMATTER, coloured);
+ }
+
+ /**
+ * Constructs a tickbox controller
+ *
+ * @param option bound option
+ * @param valueFormatter format value into any {@link Component}
+ * @param coloured value format is green or red depending on the state
+ */
+ public BooleanController(Option<Boolean> option, Function<Boolean, Component> valueFormatter, boolean coloured) {
+ this.option = option;
+ this.valueFormatter = valueFormatter::apply;
+ this.coloured = coloured;
+ }
+
+ @ApiStatus.Internal
+ public static BooleanController createInternal(Option<Boolean> option, ValueFormatter<Boolean> formatter, boolean coloured) {
+ return new BooleanController(option, formatter::format, coloured);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<Boolean> option() {
+ return option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return valueFormatter.format(option().pendingValue());
+ }
+
+ /**
+ * Value format is green or red depending on the state
+ */
+ public boolean coloured() {
+ return coloured;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new BooleanControllerElement(this, screen, widgetDimension);
+ }
+
+ public static class BooleanControllerElement extends ControllerWidget<BooleanController> {
+ public BooleanControllerElement(BooleanController control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim);
+ }
+
+ @Override
+ protected void drawHoveredControl(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (!isMouseOver(mouseX, mouseY) || !isAvailable())
+ return false;
+
+ toggleSetting();
+ return true;
+ }
+
+ @Override
+ protected int getHoveredControlWidth() {
+ return getUnhoveredControlWidth();
+ }
+
+ public void toggleSetting() {
+ control.option().requestSet(!control.option().pendingValue());
+ playDownSound();
+ }
+
+ @Override
+ protected Component getValueText() {
+ if (control.coloured()) {
+ return super.getValueText().copy().withStyle(control.option().pendingValue() ? ChatFormatting.GREEN : ChatFormatting.RED);
+ }
+
+ return super.getValueText();
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (!isFocused()) {
+ return false;
+ }
+
+ if (keyCode == InputConstants.KEY_RETURN || keyCode == InputConstants.KEY_SPACE || keyCode == InputConstants.KEY_NUMPADENTER) {
+ toggleSetting();
+ return true;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/ColorController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/ColorController.java
new file mode 100644
index 0000000..56e6d30
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/ColorController.java
@@ -0,0 +1,220 @@
+package dev.isxander.yacl3.gui.controllers;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.api.utils.MutableDimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.controllers.string.IStringController;
+import dev.isxander.yacl3.gui.controllers.string.StringControllerElement;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+
+import java.awt.*;
+import java.util.List;
+
+/**
+ * A color controller that uses a hex color field as input.
+ */
+public class ColorController implements IStringController<Color> {
+ private final Option<Color> option;
+ private final boolean allowAlpha;
+
+ /**
+ * Constructs a color controller with {@link ColorController#allowAlpha()} defaulting to false
+ *
+ * @param option bound option
+ */
+ public ColorController(Option<Color> option) {
+ this(option, false);
+ }
+
+ /**
+ * Constructs a color controller
+ *
+ * @param option bound option
+ * @param allowAlpha allows the color input to accept alpha values
+ */
+ public ColorController(Option<Color> option, boolean allowAlpha) {
+ this.option = option;
+ this.allowAlpha = allowAlpha;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<Color> option() {
+ return option;
+ }
+
+ public boolean allowAlpha() {
+ return allowAlpha;
+ }
+
+ @Override
+ public String getString() {
+ return formatValue().getString();
+ }
+
+ @Override
+ public Component formatValue() {
+ MutableComponent text = Component.literal("#");
+ text.append(Component.literal(toHex(option().pendingValue().getRed())).withStyle(ChatFormatting.RED));
+ text.append(Component.literal(toHex(option().pendingValue().getGreen())).withStyle(ChatFormatting.GREEN));
+ text.append(Component.literal(toHex(option().pendingValue().getBlue())).withStyle(ChatFormatting.BLUE));
+ if (allowAlpha()) text.append(toHex(option().pendingValue().getAlpha()));
+ return text;
+ }
+
+ private String toHex(int value) {
+ String hex = Integer.toString(value, 16).toUpperCase();
+ if (hex.length() == 1)
+ hex = "0" + hex;
+ return hex;
+ }
+
+ @Override
+ public void setFromString(String value) {
+ if (value.startsWith("#"))
+ value = value.substring(1);
+
+ int red = Integer.parseInt(value.substring(0, 2), 16);
+ int green = Integer.parseInt(value.substring(2, 4), 16);
+ int blue = Integer.parseInt(value.substring(4, 6), 16);
+
+ if (allowAlpha()) {
+ int alpha = Integer.parseInt(value.substring(6, 8), 16);
+ option().requestSet(new Color(red, green, blue, alpha));
+ } else {
+ option().requestSet(new Color(red, green, blue));
+ }
+ }
+
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new ColorControllerElement(this, screen, widgetDimension);
+ }
+
+ public static class ColorControllerElement extends StringControllerElement {
+ private final ColorController colorController;
+
+ protected MutableDimension<Integer> colorPreviewDim;
+
+ private final List<Character> allowedChars;
+
+ public ColorControllerElement(ColorController control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim, true);
+ this.colorController = control;
+ this.allowedChars = ImmutableList.of('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f');
+ }
+
+ @Override
+ protected void drawValueText(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ if (isHovered()) {
+ colorPreviewDim.move(-inputFieldBounds.width() - 5, 0);
+ super.drawValueText(graphics, mouseX, mouseY, delta);
+ }
+
+ graphics.fill(colorPreviewDim.x(), colorPreviewDim.y(), colorPreviewDim.xLimit(), colorPreviewDim.yLimit(), colorController.option().pendingValue().getRGB());
+ drawOutline(graphics, colorPreviewDim.x(), colorPreviewDim.y(), colorPreviewDim.xLimit(), colorPreviewDim.yLimit(), 1, 0xFF000000);
+ }
+
+ @Override
+ public void write(String string) {
+ if (string.startsWith("0x")) string = string.substring(2);
+ for (char chr : string.toCharArray()) {
+ if (!allowedChars.contains(Character.toLowerCase(chr))) {
+ return;
+ }
+ }
+
+ if (caretPos == 0)
+ return;
+
+ String trimmed = string.substring(0, Math.min(inputField.length() - caretPos, string.length()));
+
+ if (modifyInput(builder -> builder.replace(caretPos, caretPos + trimmed.length(), trimmed))) {
+ caretPos += trimmed.length();
+ setSelectionLength();
+ updateControl();
+ }
+ }
+
+ @Override
+ protected void doBackspace() {
+ if (caretPos > 1) {
+ if (modifyInput(builder -> builder.setCharAt(caretPos - 1, '0'))) {
+ caretPos--;
+ updateControl();
+ }
+ }
+ }
+
+ @Override
+ protected void doDelete() {
+ if (caretPos >= 1) {
+ if (modifyInput(builder -> builder.setCharAt(caretPos, '0'))) {
+ updateControl();
+ }
+ }
+ }
+
+ @Override
+ protected boolean doCut() {
+ return false;
+ }
+
+ @Override
+ protected boolean doCopy() {
+ return false;
+ }
+
+ @Override
+ protected boolean doSelectAll() {
+ return false;
+ }
+
+ protected void setSelectionLength() {
+ selectionLength = caretPos < inputField.length() && caretPos > 0 ? 1 : 0;
+ }
+
+ @Override
+ protected int getDefaultCaretPos() {
+ return colorController.allowAlpha() ? 3 : 1;
+ }
+
+ @Override
+ public void setDimension(Dimension<Integer> dim) {
+ super.setDimension(dim);
+
+ int previewSize = (dim.height() - getYPadding() * 2) / 2;
+ colorPreviewDim = Dimension.ofInt(dim.xLimit() - getXPadding() - previewSize, dim.centerY() - previewSize / 2, previewSize, previewSize);
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ int prevSelectionLength = selectionLength;
+ selectionLength = 0;
+ if (super.keyPressed(keyCode, scanCode, modifiers)) {
+ caretPos = Math.max(1, caretPos);
+ setSelectionLength();
+ return true;
+ } else selectionLength = prevSelectionLength;
+ return false;
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (super.mouseClicked(mouseX, mouseY, button)) {
+ caretPos = Math.max(1, caretPos);
+ setSelectionLength();
+ return true;
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/ControllerWidget.java b/src/main/java/dev/isxander/yacl3/gui/controllers/ControllerWidget.java
new file mode 100644
index 0000000..19fe2f6
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/ControllerWidget.java
@@ -0,0 +1,148 @@
+package dev.isxander.yacl3.gui.controllers;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.utils.GuiUtils;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.ComponentPath;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.MultiLineLabel;
+import net.minecraft.client.gui.narration.NarratedElementType;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.gui.navigation.FocusNavigationEvent;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.Nullable;
+
+public abstract class ControllerWidget<T extends Controller<?>> extends AbstractWidget {
+ protected final T control;
+ protected MultiLineLabel wrappedTooltip;
+ protected final YACLScreen screen;
+
+ protected boolean focused = false;
+ protected boolean hovered = false;
+
+ protected final Component modifiedOptionName;
+ protected final String optionNameString;
+
+ public ControllerWidget(T control, YACLScreen screen, Dimension<Integer> dim) {
+ super(dim);
+ this.control = control;
+ this.screen = screen;
+ control.option().addListener((opt, pending) -> updateTooltip());
+ updateTooltip();
+ this.modifiedOptionName = control.option().name().copy().withStyle(ChatFormatting.ITALIC);
+ this.optionNameString = control.option().name().getString().toLowerCase();
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ hovered = isMouseOver(mouseX, mouseY);
+
+ Component name = control.option().changed() ? modifiedOptionName : control.option().name();
+ Component shortenedName = Component.literal(GuiUtils.shortenString(name.getString(), textRenderer, getDimension().width() - getControlWidth() - getXPadding() - 7, "...")).setStyle(name.getStyle());
+
+ drawButtonRect(graphics, getDimension().x(), getDimension().y(), getDimension().xLimit(), getDimension().yLimit(), hovered || focused, isAvailable());
+ graphics.drawString(textRenderer, shortenedName, getDimension().x() + getXPadding(), getTextY(), getValueColor(), true);
+
+
+ drawValueText(graphics, mouseX, mouseY, delta);
+ if (isHovered()) {
+ drawHoveredControl(graphics, mouseX, mouseY, delta);
+ }
+ }
+
+ protected void drawHoveredControl(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+
+ }
+
+ protected void drawValueText(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ Component valueText = getValueText();
+ graphics.drawString(textRenderer, valueText, getDimension().xLimit() - textRenderer.width(valueText) - getXPadding(), getTextY(), getValueColor(), true);
+ }
+
+ private void updateTooltip() {
+ this.wrappedTooltip = MultiLineLabel.create(textRenderer, control.option().tooltip(), screen.width / 3 * 2 - 10);
+ }
+
+ protected int getControlWidth() {
+ return isHovered() ? getHoveredControlWidth() : getUnhoveredControlWidth();
+ }
+
+ public boolean isHovered() {
+ return isAvailable() && (hovered || focused);
+ }
+
+ protected abstract int getHoveredControlWidth();
+
+ protected int getUnhoveredControlWidth() {
+ return textRenderer.width(getValueText());
+ }
+
+ protected int getXPadding() {
+ return 5;
+ }
+
+ protected int getYPadding() {
+ return 2;
+ }
+
+ protected Component getValueText() {
+ return control.formatValue();
+ }
+
+ protected boolean isAvailable() {
+ return control.option().available();
+ }
+
+ protected int getValueColor() {
+ return isAvailable() ? -1 : inactiveColor;
+ }
+
+ @Override
+ public boolean canReset() {
+ return true;
+ }
+
+ protected int getTextY() {
+ return (int)(getDimension().y() + getDimension().height() / 2f - textRenderer.lineHeight / 2f);
+ }
+
+ @Nullable
+ @Override
+ public ComponentPath nextFocusPath(FocusNavigationEvent focusNavigationEvent) {
+ return !this.isFocused() ? ComponentPath.leaf(this) : null;
+ }
+
+ @Override
+ public boolean isFocused() {
+ return focused;
+ }
+
+ @Override
+ public void setFocused(boolean focused) {
+ this.focused = focused;
+ }
+
+ @Override
+ public void unfocus() {
+ this.focused = false;
+ }
+
+ @Override
+ public boolean matchesSearch(String query) {
+ return optionNameString.contains(query.toLowerCase());
+ }
+
+ @Override
+ public NarrationPriority narrationPriority() {
+ return focused ? NarrationPriority.FOCUSED : isHovered() ? NarrationPriority.HOVERED : NarrationPriority.NONE;
+ }
+
+ @Override
+ public void updateNarration(NarrationElementOutput builder) {
+ builder.add(NarratedElementType.TITLE, control.option().name());
+ builder.add(NarratedElementType.HINT, control.option().tooltip());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/LabelController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/LabelController.java
new file mode 100644
index 0000000..fee6c19
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/LabelController.java
@@ -0,0 +1,193 @@
+package dev.isxander.yacl3.gui.controllers;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.client.gui.ComponentPath;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.MultiLineLabel;
+import net.minecraft.client.gui.narration.NarratedElementType;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.gui.navigation.FocusNavigationEvent;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.HoverEvent;
+import net.minecraft.network.chat.Style;
+import net.minecraft.util.FormattedCharSequence;
+import net.minecraft.world.item.ItemStack;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * Simply renders some text as a label.
+ */
+public class LabelController implements Controller<Component> {
+ private final Option<Component> option;
+ /**
+ * Constructs a label controller
+ *
+ * @param option bound option
+ */
+ public LabelController(Option<Component> option) {
+ this.option = option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<Component> option() {
+ return option;
+ }
+
+ @Override
+ public Component formatValue() {
+ return option().pendingValue();
+ }
+
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new LabelControllerElement(screen, widgetDimension);
+ }
+
+ public class LabelControllerElement extends AbstractWidget {
+ private List<FormattedCharSequence> wrappedText;
+ protected MultiLineLabel wrappedTooltip;
+ protected boolean focused;
+
+ protected final YACLScreen screen;
+
+ public LabelControllerElement(YACLScreen screen, Dimension<Integer> dim) {
+ super(dim);
+ this.screen = screen;
+ option().addListener((opt, pending) -> updateTooltip());
+ updateTooltip();
+ updateText();
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ updateText();
+
+ int y = getDimension().y();
+ for (FormattedCharSequence text : wrappedText) {
+ graphics.drawString(textRenderer, text, getDimension().x() + getXPadding(), y + getYPadding(), option().available() ? -1 : 0xFFA0A0A0, true);
+ y += textRenderer.lineHeight;
+ }
+
+ if (isFocused()) {
+ graphics.fill(getDimension().x() - 1, getDimension().y() - 1, getDimension().xLimit() + 1, getDimension().y(), -1);
+ graphics.fill(getDimension().x() - 1, getDimension().y() - 1, getDimension().x(), getDimension().yLimit() + 1, -1);
+ graphics.fill(getDimension().x() - 1, getDimension().yLimit(), getDimension().xLimit() + 1, getDimension().yLimit() + 1, -1);
+ graphics.fill(getDimension().xLimit(), getDimension().y() - 1, getDimension().xLimit() + 1, getDimension().yLimit() + 1, -1);
+ }
+
+ graphics.pose().pushPose();
+ graphics.pose().translate(0, 0, 100);
+ if (isMouseOver(mouseX, mouseY)) {
+ YACLScreen.renderMultilineTooltip(graphics, textRenderer, wrappedTooltip, getDimension().centerX(), getDimension().y() - 5, getDimension().yLimit() + 5, screen.width, screen.height);
+
+ Style style = getStyle(mouseX, mouseY);
+ if (style != null && style.getHoverEvent() != null) {
+ HoverEvent hoverEvent = style.getHoverEvent();
+ HoverEvent.ItemStackInfo itemStackContent = hoverEvent.getValue(HoverEvent.Action.SHOW_ITEM);
+ if (itemStackContent != null) {
+ ItemStack stack = itemStackContent.getItemStack();
+ graphics.renderTooltip(textRenderer, Screen.getTooltipFromItem(client, stack), stack.getTooltipImage(), mouseX, mouseY);
+ } else {
+ HoverEvent.EntityTooltipInfo entityContent = hoverEvent.getValue(HoverEvent.Action.SHOW_ENTITY);
+ if (entityContent != null) {
+ if (this.client.options.advancedItemTooltips) {
+ graphics.renderComponentTooltip(textRenderer, entityContent.getTooltipLines(), mouseX, mouseY);
+ }
+ } else {
+ Component text = hoverEvent.getValue(HoverEvent.Action.SHOW_TEXT);
+ if (text != null) {
+ MultiLineLabel multilineText = MultiLineLabel.create(textRenderer, text, getDimension().width());
+ YACLScreen.renderMultilineTooltip(graphics, textRenderer, multilineText, getDimension().centerX(), getDimension().y(), getDimension().yLimit(), screen.width, screen.height);
+ }
+ }
+ }
+ }
+ }
+ graphics.pose().popPose();
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (!isMouseOver(mouseX, mouseY))
+ return false;
+
+ Style style = getStyle((int) mouseX, (int) mouseY);
+ return screen.handleComponentClicked(style);
+ }
+
+ protected Style getStyle(int mouseX, int mouseY) {
+ if (!getDimension().isPointInside(mouseX, mouseY))
+ return null;
+
+ int x = mouseX - getDimension().x();
+ int y = mouseY - getDimension().y() - getYPadding();
+ int line = y / textRenderer.lineHeight;
+
+ if (x < 0 || x > getDimension().xLimit()) return null;
+ if (y < 0 || y > getDimension().yLimit()) return null;
+ if (line < 0 || line >= wrappedText.size()) return null;
+
+ return textRenderer.getSplitter().componentStyleAtWidth(wrappedText.get(line), x);
+ }
+
+ private int getXPadding() {
+ return 4;
+ }
+
+ private int getYPadding() {
+ return 3;
+ }
+
+ private void updateText() {
+ wrappedText = textRenderer.split(formatValue(), getDimension().width() - getXPadding() * 2);
+ setDimension(getDimension().withHeight(wrappedText.size() * textRenderer.lineHeight + getYPadding() * 2));
+ }
+
+ private void updateTooltip() {
+ this.wrappedTooltip = MultiLineLabel.create(textRenderer, option().tooltip(), screen.width / 3 * 2 - 10);
+ }
+
+ @Override
+ public boolean matchesSearch(String query) {
+ return formatValue().getString().toLowerCase().contains(query.toLowerCase());
+ }
+
+ @Nullable
+ @Override
+ public ComponentPath nextFocusPath(FocusNavigationEvent focusNavigationEvent) {
+ if (!option().available())
+ return null;
+ return !this.isFocused() ? ComponentPath.leaf(this) : null;
+ }
+
+ @Override
+ public boolean isFocused() {
+ return focused;
+ }
+
+ @Override
+ public void setFocused(boolean focused) {
+ this.focused = focused;
+ }
+
+ @Override
+ public void updateNarration(NarrationElementOutput builder) {
+ builder.add(NarratedElementType.TITLE, formatValue());
+ }
+
+ @Override
+ public NarrationPriority narrationPriority() {
+ return NarrationPriority.FOCUSED;
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/ListEntryWidget.java b/src/main/java/dev/isxander/yacl3/gui/controllers/ListEntryWidget.java
new file mode 100644
index 0000000..7e71cc7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/ListEntryWidget.java
@@ -0,0 +1,128 @@
+package dev.isxander.yacl3.gui.controllers;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.ListOption;
+import dev.isxander.yacl3.api.ListOptionEntry;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.TooltipButtonWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.events.ContainerEventHandler;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class ListEntryWidget extends AbstractWidget implements ContainerEventHandler {
+ private final TooltipButtonWidget removeButton, moveUpButton, moveDownButton;
+ private final AbstractWidget entryWidget;
+
+ private final ListOption<?> listOption;
+ private final ListOptionEntry<?> listOptionEntry;
+
+ private final String optionNameString;
+
+ private GuiEventListener focused;
+ private boolean dragging;
+
+ public ListEntryWidget(YACLScreen screen, ListOptionEntry<?> listOptionEntry, AbstractWidget entryWidget) {
+ super(entryWidget.getDimension().withHeight(Math.max(entryWidget.getDimension().height(), 20) - ((listOptionEntry.parentGroup().indexOf(listOptionEntry) == listOptionEntry.parentGroup().options().size() - 1) ? 0 : 2))); // -2 to remove the padding
+ this.listOptionEntry = listOptionEntry;
+ this.listOption = listOptionEntry.parentGroup();
+ this.optionNameString = listOptionEntry.name().getString().toLowerCase();
+ this.entryWidget = entryWidget;
+
+ Dimension<Integer> dim = entryWidget.getDimension();
+ entryWidget.setDimension(dim.clone().move(20 * 2, 0).expand(-20 * 3, 0));
+
+ removeButton = new TooltipButtonWidget(screen, dim.xLimit() - 20, dim.y(), 20, 20, Component.literal("\u274c"), Component.translatable("yacl.list.remove"), btn -> {
+ listOption.removeEntry(listOptionEntry);
+ updateButtonStates();
+ });
+
+ moveUpButton = new TooltipButtonWidget(screen, dim.x(), dim.y(), 20, 20, Component.literal("\u2191"), Component.translatable("yacl.list.move_up"), btn -> {
+ int index = listOption.indexOf(listOptionEntry) - 1;
+ if (index >= 0) {
+ listOption.removeEntry(listOptionEntry);
+ listOption.insertEntry(index, listOptionEntry);
+ updateButtonStates();
+ }
+ });
+
+ moveDownButton = new TooltipButtonWidget(screen, dim.x() + 20, dim.y(), 20, 20, Component.literal("\u2193"), Component.translatable("yacl.list.move_down"), btn -> {
+ int index = listOption.indexOf(listOptionEntry) + 1;
+ if (index < listOption.options().size()) {
+ listOption.removeEntry(listOptionEntry);
+ listOption.insertEntry(index, listOptionEntry);
+ updateButtonStates();
+ }
+ });
+
+ updateButtonStates();
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ updateButtonStates(); // update every render in case option becomes available/unavailable
+
+ removeButton.setY(getDimension().y());
+ moveUpButton.setY(getDimension().y());
+ moveDownButton.setY(getDimension().y());
+ entryWidget.setDimension(entryWidget.getDimension().withY(getDimension().y()));
+
+ removeButton.render(graphics, mouseX, mouseY, delta);
+ moveUpButton.render(graphics, mouseX, mouseY, delta);
+ moveDownButton.render(graphics, mouseX, mouseY, delta);
+ entryWidget.render(graphics, mouseX, mouseY, delta);
+ }
+
+ protected void updateButtonStates() {
+ removeButton.active = listOption.available() && listOption.numberOfEntries() > listOption.minimumNumberOfEntries();
+ moveUpButton.active = listOption.indexOf(listOptionEntry) > 0 && listOption.available();
+ moveDownButton.active = listOption.indexOf(listOptionEntry) < listOption.options().size() - 1 && listOption.available();
+ }
+
+ @Override
+ public void unfocus() {
+ entryWidget.unfocus();
+ }
+
+ @Override
+ public void updateNarration(NarrationElementOutput builder) {
+ entryWidget.updateNarration(builder);
+ }
+
+ @Override
+ public boolean matchesSearch(String query) {
+ return optionNameString.contains(query.toLowerCase());
+ }
+
+ @Override
+ public List<? extends GuiEventListener> children() {
+ return ImmutableList.of(moveUpButton, moveDownButton, entryWidget, removeButton);
+ }
+
+ @Override
+ public boolean isDragging() {
+ return dragging;
+ }
+
+ @Override
+ public void setDragging(boolean dragging) {
+ this.dragging = dragging;
+ }
+
+ @Nullable
+ @Override
+ public GuiEventListener getFocused() {
+ return focused;
+ }
+
+ @Override
+ public void setFocused(@Nullable GuiEventListener focused) {
+ this.focused = focused;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/TickBoxController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/TickBoxController.java
new file mode 100644
index 0000000..de19c14
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/TickBoxController.java
@@ -0,0 +1,119 @@
+package dev.isxander.yacl3.gui.controllers;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.network.chat.Component;
+
+/**
+ * This controller renders a tickbox
+ */
+public class TickBoxController implements Controller<Boolean> {
+ private final Option<Boolean> option;
+
+ /**
+ * Constructs a tickbox controller
+ *
+ * @param option bound option
+ */
+ public TickBoxController(Option<Boolean> option) {
+ this.option = option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<Boolean> option() {
+ return option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return Component.empty();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new TickBoxControllerElement(this, screen, widgetDimension);
+ }
+
+ public static class TickBoxControllerElement extends ControllerWidget<TickBoxController> {
+ public TickBoxControllerElement(TickBoxController control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim);
+ }
+
+ @Override
+ protected void drawHoveredControl(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ int outlineSize = 10;
+ int outlineX1 = getDimension().xLimit() - getXPadding() - outlineSize;
+ int outlineY1 = getDimension().centerY() - outlineSize / 2;
+ int outlineX2 = getDimension().xLimit() - getXPadding();
+ int outlineY2 = getDimension().centerY() + outlineSize / 2;
+
+ int color = getValueColor();
+ int shadowColor = multiplyColor(color, 0.25f);
+
+ drawOutline(graphics, outlineX1 + 1, outlineY1 + 1, outlineX2 + 1, outlineY2 + 1, 1, shadowColor);
+ drawOutline(graphics, outlineX1, outlineY1, outlineX2, outlineY2, 1, color);
+ if (control.option().pendingValue()) {
+ graphics.fill(outlineX1 + 3, outlineY1 + 3, outlineX2 - 1, outlineY2 - 1, shadowColor);
+ graphics.fill(outlineX1 + 2, outlineY1 + 2, outlineX2 - 2, outlineY2 - 2, color);
+ }
+ }
+
+ @Override
+ protected void drawValueText(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ if (!isHovered())
+ drawHoveredControl(graphics, mouseX, mouseY, delta);
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (!isMouseOver(mouseX, mouseY) || !isAvailable())
+ return false;
+
+ toggleSetting();
+ return true;
+ }
+
+ @Override
+ protected int getHoveredControlWidth() {
+ return 10;
+ }
+
+ @Override
+ protected int getUnhoveredControlWidth() {
+ return 10;
+ }
+
+ public void toggleSetting() {
+ control.option().requestSet(!control.option().pendingValue());
+ playDownSound();
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (!focused) {
+ return false;
+ }
+
+ if (keyCode == InputConstants.KEY_RETURN || keyCode == InputConstants.KEY_SPACE || keyCode == InputConstants.KEY_NUMPADENTER) {
+ toggleSetting();
+ return true;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/CyclingControllerElement.java b/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/CyclingControllerElement.java
new file mode 100644
index 0000000..3d85afe
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/CyclingControllerElement.java
@@ -0,0 +1,60 @@
+package dev.isxander.yacl3.gui.controllers.cycling;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.controllers.ControllerWidget;
+import net.minecraft.client.gui.screens.Screen;
+
+public class CyclingControllerElement extends ControllerWidget<ICyclingController<?>> {
+
+ public CyclingControllerElement(ICyclingController<?> control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim);
+ }
+
+ public void cycleValue(int increment) {
+ int targetIdx = control.getPendingValue() + increment;
+ if (targetIdx >= control.getCycleLength()) {
+ targetIdx -= control.getCycleLength();
+ } else if (targetIdx < 0) {
+ targetIdx += control.getCycleLength();
+ }
+ control.setPendingValue(targetIdx);
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (!isMouseOver(mouseX, mouseY) || (button != 0 && button != 1) || !isAvailable())
+ return false;
+
+ playDownSound();
+ cycleValue(button == 1 || Screen.hasShiftDown() || Screen.hasControlDown() ? -1 : 1);
+
+ return true;
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (!focused)
+ return false;
+
+ switch (keyCode) {
+ case InputConstants.KEY_LEFT ->
+ cycleValue(-1);
+ case InputConstants.KEY_RIGHT ->
+ cycleValue(1);
+ case InputConstants.KEY_RETURN, InputConstants.KEY_SPACE, InputConstants.KEY_NUMPADENTER ->
+ cycleValue(Screen.hasControlDown() || Screen.hasShiftDown() ? -1 : 1);
+ default -> {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ protected int getHoveredControlWidth() {
+ return getUnhoveredControlWidth();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/CyclingListController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/CyclingListController.java
new file mode 100644
index 0000000..3fce3cf
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/CyclingListController.java
@@ -0,0 +1,86 @@
+package dev.isxander.yacl3.gui.controllers.cycling;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.function.Function;
+
+/**
+ * A controller where once clicked, cycles through elements
+ * in the provided list.
+ */
+public class CyclingListController<T> implements ICyclingController<T> {
+ private final Option<T> option;
+ private final ValueFormatter<T> valueFormatter;
+ private final ImmutableList<T> values;
+
+ /**
+ * Constructs a {@link CyclingListController}, with a default
+ * value formatter of {@link Object#toString()}.
+ * @param option option of which to bind the controller to
+ * @param values the values to cycle through
+ */
+ public CyclingListController(Option<T> option, Iterable<? extends T> values) {
+ this(option, values, value -> Component.literal(value.toString()));
+ }
+
+ /**
+ * Constructs a {@link CyclingListController}
+ * @param option option of which to bind the controller to
+ * @param values the values to cycle through
+ * @param valueFormatter function of how to convert each value to a string to display
+ */
+ public CyclingListController(Option<T> option, Iterable<? extends T> values, Function<T, Component> valueFormatter) {
+ this.option = option;
+ this.valueFormatter = valueFormatter::apply;
+ this.values = ImmutableList.copyOf(values);
+ }
+
+ @ApiStatus.Internal
+ public static <T> CyclingListController<T> createInternal(Option<T> option, Iterable<? extends T> values, ValueFormatter<T> formatter) {
+ return new CyclingListController<>(option, values, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<T> option() {
+ return option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return valueFormatter.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(int ordinal) {
+ option().requestSet(values.get(ordinal));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int getPendingValue() {
+ return values.indexOf(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int getCycleLength() {
+ return values.size();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/EnumController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/EnumController.java
new file mode 100644
index 0000000..5a6a912
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/EnumController.java
@@ -0,0 +1,48 @@
+package dev.isxander.yacl3.gui.controllers.cycling;
+
+import dev.isxander.yacl3.api.NameableEnum;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import net.minecraft.network.chat.Component;
+import net.minecraft.util.OptionEnum;
+
+import java.util.Arrays;
+import java.util.function.Function;
+
+/**
+ * Simple controller type that displays the enum on the right.
+ * <p>
+ * Cycles forward with left click, cycles backward with right click or when shift is held
+ *
+ * @param <T> enum type
+ */
+public class EnumController<T extends Enum<T>> extends CyclingListController<T> {
+ public static <T extends Enum<T>> Function<T, Component> getDefaultFormatter() {
+ return value -> {
+ if (value instanceof NameableEnum nameableEnum)
+ return nameableEnum.getDisplayName();
+ if (value instanceof OptionEnum translatableOption)
+ return translatableOption.getCaption();
+ return Component.literal(value.toString());
+ };
+ }
+
+ public EnumController(Option<T> option, Class<T> enumClass) {
+ this(option, getDefaultFormatter(), enumClass.getEnumConstants());
+ }
+
+ /**
+ * Constructs a cycling enum controller.
+ *
+ * @param option bound option
+ * @param valueFormatter format the enum into any {@link Component}
+ * @param availableValues all enum constants that can be cycled through
+ */
+ public EnumController(Option<T> option, Function<T, Component> valueFormatter, T[] availableValues) {
+ super(option, Arrays.asList(availableValues), valueFormatter);
+ }
+
+ public static <T extends Enum<T>> EnumController<T> createInternal(Option<T> option, ValueFormatter<T> formatter, T[] values) {
+ return new EnumController<>(option, formatter::format, values);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/ICyclingController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/ICyclingController.java
new file mode 100644
index 0000000..cfddefa
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/cycling/ICyclingController.java
@@ -0,0 +1,38 @@
+package dev.isxander.yacl3.gui.controllers.cycling;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+
+/**
+ * This interface simply generifies setting and getting of
+ * the pending value, using an ordinal so elements can cycle through
+ * without knowing the content.
+ */
+public interface ICyclingController<T> extends Controller<T> {
+ /**
+ * Sets the pending value to whatever corresponds to the ordinal
+ * @param ordinal index of element to set
+ */
+ void setPendingValue(int ordinal);
+
+ /**
+ * Gets the pending ordinal that corresponds to the actual value
+ * @return ordinal
+ */
+ int getPendingValue();
+
+ /**
+ * Allows the element when it should wrap-around back to zeroth ordinal
+ */
+ int getCycleLength();
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ default AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new CyclingControllerElement(this, screen, widgetDimension);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownController.java
new file mode 100644
index 0000000..b913d15
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownController.java
@@ -0,0 +1,87 @@
+package dev.isxander.yacl3.gui.controllers.dropdown;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.gui.controllers.string.IStringController;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public abstract class AbstractDropdownController<T> implements IStringController<T> {
+ protected final Option<T> option;
+ private final List<String> allowedValues;
+ public final boolean allowEmptyValue;
+ public final boolean allowAnyValue;
+
+ /**
+ * Constructs a dropdown controller
+ *
+ * @param option bound option
+ * @param allowedValues possible values
+ */
+ protected AbstractDropdownController(Option<T> option, List<String> allowedValues, boolean allowEmptyValue, boolean allowAnyValue) {
+ this.option = option;
+ this.allowedValues = allowedValues;
+ this.allowEmptyValue = allowEmptyValue;
+ this.allowAnyValue = allowAnyValue;
+ }
+
+ protected AbstractDropdownController(Option<T> option, List<String> allowedValues) {
+ this(option, allowedValues, false, false);
+ }
+
+ protected AbstractDropdownController(Option<T> option) {
+ this(option, Collections.emptyList());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<T> option() {
+ return option;
+ }
+
+ public List<String> getAllowedValues() {
+ return getAllowedValues("");
+ }
+ public List<String> getAllowedValues(String inputField) {
+ List<String> values = new ArrayList<>(allowedValues);
+ if (allowEmptyValue && !values.contains("")) values.add("");
+ if (allowAnyValue && !inputField.isBlank() && !allowedValues.contains(inputField)) {
+ values.add(inputField);
+ }
+ String currentValue = getString();
+ if (allowAnyValue && !allowedValues.contains(currentValue)) {
+ values.add(currentValue);
+ }
+ return values;
+ }
+
+ public boolean isValueValid(String value) {
+ if (value.isBlank()) return allowEmptyValue;
+ return allowAnyValue || getAllowedValues().contains(value);
+ }
+
+ protected String getValidValue(String value) {
+ return getValidValue(value, 0);
+ }
+ protected String getValidValue(String value, int offset) {
+ if (offset == -1) return getString();
+
+ String valueLowerCase = value.toLowerCase();
+ return getAllowedValues(value).stream()
+ .filter(val -> val.toLowerCase().contains(valueLowerCase))
+ .sorted((s1, s2) -> {
+ String s1LowerCase = s1.toLowerCase();
+ String s2LowerCase = s2.toLowerCase();
+ if (s1LowerCase.startsWith(valueLowerCase) && !s2LowerCase.startsWith(valueLowerCase)) return -1;
+ if (!s1LowerCase.startsWith(valueLowerCase) && s2LowerCase.startsWith(valueLowerCase)) return 1;
+ return s1.compareTo(s2);
+ })
+ .skip(offset)
+ .findFirst()
+ .orElseGet(this::getString);
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownControllerElement.java b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownControllerElement.java
new file mode 100644
index 0000000..49e0c0e
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownControllerElement.java
@@ -0,0 +1,248 @@
+package dev.isxander.yacl3.gui.controllers.dropdown;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import com.mojang.blaze3d.vertex.PoseStack;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.controllers.string.StringControllerElement;
+import dev.isxander.yacl3.gui.utils.GuiUtils;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+
+import java.awt.Color;
+import java.util.List;
+import java.util.function.Consumer;
+
+public abstract class AbstractDropdownControllerElement<T, U> extends StringControllerElement {
+ public static final int MAX_SHOWN_NUMBER_OF_ITEMS = 7;
+
+ private final AbstractDropdownController<T> dropdownController;
+ protected boolean dropdownVisible = false;
+ // Stores the current selection position. The item at this position in the dropdown list will be chosen as the
+ // accepted value when the element is closed.
+ protected int selectedIndex = 0;
+ // Stores a cached list of matching values
+ protected List<U> matchingValues = null;
+
+ public AbstractDropdownControllerElement(AbstractDropdownController<T> control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim, false);
+ this.dropdownController = control;
+ this.dropdownController.option.addListener((opt, val) -> this.matchingValues = this.computeMatchingValues());
+ }
+
+ public void showDropdown() {
+ dropdownVisible = true;
+ selectedIndex = 0;
+ }
+
+ public void closeDropdown() {
+ dropdownVisible = false;
+ ensureValidValue();
+ }
+
+ public void ensureValidValue() {
+ inputField = dropdownController.getValidValue(inputField, selectedIndex);
+ this.matchingValues = this.computeMatchingValues();
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (super.mouseClicked(mouseX, mouseY, button)) {
+ if (!dropdownVisible) {
+ showDropdown();
+ doSelectAll();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void setFocused(boolean focused) {
+ if (focused) {
+ doSelectAll();
+ super.setFocused(true);
+ } else unfocus();
+ }
+
+ @Override
+ public void unfocus() {
+ closeDropdown();
+ super.unfocus();
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (!inputFieldFocused)
+ return false;
+ if (dropdownVisible) {
+ switch (keyCode) {
+ case InputConstants.KEY_DOWN -> {
+ selectNextEntry();
+ return true;
+ }
+ case InputConstants.KEY_UP -> {
+ selectPreviousEntry();
+ return true;
+ }
+ case InputConstants.KEY_TAB -> {
+ if (Screen.hasShiftDown()) {
+ selectPreviousEntry();
+ } else {
+ selectNextEntry();
+ }
+ return true;
+ }
+ }
+ } else {
+ if (keyCode == InputConstants.KEY_RETURN || keyCode == InputConstants.KEY_NUMPADENTER) {
+ showDropdown();
+ return true;
+ }
+ }
+ return super.keyPressed(keyCode, scanCode, modifiers);
+ }
+
+ @Override
+ public boolean charTyped(char chr, int modifiers) {
+ if (!dropdownVisible) {
+ showDropdown();
+ }
+ return super.charTyped(chr, modifiers);
+ }
+
+ @Override
+ protected int getValueColor() {
+ if (inputFieldFocused) {
+ if (!dropdownController.isValueValid(inputField)) {
+ return 0xFFF06080;
+ }
+ }
+ return super.getValueColor();
+ }
+
+ public void selectNextEntry() {
+ if (selectedIndex == getDropdownLength() - 1) {
+ selectedIndex = 0;
+ } else {
+ selectedIndex++;
+ }
+ }
+
+ public void selectPreviousEntry() {
+ if (selectedIndex == 0) {
+ selectedIndex = getDropdownLength() - 1;
+ } else {
+ selectedIndex--;
+ }
+ }
+
+ public int getDropdownLength() {
+ return matchingValues.size();
+ }
+
+ @Override
+ public boolean modifyInput(Consumer<StringBuilder> builder) {
+ boolean success = super.modifyInput(builder);
+ if (success) {
+ this.matchingValues = this.computeMatchingValues();
+ }
+ return success;
+ }
+
+ public abstract List<U> computeMatchingValues();
+
+ public boolean matchingValue(String value) {
+ return value.toLowerCase().contains(inputField.toLowerCase());
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ if (matchingValues == null) matchingValues = computeMatchingValues();
+
+ super.render(graphics, mouseX, mouseY, delta);
+
+ if (inputFieldFocused && dropdownVisible) {
+ PoseStack matrices = graphics.pose();
+ matrices.pushPose();
+ matrices.translate(0, 0, 200);
+ renderDropdown(graphics);
+ matrices.popPose();
+ }
+ }
+
+ public void renderDropdown(GuiGraphics graphics) {
+ if (matchingValues.isEmpty()) return;
+ // Limit the visible options to allow scrolling through the suggestion list
+ int begin = Math.max(0, selectedIndex - MAX_SHOWN_NUMBER_OF_ITEMS / 2);
+ int end = begin + MAX_SHOWN_NUMBER_OF_ITEMS;
+ if (end >= matchingValues.size()) {
+ end = matchingValues.size();
+ begin = Math.max(0, end - MAX_SHOWN_NUMBER_OF_ITEMS);
+ }
+
+ renderDropdownBackground(graphics, end - begin);
+ if (!matchingValues.isEmpty()) {
+ // Highlight the currently selected element
+ graphics.setColor(0.0f, 0.0f, 0.0f, 0.5f);
+ int x = getDimension().x();
+ int y = getDimension().yLimit() + 2 + getDimension().height() * (selectedIndex - begin);
+ graphics.fill(x, y, x + getDimension().width(), y + getDimension().height(), -1);
+ graphics.setColor(1.0f, 1.0f, 1.0f, 1.0f);
+ graphics.renderOutline(x, y, getDimension().width(), getDimension().height(), -1);
+
+ }
+
+ int n = 1;
+ for (int i = begin; i < end; ++i) {
+ renderDropdownEntry(graphics, matchingValues.get(i), n);
+ ++n;
+ }
+ }
+
+ protected int getDropdownEntryPadding() {
+ return 0;
+ }
+
+ protected void renderDropdownEntry(GuiGraphics graphics, U value, int n) {
+ String entry = getString(value);
+ int color = -1;
+ Component text;
+ if (entry.isBlank()) {
+ text = Component.translatable("yacl.control.text.blank").withStyle(ChatFormatting.GRAY);
+ } else {
+ text = shortenString(entry);
+ }
+ graphics.drawString(textRenderer, text, getDimension().xLimit() - textRenderer.width(text) - getDecorationPadding() - getDropdownEntryPadding(), getTextY() + n * getDimension().height() + 2, color, true);
+ }
+
+ public abstract String getString(U object);
+
+ public Component shortenString(String value) {
+ return Component.literal(GuiUtils.shortenString(value, textRenderer, getDimension().width() - 20, "..."));
+ }
+
+ public void renderDropdownBackground(GuiGraphics graphics, int numberOfItems) {
+ graphics.setColor(0.25f, 0.25f, 0.25f, 1.0f);
+ graphics.blit(
+ /*? if >1.20.4 {*//*
+ Screen.MENU_BACKGROUND,
+ *//*?} else {*/
+ Screen.BACKGROUND_LOCATION,
+ /*?}*/
+ getDimension().x(), getDimension().yLimit() + 2, 0,
+ 0.0f, 0.0f,
+ getDimension().width(), getDimension().height() * numberOfItems + 2,
+ 32, 32
+ );
+ graphics.setColor(1.0f, 1.0f, 1.0f, 1.0f);
+ graphics.renderOutline(getDimension().x(), getDimension().yLimit() + 2, getDimension().width(), getDimension().height() * numberOfItems, -1);
+ }
+
+ protected int getDecorationPadding() {
+ return super.getXPadding();
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringController.java
new file mode 100644
index 0000000..fafc759
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringController.java
@@ -0,0 +1,34 @@
+package dev.isxander.yacl3.gui.controllers.dropdown;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+
+import java.util.List;
+
+public class DropdownStringController extends AbstractDropdownController<String> {
+
+ public DropdownStringController(Option<String> option, List<String> allowedValues, boolean allowEmptyValue, boolean allowAnyValue) {
+ super(option, allowedValues, allowEmptyValue, allowAnyValue);
+ }
+
+ @Override
+ public String getString() {
+ return option().pendingValue();
+ }
+
+ @Override
+ public void setFromString(String value) {
+ option().requestSet(getValidValue(value));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new DropdownStringControllerElement(this, screen, widgetDimension);
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringControllerElement.java b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringControllerElement.java
new file mode 100644
index 0000000..615aada
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringControllerElement.java
@@ -0,0 +1,31 @@
+package dev.isxander.yacl3.gui.controllers.dropdown;
+
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.YACLScreen;
+
+import java.util.List;
+
+public class DropdownStringControllerElement extends AbstractDropdownControllerElement<String, String> {
+ private final DropdownStringController controller;
+
+ public DropdownStringControllerElement(DropdownStringController control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim);
+ this.controller = control;
+ }
+
+ @Override
+ public List<String> computeMatchingValues() {
+ return controller.getAllowedValues(inputField).stream()
+ .filter(this::matchingValue)
+ .sorted((s1, s2) -> {
+ if (s1.startsWith(inputField) && !s2.startsWith(inputField)) return -1;
+ if (!s1.startsWith(inputField) && s2.startsWith(inputField)) return 1;
+ return s1.compareTo(s2);
+ })
+ .toList();
+ }
+
+ public String getString(String object) {
+ return object;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/EnumDropdownController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/EnumDropdownController.java
new file mode 100644
index 0000000..8e6e0e6
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/EnumDropdownController.java
@@ -0,0 +1,92 @@
+package dev.isxander.yacl3.gui.controllers.dropdown;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+public class EnumDropdownController<E extends Enum<E>> extends AbstractDropdownController<E> {
+ /**
+ * The function used to convert enum constants to strings used for display, suggestion, and validation. Defaults to {@link Enum#toString}.
+ */
+ protected final ValueFormatter<E> formatter;
+
+ public EnumDropdownController(Option<E> option, ValueFormatter<E> formatter) {
+ super(option, Arrays.stream(option.pendingValue().getDeclaringClass().getEnumConstants()).map(formatter::format).map(Component::getString).toList());
+ this.formatter = formatter;
+ }
+
+ @Override
+ public String getString() {
+ return formatter.format(option().pendingValue()).getString();
+ }
+
+ @Override
+ public void setFromString(String value) {
+ option().requestSet(getEnumFromString(value));
+ }
+
+ /**
+ * Searches through enum constants for one whose {@link #formatter} result equals {@code value}
+ *
+ * @return The enum constant associated with the {@code value} or the pending value if none are found
+ * @implNote The return value of {@link #formatter} on each enum constant should be unique in order to ensure accuracy
+ */
+ private E getEnumFromString(String value) {
+ value = value.toLowerCase();
+ for (E constant : option().pendingValue().getDeclaringClass().getEnumConstants()) {
+ if (formatter.format(constant).getString().toLowerCase().equals(value)) return constant;
+ }
+
+ return option().pendingValue();
+ }
+
+ @Override
+ public boolean isValueValid(String value) {
+ value = value.toLowerCase();
+ for (String constant : getAllowedValues()) {
+ if (constant.equals(value)) return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected String getValidValue(String value, int offset) {
+ return getValidEnumConstants(value)
+ .skip(offset)
+ .findFirst()
+ .orElseGet(this::getString);
+ }
+
+ /**
+ * Filters and sorts through enum constants for those whose {@link #formatter} result equals {@code value}
+ *
+ * @return a sorted stream containing enum constants associated with the {@code value}
+ * @implNote The return value of {@link #formatter} on each enum constant should be unique in order to ensure accuracy
+ */
+ @NotNull
+ protected Stream<String> getValidEnumConstants(String value) {
+ String valueLowerCase = value.toLowerCase();
+ return getAllowedValues().stream()
+ .filter(constant -> constant.toLowerCase().contains(valueLowerCase))
+ .sorted((s1, s2) -> {
+ String s1LowerCase = s1.toLowerCase();
+ String s2LowerCase = s2.toLowerCase();
+ if (s1LowerCase.startsWith(valueLowerCase) && !s2LowerCase.startsWith(valueLowerCase)) return -1;
+ if (!s1LowerCase.startsWith(valueLowerCase) && s2LowerCase.startsWith(valueLowerCase)) return 1;
+ return s1.compareTo(s2);
+ });
+ }
+
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new EnumDropdownControllerElement<>(this, screen, widgetDimension);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/EnumDropdownControllerElement.java b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/EnumDropdownControllerElement.java
new file mode 100644
index 0000000..2df6f6b
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/EnumDropdownControllerElement.java
@@ -0,0 +1,25 @@
+package dev.isxander.yacl3.gui.controllers.dropdown;
+
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.YACLScreen;
+
+import java.util.List;
+
+public class EnumDropdownControllerElement<E extends Enum<E>> extends AbstractDropdownControllerElement<E, String> {
+ private final EnumDropdownController<E> controller;
+
+ public EnumDropdownControllerElement(EnumDropdownController<E> control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim);
+ this.controller = control;
+ }
+
+ @Override
+ public List<String> computeMatchingValues() {
+ return controller.getValidEnumConstants(inputField).toList();
+ }
+
+ @Override
+ public String getString(String object) {
+ return object;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemController.java
new file mode 100644
index 0000000..ac903c7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemController.java
@@ -0,0 +1,68 @@
+package dev.isxander.yacl3.gui.controllers.dropdown;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.utils.ItemRegistryHelper;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.Item;
+
+/**
+ * Simple controller that simply runs the button action on press
+ * and renders a {@link} Text on the right.
+ */
+public class ItemController extends AbstractDropdownController<Item> {
+
+ /**
+ * Constructs an item controller
+ *
+ * @param option bound option
+ */
+ public ItemController(Option<Item> option) {
+ super(option);
+ }
+
+ @Override
+ public String getString() {
+ return BuiltInRegistries.ITEM.getKey(option.pendingValue()).toString();
+ }
+
+ @Override
+ public void setFromString(String value) {
+ option.requestSet(ItemRegistryHelper.getItemFromName(value, option.pendingValue()));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return Component.literal(getString());
+ }
+
+
+ @Override
+ public boolean isValueValid(String value) {
+ return ItemRegistryHelper.isRegisteredItem(value);
+ }
+
+ @Override
+ protected String getValidValue(String value, int offset) {
+ return ItemRegistryHelper.getMatchingItemIdentifiers(value)
+ .skip(offset)
+ .findFirst()
+ .map(ResourceLocation::toString)
+ .orElseGet(this::getString);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new ItemControllerElement(this, screen, widgetDimension);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemControllerElement.java b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemControllerElement.java
new file mode 100644
index 0000000..1617c41
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemControllerElement.java
@@ -0,0 +1,87 @@
+package dev.isxander.yacl3.gui.controllers.dropdown;
+
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.utils.ItemRegistryHelper;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+public class ItemControllerElement extends AbstractDropdownControllerElement<Item, ResourceLocation> {
+ private final ItemController itemController;
+ protected Item currentItem = null;
+ protected Map<ResourceLocation, Item> matchingItems = new HashMap<>();
+
+
+ public ItemControllerElement(ItemController control, YACLScreen screen, Dimension<Integer> dim) {
+ super(control, screen, dim);
+ this.itemController = control;
+ }
+
+ @Override
+ protected void drawValueText(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ var oldDimension = getDimension();
+ setDimension(getDimension().withWidth(getDimension().width() - getDecorationPadding()));
+ super.drawValueText(graphics, mouseX, mouseY, delta);
+ setDimension(oldDimension);
+ if (currentItem != null) {
+ graphics.renderFakeItem(new ItemStack(currentItem), getDimension().xLimit() - getXPadding() - getDecorationPadding() + 2, getDimension().y() + 2);
+ }
+ }
+
+ @Override
+ public List<ResourceLocation> computeMatchingValues() {
+ List<ResourceLocation> identifiers = ItemRegistryHelper.getMatchingItemIdentifiers(inputField).toList();
+ currentItem = ItemRegistryHelper.getItemFromName(inputField, null);
+ for (ResourceLocation identifier : identifiers) {
+ matchingItems.put(identifier, BuiltInRegistries.ITEM.get(identifier));
+ }
+ return identifiers;
+ }
+
+ @Override
+ protected void renderDropdownEntry(GuiGraphics graphics, ResourceLocation identifier, int n) {
+ super.renderDropdownEntry(graphics, identifier, n);
+ graphics.renderFakeItem(new ItemStack(matchingItems.get(identifier)), getDimension().xLimit() - getDecorationPadding() - 2, getDimension().y() + n * getDimension().height() + 4);
+ }
+
+ @Override
+ public String getString(ResourceLocation identifier) {
+ return identifier.toString();
+ }
+
+ @Override
+ protected int getDecorationPadding() {
+ return 16;
+ }
+
+ @Override
+ protected int getDropdownEntryPadding() {
+ return 4;
+ }
+
+ @Override
+ protected int getControlWidth() {
+ return super.getControlWidth() + getDecorationPadding();
+ }
+
+ @Override
+ protected Component getValueText() {
+ if (inputField.isEmpty() || itemController == null)
+ return super.getValueText();
+
+ if (inputFieldFocused)
+ return Component.literal(inputField);
+
+ return itemController.option().pendingValue().getDescription();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/package-info.java b/src/main/java/dev/isxander/yacl3/gui/controllers/package-info.java
new file mode 100644
index 0000000..1819a64
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/package-info.java
@@ -0,0 +1,12 @@
+/**
+ * This package contains all {@link dev.isxander.yacl3.api.Controller} implementations
+ *
+ * <ul>
+ * <li>For numbers: {@link dev.isxander.yacl3.gui.controllers.slider}</li>
+ * <li>For booleans: {@link dev.isxander.yacl3.gui.controllers.TickBoxController}</li>
+ * <li>For lists/enums: {@link dev.isxander.yacl3.gui.controllers.cycling}</li>
+ * <li>For strings: {@link dev.isxander.yacl3.gui.controllers.string.StringController}</li>
+ * <li>For {@link dev.isxander.yacl3.api.ButtonOption}: {@link dev.isxander.yacl3.gui.controllers.ActionController}</li>
+ * </ul>
+ */
+package dev.isxander.yacl3.gui.controllers;
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/slider/DoubleSliderController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/DoubleSliderController.java
new file mode 100644
index 0000000..89308a8
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/DoubleSliderController.java
@@ -0,0 +1,119 @@
+package dev.isxander.yacl3.gui.controllers.slider;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+
+import java.util.function.Function;
+
+/**
+ * {@link ISliderController} for doubles.
+ */
+public class DoubleSliderController implements ISliderController<Double> {
+ /**
+ * Formats doubles to two decimal places
+ */
+ public static final Function<Double, Component> DEFAULT_FORMATTER = value -> Component.literal(String.format("%,.2f", value).replaceAll("[\u00a0\u202F]", " "));
+
+ private final Option<Double> option;
+
+ private final double min, max, interval;
+
+ private final ValueFormatter<Double> valueFormatter;
+
+ /**
+ * Constructs a {@link ISliderController} for doubles
+ * using the default value formatter {@link DoubleSliderController#DEFAULT_FORMATTER}.
+ *
+ * @param option bound option
+ * @param min minimum slider value
+ * @param max maximum slider value
+ * @param interval step size (or increments) for the slider
+ */
+ public DoubleSliderController(Option<Double> option, double min, double max, double interval) {
+ this(option, min, max, interval, DEFAULT_FORMATTER);
+ }
+
+ /**
+ * Constructs a {@link ISliderController} for doubles.
+ *
+ * @param option bound option
+ * @param min minimum slider value
+ * @param max maximum slider value
+ * @param interval step size (or increments) for the slider
+ * @param valueFormatter format the value into any {@link Component}
+ */
+ public DoubleSliderController(Option<Double> option, double min, double max, double interval, Function<Double, Component> valueFormatter) {
+ Validate.isTrue(max > min, "`max` cannot be smaller than `min`");
+ Validate.isTrue(interval > 0, "`interval` must be more than 0");
+ Validate.notNull(valueFormatter, "`valueFormatter` must not be null");
+
+ this.option = option;
+ this.min = min;
+ this.max = max;
+ this.interval = interval;
+ this.valueFormatter = valueFormatter::apply;
+ }
+
+ public static DoubleSliderController createInternal(Option<Double> option, double min, double max, double interval, ValueFormatter<Double> formatter) {
+ return new DoubleSliderController(option, min, max, interval, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<Double> option() {
+ return option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return valueFormatter.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double min() {
+ return min;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double max() {
+ return max;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double interval() {
+ return interval;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(double value) {
+ option().requestSet(value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double pendingValue() {
+ return option().pendingValue();
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/slider/FloatSliderController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/FloatSliderController.java
new file mode 100644
index 0000000..79246dd
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/FloatSliderController.java
@@ -0,0 +1,119 @@
+package dev.isxander.yacl3.gui.controllers.slider;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+
+import java.util.function.Function;
+
+/**
+ * {@link ISliderController} for floats.
+ */
+public class FloatSliderController implements ISliderController<Float> {
+ /**
+ * Formats floats to one decimal place
+ */
+ public static final Function<Float, Component> DEFAULT_FORMATTER = value -> Component.literal(String.format("%,.1f", value).replaceAll("[\u00a0\u202F]", " "));
+
+ private final Option<Float> option;
+
+ private final float min, max, interval;
+
+ private final ValueFormatter<Float> valueFormatter;
+
+ /**
+ * Constructs a {@link ISliderController} for floats
+ * using the default value formatter {@link FloatSliderController#DEFAULT_FORMATTER}.
+ *
+ * @param option bound option
+ * @param min minimum slider value
+ * @param max maximum slider value
+ * @param interval step size (or increments) for the slider
+ */
+ public FloatSliderController(Option<Float> option, float min, float max, float interval) {
+ this(option, min, max, interval, DEFAULT_FORMATTER);
+ }
+
+ /**
+ * Constructs a {@link ISliderController} for floats.
+ *
+ * @param option bound option
+ * @param min minimum slider value
+ * @param max maximum slider value
+ * @param interval step size (or increments) for the slider
+ * @param valueFormatter format the value into any {@link Component}
+ */
+ public FloatSliderController(Option<Float> option, float min, float max, float interval, Function<Float, Component> valueFormatter) {
+ Validate.isTrue(max > min, "`max` cannot be smaller than `min`");
+ Validate.isTrue(interval > 0, "`interval` must be more than 0");
+ Validate.notNull(valueFormatter, "`valueFormatter` must not be null");
+
+ this.option = option;
+ this.min = min;
+ this.max = max;
+ this.interval = interval;
+ this.valueFormatter = valueFormatter::apply;
+ }
+
+ public static FloatSliderController createInternal(Option<Float> option, float min, float max, float interval, ValueFormatter<Float> formatter) {
+ return new FloatSliderController(option, min, max, interval, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<Float> option() {
+ return option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return valueFormatter.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double min() {
+ return min;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double max() {
+ return max;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double interval() {
+ return interval;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(double value) {
+ option().requestSet((float) value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double pendingValue() {
+ return option().pendingValue();
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/slider/ISliderController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/ISliderController.java
new file mode 100644
index 0000000..4a3f36b
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/ISliderController.java
@@ -0,0 +1,54 @@
+package dev.isxander.yacl3.gui.controllers.slider;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+
+/**
+ * Simple custom slider implementation that shifts the current value across when shown.
+ * <p>
+ * For simplicity, {@link SliderControllerElement} works in doubles so each
+ * {@link ISliderController} must cast to double. This is to get around re-writing the element for every type.
+ */
+public interface ISliderController<T extends Number> extends Controller<T> {
+ /**
+ * Gets the minimum value for the slider
+ */
+ double min();
+
+ /**
+ * Gets the maximum value for the slider
+ */
+ double max();
+
+ /**
+ * Gets the interval (or step size) for the slider.
+ */
+ double interval();
+
+ /**
+ * Gets the range of the slider.
+ */
+ default double range() {
+ return max() - min();
+ }
+
+ /**
+ * Sets the {@link dev.isxander.yacl3.api.Option}'s pending value
+ */
+ void setPendingValue(double value);
+
+ /**
+ * Gets the {@link dev.isxander.yacl3.api.Option}'s pending value
+ */
+ double pendingValue();
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ default AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new SliderControllerElement(this, screen, widgetDimension, min(), max(), interval());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/slider/IntegerSliderController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/IntegerSliderController.java
new file mode 100644
index 0000000..bcb551d
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/IntegerSliderController.java
@@ -0,0 +1,116 @@
+package dev.isxander.yacl3.gui.controllers.slider;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+
+import java.util.function.Function;
+
+/**
+ * {@link ISliderController} for integers.
+ */
+public class IntegerSliderController implements ISliderController<Integer> {
+ public static final Function<Integer, Component> DEFAULT_FORMATTER = value -> Component.literal(String.format("%,d", value).replaceAll("[\u00a0\u202F]", " "));
+
+ private final Option<Integer> option;
+
+ private final int min, max, interval;
+
+ private final ValueFormatter<Integer> valueFormatter;
+
+ /**
+ * Constructs a {@link ISliderController} for integers
+ * using the default value formatter {@link IntegerSliderController#DEFAULT_FORMATTER}.
+ *
+ * @param option bound option
+ * @param min minimum slider value
+ * @param max maximum slider value
+ * @param interval step size (or increments) for the slider
+ */
+ public IntegerSliderController(Option<Integer> option, int min, int max, int interval) {
+ this(option, min, max, interval, DEFAULT_FORMATTER);
+ }
+
+ /**
+ * Constructs a {@link ISliderController} for integers.
+ *
+ * @param option bound option
+ * @param min minimum slider value
+ * @param max maximum slider value
+ * @param interval step size (or increments) for the slider
+ * @param valueFormatter format the value into any {@link Component}
+ */
+ public IntegerSliderController(Option<Integer> option, int min, int max, int interval, Function<Integer, Component> valueFormatter) {
+ Validate.isTrue(max > min, "`max` cannot be smaller than `min`");
+ Validate.isTrue(interval > 0, "`interval` must be more than 0");
+ Validate.notNull(valueFormatter, "`valueFormatter` must not be null");
+
+ this.option = option;
+ this.min = min;
+ this.max = max;
+ this.interval = interval;
+ this.valueFormatter = valueFormatter::apply;
+ }
+
+ public static IntegerSliderController createInternal(Option<Integer> option, int min, int max, int interval, ValueFormatter<Integer> formatter) {
+ return new IntegerSliderController(option, min, max, interval, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<Integer> option() {
+ return option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return valueFormatter.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double min() {
+ return min;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double max() {
+ return max;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double interval() {
+ return interval;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(double value) {
+ option().requestSet((int) value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double pendingValue() {
+ return option().pendingValue();
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/slider/LongSliderController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/LongSliderController.java
new file mode 100644
index 0000000..105bd46
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/LongSliderController.java
@@ -0,0 +1,116 @@
+package dev.isxander.yacl3.gui.controllers.slider;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+
+import java.util.function.Function;
+
+/**
+ * {@link ISliderController} for longs.
+ */
+public class LongSliderController implements ISliderController<Long> {
+ public static final Function<Long, Component> DEFAULT_FORMATTER = value -> Component.literal(String.format("%,d", value).replaceAll("[\u00a0\u202F]", " "));
+
+ private final Option<Long> option;
+
+ private final long min, max, interval;
+
+ private final ValueFormatter<Long> valueFormatter;
+
+ /**
+ * Constructs a {@link ISliderController} for longs
+ * using the default value formatter {@link LongSliderController#DEFAULT_FORMATTER}.
+ *
+ * @param option bound option
+ * @param min minimum slider value
+ * @param max maximum slider value
+ * @param interval step size (or increments) for the slider
+ */
+ public LongSliderController(Option<Long> option, long min, long max, long interval) {
+ this(option, min, max, interval, DEFAULT_FORMATTER);
+ }
+
+ /**
+ * Constructs a {@link ISliderController} for longs.
+ *
+ * @param option bound option
+ * @param min minimum slider value
+ * @param max maximum slider value
+ * @param interval step size (or increments) for the slider
+ * @param valueFormatter format the value into any {@link Component}
+ */
+ public LongSliderController(Option<Long> option, long min, long max, long interval, Function<Long, Component> valueFormatter) {
+ Validate.isTrue(max > min, "`max` cannot be smaller than `min`");
+ Validate.isTrue(interval > 0, "`interval` must be more than 0");
+ Validate.notNull(valueFormatter, "`valueFormatter` must not be null");
+
+ this.option = option;
+ this.min = min;
+ this.max = max;
+ this.interval = interval;
+ this.valueFormatter = valueFormatter::apply;
+ }
+
+ public static LongSliderController createInternal(Option<Long> option, long min, long max, long interval, ValueFormatter<Long> formatter) {
+ return new LongSliderController(option, min, max, interval, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<Long> option() {
+ return option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Component formatValue() {
+ return valueFormatter.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double min() {
+ return min;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double max() {
+ return max;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double interval() {
+ return interval;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(double value) {
+ option().requestSet((long) value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double pendingValue() {
+ return option().pendingValue();
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/slider/SliderControllerElement.java b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/SliderControllerElement.java
new file mode 100644
index 0000000..05e8da3
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/SliderControllerElement.java
@@ -0,0 +1,157 @@
+package dev.isxander.yacl3.gui.controllers.slider;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.controllers.ControllerWidget;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.util.Mth;
+
+public class SliderControllerElement extends ControllerWidget<ISliderController<?>> {
+ private final double min, max, interval;
+
+ private float interpolation;
+
+ private Dimension<Integer> sliderBounds;
+
+ private boolean mouseDown = false;
+
+ public SliderControllerElement(ISliderController<?> option, YACLScreen screen, Dimension<Integer> dim, double min, double max, double interval) {
+ super(option, screen, dim);
+ this.min = min;
+ this.max = max;
+ this.interval = interval;
+ setDimension(dim);
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ super.render(graphics, mouseX, mouseY, delta);
+
+ calculateInterpolation();
+ }
+
+ @Override
+ protected void drawHoveredControl(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ // track
+ graphics.fill(sliderBounds.x(), sliderBounds.centerY() - 1, sliderBounds.xLimit(), sliderBounds.centerY(), -1);
+ // track shadow
+ graphics.fill(sliderBounds.x() + 1, sliderBounds.centerY(), sliderBounds.xLimit() + 1, sliderBounds.centerY() + 1, 0xFF404040);
+
+ // thumb shadow
+ graphics.fill(getThumbX() - getThumbWidth() / 2 + 1, sliderBounds.y() + 1, getThumbX() + getThumbWidth() / 2 + 1, sliderBounds.yLimit() + 1, 0xFF404040);
+ // thumb
+ graphics.fill(getThumbX() - getThumbWidth() / 2, sliderBounds.y(), getThumbX() + getThumbWidth() / 2, sliderBounds.yLimit(), -1);
+ }
+
+ @Override
+ protected void drawValueText(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ graphics.pose().pushPose();
+ if (isHovered())
+ graphics.pose().translate(-(sliderBounds.width() + 6 + getThumbWidth() / 2f), 0, 0);
+ super.drawValueText(graphics, mouseX, mouseY, delta);
+ graphics.pose().popPose();
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (!isAvailable() || button != 0 || !sliderBounds.isPointInside((int) mouseX, (int) mouseY))
+ return false;
+
+ mouseDown = true;
+
+ setValueFromMouse(mouseX);
+ return true;
+ }
+
+ @Override
+ public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
+ if (!isAvailable() || button != 0 || !mouseDown)
+ return false;
+
+ setValueFromMouse(mouseX);
+ return true;
+ }
+
+ public void incrementValue(double amount) {
+ control.setPendingValue(Mth.clamp(control.pendingValue() + interval * amount, min, max));
+ calculateInterpolation();
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, /*? if >1.20.2 {*/ double horizontal, /*?}*/ double vertical) {
+ if (!isAvailable() || (!isMouseOver(mouseX, mouseY)) || (!Screen.hasShiftDown() && !Screen.hasControlDown()))
+ return false;
+
+ incrementValue(vertical);
+ return true;
+ }
+
+ @Override
+ public boolean mouseReleased(double mouseX, double mouseY, int button) {
+ if (isAvailable() && mouseDown)
+ playDownSound();
+ mouseDown = false;
+
+ return super.mouseReleased(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (!focused)
+ return false;
+
+ switch (keyCode) {
+ case InputConstants.KEY_LEFT -> incrementValue(-1);
+ case InputConstants.KEY_RIGHT -> incrementValue(1);
+ default -> {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean isMouseOver(double mouseX, double mouseY) {
+ return super.isMouseOver(mouseX, mouseY) || mouseDown;
+ }
+
+ protected void setValueFromMouse(double mouseX) {
+ double value = (mouseX - sliderBounds.x()) / sliderBounds.width() * control.range();
+ control.setPendingValue(roundToInterval(value));
+ calculateInterpolation();
+ }
+
+ protected double roundToInterval(double value) {
+ return Mth.clamp(min + (interval * Math.round(value / interval)), min, max); // extremely imprecise, requires clamping
+ }
+
+ @Override
+ protected int getHoveredControlWidth() {
+ return sliderBounds.width() + getUnhoveredControlWidth() + 6 + getThumbWidth() / 2;
+ }
+
+ protected void calculateInterpolation() {
+ interpolation = Mth.clamp((float) ((control.pendingValue() - control.min()) * 1 / control.range()), 0f, 1f);
+ }
+
+ @Override
+ public void setDimension(Dimension<Integer> dim) {
+ super.setDimension(dim);
+ int trackWidth = dim.width() / 3;
+ if (optionNameString.isEmpty())
+ trackWidth = dim.width() / 2;
+
+ sliderBounds = Dimension.ofInt(dim.xLimit() - getXPadding() - getThumbWidth() / 2 - trackWidth, dim.centerY() - 5, trackWidth, 10);
+ }
+
+ protected int getThumbX() {
+ return (int) (sliderBounds.x() + sliderBounds.width() * interpolation);
+ }
+
+ protected int getThumbWidth() {
+ return 4;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/slider/package-info.java b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/package-info.java
new file mode 100644
index 0000000..e2cb0e3
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/slider/package-info.java
@@ -0,0 +1,10 @@
+/**
+ * This package contains implementations of sliders for different number types
+ * <ul>
+ * <li>For doubles: {@link dev.isxander.yacl3.gui.controllers.slider.DoubleSliderController}</li>
+ * <li>For floats: {@link dev.isxander.yacl3.gui.controllers.slider.FloatSliderController}</li>
+ * <li>For integers: {@link dev.isxander.yacl3.gui.controllers.slider.IntegerSliderController}</li>
+ * <li>For longs: {@link dev.isxander.yacl3.gui.controllers.slider.LongSliderController}</li>
+ * </ul>
+ */
+package dev.isxander.yacl3.gui.controllers.slider;
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/IStringController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/IStringController.java
new file mode 100644
index 0000000..14d10dd
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/IStringController.java
@@ -0,0 +1,44 @@
+package dev.isxander.yacl3.gui.controllers.string;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.network.chat.Component;
+
+/**
+ * A controller that can be any type but can input and output a string.
+ */
+public interface IStringController<T> extends Controller<T> {
+ /**
+ * Gets the option's pending value as a string.
+ *
+ * @see Option#pendingValue()
+ */
+ String getString();
+
+ /**
+ * Sets the option's pending value from a string.
+ *
+ * @see Option#requestSet(Object)
+ */
+ void setFromString(String value);
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ default Component formatValue() {
+ return Component.literal(getString());
+ }
+
+ default boolean isInputValid(String input) {
+ return true;
+ }
+
+ @Override
+ default AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new StringControllerElement(this, screen, widgetDimension, true);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringController.java
new file mode 100644
index 0000000..4bafc0f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringController.java
@@ -0,0 +1,37 @@
+package dev.isxander.yacl3.gui.controllers.string;
+
+import dev.isxander.yacl3.api.Option;
+
+/**
+ * A custom text field implementation for strings.
+ */
+public class StringController implements IStringController<String> {
+ private final Option<String> option;
+
+ /**
+ * Constructs a string controller
+ *
+ * @param option bound option
+ */
+ public StringController(Option<String> option) {
+ this.option = option;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Option<String> option() {
+ return option;
+ }
+
+ @Override
+ public String getString() {
+ return option().pendingValue();
+ }
+
+ @Override
+ public void setFromString(String value) {
+ option().requestSet(value);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringControllerElement.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringControllerElement.java
new file mode 100644
index 0000000..689d8e2
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringControllerElement.java
@@ -0,0 +1,466 @@
+package dev.isxander.yacl3.gui.controllers.string;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.controllers.ControllerWidget;
+import dev.isxander.yacl3.gui.utils.GuiUtils;
+import dev.isxander.yacl3.gui.utils.UndoRedoHelper;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Consumer;
+
+public class StringControllerElement extends ControllerWidget<IStringController<?>> {
+ protected final boolean instantApply;
+
+ protected String inputField;
+ protected Dimension<Integer> inputFieldBounds;
+ protected boolean inputFieldFocused;
+
+ protected int caretPos;
+ protected int previousCaretPos;
+ protected int selectionLength;
+ protected int renderOffset;
+
+ protected UndoRedoHelper undoRedoHelper;
+
+ protected float ticks;
+ protected float caretTicks;
+
+ private final Component emptyText;
+
+ public StringControllerElement(IStringController<?> control, YACLScreen screen, Dimension<Integer> dim, boolean instantApply) {
+ super(control, screen, dim);
+ this.instantApply = instantApply;
+ inputField = control.getString();
+ inputFieldFocused = false;
+ selectionLength = 0;
+ emptyText = Component.literal("Click to type...").withStyle(ChatFormatting.GRAY);
+ control.option().addListener((opt, val) -> {
+ inputField = control.getString();
+ });
+ setDimension(dim);
+ }
+
+ @Override
+ protected void drawHoveredControl(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+
+ }
+
+ @Override
+ protected void drawValueText(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ Component valueText = getValueText();
+ if (!isHovered()) valueText = Component.literal(GuiUtils.shortenString(valueText.getString(), textRenderer, getMaxUnwrapLength(), "...")).setStyle(valueText.getStyle());
+
+ int textX = getDimension().xLimit() - textRenderer.width(valueText) + renderOffset - getXPadding();
+ graphics.enableScissor(inputFieldBounds.x(), inputFieldBounds.y() - 2, inputFieldBounds.xLimit() + 1, inputFieldBounds.yLimit() + 4);
+ graphics.drawString(textRenderer, valueText, textX, getTextY(), getValueColor(), true);
+
+ if (isHovered()) {
+ ticks += delta;
+
+ String text = getValueText().getString();
+
+ graphics.fill(inputFieldBounds.x(), inputFieldBounds.yLimit(), inputFieldBounds.xLimit(), inputFieldBounds.yLimit() + 1, -1);
+ graphics.fill(inputFieldBounds.x() + 1, inputFieldBounds.yLimit() + 1, inputFieldBounds.xLimit() + 1, inputFieldBounds.yLimit() + 2, 0xFF404040);
+
+ if (inputFieldFocused || focused) {
+ if (caretPos > text.length())
+ caretPos = text.length();
+
+ int caretX = textX + textRenderer.width(text.substring(0, caretPos));
+ if (text.isEmpty())
+ caretX = inputFieldBounds.x() + inputFieldBounds.width() / 2;
+
+ if (selectionLength != 0) {
+ int selectionX = textX + textRenderer.width(text.substring(0, caretPos + selectionLength));
+ graphics.fill(caretX, inputFieldBounds.y() - 2, selectionX, inputFieldBounds.yLimit() - 1, 0x803030FF);
+ }
+
+ if(caretPos != previousCaretPos) {
+ previousCaretPos = caretPos;
+ caretTicks = 0;
+ }
+
+ if ((caretTicks += delta) % 20 <= 10)
+ graphics.fill(caretX, inputFieldBounds.y() - 2, caretX + 1, inputFieldBounds.yLimit() - 1, -1);
+ }
+ }
+ graphics.disableScissor();
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (isAvailable() && getDimension().isPointInside((int) mouseX, (int) mouseY)) {
+ inputFieldFocused = true;
+
+ if (!inputFieldBounds.isPointInside((int) mouseX, (int) mouseY)) {
+ caretPos = getDefaultCaretPos();
+ } else {
+ // gets the appropriate caret position for where you click
+ int textX = (int) mouseX - (inputFieldBounds.xLimit() - textRenderer.width(getValueText()));
+ int pos = -1;
+ int currentWidth = 0;
+ for (char ch : inputField.toCharArray()) {
+ pos++;
+ int charLength = textRenderer.width(String.valueOf(ch));
+ if (currentWidth + charLength / 2 > textX) { // if more than halfway past the characters select in front of that char
+ caretPos = pos;
+ break;
+ } else if (pos == inputField.length() - 1) {
+ // if we have reached the end and no matches, it must be the second half of the char so the last position
+ caretPos = pos + 1;
+ }
+ currentWidth += charLength;
+ }
+
+ selectionLength = 0;
+ }
+// if (undoRedoHelper == null) {
+// undoRedoHelper = new UndoRedoHelper(inputField, caretPos, selectionLength);
+// }
+
+ return true;
+ } else {
+ unfocus();
+ }
+
+ return false;
+ }
+
+ protected int getDefaultCaretPos() {
+ return inputField.length();
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (!inputFieldFocused)
+ return false;
+
+ switch (keyCode) {
+ case InputConstants.KEY_ESCAPE, InputConstants.KEY_RETURN -> {
+ unfocus();
+ return true;
+ }
+ case InputConstants.KEY_LEFT -> {
+ if (Screen.hasShiftDown()) {
+ if (Screen.hasControlDown()) {
+ int spaceChar = findSpaceIndex(true);
+ selectionLength += caretPos - spaceChar;
+ caretPos = spaceChar;
+ } else if (caretPos > 0) {
+ caretPos--;
+ selectionLength += 1;
+ }
+ checkRenderOffset();
+ } else {
+ if (caretPos > 0) {
+ if (Screen.hasControlDown()) {
+ caretPos = findSpaceIndex(true);
+ } else {
+ if (selectionLength != 0) {
+ caretPos += Math.min(selectionLength, 0);
+ } else caretPos--;
+ }
+ }
+ checkRenderOffset();
+ selectionLength = 0;
+ }
+
+ return true;
+ }
+ case InputConstants.KEY_RIGHT -> {
+ if (Screen.hasShiftDown()) {
+ if (Screen.hasControlDown()) {
+ int spaceChar = findSpaceIndex(false);
+ selectionLength -= spaceChar - caretPos;
+ caretPos = spaceChar;
+ } else if (caretPos < inputField.length()) {
+ caretPos++;
+ selectionLength -= 1;
+ }
+ checkRenderOffset();
+ } else {
+ if (caretPos < inputField.length()) {
+ if (Screen.hasControlDown()) {
+ caretPos = findSpaceIndex(false);
+ } else {
+ if (selectionLength != 0) {
+ caretPos += Math.max(selectionLength, 0);
+ } else caretPos++;
+ }
+ checkRenderOffset();
+ }
+ selectionLength = 0;
+ }
+
+ return true;
+ }
+ case InputConstants.KEY_BACKSPACE -> {
+ doBackspace();
+ return true;
+ }
+ case InputConstants.KEY_DELETE -> {
+ doDelete();
+ return true;
+ }
+ case InputConstants.KEY_END -> {
+ if (Screen.hasShiftDown()) {
+ selectionLength -= inputField.length() - caretPos;
+ } else selectionLength = 0;
+ caretPos = inputField.length();
+ checkRenderOffset();
+ return true;
+ }
+ case InputConstants.KEY_HOME -> {
+ if (Screen.hasShiftDown()) {
+ selectionLength += caretPos;
+ caretPos = 0;
+ } else {
+ caretPos = 0;
+ selectionLength = 0;
+ }
+ checkRenderOffset();
+ return true;
+ }
+// case InputConstants.KEY_Z -> {
+// if (Screen.hasControlDown()) {
+// UndoRedoHelper.FieldState updated = Screen.hasShiftDown() ? undoRedoHelper.redo() : undoRedoHelper.undo();
+// if (updated != null) {
+// System.out.println("Updated: " + updated);
+// if (modifyInput(builder -> builder.replace(0, inputField.length(), updated.text()))) {
+// caretPos = updated.cursorPos();
+// selectionLength = updated.selectionLength();
+// checkRenderOffset();
+// }
+// }
+// return true;
+// }
+// }
+ }
+
+ if (Screen.isPaste(keyCode)) {
+ return doPaste();
+ } else if (Screen.isCopy(keyCode)) {
+ return doCopy();
+ } else if (Screen.isCut(keyCode)) {
+ return doCut();
+ } else if (Screen.isSelectAll(keyCode)) {
+ return doSelectAll();
+ }
+
+ return false;
+ }
+
+ protected boolean doPaste() {
+ this.write(client.keyboardHandler.getClipboard());
+ updateUndoHistory();
+ return true;
+ }
+
+ protected boolean doCopy() {
+ if (selectionLength != 0) {
+ client.keyboardHandler.setClipboard(getSelection());
+ return true;
+ }
+ return false;
+ }
+
+ protected boolean doCut() {
+ if (selectionLength != 0) {
+ client.keyboardHandler.setClipboard(getSelection());
+ this.write("");
+ updateUndoHistory();
+ return true;
+ }
+ return false;
+ }
+
+ protected boolean doSelectAll() {
+ caretPos = inputField.length();
+ checkRenderOffset();
+ selectionLength = -caretPos;
+ return true;
+ }
+
+ protected void checkRenderOffset() {
+ if (textRenderer.width(inputField) < getUnshiftedLength()) {
+ renderOffset = 0;
+ return;
+ }
+
+ int textX = getDimension().xLimit() - textRenderer.width(inputField) - getXPadding();
+ int caretX = textX + textRenderer.width(inputField.substring(0, caretPos));
+
+ int minX = getDimension().xLimit() - getXPadding() - getUnshiftedLength();
+ int maxX = minX + getUnshiftedLength();
+
+ if (caretX + renderOffset < minX) {
+ renderOffset = minX - caretX;
+ } else if (caretX + renderOffset > maxX) {
+ renderOffset = maxX - caretX;
+ }
+ }
+
+ @Override
+ public boolean charTyped(char chr, int modifiers) {
+ if (!inputFieldFocused)
+ return false;
+
+ if (!Screen.hasControlDown()) {
+ write(Character.toString(chr));
+ updateUndoHistory();
+ return true;
+ }
+
+ return false;
+ }
+
+ protected void doBackspace() {
+ if (selectionLength != 0) {
+ write("");
+ } else if (caretPos > 0) {
+ if (modifyInput(builder -> builder.deleteCharAt(caretPos - 1))) {
+ caretPos--;
+ checkRenderOffset();
+ }
+ }
+ updateUndoHistory();
+ }
+
+ protected void doDelete() {
+ if (selectionLength != 0) {
+ write("");
+ } else if (caretPos < inputField.length()) {
+ modifyInput(builder -> builder.deleteCharAt(caretPos));
+ }
+ updateUndoHistory();
+ }
+
+ public void write(String string) {
+ if (selectionLength == 0) {
+ if (modifyInput(builder -> builder.insert(caretPos, string))) {
+ caretPos += string.length();
+ checkRenderOffset();
+ }
+ } else {
+ int start = getSelectionStart();
+ int end = getSelectionEnd();
+
+ if (modifyInput(builder -> builder.replace(start, end, string))) {
+ caretPos = start + string.length();
+ selectionLength = 0;
+ checkRenderOffset();
+ }
+ }
+ }
+
+ public boolean modifyInput(Consumer<StringBuilder> consumer) {
+ StringBuilder temp = new StringBuilder(inputField);
+ consumer.accept(temp);
+ if (!control.isInputValid(temp.toString()))
+ return false;
+ inputField = temp.toString();
+ if (instantApply)
+ updateControl();
+ return true;
+ }
+
+ protected void updateUndoHistory() {
+// undoRedoHelper.save(inputField, caretPos, selectionLength);
+ }
+
+ public int getUnshiftedLength() {
+ if (optionNameString.isEmpty())
+ return getDimension().width() - getXPadding() * 2;
+ return getDimension().width() / 8 * 5;
+ }
+
+ public int getMaxUnwrapLength() {
+ if (optionNameString.isEmpty())
+ return getDimension().width() - getXPadding() * 2;
+ return getDimension().width() / 2;
+ }
+
+ public int getSelectionStart() {
+ return Math.min(caretPos, caretPos + selectionLength);
+ }
+
+ public int getSelectionEnd() {
+ return Math.max(caretPos, caretPos + selectionLength);
+ }
+
+ protected String getSelection() {
+ return inputField.substring(getSelectionStart(), getSelectionEnd());
+ }
+
+ protected int findSpaceIndex(boolean reverse) {
+ int i;
+ int fromIndex = caretPos;
+ if (reverse) {
+ if (caretPos > 0)
+ fromIndex -= 2;
+ i = this.inputField.lastIndexOf(" ", fromIndex) + 1;
+ } else {
+ if (caretPos < inputField.length())
+ fromIndex += 1;
+ i = this.inputField.indexOf(" ", fromIndex) + 1;
+
+ if (i == 0) i = inputField.length();
+ }
+
+ return i;
+ }
+
+ @Override
+ public void setFocused(boolean focused) {
+ super.setFocused(focused);
+ inputFieldFocused = focused;
+ }
+
+ @Override
+ public void unfocus() {
+ super.unfocus();
+ inputFieldFocused = false;
+ renderOffset = 0;
+ if (!instantApply) updateControl();
+ }
+
+ @Override
+ public void setDimension(Dimension<Integer> dim) {
+ super.setDimension(dim);
+
+ int width = Math.max(6, Math.min(textRenderer.width(getValueText()), getUnshiftedLength()));
+ inputFieldBounds = Dimension.ofInt(dim.xLimit() - getXPadding() - width, dim.centerY() - textRenderer.lineHeight / 2, width, textRenderer.lineHeight);
+ }
+
+ @Override
+ public boolean isHovered() {
+ return super.isHovered() || inputFieldFocused;
+ }
+
+ protected void updateControl() {
+ control.setFromString(inputField);
+ }
+
+ @Override
+ protected int getUnhoveredControlWidth() {
+ return !isHovered() ? Math.min(getHoveredControlWidth(), getMaxUnwrapLength()) : getHoveredControlWidth();
+ }
+
+ @Override
+ protected int getHoveredControlWidth() {
+ return Math.min(textRenderer.width(getValueText()), getUnshiftedLength());
+ }
+
+ @Override
+ protected Component getValueText() {
+ if (!inputFieldFocused && inputField.isEmpty())
+ return emptyText;
+
+ return instantApply || !inputFieldFocused ? control.formatValue() : Component.literal(inputField);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/DoubleFieldController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/DoubleFieldController.java
new file mode 100644
index 0000000..1fe3e41
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/DoubleFieldController.java
@@ -0,0 +1,111 @@
+package dev.isxander.yacl3.gui.controllers.string.number;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.DoubleSliderController;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.function.Function;
+
+/**
+ * {@inheritDoc}
+ */
+public class DoubleFieldController extends NumberFieldController<Double> {
+ private final double min, max;
+
+ /**
+ * Constructs a double field controller
+ *
+ * @param option option to bind controller to
+ * @param min minimum allowed value (clamped on apply)
+ * @param max maximum allowed value (clamped on apply)
+ * @param formatter display text, not used whilst editing
+ */
+ public DoubleFieldController(Option<Double> option, double min, double max, Function<Double, Component> formatter) {
+ super(option, formatter);
+ this.min = min;
+ this.max = max;
+ }
+
+ /**
+ * Constructs a double field controller.
+ * Uses {@link DoubleSliderController#DEFAULT_FORMATTER} as display text,
+ * not used whilst editing.
+ *
+ * @param option option to bind controller to
+ * @param min minimum allowed value (clamped on apply)
+ * @param max maximum allowed value (clamped on apply)
+ */
+ public DoubleFieldController(Option<Double> option, double min, double max) {
+ this(option, min, max, DoubleSliderController.DEFAULT_FORMATTER);
+ }
+
+ /**
+ * Constructs a double field controller.
+ * Does not have a minimum or a maximum range.
+ *
+ * @param option option to bind controller to
+ * @param formatter display text, not used whilst editing
+ */
+ public DoubleFieldController(Option<Double> option, Function<Double, Component> formatter) {
+ this(option, -Double.MAX_VALUE, Double.MAX_VALUE, formatter);
+ }
+
+ /**
+ * Constructs a double field controller.
+ * Uses {@link DoubleSliderController#DEFAULT_FORMATTER} as display text,
+ * not used whilst editing.
+ * Does not have a minimum or a maximum range.
+ *
+ * @param option option to bind controller to
+ */
+ public DoubleFieldController(Option<Double> option) {
+ this(option, -Double.MAX_VALUE, Double.MAX_VALUE, DoubleSliderController.DEFAULT_FORMATTER);
+ }
+
+ @ApiStatus.Internal
+ public static DoubleFieldController createInternal(Option<Double> option, double min, double max, ValueFormatter<Double> formatter) {
+ return new DoubleFieldController(option, min, max, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double min() {
+ return this.min;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double max() {
+ return this.max;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getString() {
+ return NUMBER_FORMAT.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(double value) {
+ option().requestSet(value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double pendingValue() {
+ return option().pendingValue();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/FloatFieldController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/FloatFieldController.java
new file mode 100644
index 0000000..8c81b49
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/FloatFieldController.java
@@ -0,0 +1,111 @@
+package dev.isxander.yacl3.gui.controllers.string.number;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.FloatSliderController;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.function.Function;
+
+/**
+ * {@inheritDoc}
+ */
+public class FloatFieldController extends NumberFieldController<Float> {
+ private final float min, max;
+
+ /**
+ * Constructs a float field controller
+ *
+ * @param option option to bind controller to
+ * @param min minimum allowed value (clamped on apply)
+ * @param max maximum allowed value (clamped on apply)
+ * @param formatter display text, not used whilst editing
+ */
+ public FloatFieldController(Option<Float> option, float min, float max, Function<Float, Component> formatter) {
+ super(option, formatter);
+ this.min = min;
+ this.max = max;
+ }
+
+ /**
+ * Constructs a float field controller.
+ * Uses {@link FloatSliderController#DEFAULT_FORMATTER} as display text,
+ * not used whilst editing.
+ *
+ * @param option option to bind controller to
+ * @param min minimum allowed value (clamped on apply)
+ * @param max maximum allowed value (clamped on apply)
+ */
+ public FloatFieldController(Option<Float> option, float min, float max) {
+ this(option, min, max, FloatSliderController.DEFAULT_FORMATTER);
+ }
+
+ /**
+ * Constructs a float field controller.
+ * Does not have a minimum or a maximum range.
+ *
+ * @param option option to bind controller to
+ * @param formatter display text, not used whilst editing
+ */
+ public FloatFieldController(Option<Float> option, Function<Float, Component> formatter) {
+ this(option, -Float.MAX_VALUE, Float.MAX_VALUE, formatter);
+ }
+
+ /**
+ * Constructs a float field controller.
+ * Uses {@link FloatSliderController#DEFAULT_FORMATTER} as display text,
+ * not used whilst editing.
+ * Does not have a minimum or a maximum range.
+ *
+ * @param option option to bind controller to
+ */
+ public FloatFieldController(Option<Float> option) {
+ this(option, -Float.MAX_VALUE, Float.MAX_VALUE, FloatSliderController.DEFAULT_FORMATTER);
+ }
+
+ @ApiStatus.Internal
+ public static FloatFieldController createInternal(Option<Float> option, float min, float max, ValueFormatter<Float> formatter) {
+ return new FloatFieldController(option, min, max, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double min() {
+ return this.min;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double max() {
+ return this.max;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getString() {
+ return NUMBER_FORMAT.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(double value) {
+ option().requestSet((float) value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double pendingValue() {
+ return option().pendingValue();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/IntegerFieldController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/IntegerFieldController.java
new file mode 100644
index 0000000..6286978
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/IntegerFieldController.java
@@ -0,0 +1,111 @@
+package dev.isxander.yacl3.gui.controllers.string.number;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.IntegerSliderController;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.function.Function;
+
+/**
+ * {@inheritDoc}
+ */
+public class IntegerFieldController extends NumberFieldController<Integer> {
+ private final int min, max;
+
+ /**
+ * Constructs a integer field controller
+ *
+ * @param option option to bind controller to
+ * @param min minimum allowed value (clamped on apply)
+ * @param max maximum allowed value (clamped on apply)
+ * @param formatter display text, not used whilst editing
+ */
+ public IntegerFieldController(Option<Integer> option, int min, int max, Function<Integer, Component> formatter) {
+ super(option, formatter);
+ this.min = min;
+ this.max = max;
+ }
+
+ /**
+ * Constructs a integer field controller.
+ * Uses {@link IntegerSliderController#DEFAULT_FORMATTER} as display text,
+ * not used whilst editing.
+ *
+ * @param option option to bind controller to
+ * @param min minimum allowed value (clamped on apply)
+ * @param max maximum allowed value (clamped on apply)
+ */
+ public IntegerFieldController(Option<Integer> option, int min, int max) {
+ this(option, min, max, IntegerSliderController.DEFAULT_FORMATTER);
+ }
+
+ /**
+ * Constructs a integer field controller.
+ * Does not have a minimum or a maximum range.
+ *
+ * @param option option to bind controller to
+ * @param formatter display text, not used whilst editing
+ */
+ public IntegerFieldController(Option<Integer> option, Function<Integer, Component> formatter) {
+ this(option, -Integer.MAX_VALUE, Integer.MAX_VALUE, formatter);
+ }
+
+ /**
+ * Constructs a integer field controller.
+ * Uses {@link IntegerSliderController#DEFAULT_FORMATTER} as display text,
+ * not used whilst editing.
+ * Does not have a minimum or a maximum range.
+ *
+ * @param option option to bind controller to
+ */
+ public IntegerFieldController(Option<Integer> option) {
+ this(option, -Integer.MAX_VALUE, Integer.MAX_VALUE, IntegerSliderController.DEFAULT_FORMATTER);
+ }
+
+ @ApiStatus.Internal
+ public static IntegerFieldController createInternal(Option<Integer> option, int min, int max, ValueFormatter<Integer> formatter) {
+ return new IntegerFieldController(option, min, max, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double min() {
+ return this.min;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double max() {
+ return this.max;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getString() {
+ return NUMBER_FORMAT.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(double value) {
+ option().requestSet((int) value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double pendingValue() {
+ return option().pendingValue();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/LongFieldController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/LongFieldController.java
new file mode 100644
index 0000000..906a2b5
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/LongFieldController.java
@@ -0,0 +1,111 @@
+package dev.isxander.yacl3.gui.controllers.string.number;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.LongSliderController;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.function.Function;
+
+/**
+ * {@inheritDoc}
+ */
+public class LongFieldController extends NumberFieldController<Long> {
+ private final long min, max;
+
+ /**
+ * Constructs a long field controller
+ *
+ * @param option option to bind controller to
+ * @param min minimum allowed value (clamped on apply)
+ * @param max maximum allowed value (clamped on apply)
+ * @param formatter display text, not used whilst editing
+ */
+ public LongFieldController(Option<Long> option, long min, long max, Function<Long, Component> formatter) {
+ super(option, formatter);
+ this.min = min;
+ this.max = max;
+ }
+
+ /**
+ * Constructs a long field controller.
+ * Uses {@link LongSliderController#DEFAULT_FORMATTER} as display text,
+ * not used whilst editing.
+ *
+ * @param option option to bind controller to
+ * @param min minimum allowed value (clamped on apply)
+ * @param max maximum allowed value (clamped on apply)
+ */
+ public LongFieldController(Option<Long> option, long min, long max) {
+ this(option, min, max, LongSliderController.DEFAULT_FORMATTER);
+ }
+
+ /**
+ * Constructs a long field controller.
+ * Does not have a minimum or a maximum range.
+ *
+ * @param option option to bind controller to
+ * @param formatter display text, not used whilst editing
+ */
+ public LongFieldController(Option<Long> option, Function<Long, Component> formatter) {
+ this(option, -Long.MAX_VALUE, Long.MAX_VALUE, formatter);
+ }
+
+ /**
+ * Constructs a long field controller.
+ * Uses {@link LongSliderController#DEFAULT_FORMATTER} as display text,
+ * not used whilst editing.
+ * Does not have a minimum or a maximum range.
+ *
+ * @param option option to bind controller to
+ */
+ public LongFieldController(Option<Long> option) {
+ this(option, -Long.MAX_VALUE, Long.MAX_VALUE, LongSliderController.DEFAULT_FORMATTER);
+ }
+
+ @ApiStatus.Internal
+ public static LongFieldController createInternal(Option<Long> option, long min, long max, ValueFormatter<Long> formatter) {
+ return new LongFieldController(option, min, max, formatter::format);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double min() {
+ return this.min;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double max() {
+ return this.max;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getString() {
+ return NUMBER_FORMAT.format(option().pendingValue());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setPendingValue(double value) {
+ option().requestSet((long) value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public double pendingValue() {
+ return option().pendingValue();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/NumberFieldController.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/NumberFieldController.java
new file mode 100644
index 0000000..3c06876
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/NumberFieldController.java
@@ -0,0 +1,80 @@
+package dev.isxander.yacl3.gui.controllers.string.number;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.controllers.slider.ISliderController;
+import dev.isxander.yacl3.gui.controllers.string.IStringController;
+import dev.isxander.yacl3.gui.controllers.string.StringControllerElement;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.network.chat.Component;
+import net.minecraft.util.Mth;
+
+import java.text.DecimalFormatSymbols;
+import java.text.NumberFormat;
+import java.text.ParseException;
+import java.text.ParsePosition;
+import java.util.function.Function;
+
+/**
+ * Controller that allows you to enter in numbers using a text field.
+ *
+ * @param <T> number type
+ */
+public abstract class NumberFieldController<T extends Number> implements ISliderController<T>, IStringController<T> {
+
+ protected static final NumberFormat NUMBER_FORMAT = NumberFormat.getInstance();
+ private static final DecimalFormatSymbols DECIMAL_FORMAT_SYMBOLS = DecimalFormatSymbols.getInstance();
+
+ private final Option<T> option;
+ private final ValueFormatter<T> displayFormatter;
+
+ public NumberFieldController(Option<T> option, Function<T, Component> displayFormatter) {
+ this.option = option;
+ this.displayFormatter = displayFormatter::apply;
+ }
+
+ @Override
+ public Option<T> option() {
+ return this.option;
+ }
+
+ @Override
+ public void setFromString(String value) {
+ try {
+ setPendingValue(Mth.clamp(NUMBER_FORMAT.parse(value).doubleValue(), min(), max()));
+ } catch (ParseException ignore) {
+ YACLConstants.LOGGER.warn("Failed to parse number: {}", value);
+ }
+ }
+
+ @Override
+ public double pendingValue() {
+ return option().pendingValue().doubleValue();
+ }
+
+ @Override
+ public boolean isInputValid(String input) {
+ input = input.replace(DECIMAL_FORMAT_SYMBOLS.getGroupingSeparator() + "", "");
+ ParsePosition parsePosition = new ParsePosition(0);
+ NUMBER_FORMAT.parse(input, parsePosition);
+ return parsePosition.getIndex() == input.length();
+ }
+
+ @Override
+ public Component formatValue() {
+ return displayFormatter.format(option().pendingValue());
+ }
+
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new StringControllerElement(this, screen, widgetDimension, false);
+ }
+
+ @Override
+ public double interval() {
+ return -1;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/package-info.java b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/package-info.java
new file mode 100644
index 0000000..4d8bbc2
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/controllers/string/number/package-info.java
@@ -0,0 +1,10 @@
+/**
+ * This package contains implementations of input fields for different number types
+ * <ul>
+ * <li>For doubles: {@link dev.isxander.yacl3.gui.controllers.string.number.DoubleFieldController}</li>
+ * <li>For floats: {@link dev.isxander.yacl3.gui.controllers.string.number.FloatFieldController}</li>
+ * <li>For integers: {@link dev.isxander.yacl3.gui.controllers.string.number.IntegerFieldController}</li>
+ * <li>For longs: {@link dev.isxander.yacl3.gui.controllers.string.number.LongFieldController}</li>
+ * </ul>
+ */
+package dev.isxander.yacl3.gui.controllers.string.number;
diff --git a/src/main/java/dev/isxander/yacl3/gui/image/ImageRenderer.java b/src/main/java/dev/isxander/yacl3/gui/image/ImageRenderer.java
new file mode 100644
index 0000000..d3fb4bf
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/image/ImageRenderer.java
@@ -0,0 +1,11 @@
+package dev.isxander.yacl3.gui.image;
+
+import net.minecraft.client.gui.GuiGraphics;
+
+public interface ImageRenderer {
+ int render(GuiGraphics graphics, int x, int y, int renderWidth, float tickDelta);
+
+ void close();
+
+ default void tick() {}
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererFactory.java b/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererFactory.java
new file mode 100644
index 0000000..d9d2e2d
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererFactory.java
@@ -0,0 +1,24 @@
+package dev.isxander.yacl3.gui.image;
+
+public interface ImageRendererFactory {
+ /**
+ * Prepares the image. This can be run off-thread,
+ * and should NOT contain any GL calls whatsoever.
+ */
+ ImageSupplier prepareImage() throws Exception;
+
+ default boolean requiresOffThreadPreparation() {
+ return true;
+ }
+
+ interface ImageSupplier {
+ ImageRenderer completeImage() throws Exception;
+ }
+
+ interface OnThread extends ImageRendererFactory {
+ @Override
+ default boolean requiresOffThreadPreparation() {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererManager.java b/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererManager.java
new file mode 100644
index 0000000..0c9b8a3
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/image/ImageRendererManager.java
@@ -0,0 +1,120 @@
+package dev.isxander.yacl3.gui.image;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+import dev.isxander.yacl3.gui.image.impl.AnimatedDynamicTextureImage;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.client.Minecraft;
+import net.minecraft.resources.ResourceLocation;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.*;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+public class ImageRendererManager {
+ private static final ExecutorService SINGLE_THREAD_EXECUTOR = Executors.newSingleThreadExecutor(task -> new Thread(task, "YACL Image Prep"));
+
+ private static final Map<ResourceLocation, CompletableFuture<ImageRenderer>> IMAGE_CACHE = new ConcurrentHashMap<>();
+ static final Map<ResourceLocation, ImageRenderer> PRELOADED_IMAGE_CACHE = new ConcurrentHashMap<>();
+
+ static final List<PreloadedImageFactory> PRELOADED_IMAGE_FACTORIES = List.of(
+ new PreloadedImageFactory(
+ location -> location.getPath().endsWith(".webp"),
+ AnimatedDynamicTextureImage::createWEBPFromTexture
+ ),
+ new PreloadedImageFactory(
+ location -> location.getPath().endsWith(".gif"),
+ AnimatedDynamicTextureImage::createGIFFromTexture
+ )
+ );
+
+ public static <T extends ImageRenderer> Optional<T> getImage(ResourceLocation id) {
+ if (PRELOADED_IMAGE_CACHE.containsKey(id)) {
+ return Optional.of((T) PRELOADED_IMAGE_CACHE.get(id));
+ }
+
+ if (IMAGE_CACHE.containsKey(id)) {
+ return Optional.ofNullable((T) IMAGE_CACHE.get(id).getNow(null));
+ }
+
+ return Optional.empty();
+ }
+
+ @SuppressWarnings("unchecked")
+ public static <T extends ImageRenderer> CompletableFuture<T> registerImage(ResourceLocation id, ImageRendererFactory factory) {
+ System.out.println(PRELOADED_IMAGE_CACHE.get(id));
+
+ if (IMAGE_CACHE.containsKey(id)) {
+ return (CompletableFuture<T>) IMAGE_CACHE.get(id);
+ }
+
+ var future = new CompletableFuture<ImageRenderer>();
+ IMAGE_CACHE.put(id, future);
+
+ SINGLE_THREAD_EXECUTOR.submit(() -> {
+ Supplier<Optional<ImageRendererFactory.ImageSupplier>> supplier =
+ factory.requiresOffThreadPreparation()
+ ? new CompletedSupplier<>(safelyPrepareFactory(id, factory))
+ : () -> safelyPrepareFactory(id, factory);
+
+ Minecraft.getInstance().execute(() -> completeImageFactory(id, supplier, future));
+ });
+
+ return (CompletableFuture<T>) future;
+ }
+
+ private static <T extends ImageRenderer> void completeImageFactory(ResourceLocation id, Supplier<Optional<ImageRendererFactory.ImageSupplier>> supplier, CompletableFuture<ImageRenderer> future) {
+ RenderSystem.assertOnRenderThread();
+
+ ImageRendererFactory.ImageSupplier completableImage = supplier.get().orElse(null);
+ if (completableImage == null) {
+ return;
+ }
+
+ // sanity check - this should never happen
+ if (future.isDone()) {
+ YACLConstants.LOGGER.error("Image '{}' was already completed", id);
+ return;
+ }
+
+ ImageRenderer image;
+ try {
+ image = completableImage.completeImage();
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to create image '{}'", id, e);
+ return;
+ }
+
+ future.complete(image);
+ }
+
+ public static void closeAll() {
+ SINGLE_THREAD_EXECUTOR.shutdownNow();
+ IMAGE_CACHE.values().removeIf(future -> {
+ if (future.isDone()) {
+ future.join().close();
+ }
+ return true;
+ });
+ }
+
+ static Optional<ImageRendererFactory.ImageSupplier> safelyPrepareFactory(ResourceLocation id, ImageRendererFactory factory) {
+ try {
+ return Optional.of(factory.prepareImage());
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to prepare image '{}'", id, e);
+ IMAGE_CACHE.remove(id);
+ return Optional.empty();
+ }
+ }
+
+ public record PreloadedImageFactory(Predicate<ResourceLocation> predicate, Function<ResourceLocation, ImageRendererFactory> factory) {
+ }
+
+ private record CompletedSupplier<T>(T get) implements Supplier<T> {
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/image/YACLImageReloadListener.java b/src/main/java/dev/isxander/yacl3/gui/image/YACLImageReloadListener.java
new file mode 100644
index 0000000..b6524a7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/image/YACLImageReloadListener.java
@@ -0,0 +1,110 @@
+package dev.isxander.yacl3.gui.image;
+
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.CrashReport;
+import net.minecraft.CrashReportCategory;
+import net.minecraft.ReportedException;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.packs.resources.PreparableReloadListener;
+import net.minecraft.server.packs.resources.Resource;
+import net.minecraft.server.packs.resources.ResourceManager;
+import net.minecraft.util.profiling.ProfilerFiller;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+
+public class YACLImageReloadListener
+ implements PreparableReloadListener
+ /*? if fabric {*/,
+ net.fabricmc.fabric.api.resource.IdentifiableResourceReloadListener
+ /*?}*/
+{
+ @Override
+ public CompletableFuture<Void> reload(
+ PreparationBarrier preparationBarrier,
+ ResourceManager resourceManager,
+ ProfilerFiller preparationsProfiler,
+ ProfilerFiller reloadProfiler,
+ Executor backgroundExecutor,
+ Executor gameExecutor
+ ) {
+ Map<ResourceLocation, Resource> imageResources = resourceManager.listResources(
+ "",
+ location -> ImageRendererManager.PRELOADED_IMAGE_FACTORIES
+ .stream()
+ .anyMatch(factory -> factory.predicate().test(location))
+ );
+
+ // extreme mojang hackery.
+ // for some reason this wait method needs to be called for the reload
+ // instance to be marked as complete
+ if (imageResources.isEmpty()) {
+ preparationBarrier.wait(null);
+ }
+
+ List<CompletableFuture<?>> futures = new ArrayList<>(imageResources.size());
+
+ for (Map.Entry<ResourceLocation, Resource> entry : imageResources.entrySet()) {
+ ResourceLocation location = entry.getKey();
+ Resource resource = entry.getValue();
+
+ ImageRendererFactory imageFactory = ImageRendererManager.PRELOADED_IMAGE_FACTORIES
+ .stream()
+ .filter(factory -> factory.predicate().test(location))
+ .map(factory -> factory.factory().apply(location))
+ .findAny()
+ .orElseThrow();
+
+ CompletableFuture<Optional<ImageRenderer>> imageFuture =
+ CompletableFuture.supplyAsync(
+ () -> ImageRendererManager.safelyPrepareFactory(
+ location, imageFactory
+ ),
+ backgroundExecutor
+ )
+ .thenCompose(preparationBarrier::wait)
+ .thenApplyAsync(imageSupplierOpt -> {
+ if (imageSupplierOpt.isEmpty()) {
+ return Optional.empty();
+ }
+ ImageRendererFactory.ImageSupplier supplier = imageSupplierOpt.get();
+
+ ImageRenderer imageRenderer;
+ try {
+ imageRenderer = supplier.completeImage();
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Failed to create image '{}'", location, e);
+ return Optional.empty();
+ }
+
+ ImageRendererManager.PRELOADED_IMAGE_CACHE.put(location, imageRenderer);
+
+ return Optional.of(imageRenderer);
+ }, gameExecutor);
+
+ futures.add(imageFuture);
+
+ imageFuture.whenComplete((result, throwable) -> {
+ if (throwable != null) {
+ CrashReport crashReport = CrashReport.forThrowable(throwable, "Failed to load image");
+ CrashReportCategory category = crashReport.addCategory("YACL Gui");
+ category.setDetail("Image identifier", location.toString());
+ throw new ReportedException(crashReport);
+ }
+ });
+ }
+
+ return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
+ }
+
+ /*? if fabric {*/
+ @Override
+ public ResourceLocation getFabricId() {
+ return new ResourceLocation("yet_another_config_lib_v3", "image_reload_listener");
+ }
+ /*?}*/
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/image/impl/AnimatedDynamicTextureImage.java b/src/main/java/dev/isxander/yacl3/gui/image/impl/AnimatedDynamicTextureImage.java
new file mode 100644
index 0000000..39ddb55
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/image/impl/AnimatedDynamicTextureImage.java
@@ -0,0 +1,286 @@
+package dev.isxander.yacl3.gui.image.impl;
+
+import com.mojang.blaze3d.Blaze3D;
+import com.mojang.blaze3d.platform.GlConst;
+import com.mojang.blaze3d.platform.GlStateManager;
+import com.mojang.blaze3d.platform.NativeImage;
+import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
+import dev.isxander.yacl3.debug.DebugProperties;
+import dev.isxander.yacl3.gui.image.ImageRendererFactory;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.CrashReport;
+import net.minecraft.CrashReportCategory;
+import net.minecraft.ReportedException;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.packs.resources.Resource;
+import net.minecraft.server.packs.resources.ResourceManager;
+import net.minecraft.util.FastColor;
+
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.metadata.IIOMetadata;
+import javax.imageio.metadata.IIOMetadataNode;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.IntStream;
+
+public class AnimatedDynamicTextureImage extends DynamicTextureImage {
+ private int currentFrame;
+ private double lastFrameTime;
+
+ private final double[] frameDelays;
+ private final int frameCount;
+
+ private final int packCols, packRows;
+ private final int frameWidth, frameHeight;
+
+ public AnimatedDynamicTextureImage(NativeImage image, int frameWidth, int frameHeight, int frameCount, double[] frameDelayMS, int packCols, int packRows, ResourceLocation uniqueLocation) {
+ super(image, uniqueLocation);
+ this.frameWidth = frameWidth;
+ this.frameHeight = frameHeight;
+ this.frameCount = frameCount;
+ this.frameDelays = frameDelayMS;
+ this.packCols = packCols;
+ this.packRows = packRows;
+ }
+
+ @Override
+ public int render(GuiGraphics graphics, int x, int y, int renderWidth, float tickDelta) {
+ if (image == null) return 0;
+
+ float ratio = renderWidth / (float)frameWidth;
+ int targetHeight = (int) (frameHeight * ratio);
+
+ int currentCol = currentFrame % packCols;
+ int currentRow = (int) Math.floor(currentFrame / (double)packCols);
+
+ graphics.pose().pushPose();
+ graphics.pose().translate(x, y, 0);
+ graphics.pose().scale(ratio, ratio, 1);
+
+ if (DebugProperties.IMAGE_FILTERING) {
+ GlStateManager._texParameter(GlConst.GL_TEXTURE_2D, GlConst.GL_TEXTURE_MAG_FILTER, GlConst.GL_LINEAR);
+ GlStateManager._texParameter(GlConst.GL_TEXTURE_2D, GlConst.GL_TEXTURE_MIN_FILTER, GlConst.GL_LINEAR);
+ }
+
+ graphics.blit(
+ uniqueLocation,
+ 0, 0,
+ frameWidth * currentCol, frameHeight * currentRow,
+ frameWidth, frameHeight,
+ this.width, this.height
+ );
+ graphics.pose().popPose();
+
+ if (frameCount > 1) {
+ double timeMS = Blaze3D.getTime() * 1000;
+ if (lastFrameTime == 0) lastFrameTime = timeMS;
+ if (timeMS - lastFrameTime >= frameDelays[currentFrame]) {
+ currentFrame++;
+ lastFrameTime = timeMS;
+ }
+ if (currentFrame >= frameCount - 1)
+ currentFrame = 0;
+ }
+
+ return targetHeight;
+ }
+
+ public static ImageRendererFactory createGIFFromTexture(ResourceLocation textureLocation) {
+ return () -> {
+ ResourceManager resourceManager = Minecraft.getInstance().getResourceManager();
+ Resource resource = resourceManager.getResource(textureLocation).orElseThrow();
+
+ return createGIFSupplier(resource.open(), textureLocation);
+ };
+ }
+
+ public static ImageRendererFactory createGIFFromPath(Path path, ResourceLocation uniqueLocation) {
+ return () -> createGIFSupplier(new FileInputStream(path.toFile()), uniqueLocation);
+ }
+
+ public static ImageRendererFactory createWEBPFromTexture(ResourceLocation textureLocation) {
+ return () -> {
+ ResourceManager resourceManager = Minecraft.getInstance().getResourceManager();
+ Resource resource = resourceManager.getResource(textureLocation).orElseThrow();
+
+ return createWEBPSupplier(resource.open(), textureLocation);
+ };
+ }
+
+ public static ImageRendererFactory createWEBPFromPath(Path path, ResourceLocation uniqueLocation) {
+ return () -> createWEBPSupplier(new FileInputStream(path.toFile()), uniqueLocation);
+ }
+
+ private static ImageRendererFactory.ImageSupplier createGIFSupplier(InputStream is, ResourceLocation uniqueLocation) {
+ try (is) {
+ ImageReader reader = ImageIO.getImageReadersBySuffix("gif").next();
+ reader.setInput(ImageIO.createImageInputStream(is));
+
+ AnimFrameProvider animFrameFunction = i -> {
+ IIOMetadata metadata = reader.getImageMetadata(i);
+ String metaFormatName = metadata.getNativeMetadataFormatName();
+ IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree(metaFormatName);
+ IIOMetadataNode graphicsControlExtensionNode = (IIOMetadataNode) root.getElementsByTagName("GraphicControlExtension").item(0);
+ int delay = Integer.parseInt(graphicsControlExtensionNode.getAttribute("delayTime")) * 10;
+
+ return new AnimFrame(delay, 0, 0);
+ };
+
+ return createFromImageReader(reader, animFrameFunction, uniqueLocation);
+ } catch (Exception e) {
+ CrashReport crashReport = CrashReport.forThrowable(e, "Failed to load GIF image");
+ CrashReportCategory category = crashReport.addCategory("YACL Gui");
+ category.setDetail("Image identifier", uniqueLocation.toString());
+ throw new ReportedException(crashReport);
+ }
+ }
+
+ private static ImageRendererFactory.ImageSupplier createWEBPSupplier(InputStream is, ResourceLocation uniqueLocation) {
+ try (is) {
+ ImageReader reader = new WebPImageReaderSpi().createReaderInstance();
+ reader.setInput(ImageIO.createImageInputStream(is));
+
+ int numImages = reader.getNumImages(true); // Force reading of all frames
+ AnimFrameProvider animFrameFunction = i -> null;
+ if (numImages > 1) {
+ // WebP reader does not expose frame delay, prepare for reflection hell
+ Class<?> webpReaderClass = Class.forName("com.twelvemonkeys.imageio.plugins.webp.WebPImageReader");
+ Field framesField = webpReaderClass.getDeclaredField("frames");
+ framesField.setAccessible(true);
+ java.util.List<?> frames = (List<?>) framesField.get(reader);
+
+ Class<?> animationFrameClass = Class.forName("com.twelvemonkeys.imageio.plugins.webp.AnimationFrame");
+ Field durationField = animationFrameClass.getDeclaredField("duration");
+ durationField.setAccessible(true);
+ Field boundsField = animationFrameClass.getDeclaredField("bounds");
+ boundsField.setAccessible(true);
+
+ animFrameFunction = i -> {
+ Rectangle bounds = (Rectangle) boundsField.get(frames.get(i));
+ return new AnimFrame((int) durationField.get(frames.get(i)), bounds.x, bounds.y);
+ };
+ // that was fun
+ }
+
+ return createFromImageReader(reader, animFrameFunction, uniqueLocation);
+ } catch (Throwable e) {
+ CrashReport crashReport = CrashReport.forThrowable(e, "Failed to load WEBP image");
+ CrashReportCategory category = crashReport.addCategory("YACL Gui");
+ category.setDetail("Image identifier", uniqueLocation.toString());
+ throw new ReportedException(crashReport);
+ }
+ }
+
+ private static ImageRendererFactory.ImageSupplier createFromImageReader(ImageReader reader, AnimFrameProvider animationProvider, ResourceLocation uniqueLocation) throws Exception {
+ if (reader.isSeekForwardOnly()) {
+ throw new RuntimeException("Image reader is not seekable");
+ }
+
+ int frameCount = reader.getNumImages(true);
+
+ // Because this is being backed into a texture atlas, we need a maximum dimension
+ // so you can get the texture atlas size.
+ // Smaller frames are given black borders
+ int frameWidth = IntStream.range(0, frameCount).map(i -> {
+ try {
+ return reader.getWidth(i);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }).max().orElseThrow();
+ int frameHeight = IntStream.range(0, frameCount).map(i -> {
+ try {
+ return reader.getHeight(i);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }).max().orElseThrow();
+
+ // Packs the frames into an optimal 1:1 texture.
+ // OpenGL can only have texture axis with a max of 32768 pixels,
+ // and packing them to that length is not efficient, apparently.
+ double ratio = frameWidth / (double)frameHeight;
+ int cols = (int)Math.ceil(Math.sqrt(frameCount) / Math.sqrt(ratio));
+ int rows = (int)Math.ceil(frameCount / (double)cols);
+
+ NativeImage image = new NativeImage(NativeImage.Format.RGBA, frameWidth * cols, frameHeight * rows, false);
+
+// // Fill whole atlas with black, as each frame may have different dimensions
+// // that would cause borders of transparent pixels to appear around the frames
+// for (int x = 0; x < frameWidth * cols; x++) {
+// for (int y = 0; y < frameHeight * rows; y++) {
+// image.setPixelRGBA(x, y, 0xFF000000);
+// }
+// }
+
+ BufferedImage bi = null;
+ Graphics2D graphics = null;
+
+ // each frame may have a different delay
+ double[] frameDelays = new double[frameCount];
+
+ for (int i = 0; i < frameCount; i++) {
+ AnimFrame frame = animationProvider.get(i);
+ if (frameCount > 1) // frame will be null if not animation
+ frameDelays[i] = frame.durationMS;
+
+ if (bi == null) {
+ // first frame...
+ bi = reader.read(i);
+ graphics = bi.createGraphics();
+ } else {
+ // WebP reader sometimes provides delta frames, (only the pixels that changed since the last frame)
+ // so instead of overwriting the image every frame, we draw delta frames on top of the previous frame
+ // to keep a complete image.
+ BufferedImage deltaFrame = reader.read(i);
+ graphics.drawImage(deltaFrame, frame.xOffset, frame.yOffset, null);
+ }
+
+ // Each frame may have different dimensions, so we need to center them.
+ int xOffset = (frameWidth - bi.getWidth()) / 2;
+ int yOffset = (frameHeight - bi.getHeight()) / 2;
+
+ for (int w = 0; w < bi.getWidth(); w++) {
+ for (int h = 0; h < bi.getHeight(); h++) {
+ int rgb = bi.getRGB(w, h);
+ int r = FastColor.ARGB32.red(rgb);
+ int g = FastColor.ARGB32.green(rgb);
+ int b = FastColor.ARGB32.blue(rgb);
+ int a = FastColor.ARGB32.alpha(rgb);
+
+ int col = i % cols;
+ int row = (int) Math.floor(i / (double)cols);
+
+ image.setPixelRGBA(
+ frameWidth * col + w + xOffset,
+ frameHeight * row + h + yOffset,
+ FastColor.ABGR32.color(a, b, g, r) // NativeImage uses ABGR for some reason
+ );
+ }
+ }
+ }
+
+ if (graphics != null)
+ graphics.dispose();
+ reader.dispose();
+
+ return () -> new AnimatedDynamicTextureImage(image, frameWidth, frameHeight, frameCount, frameDelays, cols, rows, uniqueLocation);
+ }
+
+ @FunctionalInterface
+ private interface AnimFrameProvider {
+ AnimFrame get(int frame) throws Exception;
+ }
+
+ private record AnimFrame(int durationMS, int xOffset, int yOffset) {}
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/image/impl/DynamicTextureImage.java b/src/main/java/dev/isxander/yacl3/gui/image/impl/DynamicTextureImage.java
new file mode 100644
index 0000000..2d2abb9
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/image/impl/DynamicTextureImage.java
@@ -0,0 +1,72 @@
+package dev.isxander.yacl3.gui.image.impl;
+
+import com.mojang.blaze3d.platform.GlConst;
+import com.mojang.blaze3d.platform.GlStateManager;
+import com.mojang.blaze3d.platform.NativeImage;
+import com.mojang.blaze3d.systems.RenderSystem;
+import dev.isxander.yacl3.debug.DebugProperties;
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+import dev.isxander.yacl3.gui.image.ImageRendererFactory;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.renderer.texture.DynamicTexture;
+import net.minecraft.client.renderer.texture.TextureManager;
+import net.minecraft.resources.ResourceLocation;
+
+import java.io.FileInputStream;
+import java.nio.file.Path;
+
+public class DynamicTextureImage implements ImageRenderer {
+ protected static final TextureManager textureManager = Minecraft.getInstance().getTextureManager();
+
+ protected NativeImage image;
+ protected DynamicTexture texture;
+ protected final ResourceLocation uniqueLocation;
+ protected final int width, height;
+
+ public DynamicTextureImage(NativeImage image, ResourceLocation location) {
+ RenderSystem.assertOnRenderThread();
+
+ this.image = image;
+ this.texture = new DynamicTexture(image);
+ this.uniqueLocation = location;
+ textureManager.register(this.uniqueLocation, this.texture);
+ this.width = image.getWidth();
+ this.height = image.getHeight();
+ }
+
+ @Override
+ public int render(GuiGraphics graphics, int x, int y, int renderWidth, float tickDelta) {
+ if (image == null) return 0;
+
+ float ratio = renderWidth / (float)this.width;
+ int targetHeight = (int) (this.height * ratio);
+
+ graphics.pose().pushPose();
+ graphics.pose().translate(x, y, 0);
+ graphics.pose().scale(ratio, ratio, 1);
+
+ if (DebugProperties.IMAGE_FILTERING) {
+ GlStateManager._texParameter(GlConst.GL_TEXTURE_2D, GlConst.GL_TEXTURE_MAG_FILTER, GlConst.GL_LINEAR);
+ GlStateManager._texParameter(GlConst.GL_TEXTURE_2D, GlConst.GL_TEXTURE_MIN_FILTER, GlConst.GL_LINEAR);
+ }
+
+ graphics.blit(uniqueLocation, 0, 0, 0, 0, this.width, this.height, this.width, this.height);
+
+ graphics.pose().popPose();
+
+ return targetHeight;
+ }
+
+ @Override
+ public void close() {
+ image.close();
+ image = null;
+ texture = null;
+ textureManager.release(uniqueLocation);
+ }
+
+ public static ImageRendererFactory fromPath(Path imagePath, ResourceLocation location) {
+ return (ImageRendererFactory.OnThread) () -> () -> new DynamicTextureImage(NativeImage.read(new FileInputStream(imagePath.toFile())), location);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/image/impl/ResourceTextureImage.java b/src/main/java/dev/isxander/yacl3/gui/image/impl/ResourceTextureImage.java
new file mode 100644
index 0000000..abbeec7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/image/impl/ResourceTextureImage.java
@@ -0,0 +1,56 @@
+package dev.isxander.yacl3.gui.image.impl;
+
+import com.mojang.blaze3d.platform.GlConst;
+import com.mojang.blaze3d.platform.GlStateManager;
+import dev.isxander.yacl3.debug.DebugProperties;
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+import dev.isxander.yacl3.gui.image.ImageRendererFactory;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.resources.ResourceLocation;
+
+public class ResourceTextureImage implements ImageRenderer {
+ private final ResourceLocation location;
+ private final int width, height;
+ private final int textureWidth, textureHeight;
+ private final float u, v;
+
+ public ResourceTextureImage(ResourceLocation location, float u, float v, int width, int height, int textureWidth, int textureHeight) {
+ this.location = location;
+ this.width = width;
+ this.height = height;
+ this.textureWidth = textureWidth;
+ this.textureHeight = textureHeight;
+ this.u = u;
+ this.v = v;
+ }
+
+ @Override
+ public int render(GuiGraphics graphics, int x, int y, int renderWidth, float tickDelta) {
+ float ratio = renderWidth / (float)this.width;
+ int targetHeight = (int) (this.height * ratio);
+
+ graphics.pose().pushPose();
+ graphics.pose().translate(x, y, 0);
+ graphics.pose().scale(ratio, ratio, 1);
+
+ if (DebugProperties.IMAGE_FILTERING) {
+ GlStateManager._texParameter(GlConst.GL_TEXTURE_2D, GlConst.GL_TEXTURE_MAG_FILTER, GlConst.GL_LINEAR);
+ GlStateManager._texParameter(GlConst.GL_TEXTURE_2D, GlConst.GL_TEXTURE_MIN_FILTER, GlConst.GL_LINEAR);
+ }
+
+ graphics.blit(location, 0, 0, this.u, this.v, this.width, this.height, this.textureWidth, this.textureHeight);
+
+ graphics.pose().popPose();
+
+ return targetHeight;
+ }
+
+ @Override
+ public void close() {
+
+ }
+
+ public static ImageRendererFactory createFactory(ResourceLocation location, float u, float v, int width, int height, int textureWidth, int textureHeight) {
+ return (ImageRendererFactory.OnThread) () -> () -> new ResourceTextureImage(location, u, v, width, height, textureWidth, textureHeight);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/tab/ListHolderWidget.java b/src/main/java/dev/isxander/yacl3/gui/tab/ListHolderWidget.java
new file mode 100644
index 0000000..a533290
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/tab/ListHolderWidget.java
@@ -0,0 +1,116 @@
+package dev.isxander.yacl3.gui.tab;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.gui.ElementListWidgetExt;
+import net.minecraft.client.gui.ComponentPath;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.events.ContainerEventHandler;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.gui.navigation.FocusNavigationEvent;
+import net.minecraft.client.gui.navigation.ScreenRectangle;
+import net.minecraft.network.chat.CommonComponents;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+public class ListHolderWidget<T extends ElementListWidgetExt<?>> extends AbstractWidget implements ContainerEventHandler {
+ private final Supplier<ScreenRectangle> dimensions;
+ private final T list;
+
+ public ListHolderWidget(Supplier<ScreenRectangle> dimensions, T list) {
+ super(0, 0, 100, 0, CommonComponents.EMPTY);
+ this.dimensions = dimensions;
+ this.list = list;
+ }
+
+ @Override
+ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float deltaTick) {
+ ScreenRectangle dimensions = this.dimensions.get();
+ this.setX(dimensions.left());
+ this.setY(dimensions.top());
+ this.width = dimensions.width();
+ this.height = dimensions.height();
+ this.list.updateDimensions(dimensions);
+ this.list.render(guiGraphics, mouseX, mouseY, deltaTick);
+ }
+
+ @Override
+ protected void updateWidgetNarration(NarrationElementOutput output) {
+ this.list.updateNarration(output);
+ }
+
+ @Override
+ public List<? extends GuiEventListener> children() {
+ return ImmutableList.of(this.list);
+ }
+
+ public T getList() {
+ return list;
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ return this.list.mouseClicked(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
+ return this.list.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
+ }
+
+ @Override
+ public boolean mouseReleased(double mouseX, double mouseY, int button) {
+ return this.list.mouseReleased(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, /*? if >1.20.2 {*/ double horizontal, /*?}*/ double vertical) {
+ return this.list.mouseScrolled(mouseX, mouseY, /*? if >1.20.2 {*/ horizontal, /*?}*/ vertical);
+ }
+
+ @Override
+ public boolean keyPressed(int i, int j, int k) {
+ return this.list.keyPressed(i, j, k);
+ }
+
+ @Override
+ public boolean charTyped(char c, int i) {
+ return this.list.charTyped(c, i);
+ }
+
+ @Override
+ public boolean isDragging() {
+ return this.list.isDragging();
+ }
+
+ @Override
+ public void setDragging(boolean dragging) {
+ this.list.setDragging(dragging);
+ }
+
+ @Nullable
+ @Override
+ public GuiEventListener getFocused() {
+ return this.list.getFocused();
+ }
+
+ @Override
+ public void setFocused(@Nullable GuiEventListener listener) {
+ this.list.setFocused(listener);
+ }
+
+ @Nullable
+ @Override
+ public ComponentPath nextFocusPath(FocusNavigationEvent event) {
+ return this.list.nextFocusPath(event);
+ }
+
+ @Nullable
+ @Override
+ public ComponentPath getCurrentFocusPath() {
+ return this.list.getCurrentFocusPath();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/tab/ScrollableNavigationBar.java b/src/main/java/dev/isxander/yacl3/gui/tab/ScrollableNavigationBar.java
new file mode 100644
index 0000000..5829202
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/tab/ScrollableNavigationBar.java
@@ -0,0 +1,120 @@
+package dev.isxander.yacl3.gui.tab;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.mixin.TabNavigationBarAccessor;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.TabButton;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.components.tabs.Tab;
+import net.minecraft.client.gui.components.tabs.TabManager;
+import net.minecraft.client.gui.components.tabs.TabNavigationBar;
+import net.minecraft.client.gui.layouts.Layout;
+import net.minecraft.util.Mth;
+import org.jetbrains.annotations.Nullable;
+
+public class ScrollableNavigationBar extends TabNavigationBar {
+ private static final int NAVBAR_MARGIN = 28;
+
+ private static final Font font = Minecraft.getInstance().font;
+
+ private int scrollOffset;
+ private int maxScrollOffset;
+
+ public ScrollableNavigationBar(int width, TabManager tabManager, Iterable<? extends Tab> tabs) {
+ super(width, tabManager, ImmutableList.copyOf(tabs));
+
+ // add tab tooltips to the tab buttons
+ for (TabButton tabButton : this.tabButtons) {
+ if (tabButton.tab() instanceof TabExt tab) {
+ tabButton.setTooltip(tab.getTooltip());
+ }
+ }
+ }
+
+ @Override
+ public void arrangeElements() {
+ int noScrollWidth = this.width - NAVBAR_MARGIN*2;
+
+ int allTabsWidth = 0;
+ // first pass: set the width of each tab button
+ for (TabButton tabButton : this.tabButtons) {
+ int buttonWidth = font.width(tabButton.getMessage()) + 20;
+ allTabsWidth += buttonWidth;
+ tabButton.setWidth(buttonWidth);
+ }
+
+ if (allTabsWidth < noScrollWidth) {
+ int equalWidth = noScrollWidth / this.tabButtons.size();
+ var smallTabs = this.tabButtons.stream().filter(btn -> btn.getWidth() < equalWidth).toList();
+ var bigTabs = this.tabButtons.stream().filter(btn -> btn.getWidth() >= equalWidth).toList();
+ int leftoverWidth = noScrollWidth - bigTabs.stream().mapToInt(AbstractWidget::getWidth).sum();
+ int equalWidthForSmallTabs = leftoverWidth / smallTabs.size();
+ for (TabButton tabButton : smallTabs) {
+ tabButton.setWidth(equalWidthForSmallTabs);
+ }
+
+ allTabsWidth = noScrollWidth;
+ }
+
+ Layout layout = ((TabNavigationBarAccessor) this).getLayout();
+ layout.arrangeElements();
+ layout.setY(0);
+ scrollOffset = 0;
+
+ layout.setX(Math.max((this.width - allTabsWidth) / 2, NAVBAR_MARGIN));
+ this.maxScrollOffset = Math.max(0, allTabsWidth - noScrollWidth);
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ graphics.pose().pushPose();
+ // render option list BELOW the navbar without need to scissor
+ graphics.pose().translate(0, 0, 10);
+
+ super.render(graphics, mouseX, mouseY, delta);
+
+ graphics.pose().popPose();
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, /*? if >1.20.2 {*/ double horizontal, /*?}*/ double vertical) {
+ this.setScrollOffset(this.scrollOffset - (int)(vertical*15));
+ return true;
+ }
+
+ @Override
+ public boolean isMouseOver(double mouseX, double mouseY) {
+ return mouseY <= 24;
+ }
+
+ public void setScrollOffset(int scrollOffset) {
+ Layout layout = ((TabNavigationBarAccessor) this).getLayout();
+
+ layout.setX(layout.getX() + this.scrollOffset);
+ this.scrollOffset = Mth.clamp(scrollOffset, 0, maxScrollOffset);
+ layout.setX(layout.getX() - this.scrollOffset);
+ }
+
+ public int getScrollOffset() {
+ return scrollOffset;
+ }
+
+ @Override
+ public void setFocused(@Nullable GuiEventListener child) {
+ super.setFocused(child);
+ if (child instanceof TabButton tabButton) {
+ this.ensureVisible(tabButton);
+ }
+ }
+
+ protected void ensureVisible(TabButton tabButton) {
+ if (tabButton.getX() < NAVBAR_MARGIN) {
+ this.setScrollOffset(this.scrollOffset - (NAVBAR_MARGIN - tabButton.getX()));
+ } else if (tabButton.getX() + tabButton.getWidth() > this.width - NAVBAR_MARGIN) {
+ this.setScrollOffset(this.scrollOffset + (tabButton.getX() + tabButton.getWidth() - (this.width - NAVBAR_MARGIN)));
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/tab/TabExt.java b/src/main/java/dev/isxander/yacl3/gui/tab/TabExt.java
new file mode 100644
index 0000000..1b4b3e5
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/tab/TabExt.java
@@ -0,0 +1,14 @@
+package dev.isxander.yacl3.gui.tab;
+
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.Tooltip;
+import net.minecraft.client.gui.components.tabs.Tab;
+import org.jetbrains.annotations.Nullable;
+
+public interface TabExt extends Tab {
+ @Nullable Tooltip getTooltip();
+
+ default void tick() {}
+
+ default void renderBackground(GuiGraphics graphics) {}
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/utils/ButtonTextureRenderer.java b/src/main/java/dev/isxander/yacl3/gui/utils/ButtonTextureRenderer.java
new file mode 100644
index 0000000..aa52a3f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/utils/ButtonTextureRenderer.java
@@ -0,0 +1,34 @@
+package dev.isxander.yacl3.gui.utils;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.resources.ResourceLocation;
+
+public class ButtonTextureRenderer {
+ /*? if >1.20.1 {*/
+ private static final net.minecraft.client.gui.components.WidgetSprites SPRITES = new net.minecraft.client.gui.components.WidgetSprites(
+ new ResourceLocation("widget/button"), // normal
+ new ResourceLocation("widget/button_disabled"), // disabled & !focused
+ new ResourceLocation("widget/button_highlighted"), // !disabled & focused
+ new ResourceLocation("widget/slider_highlighted") // disabled & focused
+ );
+ /*?} else {*//*
+ private static final ResourceLocation SLIDER_LOCATION = new ResourceLocation("textures/gui/slider.png");
+ *//*?}*/
+
+ public static void render(GuiGraphics graphics, int x, int y, int width, int height, boolean enabled, boolean focused) {
+ /*? if >1.20.1 {*/
+ graphics.blitSprite(SPRITES.get(enabled, focused), x, y, width, height);
+ /*?} else {*//*
+ int textureV;
+ if (enabled) {
+ textureV = focused ? 60 : 40;
+ } else {
+ textureV = focused ? 20 : 0;
+ }
+
+ RenderSystem.setShaderColor(1f, 1f, 1f, 1f);
+ graphics.blitNineSliced(SLIDER_LOCATION, x, y, width, height, 20, 4, 200, 20, 0, textureV);
+ *//*?}*/
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/utils/GuiUtils.java b/src/main/java/dev/isxander/yacl3/gui/utils/GuiUtils.java
new file mode 100644
index 0000000..2910d0f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/utils/GuiUtils.java
@@ -0,0 +1,32 @@
+package dev.isxander.yacl3.gui.utils;
+
+import net.minecraft.client.gui.Font;
+import net.minecraft.locale.Language;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+
+public class GuiUtils {
+ public static MutableComponent translatableFallback(String key, Component fallback) {
+ if (Language.getInstance().has(key))
+ return Component.translatable(key);
+ return fallback.copy();
+ }
+
+ public static String shortenString(String string, Font font, int maxWidth, String suffix) {
+ if (string.isEmpty())
+ return string;
+
+ boolean firstIter = true;
+ while (font.width(string) > maxWidth) {
+ string = string.substring(0, Math.max(string.length() - 1 - (firstIter ? 1 : suffix.length() + 1), 0)).trim();
+ string += suffix;
+
+ if (string.equals(suffix))
+ break;
+
+ firstIter = false;
+ }
+
+ return string;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/utils/ItemRegistryHelper.java b/src/main/java/dev/isxander/yacl3/gui/utils/ItemRegistryHelper.java
new file mode 100644
index 0000000..3c4f03a
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/utils/ItemRegistryHelper.java
@@ -0,0 +1,116 @@
+package dev.isxander.yacl3.gui.utils;
+
+
+import net.minecraft.ResourceLocationException;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.Items;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+
+public final class ItemRegistryHelper {
+
+ /**
+ * Checks whether the given string is an identifier referring to a known item
+ *
+ * @param identifier Item identifier, either of the format "namespace:path" or "path". If no namespace is included,
+ * the default vanilla namespace "minecraft" is used.
+ * @return true if the identifier refers to a registered item, false otherwise
+ */
+ public static boolean isRegisteredItem(String identifier) {
+ try {
+ ResourceLocation itemIdentifier = new ResourceLocation(identifier.toLowerCase());
+ return BuiltInRegistries.ITEM.containsKey(itemIdentifier);
+ } catch (ResourceLocationException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Looks up the item of the given identifier string.
+ *
+ * @param identifier Item identifier, either of the format "namespace:path" or "path". If no namespace is included,
+ * the default vanilla namespace "minecraft" is used.
+ * @param defaultItem Fallback item that gets returned if the identifier does not name a registered item.
+ * @return The item identified by the given string, or the fallback if the identifier is not known.
+ */
+ public static Item getItemFromName(String identifier, Item defaultItem) {
+ try {
+ ResourceLocation itemIdentifier = new ResourceLocation(identifier.toLowerCase());
+ if (BuiltInRegistries.ITEM.containsKey(itemIdentifier)) {
+ return BuiltInRegistries.ITEM.get(itemIdentifier);
+ }
+ } catch (ResourceLocationException ignored) {
+ }
+ return defaultItem;
+ }
+
+ /**
+ * Looks up the item of the given identifier string.
+ *
+ * @param identifier Item identifier, either of the format "namespace:path" or "path". If no namespace is included,
+ * the default vanilla namespace "minecraft" is used.
+ * @return The item identified by the given string, or `Items.AIR` if the identifier is not known.
+ */
+ public static Item getItemFromName(String identifier) {
+ return getItemFromName(identifier, Items.AIR);
+ }
+
+ /**
+ * Returns a list of item identifiers matching the given string. The value matches an identifier if:
+ * <li>No namespace is provided in the value and the value is a substring of the path segment of any identifier,
+ * regardless of namespace.</li>
+ * <li>A namespace is provided, equals the identifier's namespace, and the value is the begin of the identifier's
+ * path segment.</li>
+ *
+ * @param value (partial) identifier, either of the format "namespace:path" or "path".
+ * @return list of matching item identifiers; empty if the given string does not correspond to any known identifiers
+ */
+ public static Stream<ResourceLocation> getMatchingItemIdentifiers(String value) {
+ int sep = value.indexOf(ResourceLocation.NAMESPACE_SEPARATOR);
+ Predicate<ResourceLocation> filterPredicate;
+ if (sep == -1) {
+ filterPredicate = identifier ->
+ identifier.getPath().contains(value)
+ || BuiltInRegistries.ITEM.get(identifier).getDescription().getString().toLowerCase().contains(value.toLowerCase());
+ } else {
+ String namespace = value.substring(0, sep);
+ String path = value.substring(sep + 1);
+ filterPredicate = identifier -> identifier.getNamespace().equals(namespace) && identifier.getPath().startsWith(path);
+ }
+ return BuiltInRegistries.ITEM.holders()
+ .map(holder -> holder.key().location())
+ .filter(filterPredicate)
+ /*
+ Sort items as follows based on the given "value" string's path:
+ - if both items' paths begin with the entered string, sort the identifiers (including namespace)
+ - otherwise, if either of the items' path begins with the entered string, sort it to the left
+ - else neither path matches: sort by identifiers again
+
+ This allows the user to enter "diamond_ore" and match "minecraft:diamond_ore" before
+ "minecraft:deepslate_diamond_ore", even though the second is lexicographically smaller
+ */
+ .sorted((id1, id2) -> {
+ String path = (sep == -1 ? value : value.substring(sep + 1)).toLowerCase();
+ boolean id1StartsWith = id1.getPath().toLowerCase().startsWith(path);
+ boolean id2StartsWith = id2.getPath().toLowerCase().startsWith(path);
+ if (id1StartsWith) {
+ if (id2StartsWith) {
+ return id1.compareTo(id2);
+ }
+ return -1;
+ }
+ if (id2StartsWith) {
+ return 1;
+ }
+ return id1.compareTo(id2);
+ });
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/gui/utils/UndoRedoHelper.java b/src/main/java/dev/isxander/yacl3/gui/utils/UndoRedoHelper.java
new file mode 100644
index 0000000..3328c16
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/gui/utils/UndoRedoHelper.java
@@ -0,0 +1,42 @@
+package dev.isxander.yacl3.gui.utils;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class UndoRedoHelper {
+ private final List<FieldState> history = new ArrayList<>();
+ private int index = 0;
+
+ public UndoRedoHelper(String text, int cursorPos, int selectionLength) {
+ history.add(new FieldState(text, cursorPos, selectionLength));
+ }
+
+ public void save(String text, int cursorPos, int selectionLength) {
+ int max = history.size();
+ history.subList(index, max).clear();
+ history.add(new FieldState(text, cursorPos, selectionLength));
+ index++;
+ }
+
+ public @Nullable FieldState undo() {
+ index--;
+ index = Math.max(index, 0);
+
+ if (history.isEmpty())
+ return null;
+ return history.get(index);
+ }
+
+ public @Nullable FieldState redo() {
+ if (index < history.size() - 1) {
+ index++;
+ return history.get(index);
+ } else {
+ return null;
+ }
+ }
+
+ public record FieldState(String text, int cursorPos, int selectionLength) {}
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/ButtonOptionImpl.java b/src/main/java/dev/isxander/yacl3/impl/ButtonOptionImpl.java
new file mode 100644
index 0000000..170b8e0
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/ButtonOptionImpl.java
@@ -0,0 +1,205 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableSet;
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.controllers.ActionController;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+@ApiStatus.Internal
+public final class ButtonOptionImpl implements ButtonOption {
+ private final Component name;
+ private final OptionDescription description;
+ private final BiConsumer<YACLScreen, ButtonOption> action;
+ private boolean available;
+ private final Controller<BiConsumer<YACLScreen, ButtonOption>> controller;
+ private final Binding<BiConsumer<YACLScreen, ButtonOption>> binding;
+
+ public ButtonOptionImpl(
+ @NotNull Component name,
+ @Nullable OptionDescription description,
+ @NotNull BiConsumer<YACLScreen, ButtonOption> action,
+ @Nullable Component text,
+ boolean available
+ ) {
+ this.name = name;
+ this.description = description;
+ this.action = action;
+ this.available = available;
+ this.controller = text != null ? new ActionController(this, text) : new ActionController(this);
+ this.binding = new EmptyBinderImpl();
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return name;
+ }
+
+ @Override
+ public @NotNull OptionDescription description() {
+ return description;
+ }
+
+ @Override
+ public @NotNull Component tooltip() {
+ return description().text();
+ }
+
+ @Override
+ public BiConsumer<YACLScreen, ButtonOption> action() {
+ return action;
+ }
+
+ @Override
+ public boolean available() {
+ return available;
+ }
+
+ @Override
+ public void setAvailable(boolean available) {
+ this.available = available;
+ }
+
+ @Override
+ public @NotNull Controller<BiConsumer<YACLScreen, ButtonOption>> controller() {
+ return controller;
+ }
+
+ @Override
+ public @NotNull Binding<BiConsumer<YACLScreen, ButtonOption>> binding() {
+ return binding;
+ }
+
+ @Override
+ public @NotNull ImmutableSet<OptionFlag> flags() {
+ return ImmutableSet.of();
+ }
+
+ @Override
+ public boolean changed() {
+ return false;
+ }
+
+ @Override
+ public @NotNull BiConsumer<YACLScreen, ButtonOption> pendingValue() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void requestSet(@NotNull BiConsumer<YACLScreen, ButtonOption> value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean applyValue() {
+ return false;
+ }
+
+ @Override
+ public void forgetPendingValue() {
+
+ }
+
+ @Override
+ public void requestSetDefault() {
+
+ }
+
+ @Override
+ public boolean isPendingValueDefault() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void addListener(BiConsumer<Option<BiConsumer<YACLScreen, ButtonOption>>, BiConsumer<YACLScreen, ButtonOption>> changedListener) {
+
+ }
+
+ private static class EmptyBinderImpl implements Binding<BiConsumer<YACLScreen, ButtonOption>> {
+ @Override
+ public void setValue(BiConsumer<YACLScreen, ButtonOption> value) {
+
+ }
+
+ @Override
+ public BiConsumer<YACLScreen, ButtonOption> getValue() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public BiConsumer<YACLScreen, ButtonOption> defaultValue() {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ @ApiStatus.Internal
+ public static final class BuilderImpl implements Builder {
+ private Component name;
+ private Component text = null;
+ private OptionDescription description = OptionDescription.EMPTY;
+ private boolean available = true;
+ private BiConsumer<YACLScreen, ButtonOption> action;
+
+ @Override
+ public Builder name(@NotNull Component name) {
+ Validate.notNull(name, "`name` cannot be null");
+
+ this.name = name;
+ return this;
+ }
+
+ @Override
+ public Builder text(@NotNull Component text) {
+ Validate.notNull(text, "`text` cannot be null");
+
+ this.text = text;
+ return this;
+ }
+
+ @Override
+ public Builder description(@NotNull OptionDescription description) {
+ Validate.notNull(description, "`description` cannot be null");
+
+ this.description = description;
+ return this;
+ }
+
+ @Override
+ public Builder action(@NotNull BiConsumer<YACLScreen, ButtonOption> action) {
+ Validate.notNull(action, "`action` cannot be null");
+
+ this.action = action;
+ return this;
+ }
+
+ @Override
+ @Deprecated
+ public Builder action(@NotNull Consumer<YACLScreen> action) {
+ Validate.notNull(action, "`action` cannot be null");
+
+ this.action = (screen, button) -> action.accept(screen);
+ return this;
+ }
+
+ @Override
+ public Builder available(boolean available) {
+ this.available = available;
+ return this;
+ }
+
+ @Override
+ public ButtonOption build() {
+ Validate.notNull(name, "`name` must not be null when building `ButtonOption`");
+ Validate.notNull(action, "`action` must not be null when building `ButtonOption`");
+
+ return new ButtonOptionImpl(name, description, action, text, available);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/ConfigCategoryImpl.java b/src/main/java/dev/isxander/yacl3/impl/ConfigCategoryImpl.java
new file mode 100644
index 0000000..400abf6
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/ConfigCategoryImpl.java
@@ -0,0 +1,134 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+import org.apache.commons.lang3.Validate;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@ApiStatus.Internal
+public final class ConfigCategoryImpl implements ConfigCategory {
+ private final Component name;
+ private final ImmutableList<OptionGroup> groups;
+ private final Component tooltip;
+
+ public ConfigCategoryImpl(Component name, ImmutableList<OptionGroup> groups, Component tooltip) {
+ this.name = name;
+ this.groups = groups;
+ this.tooltip = tooltip;
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return name;
+ }
+
+ @Override
+ public @NotNull ImmutableList<OptionGroup> groups() {
+ return groups;
+ }
+
+ @Override
+ public @NotNull Component tooltip() {
+ return tooltip;
+ }
+
+ @ApiStatus.Internal
+ public static final class BuilderImpl implements Builder {
+ private Component name;
+
+ private final List<Option<?>> rootOptions = new ArrayList<>();
+ private final List<OptionGroup> groups = new ArrayList<>();
+
+ private final List<Component> tooltipLines = new ArrayList<>();
+
+ @Override
+ public Builder name(@NotNull Component name) {
+ Validate.notNull(name, "`name` cannot be null");
+
+ this.name = name;
+ return this;
+ }
+
+ @Override
+ public Builder option(@NotNull Option<?> option) {
+ Validate.notNull(option, "`option` must not be null");
+
+ if (option instanceof ListOption<?> listOption) {
+ YACLConstants.LOGGER.warn("Adding list option as an option is not supported! Rerouting to group!");
+ return group(listOption);
+ }
+
+ this.rootOptions.add(option);
+ return this;
+ }
+
+ @Override
+ public Builder options(@NotNull Collection<? extends Option<?>> options) {
+ Validate.notNull(options, "`options` must not be null");
+
+ if (options.stream().anyMatch(ListOption.class::isInstance))
+ throw new UnsupportedOperationException("List options must not be added as an option but a group!");
+
+ this.rootOptions.addAll(options);
+ return this;
+ }
+
+ @Override
+ public Builder group(@NotNull OptionGroup group) {
+ Validate.notNull(group, "`group` must not be null");
+
+ this.groups.add(group);
+ return this;
+ }
+
+ @Override
+ public Builder groups(@NotNull Collection<OptionGroup> groups) {
+ Validate.notEmpty(groups, "`groups` must not be empty");
+
+ this.groups.addAll(groups);
+ return this;
+ }
+
+ @Override
+ public Builder tooltip(@NotNull Component... tooltips) {
+ Validate.notEmpty(tooltips, "`tooltips` cannot be empty");
+
+ tooltipLines.addAll(List.of(tooltips));
+ return this;
+ }
+
+ @Override
+ public ConfigCategory build() {
+ Validate.notNull(name, "`name` must not be null to build `ConfigCategory`");
+
+ List<OptionGroup> combinedGroups = new ArrayList<>();
+ combinedGroups.add(new OptionGroupImpl(CommonComponents.EMPTY, OptionDescription.EMPTY, ImmutableList.copyOf(rootOptions), false, true));
+ combinedGroups.addAll(groups);
+
+ Validate.notEmpty(combinedGroups, "at least one option must be added to build `ConfigCategory`");
+
+ MutableComponent concatenatedTooltip = Component.empty();
+ boolean first = true;
+ for (Component line : tooltipLines) {
+ if (line.getContents() == CommonComponents.EMPTY.getContents())
+ continue;
+
+ if (!first) concatenatedTooltip.append("\n");
+ first = false;
+
+ concatenatedTooltip.append(line);
+ }
+
+ return new ConfigCategoryImpl(name, ImmutableList.copyOf(combinedGroups), concatenatedTooltip);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/GenericBindingImpl.java b/src/main/java/dev/isxander/yacl3/impl/GenericBindingImpl.java
new file mode 100644
index 0000000..972c891
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/GenericBindingImpl.java
@@ -0,0 +1,35 @@
+package dev.isxander.yacl3.impl;
+
+import dev.isxander.yacl3.api.Binding;
+
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+public final class GenericBindingImpl<T> implements Binding<T> {
+ private final T def;
+ private final Supplier<T> getter;
+ private final Consumer<T> setter;
+
+ public GenericBindingImpl(T def, Supplier<T> getter, Consumer<T> setting) {
+ this.def = def;
+ this.getter = getter;
+ this.setter = setting;
+ }
+
+
+ @Override
+ public void setValue(T value) {
+ setter.accept(value);
+ }
+
+ @Override
+ public T getValue() {
+ return getter.get();
+ }
+
+ @Override
+ public T defaultValue() {
+ return def;
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/HiddenNameListOptionEntry.java b/src/main/java/dev/isxander/yacl3/impl/HiddenNameListOptionEntry.java
new file mode 100644
index 0000000..64588f2
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/HiddenNameListOptionEntry.java
@@ -0,0 +1,109 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableSet;
+import dev.isxander.yacl3.api.*;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.function.BiConsumer;
+
+public class HiddenNameListOptionEntry<T> implements ListOptionEntry<T> {
+ private final ListOptionEntry<T> option;
+
+ public HiddenNameListOptionEntry(ListOptionEntry<T> option) {
+ this.option = option;
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return Component.empty();
+ }
+
+ @Override
+ public @NotNull OptionDescription description() {
+ return option.description();
+ }
+
+ @Override
+ @Deprecated
+ public @NotNull Component tooltip() {
+ return option.tooltip();
+ }
+
+ @Override
+ public @NotNull Controller<T> controller() {
+ return option.controller();
+ }
+
+ @Override
+ public @NotNull Binding<T> binding() {
+ return option.binding();
+ }
+
+ @Override
+ public boolean available() {
+ return option.available();
+ }
+
+ @Override
+ public void setAvailable(boolean available) {
+ option.setAvailable(available);
+ }
+
+ @Override
+ public ListOption<T> parentGroup() {
+ return option.parentGroup();
+ }
+
+ @Override
+ public @NotNull ImmutableSet<OptionFlag> flags() {
+ return option.flags();
+ }
+
+ @Override
+ public boolean changed() {
+ return option.changed();
+ }
+
+ @Override
+ public @NotNull T pendingValue() {
+ return option.pendingValue();
+ }
+
+ @Override
+ public void requestSet(@NotNull T value) {
+ option.requestSet(value);
+ }
+
+ @Override
+ public boolean applyValue() {
+ return option.applyValue();
+ }
+
+ @Override
+ public void forgetPendingValue() {
+ option.forgetPendingValue();
+ }
+
+ @Override
+ public void requestSetDefault() {
+ option.requestSetDefault();
+ }
+
+ @Override
+ public boolean isPendingValueDefault() {
+ return option.isPendingValueDefault();
+ }
+
+ @Override
+ public boolean canResetToDefault() {
+ return option.canResetToDefault();
+ }
+
+ @Override
+ public void addListener(BiConsumer<Option<T>, T> changedListener) {
+ option.addListener(changedListener);
+ }
+
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/LabelOptionImpl.java b/src/main/java/dev/isxander/yacl3/impl/LabelOptionImpl.java
new file mode 100644
index 0000000..2bd2e10
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/LabelOptionImpl.java
@@ -0,0 +1,160 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableSet;
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.gui.controllers.LabelController;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+import org.apache.commons.lang3.Validate;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+@ApiStatus.Internal
+public final class LabelOptionImpl implements LabelOption {
+ private final Component label;
+ private final Component name = Component.literal("Label Option");
+ private final OptionDescription description;
+ private final Component tooltip = Component.empty();
+ private final LabelController labelController;
+ private final Binding<Component> binding;
+
+ public LabelOptionImpl(Component label) {
+ Validate.notNull(label, "`label` must not be null");
+
+ this.label = label;
+ this.labelController = new LabelController(this);
+ this.binding = Binding.immutable(label);
+ this.description = OptionDescription.createBuilder()
+ .text(this.label)
+ .build();
+ }
+
+ @Override
+ public @NotNull Component label() {
+ return label;
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return name;
+ }
+
+ @Override
+ public @NotNull OptionDescription description() {
+ return description;
+ }
+
+ @Override
+ public @NotNull Component tooltip() {
+ return tooltip;
+ }
+
+ @Override
+ public @NotNull Controller<Component> controller() {
+ return labelController;
+ }
+
+ @Override
+ public @NotNull Binding<Component> binding() {
+ return binding;
+ }
+
+ @Override
+ public boolean available() {
+ return true;
+ }
+
+ @Override
+ public void setAvailable(boolean available) {
+ throw new UnsupportedOperationException("Label options cannot be disabled.");
+ }
+
+ @Override
+ public @NotNull ImmutableSet<OptionFlag> flags() {
+ return ImmutableSet.of();
+ }
+
+ @Override
+ public boolean changed() {
+ return false;
+ }
+
+ @Override
+ public @NotNull Component pendingValue() {
+ return label;
+ }
+
+ @Override
+ public void requestSet(@NotNull Component value) {
+
+ }
+
+ @Override
+ public boolean applyValue() {
+ return false;
+ }
+
+ @Override
+ public void forgetPendingValue() {
+
+ }
+
+ @Override
+ public void requestSetDefault() {
+
+ }
+
+ @Override
+ public boolean isPendingValueDefault() {
+ return true;
+ }
+
+ @Override
+ public boolean canResetToDefault() {
+ return false;
+ }
+
+ @Override
+ public void addListener(BiConsumer<Option<Component>, Component> changedListener) {
+
+ }
+
+ @ApiStatus.Internal
+ public static final class BuilderImpl implements Builder {
+ private final List<Component> lines = new ArrayList<>();
+
+ @Override
+ public Builder line(@NotNull Component line) {
+ Validate.notNull(line, "`line` must not be null");
+
+ this.lines.add(line);
+ return this;
+ }
+
+ @Override
+ public Builder lines(@NotNull Collection<? extends Component> lines) {
+ this.lines.addAll(lines);
+ return this;
+ }
+
+ @Override
+ public LabelOption build() {
+ MutableComponent text = Component.empty();
+ Iterator<Component> iterator = lines.iterator();
+ while (iterator.hasNext()) {
+ text.append(iterator.next());
+
+ if (iterator.hasNext())
+ text.append("\n");
+ }
+
+ return new LabelOptionImpl(text);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/ListOptionEntryImpl.java b/src/main/java/dev/isxander/yacl3/impl/ListOptionEntryImpl.java
new file mode 100644
index 0000000..1cd5e55
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/ListOptionEntryImpl.java
@@ -0,0 +1,154 @@
+package dev.isxander.yacl3.impl;
+
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.gui.AbstractWidget;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.gui.controllers.ListEntryWidget;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+@ApiStatus.Internal
+public final class ListOptionEntryImpl<T> implements ListOptionEntry<T> {
+ private final ListOptionImpl<T> group;
+
+ private T value;
+
+ private final Binding<T> binding;
+ private final Controller<T> controller;
+
+ ListOptionEntryImpl(ListOptionImpl<T> group, T initialValue, @NotNull Function<ListOptionEntry<T>, Controller<T>> controlGetter) {
+ this.group = group;
+ this.value = initialValue;
+ this.binding = new EntryBinding();
+ this.controller = new EntryController<>(controlGetter.apply(new HiddenNameListOptionEntry<>(this)), this);
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return group.name();
+ }
+
+ @Override
+ public @NotNull OptionDescription description() {
+ return group.description();
+ }
+
+ @Override
+ public @NotNull Component tooltip() {
+ return group.tooltip();
+ }
+
+ @Override
+ public @NotNull Controller<T> controller() {
+ return controller;
+ }
+
+ @Override
+ public @NotNull Binding<T> binding() {
+ return binding;
+ }
+
+ @Override
+ public boolean available() {
+ return parentGroup().available();
+ }
+
+ @Override
+ public void setAvailable(boolean available) {
+
+ }
+
+ @Override
+ public ListOption<T> parentGroup() {
+ return group;
+ }
+
+ @Override
+ public boolean changed() {
+ return false;
+ }
+
+ @Override
+ public @NotNull T pendingValue() {
+ return value;
+ }
+
+ @Override
+ public void requestSet(@NotNull T value) {
+ binding.setValue(value);
+ }
+
+ @Override
+ public boolean applyValue() {
+ return false;
+ }
+
+ @Override
+ public void forgetPendingValue() {
+
+ }
+
+ @Override
+ public void requestSetDefault() {
+
+ }
+
+ @Override
+ public boolean isPendingValueDefault() {
+ return false;
+ }
+
+ @Override
+ public boolean canResetToDefault() {
+ return false;
+ }
+
+ @Override
+ public void addListener(BiConsumer<Option<T>, T> changedListener) {
+
+ }
+
+ /**
+ * Open in case mods need to find the real controller type.
+ */
+ @ApiStatus.Internal
+ public record EntryController<T>(Controller<T> controller, ListOptionEntryImpl<T> entry) implements Controller<T> {
+ @Override
+ public Option<T> option() {
+ return controller.option();
+ }
+
+ @Override
+ public Component formatValue() {
+ return controller.formatValue();
+ }
+
+ @Override
+ public AbstractWidget provideWidget(YACLScreen screen, Dimension<Integer> widgetDimension) {
+ return new ListEntryWidget(screen, entry, controller.provideWidget(screen, widgetDimension));
+ }
+ }
+
+ private class EntryBinding implements Binding<T> {
+ @Override
+ public void setValue(T newValue) {
+ value = newValue;
+ group.callListeners(true);
+ }
+
+ @Override
+ public T getValue() {
+ return value;
+ }
+
+ @Override
+ public T defaultValue() {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/ListOptionImpl.java b/src/main/java/dev/isxander/yacl3/impl/ListOptionImpl.java
new file mode 100644
index 0000000..c77d55f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/ListOptionImpl.java
@@ -0,0 +1,402 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+@ApiStatus.Internal
+public final class ListOptionImpl<T> implements ListOption<T> {
+ private final Component name;
+ private final OptionDescription description;
+ private final Binding<List<T>> binding;
+ private final Supplier<T> initialValue;
+ private final List<ListOptionEntry<T>> entries;
+ private final boolean collapsed;
+ private boolean available;
+ private final int minimumNumberOfEntries;
+ private final int maximumNumberOfEntries;
+ private final boolean insertEntriesAtEnd;
+ private final ImmutableSet<OptionFlag> flags;
+ private final EntryFactory entryFactory;
+
+ private final List<BiConsumer<Option<List<T>>, List<T>>> listeners;
+ private final List<Runnable> refreshListeners;
+ private int listenerTriggerDepth = 0;
+
+ public ListOptionImpl(@NotNull Component name, @NotNull OptionDescription description, @NotNull Binding<List<T>> binding, @NotNull Supplier<T> initialValue, @NotNull Function<ListOptionEntry<T>, Controller<T>> controllerFunction, ImmutableSet<OptionFlag> flags, boolean collapsed, boolean available, int minimumNumberOfEntries, int maximumNumberOfEntries, boolean insertEntriesAtEnd, Collection<BiConsumer<Option<List<T>>, List<T>>> listeners) {
+ this.name = name;
+ this.description = description;
+ this.binding = new SafeBinding<>(binding);
+ this.initialValue = initialValue;
+ this.entryFactory = new EntryFactory(controllerFunction);
+ this.entries = createEntries(binding().getValue());
+ this.collapsed = collapsed;
+ this.flags = flags;
+ this.available = available;
+ this.minimumNumberOfEntries = minimumNumberOfEntries;
+ this.maximumNumberOfEntries = maximumNumberOfEntries;
+ this.insertEntriesAtEnd = insertEntriesAtEnd;
+ this.listeners = new ArrayList<>();
+ this.listeners.addAll(listeners);
+ this.refreshListeners = new ArrayList<>();
+ callListeners(true);
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return this.name;
+ }
+
+ @Override
+ public @NotNull OptionDescription description() {
+ return this.description;
+ }
+
+ @Override
+ public @NotNull Component tooltip() {
+ return description().text();
+ }
+
+ @Override
+ public @NotNull ImmutableList<ListOptionEntry<T>> options() {
+ return ImmutableList.copyOf(entries);
+ }
+
+ @Override
+ public @NotNull Controller<List<T>> controller() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public @NotNull Binding<List<T>> binding() {
+ return binding;
+ }
+
+ @Override
+ public boolean collapsed() {
+ return collapsed;
+ }
+
+ @Override
+ public @NotNull ImmutableSet<OptionFlag> flags() {
+ return flags;
+ }
+
+ @Override
+ public @NotNull ImmutableList<T> pendingValue() {
+ return ImmutableList.copyOf(entries.stream().map(Option::pendingValue).toList());
+ }
+
+ @Override
+ public void insertEntry(int index, ListOptionEntry<?> entry) {
+ entries.add(index, (ListOptionEntry<T>) entry);
+ onRefresh();
+ }
+
+ @Override
+ public ListOptionEntry<T> insertNewEntry() {
+ ListOptionEntry<T> newEntry = entryFactory.create(initialValue.get());
+ if (insertEntriesAtEnd) {
+ entries.add(newEntry);
+ } else {
+ // insert at top
+ entries.add(0, newEntry);
+ }
+ onRefresh();
+ return newEntry;
+ }
+
+ @Override
+ public void removeEntry(ListOptionEntry<?> entry) {
+ if (entries.remove(entry))
+ onRefresh();
+ }
+
+ @Override
+ public int indexOf(ListOptionEntry<?> entry) {
+ return entries.indexOf(entry);
+ }
+
+ @Override
+ public void requestSet(@NotNull List<T> value) {
+ entries.clear();
+ entries.addAll(createEntries(value));
+ onRefresh();
+ }
+
+ @Override
+ public boolean changed() {
+ return !binding().getValue().equals(pendingValue());
+ }
+
+ @Override
+ public boolean applyValue() {
+ if (changed()) {
+ binding().setValue(pendingValue());
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void forgetPendingValue() {
+ requestSet(binding().getValue());
+ }
+
+ @Override
+ public void requestSetDefault() {
+ requestSet(binding().defaultValue());
+ }
+
+ @Override
+ public boolean isPendingValueDefault() {
+ return binding().defaultValue().equals(pendingValue());
+ }
+
+ @Override
+ public boolean available() {
+ return available;
+ }
+
+ @Override
+ public void setAvailable(boolean available) {
+ boolean changed = this.available != available;
+
+ this.available = available;
+
+ if (changed)
+ callListeners(false);
+ }
+
+ @Override
+ public int numberOfEntries() {
+ return this.entries.size();
+ }
+ @Override
+ public int maximumNumberOfEntries() {
+ return this.maximumNumberOfEntries;
+ }
+ @Override
+ public int minimumNumberOfEntries() {
+ return this.minimumNumberOfEntries;
+ }
+
+ @Override
+ public void addListener(BiConsumer<Option<List<T>>, List<T>> changedListener) {
+ this.listeners.add(changedListener);
+ }
+
+ @Override
+ public void addRefreshListener(Runnable changedListener) {
+ this.refreshListeners.add(changedListener);
+ }
+
+ @Override
+ public boolean isRoot() {
+ return false;
+ }
+
+ private List<ListOptionEntry<T>> createEntries(Collection<T> values) {
+ return values.stream().map(entryFactory::create).collect(Collectors.toList());
+ }
+
+ void callListeners(boolean bypass) {
+ List<T> pendingValue = pendingValue();
+ if (bypass || listenerTriggerDepth == 0) {
+ if (listenerTriggerDepth > 10) {
+ throw new IllegalStateException("Listener trigger depth exceeded 10! This means a listener triggered a listener etc etc 10 times deep. This is likely a bug in the mod using YACL!");
+ }
+
+ this.listenerTriggerDepth++;
+
+ for (BiConsumer<Option<List<T>>, List<T>> listener : listeners) {
+ try {
+ listener.accept(this, pendingValue);
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Exception whilst triggering listener for option '%s'".formatted(name.getString()), e);
+ }
+ }
+
+ this.listenerTriggerDepth--;
+ }
+ }
+
+ private void onRefresh() {
+ refreshListeners.forEach(Runnable::run);
+ callListeners(true);
+ }
+
+ private class EntryFactory {
+ private final Function<ListOptionEntry<T>, Controller<T>> controllerFunction;
+
+ private EntryFactory(Function<ListOptionEntry<T>, Controller<T>> controllerFunction) {
+ this.controllerFunction = controllerFunction;
+ }
+
+ public ListOptionEntry<T> create(T initialValue) {
+ return new ListOptionEntryImpl<>(ListOptionImpl.this, initialValue, controllerFunction);
+ }
+ }
+
+ @ApiStatus.Internal
+ public static final class BuilderImpl<T> implements Builder<T> {
+ private Component name = Component.empty();
+ private OptionDescription description = OptionDescription.EMPTY;
+ private Function<ListOptionEntry<T>, Controller<T>> controllerFunction;
+ private Binding<List<T>> binding = null;
+ private final Set<OptionFlag> flags = new HashSet<>();
+ private Supplier<T> initialValue;
+ private boolean collapsed = false;
+ private boolean available = true;
+ private int minimumNumberOfEntries = 0;
+ private int maximumNumberOfEntries = Integer.MAX_VALUE;
+ private boolean insertEntriesAtEnd = false;
+ private final List<BiConsumer<Option<List<T>>, List<T>>> listeners = new ArrayList<>();
+
+ @Override
+ public Builder<T> name(@NotNull Component name) {
+ Validate.notNull(name, "`name` must not be null");
+
+ this.name = name;
+ return this;
+ }
+
+ @Override
+ public Builder<T> description(@NotNull OptionDescription description) {
+ Validate.notNull(description, "`description` must not be null");
+
+ this.description = description;
+ return this;
+ }
+
+ @Override
+ public Builder<T> initial(@NotNull Supplier<T> initialValue) {
+ Validate.notNull(initialValue, "`initialValue` cannot be empty");
+
+ this.initialValue = initialValue;
+ return this;
+ }
+
+ @Override
+ public Builder<T> initial(@NotNull T initialValue) {
+ Validate.notNull(initialValue, "`initialValue` cannot be empty");
+
+ this.initialValue = () -> initialValue;
+ return this;
+ }
+
+ @Override
+ public Builder<T> controller(@NotNull Function<Option<T>, ControllerBuilder<T>> controller) {
+ Validate.notNull(controller, "`controller` cannot be null");
+
+ this.controllerFunction = opt -> controller.apply(opt).build();
+ return this;
+ }
+
+ @Override
+ public Builder<T> customController(@NotNull Function<ListOptionEntry<T>, Controller<T>> control) {
+ Validate.notNull(control, "`control` cannot be null");
+
+ this.controllerFunction = control;
+ return this;
+ }
+
+ @Override
+ public Builder<T> binding(@NotNull Binding<List<T>> binding) {
+ Validate.notNull(binding, "`binding` cannot be null");
+
+ this.binding = binding;
+ return this;
+ }
+
+ @Override
+ public Builder<T> binding(@NotNull List<T> def, @NotNull Supplier<@NotNull List<T>> getter, @NotNull Consumer<@NotNull List<T>> setter) {
+ Validate.notNull(def, "`def` must not be null");
+ Validate.notNull(getter, "`getter` must not be null");
+ Validate.notNull(setter, "`setter` must not be null");
+
+ this.binding = Binding.generic(def, getter, setter);
+ return this;
+ }
+
+ @Override
+ public Builder<T> available(boolean available) {
+ this.available = available;
+ return this;
+ }
+
+ @Override
+ public Builder<T> minimumNumberOfEntries(int number) {
+ this.minimumNumberOfEntries = number;
+ return this;
+ }
+
+ @Override
+ public Builder<T> maximumNumberOfEntries(int number) {
+ this.maximumNumberOfEntries = number;
+ return this;
+ }
+
+ @Override
+ public Builder<T> insertEntriesAtEnd(boolean insertAtEnd) {
+ this.insertEntriesAtEnd = insertAtEnd;
+ return this;
+ }
+
+ @Override
+ public Builder<T> flag(@NotNull OptionFlag... flag) {
+ Validate.notNull(flag, "`flag` must not be null");
+
+ this.flags.addAll(Arrays.asList(flag));
+ return this;
+ }
+
+ @Override
+ public Builder<T> flags(@NotNull Collection<OptionFlag> flags) {
+ Validate.notNull(flags, "`flags` must not be null");
+
+ this.flags.addAll(flags);
+ return this;
+ }
+
+ @Override
+ public Builder<T> collapsed(boolean collapsible) {
+ this.collapsed = collapsible;
+ return this;
+ }
+
+ @Override
+ public Builder<T> listener(@NotNull BiConsumer<Option<List<T>>, List<T>> listener) {
+ this.listeners.add(listener);
+ return this;
+ }
+
+ @Override
+ public Builder<T> listeners(@NotNull Collection<BiConsumer<Option<List<T>>, List<T>>> listeners) {
+ this.listeners.addAll(listeners);
+ return this;
+ }
+
+ @Override
+ public ListOption<T> build() {
+ Validate.notNull(controllerFunction, "`controller` must not be null");
+ Validate.notNull(binding, "`binding` must not be null");
+ Validate.notNull(initialValue, "`initialValue` must not be null");
+
+ return new ListOptionImpl<>(name, description, binding, initialValue, controllerFunction, ImmutableSet.copyOf(flags), collapsed, available, minimumNumberOfEntries, maximumNumberOfEntries, insertEntriesAtEnd, listeners);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/OptionDescriptionImpl.java b/src/main/java/dev/isxander/yacl3/impl/OptionDescriptionImpl.java
new file mode 100644
index 0000000..67fa6a6
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/OptionDescriptionImpl.java
@@ -0,0 +1,133 @@
+package dev.isxander.yacl3.impl;
+
+import dev.isxander.yacl3.api.OptionDescription;
+import dev.isxander.yacl3.gui.image.ImageRenderer;
+import dev.isxander.yacl3.gui.image.ImageRendererManager;
+import dev.isxander.yacl3.gui.image.impl.AnimatedDynamicTextureImage;
+import dev.isxander.yacl3.gui.image.impl.DynamicTextureImage;
+import dev.isxander.yacl3.gui.image.impl.ResourceTextureImage;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.resources.ResourceLocation;
+import org.apache.commons.lang3.Validate;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+
+public record OptionDescriptionImpl(Component text, CompletableFuture<Optional<ImageRenderer>> image) implements OptionDescription {
+ public static class BuilderImpl implements Builder {
+ private final List<Component> descriptionLines = new ArrayList<>();
+ private CompletableFuture<Optional<ImageRenderer>> image = CompletableFuture.completedFuture(Optional.empty());
+ private boolean imageUnset = true;
+
+ @Override
+ public Builder text(Component... description) {
+ this.descriptionLines.addAll(Arrays.asList(description));
+ return this;
+ }
+
+ @Override
+ public Builder text(Collection<? extends Component> lines) {
+ this.descriptionLines.addAll(lines);
+ return this;
+ }
+
+ @Override
+ public Builder image(ResourceLocation image, int width, int height) {
+ Validate.isTrue(imageUnset, "Image already set!");
+ Validate.isTrue(width > 0, "Width must be greater than 0!");
+ Validate.isTrue(height > 0, "Height must be greater than 0!");
+
+ this.image = ImageRendererManager.registerImage(image, ResourceTextureImage.createFactory(image, 0, 0, width, height, width, height)).thenApply(Optional::of);
+ imageUnset = false;
+ return this;
+ }
+
+ @Override
+ public Builder image(ResourceLocation image, float u, float v, int width, int height, int textureWidth, int textureHeight) {
+ Validate.isTrue(imageUnset, "Image already set!");
+ Validate.isTrue(width > 0, "Width must be greater than 0!");
+ Validate.isTrue(height > 0, "Height must be greater than 0!");
+
+ this.image = ImageRendererManager.registerImage(image, ResourceTextureImage.createFactory(image, u, v, width, height, textureWidth, textureHeight)).thenApply(Optional::of);
+ imageUnset = false;
+ return this;
+ }
+
+ @Override
+ public Builder image(Path path, ResourceLocation uniqueLocation) {
+ Validate.isTrue(imageUnset, "Image already set!");
+
+ this.image = ImageRendererManager.registerImage(uniqueLocation, DynamicTextureImage.fromPath(path, uniqueLocation)).thenApply(Optional::of);
+ imageUnset = false;
+ return this;
+ }
+
+ @Override
+ public Builder gifImage(ResourceLocation image) {
+ Validate.isTrue(imageUnset, "Image already set!");
+
+ this.image = ImageRendererManager.registerImage(image, AnimatedDynamicTextureImage.createGIFFromTexture(image)).thenApply(Optional::of);
+ imageUnset = false;
+ return this;
+ }
+
+ @Override
+ public Builder gifImage(Path path, ResourceLocation uniqueLocation) {
+ Validate.isTrue(imageUnset, "Image already set!");
+
+ this.image = ImageRendererManager.registerImage(uniqueLocation, AnimatedDynamicTextureImage.createGIFFromPath(path, uniqueLocation)).thenApply(Optional::of);
+ imageUnset = false;
+ return this;
+ }
+
+ @Override
+ public Builder webpImage(ResourceLocation image) {
+ Validate.isTrue(imageUnset, "Image already set!");
+
+ Optional<ImageRenderer> completedImage = ImageRendererManager.getImage(image);
+ if (completedImage.isPresent()) {
+ this.image = CompletableFuture.completedFuture(completedImage);
+ } else {
+ this.image = ImageRendererManager.registerImage(image, AnimatedDynamicTextureImage.createWEBPFromTexture(image)).thenApply(Optional::of);
+ }
+
+ imageUnset = false;
+ return this;
+ }
+
+ @Override
+ public Builder webpImage(Path path, ResourceLocation uniqueLocation) {
+ Validate.isTrue(imageUnset, "Image already set!");
+
+ this.image = ImageRendererManager.registerImage(uniqueLocation, AnimatedDynamicTextureImage.createWEBPFromPath(path, uniqueLocation)).thenApply(Optional::of);
+ imageUnset = false;
+ return this;
+ }
+
+ @Override
+ public Builder customImage(CompletableFuture<Optional<ImageRenderer>> image) {
+ Validate.notNull(image, "Image cannot be null!");
+ Validate.isTrue(imageUnset, "Image already set!");
+
+ this.image = image;
+ this.imageUnset = false;
+ return this;
+ }
+
+ @Override
+ public OptionDescription build() {
+ MutableComponent concatenatedDescription = Component.empty();
+ Iterator<Component> iter = descriptionLines.iterator();
+ while (iter.hasNext()) {
+ concatenatedDescription.append(iter.next());
+ if (iter.hasNext()) concatenatedDescription.append("\n");
+ }
+
+ return new OptionDescriptionImpl(concatenatedDescription, image);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/OptionGroupImpl.java b/src/main/java/dev/isxander/yacl3/impl/OptionGroupImpl.java
new file mode 100644
index 0000000..7805b29
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/OptionGroupImpl.java
@@ -0,0 +1,121 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.ListOption;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.OptionDescription;
+import dev.isxander.yacl3.api.OptionGroup;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@ApiStatus.Internal
+public final class OptionGroupImpl implements OptionGroup {
+ private final @NotNull Component name;
+ private final @NotNull OptionDescription description;
+ private final ImmutableList<? extends Option<?>> options;
+ private final boolean collapsed;
+ private final boolean isRoot;
+
+ public OptionGroupImpl(@NotNull Component name, @NotNull OptionDescription description, ImmutableList<? extends Option<?>> options, boolean collapsed, boolean isRoot) {
+ this.name = name;
+ this.description = description;
+ this.options = options;
+ this.collapsed = collapsed;
+ this.isRoot = isRoot;
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return name;
+ }
+
+ @Override
+ public OptionDescription description() {
+ return description;
+ }
+
+ @Override
+ public @NotNull Component tooltip() {
+ return description.text();
+ }
+
+ @Override
+ public @NotNull ImmutableList<? extends Option<?>> options() {
+ return options;
+ }
+
+ @Override
+ public boolean collapsed() {
+ return collapsed;
+ }
+
+ @Override
+ public boolean isRoot() {
+ return isRoot;
+ }
+
+ @ApiStatus.Internal
+ public static final class BuilderImpl implements Builder {
+ private Component name = Component.empty();
+ private OptionDescription description = OptionDescription.EMPTY;
+ private final List<Option<?>> options = new ArrayList<>();
+ private boolean collapsed = false;
+
+ @Override
+ public Builder name(@NotNull Component name) {
+ Validate.notNull(name, "`name` must not be null");
+
+ this.name = name;
+ return this;
+ }
+
+ @Override
+ public Builder description(@NotNull OptionDescription description) {
+ Validate.notNull(description, "`description` must not be null");
+
+ this.description = description;
+ return this;
+ }
+
+ @Override
+ public Builder option(@NotNull Option<?> option) {
+ Validate.notNull(option, "`option` must not be null");
+
+ if (option instanceof ListOption<?>)
+ throw new UnsupportedOperationException("List options must not be added as an option but a group!");
+
+ this.options.add(option);
+ return this;
+ }
+
+ @Override
+ public Builder options(@NotNull Collection<? extends Option<?>> options) {
+ Validate.notEmpty(options, "`options` must not be empty");
+
+ if (options.stream().anyMatch(ListOption.class::isInstance))
+ throw new UnsupportedOperationException("List options must not be added as an option but a group!");
+
+ this.options.addAll(options);
+ return this;
+ }
+
+ @Override
+ public Builder collapsed(boolean collapsible) {
+ this.collapsed = collapsible;
+ return this;
+ }
+
+ @Override
+ public OptionGroup build() {
+ Validate.notEmpty(options, "`options` must not be empty to build `OptionGroup`");
+
+ return new OptionGroupImpl(name, description, ImmutableList.copyOf(options), collapsed, false);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/OptionImpl.java b/src/main/java/dev/isxander/yacl3/impl/OptionImpl.java
new file mode 100644
index 0000000..afe9517
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/OptionImpl.java
@@ -0,0 +1,295 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableSet;
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+@ApiStatus.Internal
+public final class OptionImpl<T> implements Option<T> {
+ private final Component name;
+ private OptionDescription description;
+ private final Controller<T> controller;
+ private final Binding<T> binding;
+ private boolean available;
+
+ private final ImmutableSet<OptionFlag> flags;
+
+ private T pendingValue;
+
+ private final List<BiConsumer<Option<T>, T>> listeners;
+ private int listenerTriggerDepth = 0;
+
+ public OptionImpl(
+ @NotNull Component name,
+ @NotNull Function<T, OptionDescription> descriptionFunction,
+ @NotNull Function<Option<T>, Controller<T>> controlGetter,
+ @NotNull Binding<T> binding,
+ boolean available,
+ ImmutableSet<OptionFlag> flags,
+ @NotNull Collection<BiConsumer<Option<T>, T>> listeners
+ ) {
+ this.name = name;
+ this.binding = new SafeBinding<>(binding);
+ this.available = available;
+ this.flags = flags;
+ this.listeners = new ArrayList<>(listeners);
+
+ this.pendingValue = binding.getValue();
+ this.controller = controlGetter.apply(this);
+
+ addListener((opt, pending) -> description = descriptionFunction.apply(pending));
+ triggerListeners(true);
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return name;
+ }
+
+ @Override
+ public @NotNull OptionDescription description() {
+ return this.description;
+ }
+
+ @Override
+ public @NotNull Component tooltip() {
+ return description.text();
+ }
+
+ @Override
+ public @NotNull Controller<T> controller() {
+ return controller;
+ }
+
+ @Override
+ public @NotNull Binding<T> binding() {
+ return binding;
+ }
+
+ @Override
+ public boolean available() {
+ return available;
+ }
+
+ @Override
+ public void setAvailable(boolean available) {
+ boolean changed = this.available != available;
+
+ this.available = available;
+
+ if (changed) {
+ if (!available) {
+ this.pendingValue = binding().getValue();
+ }
+ this.triggerListeners(!available);
+ }
+ }
+
+ @Override
+ public @NotNull ImmutableSet<OptionFlag> flags() {
+ return flags;
+ }
+
+ @Override
+ public boolean changed() {
+ return !binding().getValue().equals(pendingValue);
+ }
+
+ @Override
+ public @NotNull T pendingValue() {
+ return pendingValue;
+ }
+
+ @Override
+ public void requestSet(@NotNull T value) {
+ Validate.notNull(value, "`value` cannot be null");
+
+ pendingValue = value;
+ this.triggerListeners(true);
+ }
+
+ @Override
+ public boolean applyValue() {
+ if (changed()) {
+ binding().setValue(pendingValue);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void forgetPendingValue() {
+ requestSet(binding().getValue());
+ }
+
+ @Override
+ public void requestSetDefault() {
+ requestSet(binding().defaultValue());
+ }
+
+ @Override
+ public boolean isPendingValueDefault() {
+ return binding().defaultValue().equals(pendingValue());
+ }
+
+ @Override
+ public void addListener(BiConsumer<Option<T>, T> changedListener) {
+ this.listeners.add(changedListener);
+ }
+
+ private void triggerListeners(boolean bypass) {
+ if (bypass || listenerTriggerDepth == 0) {
+ if (listenerTriggerDepth > 10) {
+ throw new IllegalStateException("Listener trigger depth exceeded 10! This means a listener triggered a listener etc etc 10 times deep. This is likely a bug in the mod using YACL!");
+ }
+
+ this.listenerTriggerDepth++;
+
+ for (BiConsumer<Option<T>, T> listener : listeners) {
+ try {
+ listener.accept(this, pendingValue);
+ } catch (Exception e) {
+ YACLConstants.LOGGER.error("Exception whilst triggering listener for option '%s'".formatted(name.getString()), e);
+ }
+ }
+
+ this.listenerTriggerDepth--;
+ }
+ }
+
+ @ApiStatus.Internal
+ public static class BuilderImpl<T> implements Builder<T> {
+ private Component name = Component.literal("Name not specified!").withStyle(ChatFormatting.RED);
+
+ private Function<T, OptionDescription> descriptionFunction = pending -> OptionDescription.EMPTY;
+
+ private Function<Option<T>, Controller<T>> controlGetter;
+
+ private Binding<T> binding;
+
+ private boolean available = true;
+
+ private boolean instant = false;
+
+ private final Set<OptionFlag> flags = new HashSet<>();
+
+ private final List<BiConsumer<Option<T>, T>> listeners = new ArrayList<>();
+
+ @Override
+ public Builder<T> name(@NotNull Component name) {
+ Validate.notNull(name, "`name` cannot be null");
+
+ this.name = name;
+ return this;
+ }
+
+ @Override
+ public Builder<T> description(@NotNull OptionDescription description) {
+ return description(opt -> description);
+ }
+
+ @Override
+ public Builder<T> description(@NotNull Function<T, OptionDescription> descriptionFunction) {
+ this.descriptionFunction = descriptionFunction;
+ return this;
+ }
+
+ @Override
+ public Builder<T> controller(@NotNull Function<Option<T>, ControllerBuilder<T>> controllerBuilder) {
+ Validate.notNull(controllerBuilder, "`controllerBuilder` cannot be null");
+
+ return customController(opt -> controllerBuilder.apply(opt).build());
+ }
+
+ @Override
+ public Builder<T> customController(@NotNull Function<Option<T>, Controller<T>> control) {
+ Validate.notNull(control, "`control` cannot be null");
+
+ this.controlGetter = control;
+ return this;
+ }
+
+ @Override
+ public Builder<T> binding(@NotNull Binding<T> binding) {
+ Validate.notNull(binding, "`binding` cannot be null");
+
+ this.binding = binding;
+ return this;
+ }
+
+ @Override
+ public Builder<T> binding(@NotNull T def, @NotNull Supplier<@NotNull T> getter, @NotNull Consumer<@NotNull T> setter) {
+ Validate.notNull(def, "`def` must not be null");
+ Validate.notNull(getter, "`getter` must not be null");
+ Validate.notNull(setter, "`setter` must not be null");
+
+ this.binding = Binding.generic(def, getter, setter);
+ return this;
+ }
+
+ @Override
+ public Builder<T> available(boolean available) {
+ this.available = available;
+ return this;
+ }
+
+ @Override
+ public Builder<T> flag(@NotNull OptionFlag... flag) {
+ Validate.notNull(flag, "`flag` must not be null");
+
+ this.flags.addAll(Arrays.asList(flag));
+ return this;
+ }
+
+ @Override
+ public Builder<T> flags(@NotNull Collection<? extends OptionFlag> flags) {
+ Validate.notNull(flags, "`flags` must not be null");
+
+ this.flags.addAll(flags);
+ return this;
+ }
+
+ @Override
+ public Builder<T> instant(boolean instant) {
+ this.instant = instant;
+ return this;
+ }
+
+ @Override
+ public Builder<T> listener(@NotNull BiConsumer<Option<T>, T> listener) {
+ this.listeners.add(listener);
+ return this;
+ }
+
+ @Override
+ public Builder<T> listeners(@NotNull Collection<BiConsumer<Option<T>, T>> listeners) {
+ this.listeners.addAll(listeners);
+ return this;
+ }
+
+ @Override
+ public Option<T> build() {
+ Validate.notNull(controlGetter, "`control` must not be null when building `Option`");
+ Validate.notNull(binding, "`binding` must not be null when building `Option`");
+ Validate.isTrue(!instant || flags.isEmpty(), "instant application does not support option flags");
+
+ if (instant) {
+ listeners.add((opt, pendingValue) -> opt.applyValue());
+ }
+
+ return new OptionImpl<>(name, descriptionFunction, controlGetter, binding, available, ImmutableSet.copyOf(flags), listeners);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/PlaceholderCategoryImpl.java b/src/main/java/dev/isxander/yacl3/impl/PlaceholderCategoryImpl.java
new file mode 100644
index 0000000..5e836a3
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/PlaceholderCategoryImpl.java
@@ -0,0 +1,99 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.OptionGroup;
+import dev.isxander.yacl3.api.PlaceholderCategory;
+import dev.isxander.yacl3.gui.YACLScreen;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+import org.apache.commons.lang3.Validate;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiFunction;
+
+@ApiStatus.Internal
+public final class PlaceholderCategoryImpl implements PlaceholderCategory {
+ private final Component name;
+ private final BiFunction<Minecraft, YACLScreen, Screen> screen;
+ private final Component tooltip;
+
+ public PlaceholderCategoryImpl(Component name, BiFunction<Minecraft, YACLScreen, Screen> screen, Component tooltip) {
+ this.name = name;
+ this.screen = screen;
+ this.tooltip = tooltip;
+ }
+
+ @Override
+ public @NotNull ImmutableList<OptionGroup> groups() {
+ return ImmutableList.of();
+ }
+
+ @Override
+ public @NotNull Component name() {
+ return name;
+ }
+
+ @Override
+ public BiFunction<Minecraft, YACLScreen, Screen> screen() {
+ return screen;
+ }
+
+ @Override
+ public @NotNull Component tooltip() {
+ return tooltip;
+ }
+
+ @ApiStatus.Internal
+ public static final class BuilderImpl implements Builder {
+ private Component name;
+
+ private final List<Component> tooltipLines = new ArrayList<>();
+
+ private BiFunction<Minecraft, YACLScreen, Screen> screenFunction;
+
+ @Override
+ public Builder name(@NotNull Component name) {
+ Validate.notNull(name, "`name` cannot be null");
+
+ this.name = name;
+ return this;
+ }
+
+ @Override
+ public Builder tooltip(@NotNull Component... tooltips) {
+ Validate.notEmpty(tooltips, "`tooltips` cannot be empty");
+
+ tooltipLines.addAll(List.of(tooltips));
+ return this;
+ }
+
+ @Override
+ public Builder screen(@NotNull BiFunction<Minecraft, YACLScreen, Screen> screenFunction) {
+ Validate.notNull(screenFunction, "`screenFunction` cannot be null");
+
+ this.screenFunction = screenFunction;
+ return this;
+ }
+
+ @Override
+ public PlaceholderCategory build() {
+ Validate.notNull(name, "`name` must not be null to build `ConfigCategory`");
+
+ MutableComponent concatenatedTooltip = Component.empty();
+ boolean first = true;
+ for (Component line : tooltipLines) {
+ if (!first) concatenatedTooltip.append("\n");
+ first = false;
+
+ concatenatedTooltip.append(line);
+ }
+
+ return new PlaceholderCategoryImpl(name, screenFunction, concatenatedTooltip);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/SafeBinding.java b/src/main/java/dev/isxander/yacl3/impl/SafeBinding.java
new file mode 100644
index 0000000..c55d2be
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/SafeBinding.java
@@ -0,0 +1,29 @@
+package dev.isxander.yacl3.impl;
+
+import dev.isxander.yacl3.api.Binding;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Objects;
+
+public class SafeBinding<T> implements Binding<T> {
+ private final Binding<T> binding;
+
+ public SafeBinding(Binding<T> binding) {
+ this.binding = binding;
+ }
+
+ @Override
+ public @NotNull T getValue() {
+ return Objects.requireNonNull(binding.getValue());
+ }
+
+ @Override
+ public void setValue(@NotNull T value) {
+ binding.setValue(Objects.requireNonNull(value));
+ }
+
+ @Override
+ public @NotNull T defaultValue() {
+ return Objects.requireNonNull(binding.defaultValue());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/YetAnotherConfigLibImpl.java b/src/main/java/dev/isxander/yacl3/impl/YetAnotherConfigLibImpl.java
new file mode 100644
index 0000000..0be02a7
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/YetAnotherConfigLibImpl.java
@@ -0,0 +1,122 @@
+package dev.isxander.yacl3.impl;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.ConfigCategory;
+import dev.isxander.yacl3.api.PlaceholderCategory;
+import dev.isxander.yacl3.api.YetAnotherConfigLib;
+import dev.isxander.yacl3.gui.YACLScreen;
+import dev.isxander.yacl3.impl.utils.YACLConstants;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Consumer;
+
+@ApiStatus.Internal
+public final class YetAnotherConfigLibImpl implements YetAnotherConfigLib {
+ private final Component title;
+ private final ImmutableList<ConfigCategory> categories;
+ private final Runnable saveFunction;
+ private final Consumer<YACLScreen> initConsumer;
+
+ private boolean generated = false;
+
+ public YetAnotherConfigLibImpl(Component title, ImmutableList<ConfigCategory> categories, Runnable saveFunction, Consumer<YACLScreen> initConsumer) {
+ this.title = title;
+ this.categories = categories;
+ this.saveFunction = saveFunction;
+ this.initConsumer = initConsumer;
+ }
+
+ @Override
+ public Screen generateScreen(Screen parent) {
+ if (generated)
+ throw new UnsupportedOperationException("To prevent memory leaks, you should only generate a Screen once per instance. Please re-build the instance to generate another GUI.");
+
+ YACLConstants.LOGGER.info("Generating YACL screen");
+ generated = true;
+ return new YACLScreen(this, parent);
+ }
+
+ @Override
+ public Component title() {
+ return title;
+ }
+
+ @Override
+ public ImmutableList<ConfigCategory> categories() {
+ return categories;
+ }
+
+ @Override
+ public Runnable saveFunction() {
+ return saveFunction;
+ }
+
+ @Override
+ public Consumer<YACLScreen> initConsumer() {
+ return initConsumer;
+ }
+
+ @ApiStatus.Internal
+ public static final class BuilderImpl implements Builder {
+ private Component title;
+ private final List<ConfigCategory> categories = new ArrayList<>();
+ private Runnable saveFunction = () -> {};
+ private Consumer<YACLScreen> initConsumer = screen -> {};
+
+ @Override
+ public Builder title(@NotNull Component title) {
+ Validate.notNull(title, "`title` cannot be null");
+
+ this.title = title;
+ return this;
+ }
+
+ @Override
+ public Builder category(@NotNull ConfigCategory category) {
+ Validate.notNull(category, "`category` cannot be null");
+
+ this.categories.add(category);
+ return this;
+ }
+
+ @Override
+ public Builder categories(@NotNull Collection<? extends ConfigCategory> categories) {
+ Validate.notNull(categories, "`categories` cannot be null");
+
+ this.categories.addAll(categories);
+ return this;
+ }
+
+ @Override
+ public Builder save(@NotNull Runnable saveFunction) {
+ Validate.notNull(saveFunction, "`saveFunction` cannot be null");
+
+ this.saveFunction = saveFunction;
+ return this;
+ }
+
+ @Override
+ public Builder screenInit(@NotNull Consumer<YACLScreen> initConsumer) {
+ Validate.notNull(initConsumer, "`initConsumer` cannot be null");
+
+ this.initConsumer = initConsumer;
+ return this;
+ }
+
+ @Override
+ public YetAnotherConfigLib build() {
+ Validate.notNull(title, "`title must not be null to build `YetAnotherConfigLib`");
+ Validate.notEmpty(categories, "`categories` must not be empty to build `YetAnotherConfigLib`");
+ Validate.isTrue(!categories.stream().allMatch(category -> category instanceof PlaceholderCategory), "At least one regular category is required to build `YetAnotherConfigLib`");
+
+ return new YetAnotherConfigLibImpl(title, ImmutableList.copyOf(categories), saveFunction, initConsumer);
+ }
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/AbstractControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/AbstractControllerBuilderImpl.java
new file mode 100644
index 0000000..66c025a
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/AbstractControllerBuilderImpl.java
@@ -0,0 +1,12 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+
+public abstract class AbstractControllerBuilderImpl<T> implements ControllerBuilder<T> {
+ protected final Option<T> option;
+
+ protected AbstractControllerBuilderImpl(Option<T> option) {
+ this.option = option;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/BooleanControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/BooleanControllerBuilderImpl.java
new file mode 100644
index 0000000..063a177
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/BooleanControllerBuilderImpl.java
@@ -0,0 +1,57 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.BooleanControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.BooleanController;
+import net.minecraft.network.chat.Component;
+import org.apache.commons.lang3.Validate;
+
+import java.util.function.Function;
+
+public class BooleanControllerBuilderImpl extends AbstractControllerBuilderImpl<Boolean> implements BooleanControllerBuilder {
+ private boolean coloured = false;
+ private ValueFormatter<Boolean> formatter = BooleanController.ON_OFF_FORMATTER::apply;
+
+ public BooleanControllerBuilderImpl(Option<Boolean> option) {
+ super(option);
+ }
+
+ @Override
+ public BooleanControllerBuilder coloured(boolean coloured) {
+ this.coloured = coloured;
+ return this;
+ }
+
+ @Override
+ public BooleanControllerBuilder formatValue(ValueFormatter<Boolean> formatter) {
+ Validate.notNull(formatter, "formatter cannot be null");
+
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public BooleanControllerBuilder onOffFormatter() {
+ this.formatter = BooleanController.ON_OFF_FORMATTER::apply;
+ return this;
+ }
+
+ @Override
+ public BooleanControllerBuilder yesNoFormatter() {
+ this.formatter = BooleanController.YES_NO_FORMATTER::apply;
+ return this;
+ }
+
+ @Override
+ public BooleanControllerBuilder trueFalseFormatter() {
+ this.formatter = BooleanController.TRUE_FALSE_FORMATTER::apply;
+ return this;
+ }
+
+ @Override
+ public Controller<Boolean> build() {
+ return BooleanController.createInternal(option, formatter, coloured);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/ColorControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/ColorControllerBuilderImpl.java
new file mode 100644
index 0000000..9412165
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/ColorControllerBuilderImpl.java
@@ -0,0 +1,27 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ColorControllerBuilder;
+import dev.isxander.yacl3.gui.controllers.ColorController;
+
+import java.awt.Color;
+
+public class ColorControllerBuilderImpl extends AbstractControllerBuilderImpl<Color> implements ColorControllerBuilder {
+ private boolean allowAlpha = false;
+
+ public ColorControllerBuilderImpl(Option<Color> option) {
+ super(option);
+ }
+
+ @Override
+ public ColorControllerBuilder allowAlpha(boolean allowAlpha) {
+ this.allowAlpha = allowAlpha;
+ return this;
+ }
+
+ @Override
+ public Controller<Color> build() {
+ return new ColorController(option, allowAlpha);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/CyclingListControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/CyclingListControllerBuilderImpl.java
new file mode 100644
index 0000000..8e2e481
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/CyclingListControllerBuilderImpl.java
@@ -0,0 +1,41 @@
+package dev.isxander.yacl3.impl.controller;
+
+import com.google.common.collect.ImmutableList;
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.CyclingListControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.cycling.CyclingListController;
+
+public final class CyclingListControllerBuilderImpl<T> extends AbstractControllerBuilderImpl<T> implements CyclingListControllerBuilder<T> {
+ private Iterable<? extends T> values;
+ private ValueFormatter<T> formatter = null;
+
+ public CyclingListControllerBuilderImpl(Option<T> option) {
+ super(option);
+ }
+
+ @Override
+ public CyclingListControllerBuilder<T> values(Iterable<? extends T> values) {
+ this.values = values;
+ return this;
+ }
+
+ @SafeVarargs
+ @Override
+ public final CyclingListControllerBuilder<T> values(T... values) {
+ this.values = ImmutableList.copyOf(values);
+ return this;
+ }
+
+ @Override
+ public CyclingListControllerBuilder<T> formatValue(ValueFormatter<T> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<T> build() {
+ return CyclingListController.createInternal(option, values, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/DoubleFieldControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/DoubleFieldControllerBuilderImpl.java
new file mode 100644
index 0000000..8d84e7d
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/DoubleFieldControllerBuilderImpl.java
@@ -0,0 +1,51 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.DoubleFieldControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.DoubleSliderController;
+import dev.isxander.yacl3.gui.controllers.string.number.DoubleFieldController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class DoubleFieldControllerBuilderImpl extends AbstractControllerBuilderImpl<Double> implements DoubleFieldControllerBuilder {
+ private double min = Double.MIN_VALUE;
+ private double max = Double.MAX_VALUE;
+ private ValueFormatter<Double> formatter = DoubleSliderController.DEFAULT_FORMATTER::apply;
+
+ public DoubleFieldControllerBuilderImpl(Option<Double> option) {
+ super(option);
+ }
+
+ @Override
+ public DoubleFieldControllerBuilder min(Double min) {
+ this.min = min;
+ return this;
+ }
+
+ @Override
+ public DoubleFieldControllerBuilder max(Double max) {
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public DoubleFieldControllerBuilder range(Double min, Double max) {
+ this.min = min;
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public DoubleFieldControllerBuilder formatValue(ValueFormatter<Double> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<Double> build() {
+ return DoubleFieldController.createInternal(option, min, max, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/DoubleSliderControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/DoubleSliderControllerBuilderImpl.java
new file mode 100644
index 0000000..b696d57
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/DoubleSliderControllerBuilderImpl.java
@@ -0,0 +1,44 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.DoubleSliderControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.DoubleSliderController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class DoubleSliderControllerBuilderImpl extends AbstractControllerBuilderImpl<Double> implements DoubleSliderControllerBuilder {
+ private double min, max;
+ private double step;
+ private ValueFormatter<Double> formatter = DoubleSliderController.DEFAULT_FORMATTER::apply;
+
+ public DoubleSliderControllerBuilderImpl(Option<Double> option) {
+ super(option);
+ }
+
+ @Override
+ public DoubleSliderControllerBuilder range(Double min, Double max) {
+ this.min = min;
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public DoubleSliderControllerBuilder step(Double step) {
+ this.step = step;
+ return this;
+ }
+
+ @Override
+ public DoubleSliderControllerBuilder formatValue(ValueFormatter<Double> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<Double> build() {
+ return DoubleSliderController.createInternal(option, min, max, step, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/DropdownStringControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/DropdownStringControllerBuilderImpl.java
new file mode 100644
index 0000000..b300a6a
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/DropdownStringControllerBuilderImpl.java
@@ -0,0 +1,49 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.DropdownStringControllerBuilder;
+import dev.isxander.yacl3.gui.controllers.dropdown.DropdownStringController;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class DropdownStringControllerBuilderImpl extends StringControllerBuilderImpl implements DropdownStringControllerBuilder {
+ private List<String> values;
+ private boolean allowEmptyValue = false;
+ private boolean allowAnyValue = false;
+
+ public DropdownStringControllerBuilderImpl(Option<String> option) {
+ super(option);
+ }
+
+ @Override
+ public DropdownStringControllerBuilder values(List<String> values) {
+ this.values = values;
+ return this;
+ }
+
+ @Override
+ public DropdownStringControllerBuilderImpl values(String... values) {
+ this.values = Arrays.asList(values);
+ return this;
+ }
+
+ @Override
+ public DropdownStringControllerBuilderImpl allowEmptyValue(boolean allowEmptyValue) {
+ this.allowEmptyValue = allowEmptyValue;
+ return this;
+ }
+
+ @Override
+ public DropdownStringControllerBuilderImpl allowAnyValue(boolean allowAnyValue) {
+ this.allowAnyValue = allowAnyValue;
+ return this;
+ }
+
+ @Override
+ public Controller<String> build() {
+ return new DropdownStringController(option, values, allowEmptyValue, allowAnyValue);
+ }
+
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/EnumControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/EnumControllerBuilderImpl.java
new file mode 100644
index 0000000..04ee2a0
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/EnumControllerBuilderImpl.java
@@ -0,0 +1,42 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.EnumControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.cycling.EnumController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class EnumControllerBuilderImpl<T extends Enum<T>> extends AbstractControllerBuilderImpl<T> implements EnumControllerBuilder<T> {
+ private Class<T> enumClass;
+ private ValueFormatter<T> formatter = null;
+
+ public EnumControllerBuilderImpl(Option<T> option) {
+ super(option);
+ }
+
+ @Override
+ public EnumControllerBuilder<T> enumClass(Class<T> enumClass) {
+ this.enumClass = enumClass;
+ return this;
+ }
+
+ @Override
+ public EnumControllerBuilder<T> formatValue(ValueFormatter<T> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<T> build() {
+ ValueFormatter<T> formatter = this.formatter;
+ if (formatter == null) {
+ Function<T, Component> formatFunction = EnumController.getDefaultFormatter();
+ formatter = formatFunction::apply;
+ }
+
+ return EnumController.createInternal(option, formatter, enumClass.getEnumConstants());
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/EnumDropdownControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/EnumDropdownControllerBuilderImpl.java
new file mode 100644
index 0000000..4ac063f
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/EnumDropdownControllerBuilderImpl.java
@@ -0,0 +1,27 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.EnumDropdownControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.cycling.EnumController;
+import dev.isxander.yacl3.gui.controllers.dropdown.EnumDropdownController;
+
+public class EnumDropdownControllerBuilderImpl<E extends Enum<E>> extends AbstractControllerBuilderImpl<E> implements EnumDropdownControllerBuilder<E> {
+ private ValueFormatter<E> formatter = EnumController.<E>getDefaultFormatter()::apply;
+
+ public EnumDropdownControllerBuilderImpl(Option<E> option) {
+ super(option);
+ }
+
+ @Override
+ public EnumDropdownControllerBuilder<E> formatValue(ValueFormatter<E> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<E> build() {
+ return new EnumDropdownController<>(option, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/FloatFieldControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/FloatFieldControllerBuilderImpl.java
new file mode 100644
index 0000000..08fefd0
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/FloatFieldControllerBuilderImpl.java
@@ -0,0 +1,51 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.FloatFieldControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.FloatSliderController;
+import dev.isxander.yacl3.gui.controllers.string.number.FloatFieldController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class FloatFieldControllerBuilderImpl extends AbstractControllerBuilderImpl<Float> implements FloatFieldControllerBuilder {
+ private float min = Float.MIN_VALUE;
+ private float max = Float.MAX_VALUE;
+ private ValueFormatter<Float> formatter = FloatSliderController.DEFAULT_FORMATTER::apply;
+
+ public FloatFieldControllerBuilderImpl(Option<Float> option) {
+ super(option);
+ }
+
+ @Override
+ public FloatFieldControllerBuilder min(Float min) {
+ this.min = min;
+ return this;
+ }
+
+ @Override
+ public FloatFieldControllerBuilder max(Float max) {
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public FloatFieldControllerBuilder range(Float min, Float max) {
+ this.min = min;
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public FloatFieldControllerBuilder formatValue(ValueFormatter<Float> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<Float> build() {
+ return FloatFieldController.createInternal(option, min, max, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/FloatSliderControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/FloatSliderControllerBuilderImpl.java
new file mode 100644
index 0000000..9b2d75b
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/FloatSliderControllerBuilderImpl.java
@@ -0,0 +1,44 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.FloatSliderControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.FloatSliderController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class FloatSliderControllerBuilderImpl extends AbstractControllerBuilderImpl<Float> implements FloatSliderControllerBuilder {
+ private float min, max;
+ private float step;
+ private ValueFormatter<Float> formatter = FloatSliderController.DEFAULT_FORMATTER::apply;
+
+ public FloatSliderControllerBuilderImpl(Option<Float> option) {
+ super(option);
+ }
+
+ @Override
+ public FloatSliderControllerBuilder range(Float min, Float max) {
+ this.min = min;
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public FloatSliderControllerBuilder step(Float step) {
+ this.step = step;
+ return this;
+ }
+
+ @Override
+ public FloatSliderControllerBuilder formatValue(ValueFormatter<Float> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<Float> build() {
+ return FloatSliderController.createInternal(option, min, max, step, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/IntegerFieldControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/IntegerFieldControllerBuilderImpl.java
new file mode 100644
index 0000000..1435c49
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/IntegerFieldControllerBuilderImpl.java
@@ -0,0 +1,51 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.IntegerFieldControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.IntegerSliderController;
+import dev.isxander.yacl3.gui.controllers.string.number.IntegerFieldController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class IntegerFieldControllerBuilderImpl extends AbstractControllerBuilderImpl<Integer> implements IntegerFieldControllerBuilder {
+ private int min = Integer.MIN_VALUE;
+ private int max = Integer.MAX_VALUE;
+ private ValueFormatter<Integer> formatter = IntegerSliderController.DEFAULT_FORMATTER::apply;
+
+ public IntegerFieldControllerBuilderImpl(Option<Integer> option) {
+ super(option);
+ }
+
+ @Override
+ public IntegerFieldControllerBuilder min(Integer min) {
+ this.min = min;
+ return this;
+ }
+
+ @Override
+ public IntegerFieldControllerBuilder max(Integer max) {
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public IntegerFieldControllerBuilder range(Integer min, Integer max) {
+ this.min = min;
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public IntegerFieldControllerBuilder formatValue(ValueFormatter<Integer> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<Integer> build() {
+ return IntegerFieldController.createInternal(option, min, max, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/IntegerSliderControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/IntegerSliderControllerBuilderImpl.java
new file mode 100644
index 0000000..b9395a0
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/IntegerSliderControllerBuilderImpl.java
@@ -0,0 +1,44 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.IntegerSliderControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.IntegerSliderController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class IntegerSliderControllerBuilderImpl extends AbstractControllerBuilderImpl<Integer> implements IntegerSliderControllerBuilder {
+ private int min, max;
+ private int step;
+ private ValueFormatter<Integer> formatter = IntegerSliderController.DEFAULT_FORMATTER::apply;
+
+ public IntegerSliderControllerBuilderImpl(Option<Integer> option) {
+ super(option);
+ }
+
+ @Override
+ public IntegerSliderControllerBuilder range(Integer min, Integer max) {
+ this.min = min;
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public IntegerSliderControllerBuilder step(Integer step) {
+ this.step = step;
+ return this;
+ }
+
+ @Override
+ public IntegerSliderControllerBuilder formatValue(ValueFormatter<Integer> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<Integer> build() {
+ return IntegerSliderController.createInternal(option, min, max, step, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/ItemControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/ItemControllerBuilderImpl.java
new file mode 100644
index 0000000..9a817fb
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/ItemControllerBuilderImpl.java
@@ -0,0 +1,18 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ItemControllerBuilder;
+import dev.isxander.yacl3.gui.controllers.dropdown.ItemController;
+import net.minecraft.world.item.Item;
+
+public class ItemControllerBuilderImpl extends AbstractControllerBuilderImpl<Item> implements ItemControllerBuilder {
+ public ItemControllerBuilderImpl(Option<Item> option) {
+ super(option);
+ }
+
+ @Override
+ public Controller<Item> build() {
+ return new ItemController(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/LongFieldControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/LongFieldControllerBuilderImpl.java
new file mode 100644
index 0000000..c7a3ea4
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/LongFieldControllerBuilderImpl.java
@@ -0,0 +1,51 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.LongFieldControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.LongSliderController;
+import dev.isxander.yacl3.gui.controllers.string.number.LongFieldController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class LongFieldControllerBuilderImpl extends AbstractControllerBuilderImpl<Long> implements LongFieldControllerBuilder {
+ private long min = Long.MIN_VALUE;
+ private long max = Long.MAX_VALUE;
+ private ValueFormatter<Long> formatter = LongSliderController.DEFAULT_FORMATTER::apply;
+
+ public LongFieldControllerBuilderImpl(Option<Long> option) {
+ super(option);
+ }
+
+ @Override
+ public LongFieldControllerBuilder min(Long min) {
+ this.min = min;
+ return this;
+ }
+
+ @Override
+ public LongFieldControllerBuilder max(Long max) {
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public LongFieldControllerBuilder range(Long min, Long max) {
+ this.min = min;
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public LongFieldControllerBuilder formatValue(ValueFormatter<Long> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<Long> build() {
+ return LongFieldController.createInternal(option, min, max, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/LongSliderControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/LongSliderControllerBuilderImpl.java
new file mode 100644
index 0000000..5eda424
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/LongSliderControllerBuilderImpl.java
@@ -0,0 +1,44 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.LongSliderControllerBuilder;
+import dev.isxander.yacl3.api.controller.ValueFormatter;
+import dev.isxander.yacl3.gui.controllers.slider.LongSliderController;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Function;
+
+public class LongSliderControllerBuilderImpl extends AbstractControllerBuilderImpl<Long> implements LongSliderControllerBuilder {
+ private long min, max;
+ private long step;
+ private ValueFormatter<Long> formatter = LongSliderController.DEFAULT_FORMATTER::apply;
+
+ public LongSliderControllerBuilderImpl(Option<Long> option) {
+ super(option);
+ }
+
+ @Override
+ public LongSliderControllerBuilder range(Long min, Long max) {
+ this.min = min;
+ this.max = max;
+ return this;
+ }
+
+ @Override
+ public LongSliderControllerBuilder step(Long step) {
+ this.step = step;
+ return this;
+ }
+
+ @Override
+ public LongSliderControllerBuilder formatValue(ValueFormatter<Long> formatter) {
+ this.formatter = formatter;
+ return this;
+ }
+
+ @Override
+ public Controller<Long> build() {
+ return LongSliderController.createInternal(option, min, max, step, formatter);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/StringControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/StringControllerBuilderImpl.java
new file mode 100644
index 0000000..a0f51b9
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/StringControllerBuilderImpl.java
@@ -0,0 +1,17 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.StringControllerBuilder;
+import dev.isxander.yacl3.gui.controllers.string.StringController;
+
+public class StringControllerBuilderImpl extends AbstractControllerBuilderImpl<String> implements StringControllerBuilder {
+ public StringControllerBuilderImpl(Option<String> option) {
+ super(option);
+ }
+
+ @Override
+ public Controller<String> build() {
+ return new StringController(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/controller/TickBoxControllerBuilderImpl.java b/src/main/java/dev/isxander/yacl3/impl/controller/TickBoxControllerBuilderImpl.java
new file mode 100644
index 0000000..3b29719
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/controller/TickBoxControllerBuilderImpl.java
@@ -0,0 +1,17 @@
+package dev.isxander.yacl3.impl.controller;
+
+import dev.isxander.yacl3.api.Controller;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.TickBoxControllerBuilder;
+import dev.isxander.yacl3.gui.controllers.TickBoxController;
+
+public class TickBoxControllerBuilderImpl extends AbstractControllerBuilderImpl<Boolean> implements TickBoxControllerBuilder {
+ public TickBoxControllerBuilderImpl(Option<Boolean> option) {
+ super(option);
+ }
+
+ @Override
+ public Controller<Boolean> build() {
+ return new TickBoxController(option);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/utils/DimensionIntegerImpl.java b/src/main/java/dev/isxander/yacl3/impl/utils/DimensionIntegerImpl.java
new file mode 100644
index 0000000..7d29bbc
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/utils/DimensionIntegerImpl.java
@@ -0,0 +1,115 @@
+package dev.isxander.yacl3.impl.utils;
+
+import dev.isxander.yacl3.api.utils.Dimension;
+import dev.isxander.yacl3.api.utils.MutableDimension;
+
+public class DimensionIntegerImpl implements MutableDimension<Integer> {
+ private int x, y;
+ private int width, height;
+
+ public DimensionIntegerImpl(int x, int y, int width, int height) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ }
+
+ @Override
+ public Integer x() {
+ return x;
+ }
+
+ @Override
+ public Integer y() {
+ return y;
+ }
+
+ @Override
+ public Integer width() {
+ return width;
+ }
+
+ @Override
+ public Integer height() {
+ return height;
+ }
+
+ @Override
+ public Integer xLimit() {
+ return x + width;
+ }
+
+ @Override
+ public Integer yLimit() {
+ return y + height;
+ }
+
+ @Override
+ public Integer centerX() {
+ return x + width / 2;
+ }
+
+ @Override
+ public Integer centerY() {
+ return y + height / 2;
+ }
+
+ @Override
+ public boolean isPointInside(Integer x, Integer y) {
+ return x >= x() && x <= xLimit() && y >= y() && y <= yLimit();
+ }
+
+ @Override
+ public MutableDimension<Integer> clone() {
+ return new DimensionIntegerImpl(x, y, width, height);
+ }
+
+ @Override public MutableDimension<Integer> setX(Integer x) { this.x = x; return this; }
+ @Override public MutableDimension<Integer> setY(Integer y) { this.y = y; return this; }
+ @Override public MutableDimension<Integer> setWidth(Integer width) { this.width = width; return this; }
+ @Override public MutableDimension<Integer> setHeight(Integer height) { this.height = height; return this; }
+
+ @Override
+ public Dimension<Integer> withX(Integer x) {
+ return clone().setX(x);
+ }
+
+ @Override
+ public Dimension<Integer> withY(Integer y) {
+ return clone().setY(y);
+ }
+
+ @Override
+ public Dimension<Integer> withWidth(Integer width) {
+ return clone().setWidth(width);
+ }
+
+ @Override
+ public Dimension<Integer> withHeight(Integer height) {
+ return clone().setHeight(height);
+ }
+
+ @Override
+ public MutableDimension<Integer> move(Integer x, Integer y) {
+ this.x += x;
+ this.y += y;
+ return this;
+ }
+
+ @Override
+ public MutableDimension<Integer> expand(Integer width, Integer height) {
+ this.width += width;
+ this.height += height;
+ return this;
+ }
+
+ @Override
+ public Dimension<Integer> moved(Integer x, Integer y) {
+ return clone().move(x, y);
+ }
+
+ @Override
+ public Dimension<Integer> expanded(Integer width, Integer height) {
+ return clone().expand(width, height);
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/impl/utils/YACLConstants.java b/src/main/java/dev/isxander/yacl3/impl/utils/YACLConstants.java
new file mode 100644
index 0000000..5ff1b79
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/impl/utils/YACLConstants.java
@@ -0,0 +1,8 @@
+package dev.isxander.yacl3.impl.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class YACLConstants {
+ public static final Logger LOGGER = LoggerFactory.getLogger("YetAnotherConfigLib");
+}
diff --git a/src/main/java/dev/isxander/yacl3/mixin/AbstractSelectionListMixin.java b/src/main/java/dev/isxander/yacl3/mixin/AbstractSelectionListMixin.java
new file mode 100644
index 0000000..471fa19
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/mixin/AbstractSelectionListMixin.java
@@ -0,0 +1,25 @@
+package dev.isxander.yacl3.mixin;
+
+import net.minecraft.client.gui.components.AbstractSelectionList;
+import org.objectweb.asm.Opcodes;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Redirect;
+
+import java.util.List;
+
+@Mixin(AbstractSelectionList.class)
+public abstract class AbstractSelectionListMixin<E extends AbstractSelectionList.Entry<E>> {
+ @Shadow public abstract List<E> children();
+
+ /**
+ * Mojang use the field access of children to get max index to loop through keyboard navigation to find the next entry.
+ * YACL modifies these children() method to filter out hidden entries, so we need to redirect the field access to the
+ * method, so we don't get ArrayIndexOutOfBoundsException.
+ */
+ @Redirect(method = "nextEntry(Lnet/minecraft/client/gui/navigation/ScreenDirection;Ljava/util/function/Predicate;Lnet/minecraft/client/gui/components/AbstractSelectionList$Entry;)Lnet/minecraft/client/gui/components/AbstractSelectionList$Entry;", at = @At(value = "FIELD", target = "Lnet/minecraft/client/gui/components/AbstractSelectionList;children:Ljava/util/List;", opcode = Opcodes.GETFIELD))
+ private List<E> modifyChildrenCall(AbstractSelectionList<E> instance) {
+ return children();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/mixin/ContainerEventHandlerMixin.java b/src/main/java/dev/isxander/yacl3/mixin/ContainerEventHandlerMixin.java
new file mode 100644
index 0000000..bd5ada0
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/mixin/ContainerEventHandlerMixin.java
@@ -0,0 +1,37 @@
+/*? if !forge {*/
+package dev.isxander.yacl3.mixin;
+
+import net.minecraft.client.gui.components.events.ContainerEventHandler;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.components.tabs.TabNavigationBar;
+import net.minecraft.client.gui.navigation.FocusNavigationEvent;
+import net.minecraft.client.gui.navigation.ScreenAxis;
+import net.minecraft.client.gui.navigation.ScreenDirection;
+import net.minecraft.client.gui.navigation.ScreenRectangle;
+import org.jetbrains.annotations.Nullable;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Redirect;
+
+import java.util.List;
+
+
+@Mixin(ContainerEventHandler.class)
+public interface ContainerEventHandlerMixin {
+ // This mixin is used to prevent the tab bar from being focused when navigating left or right
+ // through the YACL options screen. This can also apply to vanilla as navigating left or right
+ // should never result in focusing the always-at-the-top tab bar.
+ // Without this, navigating right from the option list focuses the tab bar, not the action buttons/description.
+ @Redirect(method = {"nextFocusPathVaguelyInDirection", "nextFocusPathInDirection"}, at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/components/events/ContainerEventHandler;children()Ljava/util/List;"))
+ default List<?> modifyFocusCandidates(ContainerEventHandler instance, ScreenRectangle screenArea, ScreenDirection direction, @Nullable GuiEventListener focused, FocusNavigationEvent event) {
+ if (direction.getAxis() == ScreenAxis.HORIZONTAL)
+ return instance.children().stream().filter(child -> !(child instanceof TabNavigationBar)).toList();
+ return instance.children();
+ }
+}
+/*?} else {*//*
+@Mixin(targets = {})
+public class ContainerEventHandlerMixin {
+
+}
+*//*?}*/
diff --git a/src/main/java/dev/isxander/yacl3/mixin/MinecraftMixin.java b/src/main/java/dev/isxander/yacl3/mixin/MinecraftMixin.java
new file mode 100644
index 0000000..45bc314
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/mixin/MinecraftMixin.java
@@ -0,0 +1,16 @@
+package dev.isxander.yacl3.mixin;
+
+import dev.isxander.yacl3.gui.image.ImageRendererManager;
+import net.minecraft.client.Minecraft;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(Minecraft.class)
+public class MinecraftMixin {
+ @Inject(method = "destroy", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;close()V", shift = At.Shift.BEFORE))
+ private void closeImages(CallbackInfo ci) {
+ ImageRendererManager.closeAll();
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/mixin/OptionInstanceAccessor.java b/src/main/java/dev/isxander/yacl3/mixin/OptionInstanceAccessor.java
new file mode 100644
index 0000000..429e383
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/mixin/OptionInstanceAccessor.java
@@ -0,0 +1,13 @@
+package dev.isxander.yacl3.mixin;
+
+import net.minecraft.client.OptionInstance;
+import org.jetbrains.annotations.ApiStatus;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@ApiStatus.Internal
+@Mixin(OptionInstance.class)
+public interface OptionInstanceAccessor<T> {
+ @Accessor
+ T getInitialValue();
+}
diff --git a/src/main/java/dev/isxander/yacl3/mixin/TabNavigationBarAccessor.java b/src/main/java/dev/isxander/yacl3/mixin/TabNavigationBarAccessor.java
new file mode 100644
index 0000000..388407b
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/mixin/TabNavigationBarAccessor.java
@@ -0,0 +1,16 @@
+package dev.isxander.yacl3.mixin;
+
+import net.minecraft.client.gui.components.tabs.TabNavigationBar;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@Mixin(TabNavigationBar.class)
+public interface TabNavigationBarAccessor {
+ /*? if >1.20.4 {*//*
+ @Accessor
+ net.minecraft.client.gui.layouts.LinearLayout getLayout();
+ *//*? } else {*/
+ @Accessor
+ net.minecraft.client.gui.layouts.GridLayout getLayout();
+ /*?}*/
+}
diff --git a/src/main/java/dev/isxander/yacl3/platform/Env.java b/src/main/java/dev/isxander/yacl3/platform/Env.java
new file mode 100644
index 0000000..276d294
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/platform/Env.java
@@ -0,0 +1,10 @@
+package dev.isxander.yacl3.platform;
+
+public enum Env {
+ CLIENT,
+ SERVER;
+
+ public boolean isClient() {
+ return this == Env.CLIENT;
+ }
+}
diff --git a/src/main/java/dev/isxander/yacl3/platform/PlatformEntrypoint.java b/src/main/java/dev/isxander/yacl3/platform/PlatformEntrypoint.java
new file mode 100644
index 0000000..6231b20
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/platform/PlatformEntrypoint.java
@@ -0,0 +1,42 @@
+package dev.isxander.yacl3.platform;
+
+import dev.isxander.yacl3.gui.image.YACLImageReloadListener;
+
+/*? if fabric {*/
+import net.fabricmc.api.ClientModInitializer;
+import net.fabricmc.fabric.api.resource.ResourceManagerHelper;
+import net.minecraft.server.packs.PackType;
+
+public class PlatformEntrypoint implements ClientModInitializer {
+ @Override
+ public void onInitializeClient() {
+ ResourceManagerHelper.get(PackType.CLIENT_RESOURCES).registerReloadListener(new YACLImageReloadListener());
+ }
+}
+/*?} elif neoforge {*//*
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.fml.common.Mod;
+import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent;
+
+@Mod("yet_another_config_lib_v3")
+public class PlatformEntrypoint {
+ public PlatformEntrypoint(IEventBus modEventBus) {
+ modEventBus.addListener(RegisterClientReloadListenersEvent.class, event -> {
+ event.registerReloadListener(new YACLImageReloadListener());
+ });
+ }
+}
+*//*?} elif forge {*//*
+import net.minecraftforge.fml.common.Mod;
+import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
+import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
+
+@Mod("yet_another_config_lib_v3")
+public class PlatformEntrypoint {
+ public PlatformEntrypoint() {
+ FMLJavaModLoadingContext.get().getModEventBus().<RegisterClientReloadListenersEvent>addListener(event -> {
+ event.registerReloadListener(new YACLImageReloadListener());
+ });
+ }
+}
+*//*?}*/ \ No newline at end of file
diff --git a/src/main/java/dev/isxander/yacl3/platform/YACLPlatform.java b/src/main/java/dev/isxander/yacl3/platform/YACLPlatform.java
new file mode 100644
index 0000000..d134e70
--- /dev/null
+++ b/src/main/java/dev/isxander/yacl3/platform/YACLPlatform.java
@@ -0,0 +1,45 @@
+package dev.isxander.yacl3.platform;
+
+/*?if fabric {*/
+import net.fabricmc.loader.api.FabricLoader;
+/*?} elif neoforge {*//*
+import net.neoforged.fml.loading.FMLEnvironment;
+import net.neoforged.fml.loading.FMLPaths;
+*//*?} elif forge {*//*
+import net.minecraftforge.fml.loading.FMLEnvironment;
+import net.minecraftforge.fml.loading.FMLPaths;
+*//*?}*/
+
+import java.nio.file.Path;
+
+public final class YACLPlatform {
+ public static Env getEnvironment() {
+ /*?if fabric {*/
+ return switch (FabricLoader.getInstance().getEnvironmentType()) {
+ case CLIENT -> Env.CLIENT;
+ case SERVER -> Env.SERVER;
+ };
+ /*?} elif forge-like {*//*
+ return switch (FMLEnvironment.dist) {
+ case CLIENT -> Env.CLIENT;
+ case DEDICATED_SERVER -> Env.SERVER;
+ };
+ *//*?}*/
+ }
+
+ public static Path getConfigDir() {
+ /*?if fabric {*/
+ return FabricLoader.getInstance().getConfigDir();
+ /*?} elif forge-like {*//*
+ return FMLPaths.CONFIGDIR.get();
+ *//*?}*/
+ }
+
+ public static boolean isDevelopmentEnv() {
+ /*?if fabric {*/
+ return FabricLoader.getInstance().isDevelopmentEnvironment();
+ /*?} elif forge-like {*//*
+ return !FMLEnvironment.production;
+ *//*?}*/
+ }
+}
diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml
new file mode 100644
index 0000000..b130a4e
--- /dev/null
+++ b/src/main/resources/META-INF/mods.toml
@@ -0,0 +1,31 @@
+modLoader = "javafml"
+loaderVersion = "[1,)"
+#issueTrackerURL = ""
+license = "LGPL-3.0-or-later"
+
+[[mods]]
+modId = "${id}"
+version = "${version}"
+displayName = "${name}"
+authors = "isXander"
+description = '''
+${description}
+'''
+logoFile = "yacl-128x.png"
+
+[[mixins]]
+config = "yacl.mixins.json"
+
+[["dependencies.${id}"]]
+modId = "neoforge"
+mandatory = true
+versionRange = "[20,)"
+ordering = "NONE"
+side = "BOTH"
+
+[["dependencies.${id}"]]
+modId = "minecraft"
+mandatory = true
+versionRange = "[1.20.3,)"
+ordering = "NONE"
+side = "BOTH"
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/be_by.json b/src/main/resources/assets/yet_another_config_lib/lang/be_by.json
new file mode 100644
index 0000000..16f84f0
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/be_by.json
@@ -0,0 +1,29 @@
+{
+ "yacl.control.boolean.true": "Праўда",
+ "yacl.control.boolean.false": "Няпраўда",
+
+ "yacl.control.action.execute": "ВЫКАНАЦЬ",
+
+ "yacl.gui.save": "Захаваць змены",
+ "yacl.gui.save.tooltip": "Робіць змены перманентнымі.",
+ "yacl.gui.finished.tooltip": "Закрыць GUI.",
+ "yacl.gui.cancel.tooltip": "Забыць усе змены і зачыніць GUI.",
+ "yacl.gui.reset.tooltip": "Скінуць усе налады да вызначаных. (Гэта можна адмяніць!)",
+ "yacl.gui.undo": "Адмяніць",
+ "yacl.gui.undo.tooltip": "Вярнуць усе налады да моманту змен.",
+ "yacl.gui.fail_apply": "Памылка пры ўжыванні",
+ "yacl.gui.fail_apply.tooltip": "Узнікла памылка і змены не могуць быць ужыты.",
+ "yacl.gui.save_before_exit": "Захавайце перад выхадам!",
+ "yacl.gui.save_before_exit.tooltip": "Захавайце ці скасуйце выхад з GUI.",
+
+ "yacl.list.move_up": "Перамясціць вышэй",
+ "yacl.list.move_down": "Перамясціць ніжэй",
+ "yacl.list.remove": "Выдаліць",
+ "yacl.list.add_top": "New entry",
+ "yacl.list.empty": "Спіс пусты",
+
+ "yacl.restart.title": "Налады патрабуюць перазапуск!",
+ "yacl.restart.message": "Адна ці больш налад патрабуе перазапуск гульні, каб ужыць змены.",
+ "yacl.restart.yes": "Закрыць Minecraft",
+ "yacl.restart.no": "Ігнараваць"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/el_gr.json b/src/main/resources/assets/yet_another_config_lib/lang/el_gr.json
new file mode 100644
index 0000000..b7bc2d2
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/el_gr.json
@@ -0,0 +1,23 @@
+{
+ "yacl.control.boolean.true": "Ναι",
+ "yacl.control.boolean.false": "Όχι",
+
+ "yacl.control.action.execute": "ΕΚΤΕΛΕΣΗ",
+
+ "yacl.gui.save": "Αποθήκευση Αλλαγών",
+ "yacl.gui.save.tooltip": "Μονιμοποιήει τις αλλαγές.",
+ "yacl.gui.finished.tooltip": "Κλείνει το GUI.",
+ "yacl.gui.cancel.tooltip": "Ξεχνά τις ενεργές αλλαγές και κλείνει το μενού.",
+ "yacl.gui.reset.tooltip": "Επαναφέρει όλες τις επιλογές στις προεπιλογές τους. (Μπορεί να ανερεθε)",
+ "yacl.gui.undo": "Επαναφορά",
+ "yacl.gui.undo.tooltip": "Επαναφέρει όλες τις ενεργές επιλογές στις προεπιλογές τους.",
+ "yacl.gui.fail_apply": "Αποτυχία εφαρμογής",
+ "yacl.gui.fail_apply.tooltip": "Δημιουργήθηκε ένα σφάλμα και οι αλλαγές δεν μπόρεσαν να εφαρμοστούν.",
+ "yacl.gui.save_before_exit": "Αποθήκευση πριν το κλείσιμο!",
+ "yacl.gui.save_before_exit.tooltip": "Αποθήκευσε ή ακύρωσε για να βγεις απ' το μενού."
+
+ "yacl.restart.title": "Η ρύθμιση απαιτεί επανεκκήνιση!",
+ "yacl.restart.message": "Μία ή παραπάνω επιλογές προϋποθέτουν επανεκκήνηση το παιχνιδιού για να εφαρμοστούν.",
+ "yacl.restart.yes": "Κλείσιμο του Minecraft",
+ "yacl.restart.no": "Αγνόησε το για τώρα"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/en_us.json b/src/main/resources/assets/yet_another_config_lib/lang/en_us.json
new file mode 100644
index 0000000..c04d29e
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/en_us.json
@@ -0,0 +1,31 @@
+{
+ "yacl.control.boolean.true": "True",
+ "yacl.control.boolean.false": "False",
+
+ "yacl.control.action.execute": "EXECUTE",
+
+ "yacl.control.text.blank": "<blank>",
+
+ "yacl.gui.save": "Save Changes",
+ "yacl.gui.save.tooltip": "Makes the changes made permanent.",
+ "yacl.gui.finished.tooltip": "Closes the GUI.",
+ "yacl.gui.cancel.tooltip": "Forgets pending changes and closes the GUI.",
+ "yacl.gui.reset.tooltip": "Resets all options to default. (This is reversible!)",
+ "yacl.gui.undo": "Undo",
+ "yacl.gui.undo.tooltip": "Reverts all options back to what they were before editing.",
+ "yacl.gui.fail_apply": "Failed to apply",
+ "yacl.gui.fail_apply.tooltip": "There was an error and the changes couldn't be applied.",
+ "yacl.gui.save_before_exit": "Save before exiting!",
+ "yacl.gui.save_before_exit.tooltip": "Save or cancel to exit the GUI.",
+
+ "yacl.list.move_up": "Move up",
+ "yacl.list.move_down": "Move down",
+ "yacl.list.remove": "Remove",
+ "yacl.list.add_top": "New entry",
+ "yacl.list.empty": "List is empty",
+
+ "yacl.restart.title": "Config requires restart!",
+ "yacl.restart.message": "One or more options needs you to restart the game to apply the changes.",
+ "yacl.restart.yes": "Close Minecraft",
+ "yacl.restart.no": "Ignore"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/et_ee.json b/src/main/resources/assets/yet_another_config_lib/lang/et_ee.json
new file mode 100644
index 0000000..5f5274a
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/et_ee.json
@@ -0,0 +1,18 @@
+{
+ "yacl.control.boolean.true": "Tõene",
+ "yacl.control.boolean.false": "Väär",
+
+ "yacl.control.action.execute": "KÄIVITA",
+
+ "yacl.gui.save": "Salvesta muudatused",
+ "yacl.gui.save.tooltip": "Teeb tehtud muudatused püsivaks.",
+ "yacl.gui.finished.tooltip": "Sulgeb liidese.",
+ "yacl.gui.cancel.tooltip": "Unustab tehtud muudatused ja sulgeb liidese.",
+ "yacl.gui.reset.tooltip": "Lähtestab kõik valikud vaikeväärtustele. (Seda saab tagasi võtta!)",
+ "yacl.gui.undo": "Võta tagasi",
+ "yacl.gui.undo.tooltip": "Lähtestab kõik valikud muutmise-eelsetele väärtustele.",
+ "yacl.gui.fail_apply": "Rakendamine ebaõnnestus",
+ "yacl.gui.fail_apply.tooltip": "Esines viga ja muudatusi ei saadud rakendada.",
+ "yacl.gui.save_before_exit": "Salvesta enne väljumist!",
+ "yacl.gui.save_before_exit.tooltip": "Liidesest väljumiseks salvesta või loobu."
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/fr_fr.json b/src/main/resources/assets/yet_another_config_lib/lang/fr_fr.json
new file mode 100644
index 0000000..bc069cf
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/fr_fr.json
@@ -0,0 +1,29 @@
+{
+ "yacl.control.boolean.true": "Vrai",
+ "yacl.control.boolean.false": "Faux",
+
+ "yacl.control.action.execute": "EXÉCUTION",
+
+ "yacl.gui.save": "Sauvegarder les modifications",
+ "yacl.gui.save.tooltip": "Rend les changements effectués permanents.",
+ "yacl.gui.finished.tooltip": "Ferme la superposition.",
+ "yacl.gui.cancel.tooltip": "Oublie les changements en cours et ferme la superposition.",
+ "yacl.gui.reset.tooltip": "Réinitialise toutes les options par défaut. (Réversible !)",
+ "yacl.gui.undo": "Annuler",
+ "yacl.gui.undo.tooltip": "Rétablit toutes les options telles qu'elles étaient avant l'édition.",
+ "yacl.gui.fail_apply": "Échec de l'application",
+ "yacl.gui.fail_apply.tooltip": "Il y a eu une erreur et les changements n'ont pas pu être appliqués.",
+ "yacl.gui.save_before_exit": "Sauvegardez avant de quitter !",
+ "yacl.gui.save_before_exit.tooltip": "Sauvegardez ou annulez pour quitter la superposition.",
+
+ "yacl.list.move_up": "Monter en haut",
+ "yacl.list.move_down": "Monter en bas",
+ "yacl.list.remove": "Retirer",
+ "yacl.list.add_top": "Nouvelle entrée",
+ "yacl.list.empty": "La liste est vide",
+
+ "yacl.restart.title": "La configuration nécessite un redémarrage !",
+ "yacl.restart.message": "Une ou plusieurs options nécessitent que vous redémarriez le jeu pour appliquer les changements.",
+ "yacl.restart.yes": "Fermer Minecraft",
+ "yacl.restart.no": "Ignorer"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/it_it.json b/src/main/resources/assets/yet_another_config_lib/lang/it_it.json
new file mode 100644
index 0000000..1489071
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/it_it.json
@@ -0,0 +1,31 @@
+{
+ "yacl.control.boolean.true": "Vero",
+ "yacl.control.boolean.false": "Falso",
+
+ "yacl.control.action.execute": "ESEGUI",
+
+ "yacl.control.text.blank": "<vuoto>",
+
+ "yacl.gui.save": "Salva Modifiche",
+ "yacl.gui.save.tooltip": "Rende permanenti le modifiche apportate.",
+ "yacl.gui.finished.tooltip": "Chiude la GUI.",
+ "yacl.gui.cancel.tooltip": "Scarta le modifiche in sospeso e chiude la GUI.",
+ "yacl.gui.reset.tooltip": "Ripristina tutte le opzioni ai valori predefiniti. (È reversibile!)",
+ "yacl.gui.undo": "Annulla",
+ "yacl.gui.undo.tooltip": "Ripristina tutte le opzioni a ciò che erano prima della modifica.",
+ "yacl.gui.fail_apply": "Applicazione delle opzioni non riuscita",
+ "yacl.gui.fail_apply.tooltip": "Si è verificato un errore e le modifiche non hanno potuto essere applicate.",
+ "yacl.gui.save_before_exit": "Salvare prima di uscire!",
+ "yacl.gui.save_before_exit.tooltip": "Salva o annulla per uscire dalla GUI.",
+
+ "yacl.list.move_up": "Sposta su",
+ "yacl.list.move_down": "Sposta giù",
+ "yacl.list.remove": "Rimuovi",
+ "yacl.list.add_top": "Nuova voce",
+ "yacl.list.empty": "La lista è vuota",
+
+ "yacl.restart.title": "L'opzione richiede il riavvio!",
+ "yacl.restart.message": "Una o più opzioni richiedono il riavvio del gioco per applicare le modifiche.",
+ "yacl.restart.yes": "Chiudi Minecraft",
+ "yacl.restart.no": "Ignora"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/nl_nl.json b/src/main/resources/assets/yet_another_config_lib/lang/nl_nl.json
new file mode 100644
index 0000000..c432cda
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/nl_nl.json
@@ -0,0 +1,31 @@
+{
+ "yacl.control.boolean.true": "Waar",
+ "yacl.control.boolean.false": "Niet Waar",
+
+ "yacl.control.action.execute": "UITVOEREN",
+
+ "yacl.control.text.blank": "<blanco>",
+
+ "yacl.gui.save": "Wijzigingen Opslaan",
+ "yacl.gui.save.tooltip": "Maak de aangebrachte wijzigingen permanent.",
+ "yacl.gui.finished.tooltip": "Sluit de GUI.",
+ "yacl.gui.cancel.tooltip": "Vergeet wijzigingen en sluit het GUI.",
+ "yacl.gui.reset.tooltip": "Zet alle opties terug naar de standaardwaarden. (Dit is omkeerbaar!)",
+ "yacl.gui.undo": "Ongedaan maken",
+ "yacl.gui.undo.tooltip": "Zet alle opties terug naar wat ze waren voor het bewerken.",
+ "yacl.gui.fail_apply": "Toepassen gefaald",
+ "yacl.gui.fail_apply.tooltip": "Er is een fout opgetreden en de wijzigingen konden niet worden toegepast.",
+ "yacl.gui.save_before_exit": "Slaag op voordat u afsluit!",
+ "yacl.gui.save_before_exit.tooltip": "Slaag op of annuleer om het GUI af te sluiten.",
+
+ "yacl.list.move_up": "Ga omhoog",
+ "yacl.list.move_down": "Ga omlaag",
+ "yacl.list.remove": "Verwijder",
+ "yacl.list.add_top": "Nieuwe invoer",
+ "yacl.list.empty": "Lijst is leeg",
+
+ "yacl.restart.title": "Configuratie vereist opnieuw opstarten!",
+ "yacl.restart.message": "Voor één of meer opties moet je het spel opnieuw opstarten om de wijzigingen toe te passen.",
+ "yacl.restart.yes": "Sluit Minecraft",
+ "yacl.restart.no": "Negeren"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/pl_pl.json b/src/main/resources/assets/yet_another_config_lib/lang/pl_pl.json
new file mode 100644
index 0000000..49074ea
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/pl_pl.json
@@ -0,0 +1,23 @@
+{
+ "yacl.control.boolean.true": "Tak",
+ "yacl.control.boolean.false": "Nie",
+
+ "yacl.control.action.execute": "WYKONAJ",
+
+ "yacl.gui.save": "Zapisz zmiany",
+ "yacl.gui.save.tooltip": "Sprawia, że wprowadzone zmiany są trwałe.",
+ "yacl.gui.finished.tooltip": "Zamyka GUI.",
+ "yacl.gui.cancel.tooltip": "Zapomina oczekujące zmiany i zamyka GUI.",
+ "yacl.gui.reset.tooltip": "Resetuje wszystkie opcje do wartości domyślnych. (To jest odwracalne!)",
+ "yacl.gui.undo": "Cofnij",
+ "yacl.gui.undo.tooltip": "Przywraca wszystkie opcje do stanu sprzed edycji.",
+ "yacl.gui.fail_apply": "Nie udało się zastosować",
+ "yacl.gui.fail_apply.tooltip": "Wystąpił błąd i nie udało się zastosować zmian.",
+ "yacl.gui.save_before_exit": "Zapisz przed wyjściem!",
+ "yacl.gui.save_before_exit.tooltip": "Zapisz lub anuluj, aby wyjść z GUI.",
+
+ "yacl.restart.title": "Konfiguracja wymaga restartu!",
+ "yacl.restart.message": "Jedna lub więcej opcji wymaga ponownego uruchomienia gry, aby zastosować zmiany.",
+ "yacl.restart.yes": "Zamknij Minecrafta",
+ "yacl.restart.no": "Ignoruj"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/pt_br.json b/src/main/resources/assets/yet_another_config_lib/lang/pt_br.json
new file mode 100644
index 0000000..9d4ef8d
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/pt_br.json
@@ -0,0 +1,18 @@
+{
+ "yacl.control.boolean.true": "Verdadeiro",
+ "yacl.control.boolean.false": "Falso",
+
+ "yacl.control.action.execute": "EXECUTAR",
+
+ "yacl.gui.save": "Salvar Mudanças",
+ "yacl.gui.save.tooltip": "Faz as mudanças serem permanentes.",
+ "yacl.gui.finished.tooltip": "Fecha o GUI.",
+ "yacl.gui.cancel.tooltip": "Esquece as mudanças pendentes e fecha o GUI.",
+ "yacl.gui.reset.tooltip": "Reinicia todas as opções para o valor padrão. (Isso é irreversível!)",
+ "yacl.gui.undo": "Desfazer",
+ "yacl.gui.undo.tooltip": "Reverte todas as opções como elas estavam antes de serem editadas.",
+ "yacl.gui.fail_apply": "Falha na aplicação",
+ "yacl.gui.fail_apply.tooltip": "Houve um erro e as mudanças não puderam ser aplicadas.",
+ "yacl.gui.save_before_exit": "Salve antes de sair!",
+ "yacl.gui.save_before_exit.tooltip": "Salve ou calcele para sair do GUI."
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/ru_ru.json b/src/main/resources/assets/yet_another_config_lib/lang/ru_ru.json
new file mode 100644
index 0000000..5725d34
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/ru_ru.json
@@ -0,0 +1,24 @@
+{
+ "yacl.control.boolean.true": "§atrue",
+ "yacl.control.boolean.false": "§cfalse",
+
+ "yacl.control.action.execute": "Выполнить",
+
+ "yacl.gui.save": "Сохранить",
+ "yacl.gui.save.tooltip": "Сохранить изменения до следующего редактирования.",
+ "yacl.gui.finished.tooltip": "Закрыть меню.",
+ "yacl.gui.cancel": "Назад",
+ "yacl.gui.cancel.tooltip": "Отменить изменения и закрыть настройки.",
+ "yacl.gui.reset.tooltip": "Сбросить все настройки до значений по умолчанию (их можно восстановить).",
+ "yacl.gui.undo": "Отменить",
+ "yacl.gui.undo.tooltip": "Вернуть все настройки к состоянию, в котором они были до изменений.",
+ "yacl.gui.fail_apply": "Не удалось сохранить",
+ "yacl.gui.fail_apply.tooltip": "Возникла ошибка; изменения невозможно применить.",
+ "yacl.gui.save_before_exit": "Сохраните перед закрытием",
+ "yacl.gui.save_before_exit.tooltip": "Сохраните или отмените изменения, чтобы закрыть настройки.",
+
+ "yacl.restart.title": "Настройки требуют перезагрузки.",
+ "yacl.restart.message": "Одна или несколько настроек требует перезапуска игры для применения изменений.",
+ "yacl.restart.yes": "Закрыть Minecraft",
+ "yacl.restart.no": "Игнорировать"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/sl_si.json b/src/main/resources/assets/yet_another_config_lib/lang/sl_si.json
new file mode 100644
index 0000000..743dd4d
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/sl_si.json
@@ -0,0 +1,22 @@
+{
+ "yacl.control.boolean.true": "Vklopljeno",
+ "yacl.control.boolean.false": "Izklopljeno",
+
+ "yacl.control.action.execute": "POŽENI",
+
+ "yacl.gui.save": "Shrani spremembe",
+ "yacl.gui.save.tooltip": "Uvede trajne spremembe.",
+ "yacl.gui.finished.tooltip": "Zapre meni.",
+ "yacl.gui.cancel.tooltip": "Zavrže neshranjene spremembe in zapre meni.",
+ "yacl.gui.reset.tooltip": "Ponastavi vse možnosti na privzete. (To se da razveljaviti!)",
+ "yacl.gui.undo": "Razveljavi",
+ "yacl.gui.undo.tooltip": "Ponastavi vse možnosti na take kot pred spreminjanjem.",
+ "yacl.gui.fail_apply": "Napaka pri uveljavljanju",
+ "yacl.gui.fail_apply.tooltip": "Prišlo je do napake pri uveljavljanju sprememb.",
+ "yacl.gui.save_before_exit": "Shrani pred izhodom!",
+ "yacl.gui.save_before_exit.tooltip": "Shrani ali prekliči za izhod iz menija.",
+ "yacl.restart.title": "Nastavitve potrebujejo ponovni zagon!",
+ "yacl.restart.message": "Vsaj ena izmed nastavitev potrebuje ponovni zagon igre za uveljavitev.",
+ "yacl.restart.yes": "Zapri Minecraft",
+ "yacl.restart.no": "Prezri"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/tt_ru.json b/src/main/resources/assets/yet_another_config_lib/lang/tt_ru.json
new file mode 100644
index 0000000..06d005a
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/tt_ru.json
@@ -0,0 +1,34 @@
+{
+ "modmenu.summaryTranslation.yacl": "Төзүче нигезендә Minecraft өчен көйләүләр китапханәсе.",
+ "modmenu.descriptionTranslation.yacl": "YetAnotherConfigLib (yacl) — нәкъ менә кирәк нәрсә. Төзүче нигезендә Minecraft өчен көйләүләр китапханәсе.",
+
+ "yacl.control.boolean.true": "Дөрес",
+ "yacl.control.boolean.false": "Ялган",
+
+ "yacl.control.action.execute": "ҮТӘҮ",
+
+ "yacl.control.text.blank": "<буш>",
+
+ "yacl.gui.save": "Үзгәрешләрне саклау",
+ "yacl.gui.save.tooltip": "Кертелгән үзгәрешләрне даими итә.",
+ "yacl.gui.finished.tooltip": "GUI-ны яба.",
+ "yacl.gui.cancel.tooltip": "Үзгәрешләрне баш тарта һәм GUI-ны яба.",
+ "yacl.gui.reset.tooltip": "Барлык көйләүләрне беренчелгә кайтара. (Аларны төзәтергә ярый!)",
+ "yacl.gui.undo": "Баш тарту",
+ "yacl.gui.undo.tooltip": "Үзгәрешләргә кадәр булган халәткә барлык көйләүләрне төзәтә.",
+ "yacl.gui.fail_apply": "Кулланып булмады",
+ "yacl.gui.fail_apply.tooltip": "Үзгәрешләрне кулланып булмады; хата булды.",
+ "yacl.gui.save_before_exit": "Чыкканчы саклагыз!",
+ "yacl.gui.save_before_exit.tooltip": "GUI-тан чыгу өчен саклагыз яки баш тартыгыз.",
+
+ "yacl.list.move_up": "Өскә күчү",
+ "yacl.list.move_down": "Аска күчү",
+ "yacl.list.remove": "Бетерү",
+ "yacl.list.add_top": "Яңа элемент",
+ "yacl.list.empty": "Исемлек буш",
+
+ "yacl.restart.title": "Көйләү яңадан кушуны таләп ителә!",
+ "yacl.restart.message": "Үзгәрешләр куллану өчен бер яки күбрәк көйләү уенның яңадан кушуын таләп ителә.",
+ "yacl.restart.yes": "Minecraft-ны ябу",
+ "yacl.restart.no": "Әһәмият бирмәү"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/zh_cn.json b/src/main/resources/assets/yet_another_config_lib/lang/zh_cn.json
new file mode 100644
index 0000000..9307c9b
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/zh_cn.json
@@ -0,0 +1,29 @@
+{
+ "yacl.control.boolean.true": "是",
+ "yacl.control.boolean.false": "否",
+
+ "yacl.control.action.execute": "执行",
+
+ "yacl.gui.save": "保存更改",
+ "yacl.gui.save.tooltip": "永久保存所做更改。",
+ "yacl.gui.finished.tooltip": "关闭 GUI。",
+ "yacl.gui.cancel.tooltip": "忽略犹豫不决的更改然后关闭 GUI。",
+ "yacl.gui.reset.tooltip": "将所有选项重置为默认值。(这是可逆的!)",
+ "yacl.gui.undo": "撤销",
+ "yacl.gui.undo.tooltip": "将所有选项恢复到编辑前的状态。",
+ "yacl.gui.fail_apply": "应用失败",
+ "yacl.gui.fail_apply.tooltip": "有一个错误以至于更改不能被应用。",
+ "yacl.gui.save_before_exit": "退出前保存!",
+ "yacl.gui.save_before_exit.tooltip": "保存或取消以退出 GUI。",
+
+ "yacl.list.move_up": "上移",
+ "yacl.list.move_down": "下移",
+ "yacl.list.remove": "移除",
+ "yacl.list.add_top": "新条目",
+ "yacl.list.empty": "列表为空",
+
+ "yacl.restart.title": "配置需要重新启动!",
+ "yacl.restart.message": "一个或多个选项需要你重新启动游戏来应用这些更改。",
+ "yacl.restart.yes": "关闭 Minecraft",
+ "yacl.restart.no": "忽略"
+}
diff --git a/src/main/resources/assets/yet_another_config_lib/lang/zh_tw.json b/src/main/resources/assets/yet_another_config_lib/lang/zh_tw.json
new file mode 100644
index 0000000..0ac792f
--- /dev/null
+++ b/src/main/resources/assets/yet_another_config_lib/lang/zh_tw.json
@@ -0,0 +1,29 @@
+{
+ "yacl.control.boolean.true": "是",
+ "yacl.control.boolean.false": "否",
+
+ "yacl.control.action.execute": "執行",
+
+ "yacl.gui.save": "儲存變更",
+ "yacl.gui.save.tooltip": "儲存你的變更。",
+ "yacl.gui.finished.tooltip": "關閉介面。",
+ "yacl.gui.cancel.tooltip": "取消變更並關閉介面。",
+ "yacl.gui.reset.tooltip": "重設所有選項到預設。(這可以復原!)",
+ "yacl.gui.undo": "復原",
+ "yacl.gui.undo.tooltip": "將所有選項恢復成編輯前的狀態。",
+ "yacl.gui.fail_apply": "套用失敗",
+ "yacl.gui.fail_apply.tooltip": "發生錯誤,無法套用變更。",
+ "yacl.gui.save_before_exit": "在離開時儲存!",
+ "yacl.gui.save_before_exit.tooltip": "儲存或是取消並離開介面。",
+
+ "yacl.list.move_up": "上移",
+ "yacl.list.move_down": "下移",
+ "yacl.list.remove": "移除",
+ "yacl.list.add_top": "新增項目",
+ "yacl.list.empty": "列表為空",
+
+ "yacl.restart.title": "變更設定需要重開遊戲!",
+ "yacl.restart.message": "一個或多個選項需要你重開遊戲才能套用變更。",
+ "yacl.restart.yes": "關閉 Minecraft",
+ "yacl.restart.no": "忽略"
+}
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..bc49bd9
--- /dev/null
+++ b/src/main/resources/fabric.mod.json
@@ -0,0 +1,38 @@
+{
+ "schemaVersion": 1,
+ "id": "${id}",
+ "version": "${version}",
+ "name": "${name}",
+ "description": "${description}",
+ "authors": [
+ "isXander"
+ ],
+ "contact": {
+ "homepage": "https://isxander.dev",
+ "issues": "https://github.com/${github}/issues",
+ "sources": "https://github.com/${github}"
+ },
+ "icon": "yacl-128x.png",
+ "license": "LGPL-3.0-or-later",
+ "environment": "*",
+ "depends": {
+ "fabricloader": ">=0.15.0",
+ "minecraft": "${mc}",
+ "java": ">=17",
+ "fabric-resource-loader-v0": "*"
+ },
+ "mixins": [
+ "yacl.mixins.json",
+ "yacl-fabric.mixins.json"
+ ],
+ "entrypoints": {
+ "client": [
+ "dev.isxander.yacl3.platform.PlatformEntrypoint"
+ ]
+ },
+ "custom": {
+ "modmenu": {
+ "badges": ["library"]
+ }
+ }
+}
diff --git a/src/main/resources/pack.mcmeta b/src/main/resources/pack.mcmeta
new file mode 100644
index 0000000..a9c6340
--- /dev/null
+++ b/src/main/resources/pack.mcmeta
@@ -0,0 +1,6 @@
+{
+ "pack": {
+ "description": "${name}",
+ "pack_format": 14
+ }
+} \ No newline at end of file
diff --git a/src/main/resources/yacl-128x.png b/src/main/resources/yacl-128x.png
new file mode 100644
index 0000000..c86981c
--- /dev/null
+++ b/src/main/resources/yacl-128x.png
Binary files differ
diff --git a/src/main/resources/yacl-fabric.mixins.json b/src/main/resources/yacl-fabric.mixins.json
new file mode 100644
index 0000000..f755f0f
--- /dev/null
+++ b/src/main/resources/yacl-fabric.mixins.json
@@ -0,0 +1,11 @@
+{
+ "required": true,
+ "package": "dev.isxander.yacl3.mixin",
+ "compatibilityLevel": "JAVA_17",
+ "injectors": {
+ "defaultRequire": 1
+ },
+ "client": [
+ "ContainerEventHandlerMixin"
+ ]
+}
diff --git a/src/main/resources/yacl.accesswidener b/src/main/resources/yacl.accesswidener
new file mode 100644
index 0000000..7c0e2ca
--- /dev/null
+++ b/src/main/resources/yacl.accesswidener
@@ -0,0 +1,12 @@
+accessWidener v2 named
+
+extendable method net/minecraft/client/gui/components/AbstractSelectionList children ()Ljava/util/List;
+extendable method net/minecraft/client/gui/components/AbstractSelectionList getEntryAtPosition (DD)Lnet/minecraft/client/gui/components/AbstractSelectionList$Entry;
+accessible class net/minecraft/client/gui/components/AbstractSelectionList$Entry
+accessible method net/minecraft/client/gui/components/tabs/TabNavigationBar <init> (ILnet/minecraft/client/gui/components/tabs/TabManager;Ljava/lang/Iterable;)V
+accessible field net/minecraft/client/gui/components/tabs/TabNavigationBar layout Lnet/minecraft/client/gui/layouts/GridLayout;
+accessible field net/minecraft/client/gui/components/tabs/TabNavigationBar width I
+accessible field net/minecraft/client/gui/components/tabs/TabNavigationBar tabManager Lnet/minecraft/client/gui/components/tabs/TabManager;
+accessible field net/minecraft/client/gui/components/tabs/TabNavigationBar tabs Lcom/google/common/collect/ImmutableList;
+accessible field net/minecraft/client/gui/components/tabs/TabNavigationBar tabButtons Lcom/google/common/collect/ImmutableList;
+accessible method net/minecraft/client/gui/components/Tooltip <init> (Lnet/minecraft/network/chat/Component;Lnet/minecraft/network/chat/Component;)V
diff --git a/src/main/resources/yacl.mixins.json b/src/main/resources/yacl.mixins.json
new file mode 100644
index 0000000..2385a1b
--- /dev/null
+++ b/src/main/resources/yacl.mixins.json
@@ -0,0 +1,14 @@
+{
+ "required": true,
+ "package": "dev.isxander.yacl3.mixin",
+ "compatibilityLevel": "JAVA_17",
+ "injectors": {
+ "defaultRequire": 1
+ },
+ "client": [
+ "AbstractSelectionListMixin",
+ "MinecraftMixin",
+ "OptionInstanceAccessor",
+ "TabNavigationBarAccessor"
+ ]
+}
diff --git a/src/testmod/java/dev/isxander/yacl3/test/AutogenConfigTest.java b/src/testmod/java/dev/isxander/yacl3/test/AutogenConfigTest.java
new file mode 100644
index 0000000..b3b49b6
--- /dev/null
+++ b/src/testmod/java/dev/isxander/yacl3/test/AutogenConfigTest.java
@@ -0,0 +1,130 @@
+package dev.isxander.yacl3.test;
+
+import com.google.common.collect.Lists;
+import dev.isxander.yacl3.api.NameableEnum;
+import dev.isxander.yacl3.api.Option;
+import dev.isxander.yacl3.api.controller.ControllerBuilder;
+import dev.isxander.yacl3.api.controller.StringControllerBuilder;
+import dev.isxander.yacl3.config.v2.api.ConfigClassHandler;
+import dev.isxander.yacl3.config.v2.api.ConfigField;
+import dev.isxander.yacl3.config.v2.api.autogen.Boolean;
+import dev.isxander.yacl3.config.v2.api.autogen.Label;
+import dev.isxander.yacl3.config.v2.api.serializer.GsonConfigSerializerBuilder;
+import dev.isxander.yacl3.config.v2.api.SerialEntry;
+import dev.isxander.yacl3.config.v2.api.autogen.*;
+import dev.isxander.yacl3.gui.ValueFormatters;
+import dev.isxander.yacl3.platform.YACLPlatform;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.Items;
+
+import java.awt.*;
+import java.util.List;
+
+public class AutogenConfigTest {
+ public static final ConfigClassHandler<AutogenConfigTest> INSTANCE = ConfigClassHandler.createBuilder(AutogenConfigTest.class)
+ .id(new ResourceLocation("yacl3", "config"))
+ .serializer(config -> GsonConfigSerializerBuilder.create(config)
+ .setPath(YACLPlatform.getConfigDir().resolve("yacl-test-v2.json5"))
+ .setJson5(true)
+ .build())
+ .build();
+
+ @AutoGen(category = "test", group = "master_test")
+ @MasterTickBox({ "testTickBox", "testBoolean", "testInt", "testDouble", "testFloat", "testLong", "testIntField", "testDoubleField", "testFloatField", "testLongField", "testEnum", "testColor", "testString", "testDropdown", "testItem" })
+ @SerialEntry(comment = "This option disables all the other options in this group")
+ public boolean masterOption = true;
+
+ @AutoGen(category = "test", group = "master_test")
+ @TickBox
+ @SerialEntry(comment = "This is a cool comment omg this is amazing")
+ public boolean testTickBox = true;
+
+ @AutoGen(category = "test", group = "master_test")
+ @Boolean(formatter = Boolean.Formatter.YES_NO, colored = true)
+ @SerialEntry(comment = "This is a cool comment omg this is amazing")
+ public boolean testBoolean = true;
+
+ @AutoGen(category = "test", group = "master_test")
+ @IntSlider(min = 0, max = 10, step = 2)
+ @SerialEntry public int testInt = 0;
+
+ @AutoGen(category = "test", group = "master_test")
+ @DoubleSlider(min = 0.1, max = 10.2, step = 0.1)
+ @SerialEntry public double testDouble = 0.1;
+
+ @AutoGen(category = "test", group = "master_test")
+ @FloatSlider(min = 0.0f, max = 1f, step = 0.01f)
+ @CustomFormat(ValueFormatters.PercentFormatter.class)
+ @CustomName("A cool percentage.")
+ @SerialEntry public float testFloat = 0.1f;
+
+ @AutoGen(category = "test", group = "master_test")
+ @LongSlider(min = 0, max = 10, step = 2)
+ @SerialEntry public long testLong = 0;
+
+ @AutoGen(category = "test", group = "master_test")
+ @IntField(min = 0, max = 10)
+ @SerialEntry public int testIntField = 0;
+
+ @AutoGen(category = "test", group = "master_test")
+ @DoubleField(min = 0.1, max = 10.2)
+ @SerialEntry public double testDoubleField = 0.1;
+
+ @AutoGen(category = "test", group = "master_test")
+ @FloatField(min = 0.1f, max = 10.2f)
+ @SerialEntry public float testFloatField = 0.1f;
+
+ @AutoGen(category = "test", group = "master_test")
+ @LongField(min = 0, max = 10)
+ @SerialEntry public long testLongField = 0;
+
+ @AutoGen(category = "test", group = "master_test")
+ @EnumCycler
+ @SerialEntry public Alphabet testEnum = Alphabet.A;
+
+ @AutoGen(category = "test", group = "master_test")
+ @ColorField
+ @SerialEntry public Color testColor = new Color(0xFF0000FF, true);
+
+ @AutoGen(category = "test", group = "master_test")
+ @StringField
+ @SerialEntry public String testString = "Test string";
+
+ @AutoGen(category = "test", group = "master_test")
+ @Dropdown(values = {"Apple", "Banana", "Cherry", "Date"}, allowAnyValue = true)
+ @SerialEntry public String testDropdown = "Cherry";
+
+ @AutoGen(category = "test", group = "master_test")
+ @ItemField
+ @SerialEntry public Item testItem = Items.AZURE_BLUET;
+
+ @AutoGen(category = "test", group = "misc") @Label
+ private final Component testLabel = Component.literal("Test label");
+
+ @AutoGen(category = "test")
+ @ListGroup(valueFactory = TestListFactory.class, controllerFactory = TestListFactory.class)
+ @SerialEntry public List<String> testList = Lists.newArrayList("A", "B");
+
+ public enum Alphabet implements NameableEnum {
+ A, B, C;
+
+ @Override
+ public Component getDisplayName() {
+ return Component.literal(name());
+ }
+ }
+
+ public static class TestListFactory implements ListGroup.ValueFactory<String>, ListGroup.ControllerFactory<String> {
+ @Override
+ public String provideNewValue() {
+ return "";
+ }
+
+ @Override
+ public ControllerBuilder<String> createController(ListGroup annotation, ConfigField<List<String>> field, OptionAccess storage, Option<String> option) {
+ return StringControllerBuilder.create(option);
+ }
+ }
+}
diff --git a/src/testmod/java/dev/isxander/yacl3/test/ConfigTest.java b/src/testmod/java/dev/isxander/yacl3/test/ConfigTest.java
new file mode 100644
index 0000000..a8f49b0
--- /dev/null
+++ b/src/testmod/java/dev/isxander/yacl3/test/ConfigTest.java
@@ -0,0 +1,78 @@
+package dev.isxander.yacl3.test;
+
+import dev.isxander.yacl3.config.v2.api.ConfigClassHandler;
+import dev.isxander.yacl3.config.v2.api.SerialEntry;
+import dev.isxander.yacl3.config.v2.api.serializer.GsonConfigSerializerBuilder;
+import dev.isxander.yacl3.platform.YACLPlatform;
+import net.minecraft.ChatFormatting;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.Items;
+
+import java.awt.*;
+import java.util.List;
+
+public class ConfigTest {
+ public static final ConfigClassHandler<ConfigTest> GSON = ConfigClassHandler.createBuilder(ConfigTest.class)
+ .serializer(config -> GsonConfigSerializerBuilder.create(config)
+ .setPath(YACLPlatform.getConfigDir().resolve("yacl-test.json"))
+ .build())
+ .build();
+
+ @SerialEntry
+ public boolean booleanToggle = false;
+ @SerialEntry
+ public boolean customBooleanToggle = false;
+ @SerialEntry
+ public boolean tickbox = false;
+ @SerialEntry
+ public int intSlider = 0;
+ @SerialEntry
+ public double doubleSlider = 0;
+ @SerialEntry
+ public float floatSlider = 0;
+ @SerialEntry
+ public long longSlider = 0;
+ @SerialEntry
+ public String textField = "Hello";
+ @SerialEntry
+ public Color colorOption = Color.red;
+ @SerialEntry
+ public double doubleField = 0.5;
+ @SerialEntry
+ public float floatField = 0.5f;
+ @SerialEntry
+ public int intField = 5;
+ @SerialEntry
+ public long longField = 5;
+ @SerialEntry
+ public Alphabet enumOption = Alphabet.A;
+ @SerialEntry
+ public String stringOptions = "Banana";
+ @SerialEntry
+ public String stringSuggestions = "";
+ @SerialEntry
+ public Item item = Items.OAK_LOG;
+ @SerialEntry
+ public ChatFormatting formattingOption = ChatFormatting.RED;
+
+ @SerialEntry
+ public List<String> stringList = List.of("This is quite cool.", "You can add multiple items!", "And it is integrated so well into Option groups!");
+ @SerialEntry
+ public List<Integer> intList = List.of(1, 2, 3);
+
+ @SerialEntry
+ public boolean groupTestRoot = false;
+ @SerialEntry
+ public boolean groupTestFirstGroup = false;
+ @SerialEntry
+ public boolean groupTestFirstGroup2 = false;
+ @SerialEntry
+ public boolean groupTestSecondGroup = false;
+
+ @SerialEntry
+ public int scrollingSlider = 0;
+
+ public enum Alphabet {
+ A, B, C
+ }
+}
diff --git a/src/testmod/java/dev/isxander/yacl3/test/Entrypoint.java b/src/testmod/java/dev/isxander/yacl3/test/Entrypoint.java
new file mode 100644
index 0000000..2c4875f
--- /dev/null
+++ b/src/testmod/java/dev/isxander/yacl3/test/Entrypoint.java
@@ -0,0 +1,23 @@
+/*? if neoforge { *//*
+package dev.isxander.yacl3.test;
+
+import net.neoforged.fml.common.Mod;
+
+@Mod("yacl_test")
+public class Entrypoint {
+ public Entrypoint() {
+
+ }
+}
+*//*?} elif forge {*//*
+package dev.isxander.yacl3.test;
+
+import net.minecraftforge.fml.common.Mod;
+
+@Mod("yacl_test")
+public class Entrypoint {
+ public Entrypoint() {
+
+ }
+}
+*//*?}*/
diff --git a/src/testmod/java/dev/isxander/yacl3/test/GuiTest.java b/src/testmod/java/dev/isxander/yacl3/test/GuiTest.java
new file mode 100644
index 0000000..473b04a
--- /dev/null
+++ b/src/testmod/java/dev/isxander/yacl3/test/GuiTest.java
@@ -0,0 +1,453 @@
+package dev.isxander.yacl3.test;
+
+import dev.isxander.yacl3.api.*;
+import dev.isxander.yacl3.api.controller.*;
+import dev.isxander.yacl3.gui.RequireRestartScreen;
+import dev.isxander.yacl3.gui.controllers.*;
+import dev.isxander.yacl3.gui.controllers.cycling.EnumController;
+import dev.isxander.yacl3.gui.controllers.slider.DoubleSliderController;
+import dev.isxander.yacl3.gui.controllers.slider.FloatSliderController;
+import dev.isxander.yacl3.gui.controllers.slider.IntegerSliderController;
+import dev.isxander.yacl3.gui.controllers.slider.LongSliderController;
+import dev.isxander.yacl3.gui.controllers.string.StringController;
+import dev.isxander.yacl3.gui.controllers.string.number.DoubleFieldController;
+import dev.isxander.yacl3.gui.controllers.string.number.FloatFieldController;
+import dev.isxander.yacl3.gui.controllers.string.number.IntegerFieldController;
+import dev.isxander.yacl3.gui.controllers.string.number.LongFieldController;
+import net.minecraft.ChatFormatting;
+import net.minecraft.Util;
+import net.minecraft.client.GraphicsStatus;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.toasts.SystemToast;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.network.chat.ClickEvent;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.HoverEvent;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.Item;
+import org.apache.commons.lang3.StringUtils;
+
+import java.awt.Color;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class GuiTest {
+ public static Screen getModConfigScreenFactory(Screen parent) {
+ return YetAnotherConfigLib.create(ConfigTest.GSON, (defaults, config, builder) -> builder
+ .title(Component.literal("Test Suites"))
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Suites"))
+ .option(ButtonOption.createBuilder()
+ .name(Component.literal("Full Test Suite"))
+ .action((screen, opt) -> Minecraft.getInstance().setScreen(getFullTestSuite(screen)))
+ .build())
+ .option(ButtonOption.createBuilder()
+ .name(Component.literal("Auto-gen test"))
+ .action((screen, opt) -> {
+ AutogenConfigTest.INSTANCE.load();
+ Minecraft.getInstance().setScreen(AutogenConfigTest.INSTANCE.generateGui().generateScreen(screen));
+ })
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Wiki"))
+ .option(ButtonOption.createBuilder()
+ .name(Component.literal("Get Started"))
+ .action((screen, opt) -> Minecraft.getInstance().setScreen(getWikiGetStarted(screen)))
+ .build())
+ .build())
+ .build())
+ )
+ .generateScreen(parent);
+ }
+
+ private static Screen getFullTestSuite(Screen parent) {
+ AtomicReference<Option<Boolean>> booleanOption = new AtomicReference<>();
+
+ ConfigTest.GSON.serializer().load();
+ return YetAnotherConfigLib.create(ConfigTest.GSON, (defaults, config, builder) -> builder
+ .title(Component.literal("Test GUI"))
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Control Examples"))
+ .tooltip(Component.literal("Example Category Description"))
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Boolean Controllers"))
+ .option(Util.make(() -> {
+ var opt = Option.<Boolean>createBuilder()
+ .name(Component.literal("Boolean Toggle"))
+ .description(OptionDescription.createBuilder()
+ .text(Component.empty()
+ .append(Component.literal("a").withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("a")))))
+ .append(Component.literal("b").withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("b")))))
+ .append(Component.literal("c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c").withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("c")))))
+ .append(Component.literal("e").withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("e")))))
+ .withStyle(style -> style.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://isxander.dev")))
+ )
+ .webpImage(new ResourceLocation("yacl3", "reach-around-placement.webp"))
+ .build())
+ .binding(
+ defaults.booleanToggle,
+ () -> config.booleanToggle,
+ (value) -> config.booleanToggle = value
+ )
+ .controller(BooleanControllerBuilder::create)
+ .flag(OptionFlag.GAME_RESTART)
+ .build();
+ booleanOption.set(opt);
+ return opt;
+ }))
+ .option(Option.<Boolean>createBuilder()
+ .name(Component.literal("Custom Boolean Toggle"))
+ .description(val -> OptionDescription.createBuilder()
+ .text(Component.literal("You can customize controllers like so! YACL is truly infinitely customizable! This tooltip is long in order to demonstrate the cool, smooth scrolling of these descriptions. Did you know, they are also super clickable?! I know, cool right, YACL 3.x really is amazing."))
+ .image(Path.of("D:\\Xander\\Downloads\\_MG_0860-Enhanced-NR.png"), new ResourceLocation("yacl", "f.webp")) // TODO: Add img file to git?
+ .build())
+ .binding(
+ defaults.customBooleanToggle,
+ () -> config.customBooleanToggle,
+ (value) -> config.customBooleanToggle = value
+ )
+ .controller(opt -> BooleanControllerBuilder.create(opt)
+ .formatValue(state -> state ? Component.literal("Amazing") : Component.literal("Not Amazing"))
+ .coloured(true))
+ .listener((opt, val) -> booleanOption.get().setAvailable(val))
+ .build())
+ .option(Option.<Boolean>createBuilder()
+ .name(Component.literal("Tick Box"))
+ .description(OptionDescription.of(Component.literal("There are even alternate methods of displaying the same data type!")))
+ .binding(
+ defaults.tickbox,
+ () -> config.tickbox,
+ (value) -> config.tickbox = value
+ )
+ .controller(TickBoxControllerBuilder::create)
+ .build())
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Slider Controllers"))
+ .option(Option.<Integer>createBuilder()
+ .name(Component.literal("Int Slider"))
+ .binding(
+ defaults.intSlider,
+ () -> config.intSlider,
+ value -> config.intSlider = value
+ )
+ .customController(opt -> new IntegerSliderController(opt, 0, 3, 1))
+ .build())
+ .option(Option.<Double>createBuilder()
+ .name(Component.literal("Double Slider"))
+ .binding(
+ defaults.doubleSlider,
+ () -> config.doubleSlider,
+ (value) -> config.doubleSlider = value
+ )
+ .customController(opt -> new DoubleSliderController(opt, 0, 3, 0.05))
+ .build())
+ .option(Option.<Float>createBuilder()
+ .name(Component.literal("Float Slider"))
+ .binding(
+ defaults.floatSlider,
+ () -> config.floatSlider,
+ (value) -> config.floatSlider = value
+ )
+ .customController(opt -> new FloatSliderController(opt, 0, 3, 0.1f))
+ .build())
+ .option(Option.<Long>createBuilder()
+ .name(Component.literal("Long Slider"))
+ .binding(
+ defaults.longSlider,
+ () -> config.longSlider,
+ (value) -> config.longSlider = value
+ )
+ .customController(opt -> new LongSliderController(opt, 0, 1_000_000, 100))
+ .build())
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Input Field Controllers"))
+ .option(Option.<String>createBuilder()
+ .name(Component.literal("Component Option"))
+ .binding(
+ defaults.textField,
+ () -> config.textField,
+ value -> config.textField = value
+ )
+ .customController(StringController::new)
+ .build())
+ .option(Option.<Color>createBuilder()
+ .name(Component.literal("Color Option"))
+ .binding(
+ defaults.colorOption,
+ () -> config.colorOption,
+ value -> config.colorOption = value
+ )
+ .customController(ColorController::new)
+ .build())
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Number Fields"))
+ .option(Option.<Double>createBuilder()
+ .name(Component.literal("Double Field"))
+ .binding(
+ defaults.doubleField,
+ () -> config.doubleField,
+ value -> config.doubleField = value
+ )
+ .customController(DoubleFieldController::new)
+ .build())
+ .option(Option.<Float>createBuilder()
+ .name(Component.literal("Float Field"))
+ .binding(
+ defaults.floatField,
+ () -> config.floatField,
+ value -> config.floatField = value
+ )
+ .customController(FloatFieldController::new)
+ .build())
+ .option(Option.<Integer>createBuilder()
+ .name(Component.literal("Integer Field"))
+ .binding(
+ defaults.intField,
+ () -> config.intField,
+ value -> config.intField = value
+ )
+ .customController(IntegerFieldController::new)
+ .build())
+ .option(Option.<Long>createBuilder()
+ .name(Component.literal("Long Field"))
+ .binding(
+ defaults.longField,
+ () -> config.longField,
+ value -> config.longField = value
+ )
+ .customController(LongFieldController::new)
+ .build())
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Enum Controllers"))
+ .option(Option.<ConfigTest.Alphabet>createBuilder()
+ .name(Component.literal("Enum Cycler"))
+ .binding(
+ defaults.enumOption,
+ () -> config.enumOption,
+ (value) -> config.enumOption = value
+ )
+ .customController(opt -> new EnumController<>(opt, ConfigTest.Alphabet.class))
+ .build())
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Dropdown Controllers"))
+ .option(Option.<String>createBuilder()
+ .name(Component.literal("String Dropdown"))
+ .binding(
+ defaults.stringOptions,
+ () -> config.stringOptions,
+ (value) -> config.stringOptions = value
+ )
+ .controller(opt -> DropdownStringControllerBuilder.create(opt)
+ .values("Apple", "Banana", "Cherry", "Date")
+ )
+ .build())
+ .option(Option.<String>createBuilder()
+ .name(Component.literal("String suggestions"))
+ .binding(
+ defaults.stringSuggestions,
+ () -> config.stringSuggestions,
+ (value) -> config.stringSuggestions = value
+ )
+ .controller(opt -> DropdownStringControllerBuilder.create(opt)
+ .values("Apple", "Banana", "Cherry", "Date")
+ .allowAnyValue(true)
+ )
+ .build())
+ .option(Option.<Item>createBuilder()
+ .name(Component.literal("Item Dropdown"))
+ .binding(
+ defaults.item,
+ () -> config.item,
+ (value) -> config.item = value
+ )
+ .controller(ItemControllerBuilder::create)
+ .build())
+ .option(Option.<ChatFormatting>createBuilder()
+ .name(Component.literal("Enum Dropdown"))
+ .binding(
+ defaults.formattingOption,
+ () -> config.formattingOption,
+ (value) -> config.formattingOption = value
+ )
+ .controller(option -> EnumDropdownControllerBuilder.create(option).formatValue(formatting -> Component.literal(StringUtils.capitalize(formatting.getName()).replaceAll("_", " "))))
+ .build())
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Options that aren't really options"))
+ .option(ButtonOption.createBuilder()
+ .name(Component.literal("Button \"Option\""))
+ .action((screen, opt) -> opt.setAvailable(false))
+ .build())
+ .option(LabelOption.create(
+ Component.empty()
+ .append(Component.literal("a").withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("a")))))
+ .append(Component.literal("b").withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("b")))))
+ .append(Component.literal("c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c c").withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("c")))))
+ .append(Component.literal("e").withStyle(style -> style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("e")))))
+ .withStyle(style -> style.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://isxander.dev"))))
+ )
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Minecraft Bindings"))
+ .description(OptionDescription.of(Component.literal("YACL can also bind Minecraft options!")))
+ .option(Option.<Boolean>createBuilder()
+ .name(Component.literal("Minecraft AutoJump"))
+ .description(OptionDescription.of(Component.literal("You can even bind minecraft options!")))
+ .binding(Binding.minecraft(Minecraft.getInstance().options.autoJump()))
+ .customController(TickBoxController::new)
+ .build())
+ .option(Option.<GraphicsStatus>createBuilder()
+ .name(Component.literal("Minecraft Graphics Mode"))
+ .binding(Binding.minecraft(Minecraft.getInstance().options.graphicsMode()))
+ .customController(opt -> new EnumController<>(opt, GraphicsStatus.class))
+ .build())
+ .build())
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("List Test"))
+ .group(ListOption.<String>createBuilder()
+ .name(Component.literal("String List"))
+ .binding(
+ defaults.stringList,
+ () -> config.stringList,
+ val -> config.stringList = val
+ )
+ .controller(StringControllerBuilder::create)
+ .initial("")
+ .minimumNumberOfEntries(3)
+ .maximumNumberOfEntries(5)
+ .insertEntriesAtEnd(true)
+ .build())
+ .group(ListOption.<Integer>createBuilder()
+ .name(Component.literal("Slider List"))
+ .binding(
+ defaults.intList,
+ () -> config.intList,
+ val -> config.intList = val
+ )
+ .controller(opt -> IntegerSliderControllerBuilder.create(opt)
+ .range(0, 10).step(1))
+ .initial(0)
+ .available(false)
+ .build())
+ .group(ListOption.<Component>createBuilder()
+ .name(Component.literal("Useless Label List"))
+ .binding(Binding.immutable(List.of(Component.literal("It's quite impressive that literally every single controller works, without problem."))))
+ .customController(LabelController::new)
+ .initial(Component.literal("Initial label"))
+ .build())
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Group Test"))
+ .option(Option.<Boolean>createBuilder()
+ .name(Component.literal("Root Test"))
+ .binding(
+ defaults.groupTestRoot,
+ () -> config.groupTestRoot,
+ value -> config.groupTestRoot = value
+ )
+ .customController(TickBoxController::new)
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("First Group"))
+ .option(Option.<Boolean>createBuilder()
+ .name(Component.literal("First Group Test 1"))
+ .binding(
+ defaults.groupTestFirstGroup,
+ () -> config.groupTestFirstGroup,
+ value -> config.groupTestFirstGroup = value
+ )
+ .customController(TickBoxController::new)
+ .build())
+ .option(Option.<Boolean>createBuilder()
+ .name(Component.literal("First Group Test 2"))
+ .binding(
+ defaults.groupTestFirstGroup2,
+ () -> config.groupTestFirstGroup2,
+ value -> config.groupTestFirstGroup2 = value
+ )
+ .customController(TickBoxController::new)
+ .build())
+ .build())
+ .group(OptionGroup.createBuilder()
+ .name(Component.empty())
+ .option(Option.<Boolean>createBuilder()
+ .name(Component.literal("Second Group Test"))
+ .binding(
+ defaults.groupTestSecondGroup,
+ () -> config.groupTestSecondGroup,
+ value -> config.groupTestSecondGroup = value
+ )
+ .customController(TickBoxController::new)
+ .build())
+ .build())
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Category Test"))
+ .option(LabelOption.create(Component.literal("This is a test category!")))
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Category Test"))
+ .option(LabelOption.create(Component.literal("This is a test category!")))
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Category Test"))
+ .option(LabelOption.create(Component.literal("This is a test category!")))
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
+ .option(LabelOption.create(Component.literal("This is a test category!")))
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Category Test"))
+ .option(LabelOption.create(Component.literal("This is a test category!")))
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Category Test"))
+ .option(LabelOption.create(Component.literal("This is a test category!")))
+ .build())
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Category Test"))
+ .option(LabelOption.create(Component.literal("This is a test category!")))
+ .build())
+ .category(PlaceholderCategory.createBuilder()
+ .name(Component.literal("Placeholder Category"))
+ .screen((client, yaclScreen) -> new RequireRestartScreen(yaclScreen))
+ .build())
+ .save(() -> {
+ Minecraft.getInstance().options.save();
+ ConfigTest.GSON.serializer().save();
+ })
+ )
+ .generateScreen(parent);
+ }
+
+ private static boolean myBooleanOption = true;
+
+ private static Screen getWikiGetStarted(Screen parent) {
+ return YetAnotherConfigLib.createBuilder()
+ .title(Component.literal("Used for narration. Could be used to render a title in the future."))
+ .category(ConfigCategory.createBuilder()
+ .name(Component.literal("Name of the category"))
+ .tooltip(Component.literal("This Component will appear as a tooltip when you hover or focus the button with Tab. There is no need to add \n to wrap as YACL will do it for you."))
+ .group(OptionGroup.createBuilder()
+ .name(Component.literal("Name of the group"))
+ .description(OptionDescription.of(Component.literal("This Component will appear when you hover over the name or focus on the collapse button with Tab.")))
+ .option(Option.<Boolean>createBuilder()
+ .name(Component.literal("Boolean Option"))
+ .description(OptionDescription.of(Component.literal("This Component will appear as a tooltip when you hover over the option.")))
+ .binding(true, () -> myBooleanOption, newVal -> myBooleanOption = newVal)
+ .customController(TickBoxController::new)
+ .build())
+ .build())
+ .build())
+ .build()
+ .generateScreen(parent);
+ }
+}
diff --git a/src/testmod/java/dev/isxander/yacl3/test/mixin/TitleScreenMixin.java b/src/testmod/java/dev/isxander/yacl3/test/mixin/TitleScreenMixin.java
new file mode 100644
index 0000000..c3fddbc
--- /dev/null
+++ b/src/testmod/java/dev/isxander/yacl3/test/mixin/TitleScreenMixin.java
@@ -0,0 +1,25 @@
+package dev.isxander.yacl3.test.mixin;
+
+import dev.isxander.yacl3.test.GuiTest;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.gui.screens.TitleScreen;
+import net.minecraft.network.chat.Component;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(TitleScreen.class)
+public abstract class TitleScreenMixin extends Screen {
+ protected TitleScreenMixin(Component title) {
+ super(title);
+ }
+
+ @Inject(method = "init", at = @At("RETURN"))
+ private void addButton(CallbackInfo ci) {
+ addRenderableWidget(Button.builder(Component.literal("YetAnotherConfigLib Test"), button -> {
+ minecraft.setScreen(GuiTest.getModConfigScreenFactory(minecraft.screen));
+ }).width(150).build());
+ }
+}
diff --git a/src/testmod/resources/META-INF/mods.toml b/src/testmod/resources/META-INF/mods.toml
new file mode 100644
index 0000000..74e4078
--- /dev/null
+++ b/src/testmod/resources/META-INF/mods.toml
@@ -0,0 +1,25 @@
+modLoader = "javafml"
+loaderVersion = "[1,)"
+#issueTrackerURL = ""
+license = "LGPL-3.0-or-later"
+
+[[mods]]
+modId = "yacl_test"
+version = "1"
+displayName = "YACL Testmod"
+description = '''
+Test mod for YACL
+'''
+authors = "isXander"
+
+[[mixins]]
+config = "yacl-test.mixins.json"
+
+[[dependencies.yacl_test]]
+modId = "neoforge"
+mandatory = true
+
+[[dependencies.yacl_test]]
+modId = "minecraft"
+mandatory = true
+
diff --git a/src/testmod/resources/assets/yacl3/textures/reach-around-placement.webp b/src/testmod/resources/assets/yacl3/textures/reach-around-placement.webp
new file mode 100644
index 0000000..1937809
--- /dev/null
+++ b/src/testmod/resources/assets/yacl3/textures/reach-around-placement.webp
Binary files differ
diff --git a/src/testmod/resources/assets/yacl3/textures/sample-1.webp b/src/testmod/resources/assets/yacl3/textures/sample-1.webp
new file mode 100644
index 0000000..0da983e
--- /dev/null
+++ b/src/testmod/resources/assets/yacl3/textures/sample-1.webp
Binary files differ
diff --git a/src/testmod/resources/assets/yacl3/textures/sample-2.webp b/src/testmod/resources/assets/yacl3/textures/sample-2.webp
new file mode 100644
index 0000000..e887f8c
--- /dev/null
+++ b/src/testmod/resources/assets/yacl3/textures/sample-2.webp
Binary files differ
diff --git a/src/testmod/resources/assets/yacl3/textures/sample-3.webp b/src/testmod/resources/assets/yacl3/textures/sample-3.webp
new file mode 100644
index 0000000..eda78a9
--- /dev/null
+++ b/src/testmod/resources/assets/yacl3/textures/sample-3.webp
Binary files differ
diff --git a/src/testmod/resources/assets/yacl3/textures/sample-4.webp b/src/testmod/resources/assets/yacl3/textures/sample-4.webp
new file mode 100644
index 0000000..8bbe329
--- /dev/null
+++ b/src/testmod/resources/assets/yacl3/textures/sample-4.webp
Binary files differ
diff --git a/src/testmod/resources/assets/yacl3/textures/sample-5.webp b/src/testmod/resources/assets/yacl3/textures/sample-5.webp
new file mode 100644
index 0000000..ed91379
--- /dev/null
+++ b/src/testmod/resources/assets/yacl3/textures/sample-5.webp
Binary files differ
diff --git a/src/testmod/resources/fabric.mod.json b/src/testmod/resources/fabric.mod.json
new file mode 100644
index 0000000..fca48ab
--- /dev/null
+++ b/src/testmod/resources/fabric.mod.json
@@ -0,0 +1,33 @@
+{
+ "schemaVersion": 1,
+ "id": "yacl_test",
+ "version": "1",
+ "name": "YACL Testmod",
+ "description": "",
+ "authors": [
+ "isXander"
+ ],
+ "contact": {
+ "homepage": "https://isxander.dev",
+ "issues": "https://github.com/${github}/issues",
+ "sources": "https://github.com/${github}"
+ },
+ "license": "LGPL-3.0-or-later",
+ "environment": "*",
+ "depends": {
+ "yet_another_config_lib_v3": "*"
+ },
+ "mixins": [
+ "yacl-test.mixins.json"
+ ],
+ "entrypoints": {
+ "client": [
+ "dev.isxander.yacl3.platform.PlatformEntrypoint"
+ ]
+ },
+ "custom": {
+ "modmenu": {
+ "badges": ["library"]
+ }
+ }
+}
diff --git a/src/testmod/resources/pack.mcmeta b/src/testmod/resources/pack.mcmeta
new file mode 100644
index 0000000..07adb97
--- /dev/null
+++ b/src/testmod/resources/pack.mcmeta
@@ -0,0 +1,6 @@
+{
+ "pack": {
+ "description": "YACL test",
+ "pack_format": 14
+ }
+} \ No newline at end of file
diff --git a/src/testmod/resources/yacl-test.mixins.json b/src/testmod/resources/yacl-test.mixins.json
new file mode 100644
index 0000000..c7f9a71
--- /dev/null
+++ b/src/testmod/resources/yacl-test.mixins.json
@@ -0,0 +1,11 @@
+{
+ "required": true,
+ "package": "dev.isxander.yacl3.test.mixin",
+ "compatibilityLevel": "JAVA_17",
+ "injectors": {
+ "defaultRequire": 1
+ },
+ "client": [
+ "TitleScreenMixin"
+ ]
+}