diff --git a/.idea/scopes/Fabric_sources.xml b/.idea/scopes/Fabric_sources.xml
deleted file mode 100644
index 0448412..0000000
--- a/.idea/scopes/Fabric_sources.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/.idea/scopes/Forge_sources.xml b/.idea/scopes/Forge_sources.xml
deleted file mode 100644
index 7b5f24d..0000000
--- a/.idea/scopes/Forge_sources.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00c5936..2ef376d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1 +1,10 @@
-Fix dependency logic
+- Ensure inherited fields are present in config GUIs (closes #13).
+- When both a mod ID and config file name are specified, the config file is now saved under
+ `config/{mod id}/{config name}.json5` (closes #12).
+ - This should not be a breaking change as I am not aware of any mods registering multiple configs currently.
+- Switch to fabric-api mod ID in dependencies block (closes #10).
+- Enable split source sets (closes #14).
+- Identify config managers by `(MOD_ID, CONFIG_NAME)` rather than by just `(CONFIG_NAME)` (closes #15).
+- Allow `List>` config fields (closes #11).
+- The reset button next to each config field now resets to the default value, rather than the value the field had when
+ the config screen was opened.
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index d8f2df9..36c84d7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,6 @@
plugins {
id "architectury-plugin" version "3.4-SNAPSHOT"
- id "dev.architectury.loom" version "1.7-SNAPSHOT" apply false
+ id "dev.architectury.loom" version "1.9-SNAPSHOT" apply false
id "com.github.breadmoirai.github-release" version "2.4.1"
id "maven-publish"
}
@@ -26,7 +26,7 @@ subprojects {
loom {
silentMojangMappingsLicense()
-
+
mixin {
useLegacyMixinAp = false
}
diff --git a/common/build.gradle b/common/build.gradle
index 1fa6e77..f314a78 100644
--- a/common/build.gradle
+++ b/common/build.gradle
@@ -3,7 +3,14 @@ architectury {
}
loom {
- accessWidenerPath = file("src/main/resources/jamlib.accesswidener")
+ splitEnvironmentSourceSets()
+
+ mods {
+ jamlib {
+ sourceSet sourceSets.main
+ sourceSet sourceSets.client
+ }
+ }
}
dependencies {
diff --git a/common/src/main/java/io/github/jamalam360/jamlib/JamLibClient.java b/common/src/client/java/io/github/jamalam360/jamlib/client/JamLibClient.java
similarity index 81%
rename from common/src/main/java/io/github/jamalam360/jamlib/JamLibClient.java
rename to common/src/client/java/io/github/jamalam360/jamlib/client/JamLibClient.java
index 69d0d7a..6fe5960 100644
--- a/common/src/main/java/io/github/jamalam360/jamlib/JamLibClient.java
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/JamLibClient.java
@@ -1,18 +1,21 @@
-package io.github.jamalam360.jamlib;
+package io.github.jamalam360.jamlib.client;
import static io.github.jamalam360.jamlib.JamLib.JAR_RENAMING_CHECKER;
-import net.fabricmc.api.EnvType;
-import net.fabricmc.api.Environment;
+import dev.architectury.event.events.client.ClientPlayerEvent;
import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
-@Environment(EnvType.CLIENT)
public class JamLibClient {
+ @ApiStatus.Internal
+ public static void init() {
+ ClientPlayerEvent.CLIENT_PLAYER_JOIN.register(JamLibClient::onPlayerJoin);
+ }
- public static void onPlayerJoin(LocalPlayer player) {
+ private static void onPlayerJoin(LocalPlayer player) {
if (player != Minecraft.getInstance().player) {
return;
}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/ConfigScreen.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/ConfigScreen.java
new file mode 100644
index 0000000..4c4c621
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/ConfigScreen.java
@@ -0,0 +1,164 @@
+package io.github.jamalam360.jamlib.client.config.gui;
+
+import dev.architectury.platform.Platform;
+import io.github.jamalam360.jamlib.JamLib;
+import io.github.jamalam360.jamlib.client.config.gui.entry.ConfigEntry;
+import io.github.jamalam360.jamlib.client.gui.WidgetList;
+import io.github.jamalam360.jamlib.config.ConfigExtensions;
+import io.github.jamalam360.jamlib.config.ConfigManager;
+import io.github.jamalam360.jamlib.config.HiddenInGui;
+import net.minecraft.Util;
+import net.minecraft.client.Minecraft;
+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.SpriteIconButton;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.resources.language.I18n;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A screen for editing a config managed through a {@link ConfigManager}.
+ */
+@ApiStatus.Internal
+public class ConfigScreen extends Screen {
+
+ protected final ConfigManager manager;
+ private final Screen parent;
+ private final List> entries;
+ private WidgetList widgetList;
+ private Button doneButton;
+
+ public ConfigScreen(ConfigManager manager, Screen parent) {
+ super(createTitle(manager));
+ this.manager = manager;
+ this.parent = parent;
+ this.entries = new ArrayList<>();
+ }
+
+ @ApiStatus.Internal
+ public static String createTranslationKey(String modId, String configName, String path) {
+ if (modId.equals(configName)) {
+ return "config." + modId + "." + path;
+ } else {
+ return "config." + modId + "." + configName + "." + path;
+ }
+ }
+
+ protected static Component createTitle(ConfigManager> manager) {
+ String translationKey = createTranslationKey(manager.getModId(), manager.getConfigName(), "title");
+
+ if (I18n.exists(translationKey)) {
+ return Component.translatable(translationKey);
+ } else {
+ return Component.literal(Platform.getMod(manager.getModId()).getName());
+ }
+ }
+
+ @Override
+ protected void init() {
+ super.init();
+
+ this.addRenderableWidget(Button.builder(CommonComponents.GUI_CANCEL, button -> {
+ this.manager.reloadFromDisk();
+ Objects.requireNonNull(this.minecraft).setScreen(this.parent);
+ }).pos(this.width / 2 - 154, this.height - 28).size(150, 20).build());
+
+ this.doneButton = this.addRenderableWidget(Button.builder(CommonComponents.GUI_DONE, button -> {
+ if (this.hasChanges()) {
+ this.manager.save();
+ }
+
+ Objects.requireNonNull(this.minecraft).setScreen(this.parent);
+ }).pos(this.width / 2 + 4, this.height - 28).size(150, 20).build());
+
+ SpriteIconButton editManuallyButton = this.addRenderableWidget(
+ SpriteIconButton.builder(Component.translatable("config.jamlib.edit_manually"), button -> {
+ if (this.hasChanges()) {
+ this.manager.save();
+ }
+
+ Util.getPlatform().openFile(Platform.getConfigFolder().resolve(this.manager.getConfigName() + ".json5").toFile());
+ Objects.requireNonNull(this.minecraft).setScreen(this.parent);
+ }, true).sprite(JamLib.id("writable_book"), 16, 16).size(20, 20).build()
+ );
+ editManuallyButton.setX(7);
+ editManuallyButton.setY(7);
+ this.widgetList = new WidgetList(this.minecraft, this.width, this.height - 64, 32);
+
+ if (this.entries.isEmpty()) {
+ for (Field field : this.manager.getConfigClass().getFields()) {
+ if (field.isAnnotationPresent(HiddenInGui.class)) {
+ continue;
+ }
+
+ this.entries.add(ConfigEntry.createFromField(this.manager.getModId(), this.manager.getConfigName(), field));
+ }
+ }
+
+ for (ConfigEntry entry : this.entries) {
+ this.widgetList.addWidgetGroup(entry.createWidgets(this.width));
+ }
+
+ this.addRenderableWidget(this.widgetList);
+
+ if (this.manager.get() instanceof ConfigExtensions> ext) {
+ List links = ext.getLinks();
+
+ for (int i = 0; i < links.size(); i++) {
+ ConfigExtensions.Link link = links.get(i);
+ SpriteIconButton linkButton = this.addRenderableWidget(
+ SpriteIconButton.builder(link.getTooltip(), button -> {
+ try {
+ Util.getPlatform().openUri(link.getUrl().toURI());
+ } catch (Exception e) {
+ JamLib.LOGGER.error("Failed to open link", e);
+ }
+ }, true).sprite(link.getTexture(), 16, 16).size(20, 20).build()
+
+ );
+ linkButton.setX(this.width - 30 - (28 * i));
+ linkButton.setY(5);
+ }
+ }
+ }
+
+ @Override
+ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
+ super.render(graphics, mouseX, mouseY, delta);
+ graphics.drawCenteredString(Minecraft.getInstance().font, this.title, this.width / 2, 12, 0xFFFFFF);
+ }
+
+ private boolean canExit() {
+ return this.entries.stream().allMatch(ConfigEntry::isValid);
+ }
+
+ private boolean hasChanges() {
+ return this.entries.stream().anyMatch(ConfigEntry::hasChanged);
+ }
+
+ @Override
+ public void tick() {
+ super.tick();
+ boolean canExit = this.canExit();
+
+ if (this.doneButton.active != canExit) {
+ this.doneButton.active = canExit;
+ }
+
+ for (int i = 0; i < this.entries.size(); i++) {
+ ConfigEntry entry = this.entries.get(i);
+ List widgets = entry.getNewWidgets(this.width);
+ if (widgets != null) {
+ this.widgetList.updateWidgetGroup(i, widgets);
+ }
+ }
+ }
+}
diff --git a/common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectConfigScreen.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectConfigScreen.java
similarity index 95%
rename from common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectConfigScreen.java
rename to common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectConfigScreen.java
index 79d7cdd..f265795 100644
--- a/common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectConfigScreen.java
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectConfigScreen.java
@@ -1,4 +1,4 @@
-package io.github.jamalam360.jamlib.config.gui;
+package io.github.jamalam360.jamlib.client.config.gui;
import dev.architectury.platform.Platform;
import io.github.jamalam360.jamlib.config.ConfigManager;
@@ -16,17 +16,16 @@
@ApiStatus.Internal
public class SelectConfigScreen extends Screen {
-
private final String modId;
private final Screen parent;
public SelectConfigScreen(Screen parent, String modId) {
- super(getTitleComponent(modId));
+ super(createTitle(modId));
this.parent = parent;
this.modId = modId;
}
- private static Component getTitleComponent(String modId) {
+ private static Component createTitle(String modId) {
String translationKey = "config." + modId + ".title";
if (I18n.exists(translationKey)) {
@@ -54,7 +53,6 @@ public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) {
}
private static class ConfigSelectionList extends SelectionList {
-
public ConfigSelectionList(Minecraft minecraft, int width, int height, int y, int itemHeight) {
super(minecraft, width, height, y, itemHeight);
}
diff --git a/common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectionList.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectionList.java
similarity index 97%
rename from common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectionList.java
rename to common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectionList.java
index 866fb92..270f962 100644
--- a/common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectionList.java
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectionList.java
@@ -1,4 +1,4 @@
-package io.github.jamalam360.jamlib.config.gui;
+package io.github.jamalam360.jamlib.client.config.gui;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
@@ -10,7 +10,6 @@
@ApiStatus.Internal
public class SelectionList extends ContainerObjectSelectionList {
-
public SelectionList(Minecraft minecraft, int width, int height, int y, int itemHeight) {
super(minecraft, width, height, y, itemHeight);
this.centerListVertically = false;
diff --git a/common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectionListEntry.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectionListEntry.java
similarity index 98%
rename from common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectionListEntry.java
rename to common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectionListEntry.java
index cccf1d7..3016b20 100644
--- a/common/src/main/java/io/github/jamalam360/jamlib/config/gui/SelectionListEntry.java
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/SelectionListEntry.java
@@ -1,4 +1,4 @@
-package io.github.jamalam360.jamlib.config.gui;
+package io.github.jamalam360.jamlib.client.config.gui;
import java.util.List;
@@ -17,7 +17,6 @@
@ApiStatus.Internal
public class SelectionListEntry extends ContainerObjectSelectionList.Entry {
-
private final Component title;
private final List tooltip;
private final List widgets;
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/BooleanConfigEntry.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/BooleanConfigEntry.java
new file mode 100644
index 0000000..2d5ed8e
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/BooleanConfigEntry.java
@@ -0,0 +1,39 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class BooleanConfigEntry extends ConfigEntry {
+ @Nullable
+ private Button button = null;
+
+ public BooleanConfigEntry(String modId, String configName, ConfigField field) {
+ super(modId, configName, field);
+ }
+
+ @Override
+ public List createElementWidgets(int left, int width) {
+ this.button = Button.builder(this.getComponent(Boolean.TRUE.equals(this.getFieldValue())), button -> this.setFieldValue(!(Boolean.TRUE.equals(this.getFieldValue())))).pos(left, 0).size(width, 20).build();
+
+ return List.of(this.button);
+ }
+
+ @Override
+ public void onChange() {
+ super.onChange();
+
+ if (this.button != null) {
+ this.button.setMessage(getComponent(Boolean.TRUE.equals(this.getFieldValue())));
+ }
+ }
+
+ private Component getComponent(boolean value) {
+ return Component.literal(value ? "Yes" : "No").withStyle(s -> s.withColor(value ? ChatFormatting.GREEN : ChatFormatting.RED));
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ConfigEntry.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ConfigEntry.java
new file mode 100644
index 0000000..2073f64
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ConfigEntry.java
@@ -0,0 +1,200 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import com.google.gson.Gson;
+import io.github.jamalam360.jamlib.JamLib;
+import io.github.jamalam360.jamlib.client.config.gui.ConfigScreen;
+import io.github.jamalam360.jamlib.client.gui.ScrollingStringWidget;
+import io.github.jamalam360.jamlib.client.mixinsupport.MutableSpriteImageWidget$Sprite;
+import io.github.jamalam360.jamlib.config.ConfigExtensions;
+import io.github.jamalam360.jamlib.config.ConfigManager;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.ImageWidget;
+import net.minecraft.client.gui.components.SpriteIconButton;
+import net.minecraft.client.gui.components.Tooltip;
+import net.minecraft.client.resources.language.I18n;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.Nullable;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+public abstract class ConfigEntry {
+ private static final Gson GSON = new Gson();
+ protected final ConfigField field;
+ protected final ConfigManager configManager;
+ protected final V originalValue;
+ private final String translationKey;
+ @Nullable
+ private final Component tooltip;
+ protected ImageWidget validationIcon;
+ @Nullable
+ protected List errors;
+ private boolean recreateWidgetsNextTick = false;
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public static ConfigEntry createFromField(String modId, String configName, Field field) {
+ Class> c = field.getType();
+
+ if (c == boolean.class) {
+ return new BooleanConfigEntry<>(modId, configName, new FieldConfigField(field));
+ } else if (c == float.class || c == double.class || c == int.class || c == long.class) {
+ return new NumberConfigEntry<>(modId, configName, new FieldConfigField(field));
+ } else if (c == String.class) {
+ return new StringConfigEntry<>(modId, configName, new FieldConfigField(field));
+ } else if (c.isEnum()) {
+ return new EnumConfigEntry<>(modId, configName, new FieldConfigField(field));
+ } else if (Collection.class.isAssignableFrom(c)) {
+ return new ListConfigEntry<>(modId, configName, new FieldConfigField(field));
+ } else {
+ throw new IllegalArgumentException("Unsupported config field type " + c);
+ }
+ }
+
+ public ConfigEntry(String modId, String configName, ConfigField field) {
+ this.field = field;
+ //noinspection unchecked
+ this.configManager = (ConfigManager) ConfigManager.MANAGERS.get(new ConfigManager.Key(modId, configName));
+ this.originalValue = this.cloneObject(this.getFieldValue());
+ this.translationKey = ConfigScreen.createTranslationKey(modId, configName, field.getName());
+
+ if (I18n.exists(this.translationKey + ".tooltip")) {
+ this.tooltip = Component.translatable(this.translationKey + ".tooltip");
+ } else {
+ this.tooltip = null;
+ }
+ }
+
+ public List createWidgets(int width) {
+ List widgets = new ArrayList<>();
+
+ ScrollingStringWidget title = new ScrollingStringWidget(12, Minecraft.getInstance().font.lineHeight / 2 + 1, width / 2 - 10, Minecraft.getInstance().font.lineHeight, Component.translatable(this.translationKey), Minecraft.getInstance().font);
+
+ if (this.tooltip != null) {
+ title.setTooltip(Tooltip.create(this.tooltip));
+ }
+
+ widgets.add(title);
+
+ this.validationIcon = ImageWidget.sprite(20, 20, JamLib.id("validation_warning"));
+ this.validationIcon.setX(width - 212);
+ this.validationIcon.setY(0);
+ this.validationIcon.setTooltip(Tooltip.create(Component.translatable("config.jamlib.requires_restart_tooltip")));
+ this.validationIcon.visible = false;
+ widgets.add(this.validationIcon);
+
+ widgets.addAll(this.createElementWidgets(width - 188, 150));
+
+ SpriteIconButton resetButton = SpriteIconButton.builder(Component.translatable("config.jamlib.reset"), (button) -> this.setFieldValue(this.getDefaultValue()), true).sprite(JamLib.id("reset"), 16, 16).size(20, 20).build();
+ resetButton.setX(width - 30);
+ resetButton.setY(0);
+ resetButton.setTooltip(Tooltip.create(Component.translatable("config.jamlib.reset_tooltip")));
+ widgets.add(resetButton);
+
+ return widgets;
+ }
+
+ public abstract List createElementWidgets(int left, int width);
+
+ public void onChange() {
+ this.validate();
+ }
+
+ protected void validate() {
+ V newValue = this.getFieldValue();
+
+ if (this.configManager.get() instanceof ConfigExtensions>) {
+ @SuppressWarnings("unchecked") ConfigExtensions ext = (ConfigExtensions) this.configManager.get();
+ this.errors = ext.getValidationErrors(this.configManager, new ConfigExtensions.FieldValidationInfo(this.field.getName(), newValue, this.originalValue, this.field.getBackingField()));
+ this.errors.sort((o1, o2) -> o2.type().ordinal() - o1.type().ordinal());
+ this.updateValidationIcon();
+ }
+ }
+
+ protected void updateValidationIcon() {
+ if (this.validationIcon != null) {
+ if (this.isValid()) {
+ this.validationIcon.visible = false;
+ } else {
+ this.validationIcon.visible = true;
+ ((MutableSpriteImageWidget$Sprite) this.validationIcon).setSprite(this.errors.getFirst().type().getTexture());
+ this.validationIcon.setTooltip(Tooltip.create(this.errors.getFirst().message()));
+ }
+ }
+ }
+
+ @Nullable
+ public List getNewWidgets(int width) {
+ if (this.recreateWidgetsNextTick) {
+ this.recreateWidgetsNextTick = false;
+ return this.createWidgets(width);
+ } else {
+ return null;
+ }
+ }
+
+ public void recreateWidgetsNextTick() {
+ this.recreateWidgetsNextTick = true;
+ }
+
+ public boolean hasChanged() {
+ return this.getFieldValue().equals(this.originalValue);
+ }
+
+ public boolean isValid() {
+ return this.errors == null || this.errors.stream().noneMatch(e -> e.type() == ConfigExtensions.ValidationError.Type.ERROR);
+ }
+
+ public Component getName() {
+ return Component.translatable(this.translationKey);
+ }
+
+ protected V getFieldValue() {
+ return this.field.getValue(this.configManager);
+ }
+
+ protected void setFieldValue(V v) {
+ Object realValue = v;
+
+ if (v instanceof Number n) {
+ Class> c = this.field.getElementType();
+
+ if (c == double.class || c == Double.class) {
+ realValue = n.doubleValue();
+ } else if (c == float.class || c == Float.class) {
+ realValue = n.floatValue();
+ } else if (c == int.class || c == Integer.class) {
+ realValue = n.intValue();
+ } else if (c == long.class || c == Long.class) {
+ realValue = n.longValue();
+ }
+ }
+
+ //noinspection unchecked
+ this.field.setValue(this.configManager, (V) realValue);
+ this.onChange();
+ }
+
+ private V getDefaultValue() {
+ try {
+ T defaultConfig = this.configManager.getConfigClass().getConstructor().newInstance();
+ //noinspection unchecked
+ return (V) this.field.getBackingField().get(defaultConfig);
+ } catch (InstantiationException | IllegalAccessException | InvocationTargetException |
+ NoSuchMethodException e) {
+ throw new RuntimeException("Failed to get default config for config " + this.configManager.getConfigClass(), e);
+ }
+ }
+
+ private V cloneObject(V object) {
+ if (object == null) {
+ return null;
+ }
+
+ //noinspection unchecked
+ return (V) GSON.fromJson(GSON.toJson(object), object.getClass());
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ConfigField.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ConfigField.java
new file mode 100644
index 0000000..314d560
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ConfigField.java
@@ -0,0 +1,16 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import io.github.jamalam360.jamlib.config.ConfigManager;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+
+public interface ConfigField {
+ V getValue(ConfigManager manager);
+ void setValue(ConfigManager manager, V value);
+ boolean isAnnotationPresent(Class extends Annotation> annotationClass);
+ A getAnnotation(Class annotationClass);
+ Class getElementType();
+ String getName();
+ Field getBackingField();
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/EnumButton.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/EnumButton.java
new file mode 100644
index 0000000..a78f61c
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/EnumButton.java
@@ -0,0 +1,36 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.MutableComponent;
+
+import java.util.function.Consumer;
+
+public class EnumButton> extends Button {
+ private final Class enumClass;
+ private final Consumer> onChange;
+ private int index;
+
+ @SuppressWarnings("unchecked")
+ protected EnumButton(int x, int y, int width, int height, MutableComponent description, Class> enumClass, Consumer> onChange) {
+ super(x, y, width, height, CommonComponents.EMPTY, b -> {
+ ((EnumButton) b).setIndex((((EnumButton) b).index + 1) % ((EnumButton) b).enumClass.getEnumConstants().length);
+ ((EnumButton) b).onChange.accept(((EnumButton) b));
+ }, s -> description);
+ this.enumClass = (Class) enumClass;
+ this.onChange = onChange;
+ this.index = 0;
+ }
+
+ protected E getValue() {
+ return this.enumClass.getEnumConstants()[this.index];
+ }
+
+ protected void setValue(E value) {
+ this.setIndex(value.ordinal());
+ }
+
+ protected void setIndex(int index) {
+ this.index = index;
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/EnumConfigEntry.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/EnumConfigEntry.java
new file mode 100644
index 0000000..8ad7cdd
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/EnumConfigEntry.java
@@ -0,0 +1,56 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import io.github.jamalam360.jamlib.client.config.gui.ConfigScreen;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.resources.language.I18n;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class EnumConfigEntry> extends ConfigEntry {
+ @Nullable
+ private EnumButton button = null;
+
+ public EnumConfigEntry(String modId, String configName, ConfigField field) {
+ super(modId, configName, field);
+ }
+
+ @Override
+ public List createElementWidgets(int left, int width) {
+ //noinspection unchecked
+ this.button = new EnumButton<>(
+ left,
+ 0,
+ width,
+ 20,
+ CommonComponents.EMPTY.copy(),
+ (Class>) this.field.getElementType(),
+ (b) -> this.setFieldValue(b.getValue())
+ );
+ this.button.setValue(this.getFieldValue());
+ this.button.setMessage(this.getComponent());
+
+ return List.of(this.button);
+ }
+
+ @Override
+ public void onChange() {
+ super.onChange();
+
+ if (this.button != null) {
+ this.button.setMessage(this.getComponent());
+ }
+ }
+
+ private Component getComponent() {
+ String translationKey = ConfigScreen.createTranslationKey(this.configManager.getModId(), this.configManager.getConfigName(), field.getName() + "." + this.getFieldValue().name().toLowerCase());
+
+ if (I18n.exists(translationKey)) {
+ return Component.translatable(translationKey);
+ } else {
+ return Component.literal(this.getFieldValue().name());
+ }
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/FieldConfigField.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/FieldConfigField.java
new file mode 100644
index 0000000..2930e55
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/FieldConfigField.java
@@ -0,0 +1,61 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import io.github.jamalam360.jamlib.JamLib;
+import io.github.jamalam360.jamlib.config.ConfigManager;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+
+public class FieldConfigField implements ConfigField {
+ private final Field field;
+
+ public FieldConfigField(Field field) {
+ this.field = field;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public V getValue(ConfigManager manager) {
+ try {
+ return (V) this.field.get(manager.get());
+ } catch (IllegalAccessException e) {
+ JamLib.LOGGER.error("Failed to access field for config {}", manager.getConfigName(), e);
+ return null;
+ }
+ }
+
+ @Override
+ public void setValue(ConfigManager manager, V value) {
+ try {
+ this.field.set(manager.get(), value);
+ } catch (IllegalAccessException e) {
+ JamLib.LOGGER.error("Failed to access field for config {}", manager.getConfigName(), e);
+ }
+ }
+
+ @Override
+ public boolean isAnnotationPresent(Class extends Annotation> annotationClass) {
+ return this.field.isAnnotationPresent(annotationClass);
+ }
+
+ @Override
+ public T1 getAnnotation(Class annotationClass) {
+ return this.field.getAnnotation(annotationClass);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public Class getElementType() {
+ return (Class) this.field.getType();
+ }
+
+ @Override
+ public String getName() {
+ return this.field.getName();
+ }
+
+ @Override
+ public Field getBackingField() {
+ return this.field;
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ListConfigEntry.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ListConfigEntry.java
new file mode 100644
index 0000000..ffdf2de
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ListConfigEntry.java
@@ -0,0 +1,124 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import io.github.jamalam360.jamlib.JamLib;
+import io.github.jamalam360.jamlib.client.gui.WidgetList;
+import io.github.jamalam360.jamlib.config.ConfigExtensions;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.ImageWidget;
+import net.minecraft.client.gui.components.Tooltip;
+import net.minecraft.network.chat.Component;
+
+import java.lang.reflect.ParameterizedType;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.function.Predicate;
+
+public class ListConfigEntry extends ConfigEntry> {
+ private List> listMembers;
+
+ public ListConfigEntry(String modId, String configName, ConfigField> field) {
+ super(modId, configName, field);
+ }
+
+ @Override
+ public List createElementWidgets(int left, int width) {
+ this.createListMembers();
+ List widgets = new ArrayList<>();
+ int currentY = 0;
+ int bottom = 0;
+ int childWidth = width - 20 - WidgetList.PADDING;
+
+ for (int i = 0; i < this.listMembers.size(); i++) {
+ ConfigEntry entry = this.listMembers.get(i);
+ List entryWidgets = entry.createElementWidgets(left, childWidth);
+
+ for (AbstractWidget widget : entryWidgets) {
+ bottom = Math.max(widget.getBottom(), bottom);
+ widget.setY(widget.getY() + currentY);
+ }
+
+ int finalI = i;
+ widgets.add(Button.builder(Component.literal("-"), button -> {
+ this.getFieldValue().remove(finalI);
+ this.recreateWidgetsNextTick();
+ this.onChange();
+ }).size(20, 20).pos(left + childWidth + WidgetList.PADDING, currentY).build());
+ widgets.addAll(entryWidgets);
+ currentY += bottom + WidgetList.PADDING;
+ }
+
+ widgets.add(Button.builder(Component.literal("+"), button -> {
+ //noinspection unchecked
+ this.getFieldValue().add((E) this.getDefaultNewValue());
+ this.recreateWidgetsNextTick();
+ this.onChange();
+ }).size(width, 20).pos(left, currentY).build());
+ this.updateValidationIcon();
+ return widgets;
+ }
+
+ @SuppressWarnings("unchecked")
+ private void createListMembers() {
+ this.listMembers = new ArrayList<>();
+ List list = this.getFieldValue();
+ Class elementType = (Class) ((ParameterizedType) this.field.getBackingField().getGenericType()).getActualTypeArguments()[0];
+ for (int i = 0; i < list.size(); i++) {
+ if (elementType == boolean.class) {
+ this.listMembers.add((ConfigEntry) new BooleanConfigEntry<>(this.configManager.getModId(), this.configManager.getConfigName(), (ConfigField) new ListMemberConfigField<>(this.field.getBackingField(), elementType, i)));
+ } else if (elementType == float.class || elementType == double.class || elementType == int.class || elementType == long.class || elementType == Float.class || elementType == Double.class || elementType == Integer.class || elementType == Long.class) {
+ this.listMembers.add((ConfigEntry) new NumberConfigEntry<>(this.configManager.getModId(), this.configManager.getConfigName(), (ConfigField) new ListMemberConfigField<>(this.field.getBackingField(), elementType, i)));
+ } else if (elementType == String.class) {
+ this.listMembers.add((ConfigEntry) new StringConfigEntry<>(this.configManager.getModId(), this.configManager.getConfigName(), (ConfigField) new ListMemberConfigField<>(this.field.getBackingField(), elementType, i)));
+ } else if (elementType.isEnum()) {
+ this.listMembers.add((ConfigEntry) new EnumConfigEntry<>(this.configManager.getModId(), this.configManager.getConfigName(), (ConfigField) new ListMemberConfigField<>(this.field.getBackingField(), elementType, i)));
+ } else if (Collection.class.isAssignableFrom(elementType)) {
+ throw new IllegalArgumentException("Cannot nest collections in config");
+ } else {
+ throw new IllegalArgumentException("Unsupported config field type " + elementType);
+ }
+ }
+ }
+
+ @Override
+ protected void validate() {
+ super.validate();
+ if (this.configManager.get() instanceof ConfigExtensions>) {
+ @SuppressWarnings("unchecked") ConfigExtensions ext = (ConfigExtensions) this.configManager.get();
+
+ List elementErrors = new ArrayList<>();
+
+ for (ConfigEntry entry : this.listMembers) {
+ this.errors.addAll(ext.getValidationErrors(this.configManager, new ConfigExtensions.FieldValidationInfo(entry.field.getName(), entry.getFieldValue(), entry.originalValue, entry.field.getBackingField())));
+ }
+
+ this.errors.sort((o1, o2) -> o2.type().ordinal() - o1.type().ordinal());
+ this.updateValidationIcon();
+ }
+ }
+
+ @Override
+ public boolean isValid() {
+ return super.isValid() && this.listMembers.stream().allMatch(ConfigEntry::isValid);
+ }
+
+ @SuppressWarnings("unchecked")
+ private Object getDefaultNewValue() {
+ Class c = (Class) ((ParameterizedType) this.field.getBackingField().getGenericType()).getActualTypeArguments()[0];
+
+ if (c == boolean.class) {
+ return false;
+ } else if (c == float.class || c == double.class || c == int.class || c == long.class || c == Float.class || c == Double.class || c == Integer.class || c == Long.class) {
+ return 0;
+ } else if (c == String.class) {
+ return "";
+ } else if (c.isEnum()) {
+ return c.getEnumConstants()[0];
+ } else if (Collection.class.isAssignableFrom(c)) {
+ throw new IllegalArgumentException("Cannot nest collections in config");
+ } else {
+ throw new IllegalArgumentException("Unsupported config field type " + c);
+ }
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ListMemberConfigField.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ListMemberConfigField.java
new file mode 100644
index 0000000..2db249e
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/ListMemberConfigField.java
@@ -0,0 +1,68 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import io.github.jamalam360.jamlib.JamLib;
+import io.github.jamalam360.jamlib.config.ConfigManager;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.util.List;
+
+public class ListMemberConfigField implements ConfigField {
+ private final Field listField;
+ private final Class elementClass;
+ private final int index;
+
+ public ListMemberConfigField(Field listField, Class elementClass, int index) {
+ this.listField = listField;
+ this.elementClass = elementClass;
+ this.index = index;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public V getValue(ConfigManager manager) {
+ try {
+ List list = (List) this.listField.get(manager.get());
+ return this.index >= 0 && this.index < list.size() ? list.get(this.index) : null;
+ } catch (IllegalAccessException e) {
+ JamLib.LOGGER.error("Failed to access field for config {}", manager.getConfigName(), e);
+ return null;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public void setValue(ConfigManager manager, V value) {
+ try {
+ List list = (List) this.listField.get(manager.get());
+ list.set(this.index, value);
+ } catch (IllegalAccessException e) {
+ JamLib.LOGGER.error("Failed to access field for config {}", manager.getConfigName(), e);
+ }
+ }
+
+ @Override
+ public boolean isAnnotationPresent(Class extends Annotation> annotationClass) {
+ return this.listField.isAnnotationPresent(annotationClass);
+ }
+
+ @Override
+ public T1 getAnnotation(Class annotationClass) {
+ return this.listField.getAnnotation(annotationClass);
+ }
+
+ @Override
+ public Class getElementType() {
+ return this.elementClass;
+ }
+
+ @Override
+ public String getName() {
+ return this.listField.getName() + "." + this.index;
+ }
+
+ @Override
+ public Field getBackingField() {
+ return this.listField;
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/NumberConfigEntry.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/NumberConfigEntry.java
new file mode 100644
index 0000000..94978d1
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/NumberConfigEntry.java
@@ -0,0 +1,97 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import io.github.jamalam360.jamlib.config.Slider;
+import io.github.jamalam360.jamlib.config.WithinRange;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.Nullable;
+
+import java.text.DecimalFormat;
+import java.util.List;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+
+public class NumberConfigEntry extends ConfigEntry {
+ private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#.##");
+ private final Function parser;
+ private final Pattern regex;
+ @Nullable
+ private EditBox editBox = null;
+
+ public NumberConfigEntry(String modId, String configName, ConfigField field) {
+ super(modId, configName, field);
+
+ Class> c = field.getElementType();
+
+ if (c == float.class || c == Float.class) {
+ this.parser = Float::parseFloat;
+ this.regex = Pattern.compile("^-?\\d*\\.?\\d*$");
+ } else if (c == double.class || c == Double.class) {
+ this.parser = Double::parseDouble;
+ this.regex = Pattern.compile("^-?\\d*\\.?\\d*$");
+ } else if (c == int.class || c == Integer.class) {
+ this.parser = Integer::parseInt;
+ this.regex = Pattern.compile("^-?\\d*$");
+ } else if (c == long.class || c == Long.class) {
+ this.parser = Long::parseLong;
+ this.regex = Pattern.compile("^-?\\d*$");
+ } else {
+ throw new IllegalArgumentException("Unsupported class for NumberConfigEntry " + c);
+ }
+ }
+
+ @Override
+ public List createElementWidgets(int left, int width) {
+ Number current = this.getFieldValue();
+
+ if (this.field.isAnnotationPresent(Slider.class)) {
+ WithinRange range = this.field.getAnnotation(WithinRange.class);
+
+ if (current == null) {
+ current = range.min();
+ }
+
+ SliderButton slider = new SliderButton(
+ left,
+ 0,
+ width,
+ 20,
+ this.getComponent(current),
+ range.min(),
+ range.max(),
+ current.doubleValue(),
+ value -> {
+ //noinspection unchecked
+ this.setFieldValue((V) value);
+ return this.getComponent(value);
+ }
+ );
+ return List.of(slider);
+ } else {
+ this.editBox = new EditBox(
+ Minecraft.getInstance().font,
+ left,
+ 0,
+ width,
+ 20,
+ CommonComponents.EMPTY
+ );
+ this.editBox.setValue(DECIMAL_FORMAT.format(current.doubleValue()));
+ this.editBox.setFilter(s -> this.regex.matcher(s).matches());
+ this.editBox.setResponder(s -> {
+ if (!s.isEmpty()) {
+ //noinspection unchecked
+ this.setFieldValue((V) this.parser.apply(s));
+ }
+ });
+ return List.of(this.editBox);
+ }
+ }
+
+ private Component getComponent(Number value) {
+ return Component.literal(DECIMAL_FORMAT.format(value.doubleValue()));
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/SliderButton.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/SliderButton.java
new file mode 100644
index 0000000..cb46c35
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/SliderButton.java
@@ -0,0 +1,34 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import net.minecraft.client.gui.components.AbstractSliderButton;
+import net.minecraft.network.chat.Component;
+import net.minecraft.util.Mth;
+
+import java.util.function.Function;
+
+public class SliderButton extends AbstractSliderButton {
+ private final double min;
+ private final double max;
+ private final Function onChange;
+
+ protected SliderButton(int x, int y, int width, int height, Component message, double min, double max, double value, Function onChange) {
+ super(x, y, width, height, message, value);
+ this.min = min;
+ this.max = max;
+ this.onChange = onChange;
+ this.value = ((Mth.clamp((float) value, this.min, this.max) - this.min) / (this.max - this.min));
+ }
+
+ public void setValue(double value) {
+ this.value = ((Mth.clamp((float) value, this.min, this.max) - this.min) / (this.max - this.min));
+ }
+
+ @Override
+ protected void updateMessage() {
+ }
+
+ @Override
+ protected void applyValue() {
+ this.setMessage(this.onChange.apply(Mth.lerp(Mth.clamp(this.value, 0.0F, 1.0F), this.min, this.max)));
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/StringConfigEntry.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/StringConfigEntry.java
new file mode 100644
index 0000000..208c0d2
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/StringConfigEntry.java
@@ -0,0 +1,35 @@
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class StringConfigEntry extends ConfigEntry {
+ @Nullable
+ private EditBox editBox = null;
+
+ public StringConfigEntry(String modId, String configName, ConfigField field) {
+ super(modId, configName, field);
+ }
+
+ @Override
+ public List createElementWidgets(int left, int width) {
+ this.editBox = new EditBox(
+ Minecraft.getInstance().font,
+ left,
+ 0,
+ width,
+ 20,
+ CommonComponents.EMPTY
+ );
+ this.editBox.setValue(this.getFieldValue());
+ this.editBox.setResponder(this::setFieldValue);
+
+ return List.of(this.editBox);
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/package-info.java b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/package-info.java
new file mode 100644
index 0000000..2ba9aeb
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/config/gui/entry/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * All classes in this package are internal.
+ */
+@ApiStatus.Internal
+package io.github.jamalam360.jamlib.client.config.gui.entry;
+
+import org.jetbrains.annotations.ApiStatus;
\ No newline at end of file
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/gui/ScrollingStringWidget.java b/common/src/client/java/io/github/jamalam360/jamlib/client/gui/ScrollingStringWidget.java
new file mode 100644
index 0000000..f2626c6
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/gui/ScrollingStringWidget.java
@@ -0,0 +1,25 @@
+package io.github.jamalam360.jamlib.client.gui;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.components.StringWidget;
+import net.minecraft.network.chat.Component;
+
+/**
+ * A string widget that scrolls if the component is too long for the width
+ */
+public class ScrollingStringWidget extends StringWidget {
+ public ScrollingStringWidget(int x, int y, int width, int height, Component component, Font font) {
+ super(x, y, width, height, component, font);
+ }
+
+ @Override
+ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) {
+ this.renderScrollingString(guiGraphics, this.getFont(), 2, this.getColor());
+
+ if (this.isMouseOver(mouseX, mouseY) && this.getTooltip() != null) {
+ guiGraphics.renderTooltip(Minecraft.getInstance().font, this.getTooltip().toCharSequence(Minecraft.getInstance()), mouseX, mouseY);
+ }
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/gui/WidgetList.java b/common/src/client/java/io/github/jamalam360/jamlib/client/gui/WidgetList.java
new file mode 100644
index 0000000..30c0456
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/gui/WidgetList.java
@@ -0,0 +1,156 @@
+package io.github.jamalam360.jamlib.client.gui;
+
+import com.google.common.collect.ImmutableList;
+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.narration.NarratableEntry;
+import net.minecraft.util.Mth;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * A scrollable list of widget groups. Widget groups can have arbitrary heights.
+ */
+public class WidgetList extends ContainerObjectSelectionList {
+ public static final int PADDING = 4;
+
+ public WidgetList(Minecraft minecraft, int width, int height, int y) {
+ super(minecraft, width, height, y, 1);
+ this.centerListVertically = false;
+ this.headerHeight = PADDING;
+ }
+
+ public void addWidgetGroup(List widgets) {
+ this.addEntry(new Entry(widgets));
+ }
+
+ public void updateWidgetGroup(int index, List widgets) {
+ this.children().set(index, new Entry(widgets));
+ }
+
+ @Override
+ public int getRowWidth() {
+ return this.width;
+ }
+
+ @Nullable
+ public Entry getRealEntryAtPosition(double mouseX, double mouseY) {
+ int halfRowWidth = this.getRowWidth() / 2;
+ int centerX = this.getX() + this.width / 2;
+ int left = centerX - halfRowWidth;
+ int right = centerX + halfRowWidth;
+ int m = Mth.floor(mouseY - (double) this.getY()) - this.headerHeight + (int) this.getScrollAmount() - 4;
+
+ if (mouseX < left || mouseX > right || m < 0) {
+ return null;
+ }
+
+ int height = 0;
+
+ for (int idx = 0; idx < this.getItemCount(); idx++) {
+ Entry entry = this.getEntry(idx);
+ height += entry.getHeight() + PADDING;
+ if (m < height) {
+ return entry;
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void renderListItems(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
+ for (int itemIdx = 0; itemIdx < this.getItemCount(); ++itemIdx) {
+ int top = this.getRowTop(itemIdx);
+ int bottom = this.getRowBottom(itemIdx);
+ if (bottom >= this.getY() && top <= this.getBottom()) {
+ this.renderItem(graphics, mouseX, mouseY, partialTick, itemIdx, this.getRowLeft(), top, this.getRowWidth(), this.getEntry(itemIdx).getHeight());
+ }
+ }
+ }
+
+ @Override
+ protected int getMaxPosition() {
+ int itemsHeight = 0;
+
+ for (int i = 0; i < this.getItemCount(); i++) {
+ itemsHeight += this.getEntry(i).getHeight() + PADDING;
+ }
+
+ return itemsHeight + this.headerHeight;
+ }
+
+ @Override
+ public int getRowTop(int index) {
+ int itemsHeight = 0;
+
+ for (int i = 0; i < index; i++) {
+ itemsHeight += this.getEntry(i).getHeight() + PADDING;
+ }
+
+ return this.getY() - (int) this.getScrollAmount() + itemsHeight + this.headerHeight;
+ }
+
+ @Override
+ public int getRowBottom(int index) {
+ return this.getRowTop(index) + this.getEntry(index).getHeight();
+ }
+
+ @Override
+ public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
+ this.setScrollAmount(this.getScrollAmount() - scrollY * 10);
+ return true;
+ }
+
+ @Override
+ protected int getScrollbarPosition() {
+ return this.getX() + this.width - 6;
+ }
+
+ public static class Entry extends ContainerObjectSelectionList.Entry {
+ private final List children;
+ private final List childYs;
+
+ private Entry(List list) {
+ this.children = ImmutableList.copyOf(list);
+ this.childYs = this.children.stream().map(AbstractWidget::getY).toList();
+ }
+
+ @Override
+ public void render(GuiGraphics guiGraphics, int index, int top, int left, int width, int height, int mouseX, int mouseY, boolean hovering, float partialTick) {
+ for (int i = 0; i < this.children.size(); i++) {
+ AbstractWidget widget = this.children.get(i);
+ int relativeY = this.childYs.get(i);
+ widget.setY(top + relativeY);
+ widget.render(guiGraphics, mouseX, mouseY, partialTick);
+ }
+ }
+
+ public int getHeight() {
+ int maxY = 0;
+
+ for (int i = 0; i < this.children.size(); i++) {
+ AbstractWidget widget = this.children.get(i);
+ int relativeY = this.childYs.get(i);
+ maxY = Math.max(relativeY + widget.getHeight(), maxY);
+ }
+
+ return maxY;
+ }
+
+ @Override
+ public @NotNull List extends GuiEventListener> children() {
+ return this.children;
+ }
+
+ @Override
+ public @NotNull List extends NarratableEntry> narratables() {
+ return this.children;
+ }
+ }
+}
diff --git a/common/src/client/java/io/github/jamalam360/jamlib/client/mixin/AbstractSelectionListMixin.java b/common/src/client/java/io/github/jamalam360/jamlib/client/mixin/AbstractSelectionListMixin.java
new file mode 100644
index 0000000..2cfcf83
--- /dev/null
+++ b/common/src/client/java/io/github/jamalam360/jamlib/client/mixin/AbstractSelectionListMixin.java
@@ -0,0 +1,22 @@
+package io.github.jamalam360.jamlib.client.mixin;
+
+import io.github.jamalam360.jamlib.client.gui.WidgetList;
+import net.minecraft.client.gui.components.AbstractSelectionList;
+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.CallbackInfoReturnable;
+
+@Mixin(AbstractSelectionList.class)
+public class AbstractSelectionListMixin {
+ @Inject(
+ method = "getEntryAtPosition",
+ at = @At("HEAD"),
+ cancellable = true
+ )
+ private void jamlib$modifyGetEntryAtPositionForWidgetList(double mouseX, double mouseY, CallbackInfoReturnable