diff --git a/common/src/main/java/dev/isxander/yacl3/api/MapOption.java b/common/src/main/java/dev/isxander/yacl3/api/MapOption.java new file mode 100644 index 00000000..04d0be80 --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/api/MapOption.java @@ -0,0 +1,164 @@ +package dev.isxander.yacl3.api; + +import com.google.common.collect.ImmutableList; +import dev.isxander.yacl3.api.controller.ControllerBuilder; +import dev.isxander.yacl3.impl.MapOptionImpl; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +public interface MapOption extends OptionGroup, Option> { + @Override + @NotNull ImmutableList> options(); + + @ApiStatus.Internal + int numberOfEntries(); + + @ApiStatus.Internal + int maximumNumberOfEntries(); + + @ApiStatus.Internal + int minimumNumberOfEntries(); + + @ApiStatus.Internal + MapOptionEntry insertNewEntry(); + + @ApiStatus.Internal + void insertEntry(int index, MapOptionEntry entry); + + @ApiStatus.Internal + int indexOf(MapOptionEntry entry); + + @ApiStatus.Internal + void removeEntry(MapOptionEntry entry); + + @ApiStatus.Internal + void addRefreshListener(Runnable changedListener); + + static Builder createBuilder() { + return new MapOptionImpl.BuilderImpl<>(); + } + + interface Builder { + /** + * Sets name of the list, for UX purposes, a name should always be given, + * but isn't enforced. + * + * @see ListOption#name() + */ + Builder name(@NotNull Component name); + + Builder description(@NotNull OptionDescription description); + + /** + * Sets the value that is used when creating new entries + */ + Builder initial(@NotNull Supplier> initialValue); + + /** + * Sets the value that is used when creating new entries + */ + Builder initial(@NotNull Map.Entry initialValue); + + Builder keyController(@NotNull Function, ControllerBuilder> controller); + + Builder valueController(@NotNull Function, ControllerBuilder> controller); + + /** + * Sets the controller for the option. + * This is how you interact and change the options. + * + * @see dev.isxander.yacl3.gui.controllers + */ + Builder customController(@NotNull Function, Controller> keyControl, @NotNull Function, Controller> valueControl); + + /** + * Sets the binding for the option. + * Used for default, getter and setter. + * + * @see Binding + */ + Builder binding(@NotNull Binding> binding); + + /** + * Sets the binding for the option. + * Shorthand of {@link Binding#generic(Object, Supplier, Consumer)} + * + * @param def default value of the option, used to reset + * @param getter should return the current value of the option + * @param setter should set the option to the supplied value + * @see Binding + */ + Builder binding(@NotNull Map def, @NotNull Supplier<@NotNull Map> getter, @NotNull Consumer<@NotNull Map> setter); + + /** + * Sets if the option can be configured + * + * @see Option#available() + */ + Builder available(boolean available); + + /** + * Sets a minimum size for the list. Once this size is reached, + * no further entries may be removed. + */ + Builder minimumNumberOfEntries(int number); + + /** + * Sets a maximum size for the list. Once this size is reached, + * no further entries may be added. + */ + Builder maximumNumberOfEntries(int number); + + /** + * Dictates if new entries should be added to the end of the list + * rather than the top. + */ + Builder insertEntriesAtEnd(boolean insertAtEnd); + + /** + * Adds a flag to the option. + * Upon applying changes, all flags are executed. + * {@link Option#flags()} + */ + Builder flag(@NotNull OptionFlag... flag); + + /** + * Adds a flag to the option. + * Upon applying changes, all flags are executed. + * {@link Option#flags()} + */ + Builder flags(@NotNull Collection flags); + + /** + * Dictates if the group should be collapsed by default. + * If not set, it will not be collapsed by default. + * + * @see OptionGroup#collapsed() + */ + Builder collapsed(boolean collapsible); + + /** + * Adds a listener to the option. Invoked upon changing any of the list's entries. + * + * @see Option#addListener(BiConsumer) + */ + Builder listener(@NotNull BiConsumer>, Map> listener); + + /** + * Adds multiple listeners to the option. Invoked upon changing of any of the list's entries. + * + * @see Option#addListener(BiConsumer) + */ + Builder listeners(@NotNull Collection>, Map>> listeners); + + MapOption build(); + } +} diff --git a/common/src/main/java/dev/isxander/yacl3/api/MapOptionEntry.java b/common/src/main/java/dev/isxander/yacl3/api/MapOptionEntry.java new file mode 100644 index 00000000..a9260248 --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/api/MapOptionEntry.java @@ -0,0 +1,18 @@ +package dev.isxander.yacl3.api; + +import com.google.common.collect.ImmutableSet; +import org.jetbrains.annotations.NotNull; + +public interface MapOptionEntry extends Option { + MapOption parentGroup(); + + @Override + default @NotNull ImmutableSet flags() { + return parentGroup().flags(); + } + + @Override + default boolean available() { + return parentGroup().available(); + } +} diff --git a/common/src/main/java/dev/isxander/yacl3/gui/OptionListWidget.java b/common/src/main/java/dev/isxander/yacl3/gui/OptionListWidget.java index 3197c442..a399e795 100644 --- a/common/src/main/java/dev/isxander/yacl3/gui/OptionListWidget.java +++ b/common/src/main/java/dev/isxander/yacl3/gui/OptionListWidget.java @@ -15,6 +15,7 @@ import net.minecraft.client.gui.narration.NarrationElementOutput; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; @@ -41,6 +42,8 @@ public OptionListWidget(YACLScreen screen, ConfigCategory category, Minecraft cl for (OptionGroup group : category.groups()) { if (group instanceof ListOption listOption) { listOption.addRefreshListener(() -> refreshListEntries(listOption, category)); + } else if (group instanceof MapOption mapOption) { + mapOption.addRefreshListener(() -> refreshMapEntries(mapOption, category)); } } } @@ -51,9 +54,14 @@ public void refreshOptions() { for (OptionGroup group : category.groups()) { GroupSeparatorEntry groupSeparatorEntry; if (!group.isRoot()) { - groupSeparatorEntry = group instanceof ListOption listOption - ? new ListGroupSeparatorEntry(listOption, yaclScreen) - : new GroupSeparatorEntry(group, yaclScreen); + if (group instanceof ListOption listOption) { + groupSeparatorEntry = new ListGroupSeparatorEntry(listOption, yaclScreen); + } else if (group instanceof MapOption mapOption) { + groupSeparatorEntry = new MapGroupSeparatorEntry(mapOption, yaclScreen); + } else { + groupSeparatorEntry = new GroupSeparatorEntry(group, yaclScreen); + } + addEntry(groupSeparatorEntry); } else { groupSeparatorEntry = null; @@ -116,6 +124,44 @@ private void refreshListEntries(ListOption listOption, ConfigCategory categor } } + private void refreshMapEntries(MapOption mapOption, ConfigCategory category) { + // Find group separator for group + OptionListWidget.MapGroupSeparatorEntry groupSeparator = super.children().stream().filter( + entry -> entry instanceof OptionListWidget.MapGroupSeparatorEntry mapGroupSeparatorEntry && mapGroupSeparatorEntry.group == mapOption).map( + OptionListWidget.MapGroupSeparatorEntry.class::cast).findAny().orElse(null); + + if (groupSeparator == null) { + YACLConstants.LOGGER.warn("Can't find group separator to refresh map option entries for map option " + mapOption.name()); + return; + } + + var groupSeparatorChildEntries = groupSeparator.childEntries; + for (OptionListWidget.Entry entry : groupSeparatorChildEntries) + super.removeEntry(entry); + groupSeparatorChildEntries.clear(); + + // If no entries, below loop won't run where addEntryBelow() reaches viewable children + if (mapOption.options().isEmpty()) { + OptionListWidget.EmptyMapLabel emptyMapLabel = new EmptyMapLabel(groupSeparator, category); + + addEntryBelow(groupSeparator, emptyMapLabel); + groupSeparatorChildEntries.add(emptyMapLabel); + + return; + } + + OptionListWidget.Entry lastEntry = groupSeparator; + for (MapOptionEntry mapOptionEntry : mapOption.options()) { + OptionListWidget.OptionEntry optionEntry = new OptionEntry(mapOptionEntry, category, mapOption, groupSeparator, + mapOptionEntry.controller().provideWidget(yaclScreen, getDefaultEntryDimension()) + ); + + addEntryBelow(lastEntry, optionEntry); + groupSeparatorChildEntries.add(optionEntry); + lastEntry = optionEntry; + } + } + public Dimension getDefaultEntryDimension() { return Dimension.ofInt(getRowLeft(), 0, getRowWidth(), 20); } @@ -533,6 +579,71 @@ public List children() { } } + public class MapGroupSeparatorEntry extends GroupSeparatorEntry { + private final MapOption mapOption; + private final TextScaledButtonWidget resetListButton; + private final TooltipButtonWidget addListButton; + + private MapGroupSeparatorEntry(MapOption group, Screen screen) { + super(group, screen); + this.mapOption = group; + + this.resetListButton = new TextScaledButtonWidget(screen, getRowRight() - 20, -50, 20, 20, 2f, Component.literal("\u21BB"), button -> { + group.requestSetDefault(); + }); + group.addListener((opt, val) -> this.resetListButton.active = !opt.isPendingValueDefault() && opt.available()); + this.resetListButton.active = !group.isPendingValueDefault() && group.available(); + + + this.addListButton = new TooltipButtonWidget(yaclScreen, resetListButton.getX() - 20, -50, 20, 20, Component.literal("+"), Component.translatable("yacl.list.add_top"), btn -> { + group.insertNewEntry(); + setExpanded(true); + }); + + updateExpandMinimizeText(); + minimizeIfUnavailable(); + } + + @Override + public void render(GuiGraphics graphics, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) { + updateExpandMinimizeText(); // update every render because option could become available/unavailable at any time + + super.render(graphics, index, y, x, entryWidth, entryHeight, mouseX, mouseY, hovered, tickDelta); + + int buttonY = expandMinimizeButton.getY(); + + resetListButton.setY(buttonY); + addListButton.setY(buttonY); + + resetListButton.render(graphics, mouseX, mouseY, tickDelta); + addListButton.render(graphics, mouseX, mouseY, tickDelta); + } + + private void minimizeIfUnavailable() { + if (!mapOption.available() && isExpanded()) { + setExpanded(false); + } + } + + @Override + protected void updateExpandMinimizeText() { + super.updateExpandMinimizeText(); + expandMinimizeButton.active = mapOption == null || mapOption.available(); + if (addListButton != null) + addListButton.active = expandMinimizeButton.active && mapOption.numberOfEntries() < mapOption.maximumNumberOfEntries(); + } + + @Override + public void setExpanded(boolean expanded) { + super.setExpanded(mapOption.available() && expanded); + } + + @Override + public List children() { + return ImmutableList.of(expandMinimizeButton, addListButton, resetListButton); + } + } + public class EmptyListLabel extends Entry { private final ListGroupSeparatorEntry parent; private final String groupName; @@ -569,4 +680,41 @@ public List narratables() { return ImmutableList.of(); } } + + public class EmptyMapLabel extends Entry { + private final MapGroupSeparatorEntry parent; + private final String groupName; + private final String categoryName; + + public EmptyMapLabel(MapGroupSeparatorEntry parent, ConfigCategory category) { + this.parent = parent; + this.groupName = parent.group.name().getString().toLowerCase(); + this.categoryName = category.name().getString().toLowerCase(); + } + + @Override + public void render(GuiGraphics graphics, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) { + graphics.drawCenteredString(Minecraft.getInstance().font, Component.translatable("yacl.list.empty").withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.ITALIC), x + entryWidth / 2, y, -1); + } + + @Override + public boolean isViewable() { + return parent.isExpanded() && (searchQuery.isEmpty() || groupName.contains(searchQuery)); + } + + @Override + public int getItemHeight() { + return 11; + } + + @Override + public @NotNull List children() { + return ImmutableList.of(); + } + + @Override + public @NotNull List narratables() { + return ImmutableList.of(); + } + } } diff --git a/common/src/main/java/dev/isxander/yacl3/gui/controllers/LabelController.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/LabelController.java index fee6c196..2b67e34c 100644 --- a/common/src/main/java/dev/isxander/yacl3/gui/controllers/LabelController.java +++ b/common/src/main/java/dev/isxander/yacl3/gui/controllers/LabelController.java @@ -1,6 +1,7 @@ package dev.isxander.yacl3.gui.controllers; import dev.isxander.yacl3.api.Controller; +import dev.isxander.yacl3.api.MapOptionEntry; import dev.isxander.yacl3.api.Option; import dev.isxander.yacl3.api.utils.Dimension; import dev.isxander.yacl3.gui.AbstractWidget; @@ -20,6 +21,7 @@ import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Map; /** * Simply renders some text as a label. @@ -45,6 +47,11 @@ public Option option() { @Override public Component formatValue() { + // TODO: How should this be handled? + if (option() instanceof MapOptionEntry mapOptionEntry) { + return (Component) ((Map.Entry) mapOptionEntry.pendingValue()).getKey(); + } + return option().pendingValue(); } diff --git a/common/src/main/java/dev/isxander/yacl3/gui/controllers/MapEntryWidget.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/MapEntryWidget.java new file mode 100644 index 00000000..1ef16db9 --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/gui/controllers/MapEntryWidget.java @@ -0,0 +1,136 @@ +package dev.isxander.yacl3.gui.controllers; + +import com.google.common.collect.ImmutableList; +import dev.isxander.yacl3.api.MapOption; +import dev.isxander.yacl3.api.MapOptionEntry; +import dev.isxander.yacl3.api.utils.Dimension; +import dev.isxander.yacl3.gui.AbstractWidget; +import dev.isxander.yacl3.gui.TooltipButtonWidget; +import dev.isxander.yacl3.gui.YACLScreen; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.events.ContainerEventHandler; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class MapEntryWidget extends AbstractWidget implements ContainerEventHandler { + private final TooltipButtonWidget removeButton, moveUpButton, moveDownButton; + private final AbstractWidget entryWidget; + + private final MapOption mapOption; + private final MapOptionEntry mapOptionEntry; + + private final String optionNameString; + + private GuiEventListener focused; + private boolean dragging; + + @SuppressWarnings("UnnecessaryUnicodeEscape") + public MapEntryWidget(YACLScreen screen, @NotNull MapOptionEntry mapOptionEntry, @NotNull AbstractWidget entryWidget) { + super(entryWidget.getDimension().withHeight( + Math.max(entryWidget.getDimension().height(), 20) - ((mapOptionEntry.parentGroup().indexOf( + mapOptionEntry) == mapOptionEntry.parentGroup().options().size() - 1) ? 0 : 2))); // -2 to remove the padding + this.mapOptionEntry = mapOptionEntry; + this.mapOption = mapOptionEntry.parentGroup(); + this.optionNameString = mapOptionEntry.name().getString().toLowerCase(); + this.entryWidget = entryWidget; + + Dimension dim = entryWidget.getDimension(); + entryWidget.setDimension(dim.clone().move(20 * 2, 0).expand(-20 * 3, 0)); + + removeButton = new TooltipButtonWidget(screen, dim.xLimit() - 20, dim.y(), 20, 20, Component.literal("\u274c"), + Component.translatable("yacl.list.remove"), btn -> { + mapOption.removeEntry(mapOptionEntry); + updateButtonStates(); + } + ); + + moveUpButton = new TooltipButtonWidget( + screen, dim.x(), dim.y(), 20, 20, Component.literal("\u2191"), Component.translatable("yacl.list.move_up"), btn -> { + int index = mapOption.indexOf(mapOptionEntry) - 1; + if (index >= 0) { + mapOption.removeEntry(mapOptionEntry); + mapOption.insertEntry(index, mapOptionEntry); + updateButtonStates(); + } + }); + + moveDownButton = new TooltipButtonWidget( + screen, dim.x() + 20, dim.y(), 20, 20, Component.literal("\u2193"), Component.translatable("yacl.list.move_down"), btn -> { + int index = mapOption.indexOf(mapOptionEntry) + 1; + if (index < mapOption.options().size()) { + mapOption.removeEntry(mapOptionEntry); + mapOption.insertEntry(index, mapOptionEntry); + updateButtonStates(); + } + }); + + updateButtonStates(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + updateButtonStates(); // update every render in case option becomes available/unavailable + + removeButton.setY(getDimension().y()); + moveUpButton.setY(getDimension().y()); + moveDownButton.setY(getDimension().y()); + entryWidget.setDimension(entryWidget.getDimension().withY(getDimension().y())); + + removeButton.render(graphics, mouseX, mouseY, delta); + moveUpButton.render(graphics, mouseX, mouseY, delta); + moveDownButton.render(graphics, mouseX, mouseY, delta); + entryWidget.render(graphics, mouseX, mouseY, delta); + } + + protected void updateButtonStates() { + removeButton.active = mapOption.available() && mapOption.numberOfEntries() > mapOption.minimumNumberOfEntries(); + moveUpButton.active = mapOption.indexOf(mapOptionEntry) > 0 && mapOption.available(); + moveDownButton.active = mapOption.indexOf(mapOptionEntry) < mapOption.options().size() - 1 && mapOption.available(); + } + + @Override + public void unfocus() { + entryWidget.unfocus(); + } + + @Override + public void updateNarration(NarrationElementOutput builder) { + entryWidget.updateNarration(builder); + } + + @Override + public boolean matchesSearch(String query) { + return optionNameString.contains(query.toLowerCase()); + } + + @Override + public @NotNull List children() { + return ImmutableList.of(moveUpButton, moveDownButton, entryWidget, removeButton); + } + + @Override + public boolean isDragging() { + return dragging; + } + + @Override + public void setDragging(boolean dragging) { + this.dragging = dragging; + } + + @Nullable + @Override + public GuiEventListener getFocused() { + return focused; + } + + @Override + public void setFocused(@Nullable GuiEventListener focused) { + this.focused = focused; + } +} diff --git a/common/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringController.java b/common/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringController.java index 4bafc0f6..3717e666 100644 --- a/common/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringController.java +++ b/common/src/main/java/dev/isxander/yacl3/gui/controllers/string/StringController.java @@ -1,7 +1,10 @@ package dev.isxander.yacl3.gui.controllers.string; +import dev.isxander.yacl3.api.MapOptionEntry; import dev.isxander.yacl3.api.Option; +import java.util.Map; + /** * A custom text field implementation for strings. */ @@ -27,6 +30,11 @@ public Option option() { @Override public String getString() { + // TODO: How should this be handled? + if (option() instanceof MapOptionEntry mapOptionEntry) { + return ((Map.Entry) mapOptionEntry.pendingValue()).getKey().toString(); + } + return option().pendingValue(); } diff --git a/common/src/main/java/dev/isxander/yacl3/impl/HiddenNameMapOptionEntry.java b/common/src/main/java/dev/isxander/yacl3/impl/HiddenNameMapOptionEntry.java new file mode 100644 index 00000000..585ec2b9 --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/impl/HiddenNameMapOptionEntry.java @@ -0,0 +1,107 @@ +package dev.isxander.yacl3.impl; + +import com.google.common.collect.ImmutableSet; +import dev.isxander.yacl3.api.*; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.NotNull; + +import java.util.function.BiConsumer; + +public class HiddenNameMapOptionEntry implements MapOptionEntry { + private final MapOptionEntry option; + + public HiddenNameMapOptionEntry(MapOptionEntry option) { + this.option = option; + } + + @Override + public @NotNull Component name() { + return Component.empty(); + } + + @Override + public @NotNull OptionDescription description() { + return option.description(); + } + + @Override + @Deprecated + public @NotNull Component tooltip() { + return option.tooltip(); + } + + @Override + public @NotNull Controller controller() { + return option.controller(); + } + + @Override + public @NotNull Binding binding() { + return option.binding(); + } + + @Override + public boolean available() { + return option.available(); + } + + @Override + public void setAvailable(boolean available) { + option.setAvailable(available); + } + + @Override + public MapOption parentGroup() { + return option.parentGroup(); + } + + @Override + public @NotNull ImmutableSet flags() { + return option.flags(); + } + + @Override + public boolean changed() { + return option.changed(); + } + + @Override + public @NotNull T pendingValue() { + return option.pendingValue(); + } + + @Override + public void requestSet(@NotNull T value) { + option.requestSet(value); + } + + @Override + public boolean applyValue() { + return option.applyValue(); + } + + @Override + public void forgetPendingValue() { + option.forgetPendingValue(); + } + + @Override + public void requestSetDefault() { + option.requestSetDefault(); + } + + @Override + public boolean isPendingValueDefault() { + return option.isPendingValueDefault(); + } + + @Override + public boolean canResetToDefault() { + return option.canResetToDefault(); + } + + @Override + public void addListener(BiConsumer, T> changedListener) { + option.addListener(changedListener); + } +} diff --git a/common/src/main/java/dev/isxander/yacl3/impl/MapOptionEntryImpl.java b/common/src/main/java/dev/isxander/yacl3/impl/MapOptionEntryImpl.java new file mode 100644 index 00000000..e0b397a7 --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/impl/MapOptionEntryImpl.java @@ -0,0 +1,141 @@ +package dev.isxander.yacl3.impl; + +import dev.isxander.yacl3.api.*; +import dev.isxander.yacl3.api.utils.Dimension; +import dev.isxander.yacl3.gui.AbstractWidget; +import dev.isxander.yacl3.gui.YACLScreen; +import dev.isxander.yacl3.gui.controllers.MapEntryWidget; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Function; + +public class MapOptionEntryImpl implements MapOptionEntry { + private final MapOptionImpl group; + + private Map.Entry value; + + private final Binding binding; + private final Controller controller; + + MapOptionEntryImpl(MapOptionImpl group, Map.Entry initialValue, @NotNull Function, Controller> keyControlGetter, @NotNull Function, Controller> valueControlGetter) { + this.group = group; + this.value = initialValue; + this.binding = new EntryBinding(); + this.controller = new EntryController<>( + keyControlGetter.apply(new HiddenNameMapOptionEntry<>(this)), + valueControlGetter.apply(new HiddenNameMapOptionEntry<>(this)), this + ); + } + + @Override + public MapOption parentGroup() { + return group; + } + + @Override + public @NotNull Component name() { + return group.name(); + } + + @Override + public @NotNull OptionDescription description() { + return group.description(); + } + + @Override + public @NotNull Component tooltip() { + return group.tooltip(); + } + + @Override + public @NotNull Controller controller() { + return controller; + } + + @Override + public @NotNull Binding binding() { + return binding; + } + + @Override + public void setAvailable(boolean available) {} + + @Override + public boolean changed() { + return false; + } + + @Override + public @NotNull T pendingValue() { + return (T) value; + } + + @Override + public void requestSet(@NotNull T value) { + binding.setValue(value); + } + + @Override + public boolean applyValue() { + return false; + } + + @Override + public void forgetPendingValue() {} + + @Override + public void requestSetDefault() {} + + @Override + public boolean isPendingValueDefault() { + return false; + } + + @Override + public void addListener(BiConsumer, T> changedListener) {} + + /** + * Open in case mods need to find the real controller type. + */ + @ApiStatus.Internal + public record EntryController(Controller keyController, Controller valueController, + MapOptionEntryImpl entry) implements Controller { + @Override + public Option option() { + return valueController.option(); + } + + @Override + public Component formatValue() { + return keyController.formatValue(); + } + + @Override + public AbstractWidget provideWidget(YACLScreen screen, Dimension widgetDimension) { + // TODO: Update widget to include map values alongside keys + return new MapEntryWidget(screen, entry, keyController.provideWidget(screen, widgetDimension)); + } + } + + private class EntryBinding implements Binding { + @Override + public void setValue(T newValue) { + value = (Map.Entry) newValue; + group.callListeners(true); + } + + @Override + public T getValue() { + return (T) value; + } + + @Override + public T defaultValue() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/common/src/main/java/dev/isxander/yacl3/impl/MapOptionImpl.java b/common/src/main/java/dev/isxander/yacl3/impl/MapOptionImpl.java new file mode 100644 index 00000000..3380a18b --- /dev/null +++ b/common/src/main/java/dev/isxander/yacl3/impl/MapOptionImpl.java @@ -0,0 +1,429 @@ +package dev.isxander.yacl3.impl; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import dev.isxander.yacl3.api.*; +import dev.isxander.yacl3.api.controller.ControllerBuilder; +import dev.isxander.yacl3.impl.utils.YACLConstants; +import net.minecraft.network.chat.Component; +import org.apache.commons.lang3.Validate; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +public final class MapOptionImpl implements MapOption { + private final Component name; + private final OptionDescription description; + private final Binding> binding; + private final Supplier> initialValue; + private final List> entries; + private final boolean collapsed; + private boolean available; + private final int minimumNumberOfEntries; + private final int maximumNumberOfEntries; + private final boolean insertEntriesAtEnd; + private final ImmutableSet flags; + private final EntryFactory entryFactory; + + private final List>, Map>> listeners; + private final List refreshListeners; + private int listenerTriggerDepth = 0; + + @Override + public @NotNull Controller> controller() { + throw new UnsupportedOperationException(); + } + + @Override + public @NotNull Binding> binding() { + return binding; + } + + public MapOptionImpl(@NotNull Component name, @NotNull OptionDescription description, @NotNull Binding> binding, @NotNull Supplier> initialValue, @NotNull Function, Controller> keyControllerFunction, @NotNull Function, Controller> valueControllerFunction, ImmutableSet flags, boolean collapsed, boolean available, int minimumNumberOfEntries, int maximumNumberOfEntries, boolean insertEntriesAtEnd, Collection>, Map>> listeners) { + this.name = name; + this.description = description; + this.binding = new SafeBinding<>(binding); + this.initialValue = initialValue; + this.entryFactory = new EntryFactory(keyControllerFunction, valueControllerFunction); + this.entries = createEntries(binding().getValue()); + this.collapsed = collapsed; + this.flags = flags; + this.available = available; + this.minimumNumberOfEntries = minimumNumberOfEntries; + this.maximumNumberOfEntries = maximumNumberOfEntries; + this.insertEntriesAtEnd = insertEntriesAtEnd; + this.listeners = new ArrayList<>(); + this.listeners.addAll(listeners); + this.refreshListeners = new ArrayList<>(); + callListeners(true); + } + + @Override + public @NotNull ImmutableList> options() { + return ImmutableList.copyOf(entries); + } + + @Override + public int numberOfEntries() { + return entries.size(); + } + + @Override + public int maximumNumberOfEntries() { + return maximumNumberOfEntries; + } + + @Override + public int minimumNumberOfEntries() { + return minimumNumberOfEntries; + } + + @Override + public MapOptionEntry insertNewEntry() { + MapOptionEntry newEntry = entryFactory.create(initialValue.get()); + if (insertEntriesAtEnd) { + entries.add(newEntry); + } else { + // insert at top + entries.add(0, newEntry); + } + onRefresh(); + return newEntry; + } + + @SuppressWarnings("unchecked") + @Override + public void insertEntry(int index, MapOptionEntry entry) { + entries.add(index, (MapOptionEntry) entry); + onRefresh(); + } + + @Override + public int indexOf(MapOptionEntry entry) { + return entries.indexOf(entry); + } + + @Override + public void removeEntry(MapOptionEntry entry) { + if (entries.remove(entry)) { + onRefresh(); + } + } + + @Override + public void addRefreshListener(Runnable changedListener) { + this.refreshListeners.add(changedListener); + } + + @Override + public boolean available() { + return available; + } + + @Override + public void setAvailable(boolean available) { + boolean changed = this.available != available; + this.available = available; + + if (changed) { + callListeners(false); + } + } + + @Override + public @NotNull ImmutableSet flags() { + return flags; + } + + @Override + public boolean changed() { + return !binding().getValue().equals(pendingValue()); + } + + @Override + public @NotNull Map pendingValue() { + // TODO: Refactor into a method + Map mapEntries = new HashMap<>(); + + for (MapOptionEntry entry : entries) { + var mapEntry = (Map.Entry) entry.pendingValue(); + mapEntries.put(mapEntry.getKey(), mapEntry.getValue()); + } + + return mapEntries; + } + + @Override + public boolean applyValue() { + if (changed()) { + binding().setValue(pendingValue()); + return true; + } + return false; + } + + @Override + public void forgetPendingValue() { + requestSet(binding().getValue()); + } + + @Override + public void requestSetDefault() { + requestSet(binding().defaultValue()); + } + + @Override + public boolean isPendingValueDefault() { + return binding().defaultValue().equals(pendingValue()); + } + + @Override + public void addListener(BiConsumer>, Map> changedListener) { + this.listeners.add(changedListener); + } + + @Override + public void requestSet(@NotNull Map value) { + entries.clear(); + entries.addAll(createEntries(value)); + onRefresh(); + } + + @Override + public @NotNull Component name() { + return name; + } + + @Override + public @NotNull OptionDescription description() { + return description; + } + + @Override + public @NotNull Component tooltip() { + return description().text(); + } + + @Override + public boolean collapsed() { + return collapsed; + } + + @Override + public boolean isRoot() { + return false; + } + + private List> createEntries(Map values) { + return values.entrySet().stream().filter(Objects::nonNull).map(entryFactory::create).collect(Collectors.toList()); + } + + void callListeners(boolean bypass) { + Map pendingValue = pendingValue(); + if (bypass || listenerTriggerDepth == 0) { + if (listenerTriggerDepth > 10) { + throw new IllegalStateException( + "Listener trigger depth exceeded 10! This means a listener triggered a listener etc etc 10 times deep. This is likely a bug in the mod using YACL!"); + } + + this.listenerTriggerDepth++; + + for (BiConsumer>, Map> listener : listeners) { + try { + listener.accept(this, pendingValue); + } catch (Exception e) { + YACLConstants.LOGGER.error("Exception whilst triggering listener for option '%s'".formatted(name.getString()), e); + } + } + + this.listenerTriggerDepth--; + } + } + + private void onRefresh() { + refreshListeners.forEach(Runnable::run); + callListeners(true); + } + + private class EntryFactory { + private final Function, Controller> keyControllerFunction; + private final Function, Controller> valueControllerFunction; + + private EntryFactory(Function, Controller> keyControllerFunction, Function, Controller> valueControllerFunction) { + this.keyControllerFunction = keyControllerFunction; + this.valueControllerFunction = valueControllerFunction; + } + + public MapOptionEntry create(Map.Entry initialValue) { + return new MapOptionEntryImpl<>(MapOptionImpl.this, initialValue, keyControllerFunction, valueControllerFunction); + } + } + + public static final class BuilderImpl implements Builder { + private Component name = Component.empty(); + private OptionDescription description = OptionDescription.EMPTY; + private Function, Controller> keyControllerFunction; + private Function, Controller> valueControllerFunction; + private Binding> binding = null; + private final Set flags = new HashSet<>(); + private Supplier> initialValue; + private boolean collapsed = false; + private boolean available = true; + private int minimumNumberOfEntries = 0; + private int maximumNumberOfEntries = Integer.MAX_VALUE; + private boolean insertEntriesAtEnd = false; + private final List>, Map>> listeners = new ArrayList<>(); + + @Override + public Builder name(@NotNull Component name) { + Validate.notNull(name, "`name` must not be null"); + + this.name = name; + return this; + } + + @Override + public Builder description(@NotNull OptionDescription description) { + Validate.notNull(description, "`description` must not be null"); + + this.description = description; + return this; + } + + @Override + public Builder initial(@NotNull Supplier> initialValue) { + Validate.notNull(initialValue, "`initialValue` cannot be empty"); + + this.initialValue = initialValue; + return this; + } + + @Override + public Builder initial(@NotNull Map.Entry initialValue) { + Validate.notNull(initialValue, "`initialValue` cannot be empty"); + + this.initialValue = () -> initialValue; + return this; + } + + @Override + public Builder keyController(@NotNull Function, ControllerBuilder> controller) { + Validate.notNull(controller, "`controller` cannot be null"); + + this.keyControllerFunction = opt -> controller.apply(opt).build(); + return this; + } + + @Override + public Builder valueController(@NotNull Function, ControllerBuilder> controller) { + Validate.notNull(controller, "`controller` cannot be null"); + + this.valueControllerFunction = opt -> controller.apply(opt).build(); + return this; + } + + @Override + public Builder customController(@NotNull Function, Controller> keyControl, @NotNull Function, Controller> valueControl) { + Validate.notNull(keyControl, "`keyControl` cannot be null"); + Validate.notNull(valueControl, "`valueControl` cannot be null"); + + this.keyControllerFunction = keyControl; + this.valueControllerFunction = valueControl; + return this; + } + + @Override + public Builder binding(@NotNull Binding> binding) { + Validate.notNull(binding, "`binding` cannot be null"); + + this.binding = binding; + return this; + } + + @Override + public Builder binding(@NotNull Map def, @NotNull Supplier<@NotNull Map> getter, @NotNull Consumer<@NotNull Map> setter) { + Validate.notNull(def, "`def` must not be null"); + Validate.notNull(getter, "`getter` must not be null"); + Validate.notNull(setter, "`setter` must not be null"); + + this.binding = Binding.generic(def, getter, setter); + return this; + } + + @Override + public Builder available(boolean available) { + this.available = available; + return this; + } + + @Override + public Builder minimumNumberOfEntries(int number) { + this.minimumNumberOfEntries = number; + return this; + } + + @Override + public Builder maximumNumberOfEntries(int number) { + this.maximumNumberOfEntries = number; + return this; + } + + @Override + public Builder insertEntriesAtEnd(boolean insertAtEnd) { + this.insertEntriesAtEnd = insertAtEnd; + return this; + } + + @Override + public Builder flag(@NotNull OptionFlag... flag) { + Validate.notNull(flag, "`flag` must not be null"); + + this.flags.addAll(Arrays.asList(flag)); + return this; + } + + @Override + public Builder flags(@NotNull Collection flags) { + Validate.notNull(flags, "`flags` must not be null"); + + this.flags.addAll(flags); + return this; + } + + @Override + public Builder collapsed(boolean collapsible) { + this.collapsed = collapsible; + return this; + } + + @Override + public Builder listener(@NotNull BiConsumer>, Map> listener) { + this.listeners.add(listener); + return this; + } + + @Override + public Builder listeners(@NotNull Collection>, Map>> listeners) { + this.listeners.addAll(listeners); + return this; + } + + @Override + public MapOption build() { + Validate.notNull(keyControllerFunction, "`keyController` must not be null"); + Validate.notNull(valueControllerFunction, "`valueController` must not be null"); + Validate.notNull(binding, "`binding` must not be null"); + Validate.notNull(initialValue, "`initialValue` must not be null"); + + return new MapOptionImpl<>(name, description, binding, initialValue, keyControllerFunction, valueControllerFunction, + ImmutableSet.copyOf(flags), collapsed, available, minimumNumberOfEntries, maximumNumberOfEntries, insertEntriesAtEnd, + listeners + ); + } + } +} 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 a61a1125..70df8c7f 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 @@ -9,6 +9,7 @@ import java.awt.*; import java.util.List; +import java.util.Map; public class ConfigTest { public static final ConfigClassHandler GSON = ConfigClassHandler.createBuilder(ConfigTest.class) @@ -57,6 +58,9 @@ public class ConfigTest { @SerialEntry public List intList = List.of(1, 2, 3); + @SerialEntry + public Map stringMap = Map.of("Key", "Value", "This is a map!", "Fully integrated just like lists."); + @SerialEntry public boolean groupTestRoot = false; @SerialEntry 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 c8981d44..a951b083 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 @@ -27,7 +27,9 @@ import java.awt.Color; import java.nio.file.Path; +import java.util.AbstractMap; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; public class GuiTest { @@ -334,6 +336,29 @@ private static Screen getFullTestSuite(Screen parent) { .initial(Component.literal("Initial label")) .build()) .build()) + .category(ConfigCategory.createBuilder() + .name(Component.literal("Map Test")) + .group(MapOption.createBuilder() + .name(Component.literal("String Map")) + .binding( + defaults.stringMap, + () -> config.stringMap, + val -> config.stringMap = val + ) + .keyController(StringControllerBuilder::create) + .valueController(StringControllerBuilder::create) + .initial(Map.entry("", "")) + .minimumNumberOfEntries(3) + .maximumNumberOfEntries(5) + .insertEntriesAtEnd(true) + .build()) + .group(MapOption.createBuilder() + .name(Component.literal("Useless Label Map")) + .binding(Binding.immutable(Map.of(Component.literal("It's quite impressive that literally every single controller works, without problem."), Component.literal("It's quite impressive that literally every single controller works, without problem.")))) + .customController(LabelController::new, LabelController::new) + .initial(Map.entry(Component.literal("Initial key label"), Component.literal("Initial value label"))) + .build()) + .build()) .category(ConfigCategory.createBuilder() .name(Component.literal("Group Test")) .option(Option.createBuilder(boolean.class)