diff options
22 files changed, 928 insertions, 6 deletions
diff --git a/common/src/main/java/dev/isxander/yacl3/api/controller/DropdownStringControllerBuilder.java b/common/src/main/java/dev/isxander/yacl3/api/controller/DropdownStringControllerBuilder.java new file mode 100644 index 0000000..3f5fb33 --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/api/controller/ItemControllerBuilder.java b/common/src/main/java/dev/isxander/yacl3/api/controller/ItemControllerBuilder.java new file mode 100644 index 0000000..5a1f5fa --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java b/common/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java index acbf338..deff6d7 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java +++ b/common/src/main/java/dev/isxander/yacl3/config/GsonConfigInstance.java @@ -1,9 +1,12 @@ package dev.isxander.yacl3.config; import com.google.gson.*; +import dev.isxander.yacl3.gui.utils.ItemRegistryHelper; import dev.isxander.yacl3.impl.utils.YACLConstants; +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; @@ -66,6 +69,7 @@ public class GsonConfigInstance<T> extends ConfigInstance<T> { .registerTypeHierarchyAdapter(Component.class, new Component.Serializer()) .registerTypeHierarchyAdapter(Style.class, new Style.Serializer()) .registerTypeHierarchyAdapter(Color.class, new ColorTypeAdapter()) + .registerTypeHierarchyAdapter(Item.class, new ItemTypeAdapter()) .serializeNulls() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .create(); @@ -129,6 +133,17 @@ public class GsonConfigInstance<T> extends ConfigInstance<T> { 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. @@ -148,7 +163,8 @@ public class GsonConfigInstance<T> extends ConfigInstance<T> { .serializeNulls() .registerTypeHierarchyAdapter(Component.class, new Component.Serializer()) .registerTypeHierarchyAdapter(Style.class, new Style.Serializer()) - .registerTypeHierarchyAdapter(Color.class, new ColorTypeAdapter()); + .registerTypeHierarchyAdapter(Color.class, new ColorTypeAdapter()) + .registerTypeHierarchyAdapter(Item.class, new ItemTypeAdapter()); private Builder(Class<T> configClass) { this.configClass = configClass; diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Dropdown.java b/common/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/Dropdown.java new file mode 100644 index 0000000..44239d5 --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ItemField.java b/common/src/main/java/dev/isxander/yacl3/config/v2/api/autogen/ItemField.java new file mode 100644 index 0000000..84d2c7a --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DropdownImpl.java b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/DropdownImpl.java new file mode 100644 index 0000000..c487aab --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ItemFieldImpl.java b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/ItemFieldImpl.java new file mode 100644 index 0000000..2802f5c --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionFactoryRegistry.java b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionFactoryRegistry.java index 4bcf5d6..4f6e3c7 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionFactoryRegistry.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/autogen/OptionFactoryRegistry.java @@ -31,6 +31,8 @@ public class OptionFactoryRegistry { 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<>()); diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java index 1df9cfb..d308c23 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java @@ -1,12 +1,16 @@ package dev.isxander.yacl3.config.v2.impl.serializer; import com.google.gson.*; +import dev.isxander.yacl3.config.GsonConfigInstance; 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.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; +import net.minecraft.world.item.Item; import org.quiltmc.parsers.json.JsonReader; import org.quiltmc.parsers.json.JsonWriter; import org.quiltmc.parsers.json.gson.GsonReader; @@ -129,6 +133,18 @@ public class GsonConfigSerializer<T> extends ConfigSerializer<T> { } } + 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()); + } + } + public static class Builder<T> implements GsonConfigSerializerBuilder<T> { private final ConfigClassHandler<T> config; private Path path; @@ -139,6 +155,7 @@ public class GsonConfigSerializer<T> extends ConfigSerializer<T> { .registerTypeHierarchyAdapter(Component.class, new Component.Serializer()) .registerTypeHierarchyAdapter(Style.class, new Style.Serializer()) .registerTypeHierarchyAdapter(Color.class, new ColorTypeAdapter()) + .registerTypeHierarchyAdapter(Item.class, new ItemTypeAdapter()) .setPrettyPrinting(); public Builder(ConfigClassHandler<T> config) { diff --git a/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownController.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownController.java new file mode 100644 index 0000000..8251f9e --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownController.java @@ -0,0 +1,80 @@ +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) { + this(option, Collections.emptyList(), false, false); + } + + /** + * {@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(); + + return getAllowedValues(value).stream() + .filter(val -> val.toLowerCase().contains(value.toLowerCase())) + .sorted((s1, s2) -> { + if (s1.startsWith(value) && !s2.startsWith(value)) return -1; + if (!s1.startsWith(value) && s2.startsWith(value)) return 1; + return s1.compareTo(s2); + }) + .skip(offset) + .findFirst() + .orElseGet(this::getString); + } + +} diff --git a/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownControllerElement.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownControllerElement.java new file mode 100644 index 0000000..f91fc41 --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/AbstractDropdownControllerElement.java @@ -0,0 +1,238 @@ +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.size() == 0) 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.size() >= 1) { + // 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(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/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringController.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringController.java new file mode 100644 index 0000000..fafc759 --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringControllerElement.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/DropdownStringControllerElement.java new file mode 100644 index 0000000..615aada --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemController.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemController.java new file mode 100644 index 0000000..ac903c7 --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemControllerElement.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/dropdown/ItemControllerElement.java new file mode 100644 index 0000000..b0bf566 --- /dev/null +++ b/common/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 BuiltInRegistries.ITEM.get(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/common/src/main/java/dev/isxander/yacl3/gui/utils/ItemRegistryHelper.java b/common/src/main/java/dev/isxander/yacl3/gui/utils/ItemRegistryHelper.java new file mode 100644 index 0000000..5b52246 --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/gui/utils/ItemRegistryHelper.java @@ -0,0 +1,104 @@ +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 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.keySet().stream() + .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)); + boolean id1StartsWith = id1.getPath().startsWith(path); + boolean id2StartsWith = id2.getPath().startsWith(path); + if (id1StartsWith) { + if (id2StartsWith) { + return id1.compareTo(id2); + } + return -1; + } + if (id2StartsWith) { + return 1; + } + return id1.compareTo(id2); + }); + } +} diff --git a/common/src/main/java/dev/isxander/yacl3/impl/controller/DropdownStringControllerBuilderImpl.java b/common/src/main/java/dev/isxander/yacl3/impl/controller/DropdownStringControllerBuilderImpl.java new file mode 100644 index 0000000..b300a6a --- /dev/null +++ b/common/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/common/src/main/java/dev/isxander/yacl3/impl/controller/ItemControllerBuilderImpl.java b/common/src/main/java/dev/isxander/yacl3/impl/controller/ItemControllerBuilderImpl.java new file mode 100644 index 0000000..9a817fb --- /dev/null +++ b/common/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/common/src/main/resources/assets/yet_another_config_lib/lang/en_us.json b/common/src/main/resources/assets/yet_another_config_lib/lang/en_us.json index 32621e9..c04d29e 100644 --- a/common/src/main/resources/assets/yet_another_config_lib/lang/en_us.json +++ b/common/src/main/resources/assets/yet_another_config_lib/lang/en_us.json @@ -4,6 +4,8 @@ "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.", diff --git a/test-common/src/main/java/dev/isxander/yacl3/test/AutogenConfigTest.java b/test-common/src/main/java/dev/isxander/yacl3/test/AutogenConfigTest.java index 828aa73..a664d91 100644 --- a/test-common/src/main/java/dev/isxander/yacl3/test/AutogenConfigTest.java +++ b/test-common/src/main/java/dev/isxander/yacl3/test/AutogenConfigTest.java @@ -16,6 +16,8 @@ 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; @@ -30,7 +32,7 @@ public class AutogenConfigTest { .build(); @AutoGen(category = "test", group = "master_test") - @MasterTickBox({ "testTickBox", "testBoolean", "testInt", "testDouble", "testFloat", "testLong", "testIntField", "testDoubleField", "testFloatField", "testLongField", "testEnum", "testColor", "testString" }) + @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; @@ -90,6 +92,14 @@ public class AutogenConfigTest { @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"); diff --git a/test-common/src/main/java/dev/isxander/yacl3/test/ConfigTest.java b/test-common/src/main/java/dev/isxander/yacl3/test/ConfigTest.java index fc07120..a61a112 100644 --- a/test-common/src/main/java/dev/isxander/yacl3/test/ConfigTest.java +++ b/test-common/src/main/java/dev/isxander/yacl3/test/ConfigTest.java @@ -4,6 +4,8 @@ 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.world.item.Item; +import net.minecraft.world.item.Items; import java.awt.*; import java.util.List; @@ -43,6 +45,12 @@ public class ConfigTest { 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 List<String> stringList = List.of("This is quite cool.", "You can add multiple items!", "And it is integrated so well into Option groups!"); diff --git a/test-common/src/main/java/dev/isxander/yacl3/test/GuiTest.java b/test-common/src/main/java/dev/isxander/yacl3/test/GuiTest.java index 71b57f0..c8981d4 100644 --- a/test-common/src/main/java/dev/isxander/yacl3/test/GuiTest.java +++ b/test-common/src/main/java/dev/isxander/yacl3/test/GuiTest.java @@ -1,10 +1,7 @@ package dev.isxander.yacl3.test; import dev.isxander.yacl3.api.*; -import dev.isxander.yacl3.api.controller.BooleanControllerBuilder; -import dev.isxander.yacl3.api.controller.IntegerSliderControllerBuilder; -import dev.isxander.yacl3.api.controller.StringControllerBuilder; -import dev.isxander.yacl3.api.controller.TickBoxControllerBuilder; +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; @@ -26,6 +23,7 @@ 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 java.awt.Color; import java.nio.file.Path; @@ -237,6 +235,41 @@ public class GuiTest { .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()) + .build()) + .group(OptionGroup.createBuilder() .name(Component.literal("Options that aren't really options")) .option(ButtonOption.createBuilder() .name(Component.literal("Button \"Option\"")) |