From dd65110f60aa3e32c2970863a06a7682520cce5e Mon Sep 17 00:00:00 2001 From: Xander Date: Sun, 11 Dec 2022 19:31:56 +0000 Subject: [Feature] Lists (#40) --- .../java/dev/isxander/yacl/api/ConfigCategory.java | 9 + .../java/dev/isxander/yacl/api/ListOption.java | 217 +++++++++++++++++++++ .../dev/isxander/yacl/api/ListOptionEntry.java | 23 +++ src/client/java/dev/isxander/yacl/api/Option.java | 10 +- .../java/dev/isxander/yacl/api/OptionGroup.java | 8 +- .../dev/isxander/yacl/api/utils/OptionUtils.java | 14 +- .../dev/isxander/yacl/gui/CategoryListWidget.java | 6 - .../isxander/yacl/gui/LowProfileButtonWidget.java | 2 +- .../dev/isxander/yacl/gui/OptionListWidget.java | 207 ++++++++++++++++---- .../dev/isxander/yacl/gui/TooltipButtonWidget.java | 11 +- .../yacl/gui/controllers/ControllerWidget.java | 4 +- .../yacl/gui/controllers/LabelController.java | 8 +- .../yacl/gui/controllers/ListEntryWidget.java | 132 +++++++++++++ .../slider/SliderControllerElement.java | 6 +- .../string/StringControllerElement.java | 12 +- .../java/dev/isxander/yacl/gui/utils/GuiUtils.java | 3 + .../dev/isxander/yacl/impl/ButtonOptionImpl.java | 5 - .../isxander/yacl/impl/ListOptionEntryImpl.java | 122 ++++++++++++ .../dev/isxander/yacl/impl/ListOptionImpl.java | 208 ++++++++++++++++++++ .../dev/isxander/yacl/impl/OptionGroupImpl.java | 2 +- .../java/dev/isxander/yacl/impl/OptionImpl.java | 5 - .../yacl/impl/YetAnotherConfigLibImpl.java | 67 ++++++- .../assets/yet-another-config-lib/lang/en_us.json | 5 + .../isxander/yacl/test/config/ExampleConfig.java | 4 + .../dev/isxander/yacl/test/config/GuiTest.java | 33 +++- 25 files changed, 1047 insertions(+), 76 deletions(-) create mode 100644 src/client/java/dev/isxander/yacl/api/ListOption.java create mode 100644 src/client/java/dev/isxander/yacl/api/ListOptionEntry.java create mode 100644 src/client/java/dev/isxander/yacl/gui/controllers/ListEntryWidget.java create mode 100644 src/client/java/dev/isxander/yacl/impl/ListOptionEntryImpl.java create mode 100644 src/client/java/dev/isxander/yacl/impl/ListOptionImpl.java (limited to 'src') diff --git a/src/client/java/dev/isxander/yacl/api/ConfigCategory.java b/src/client/java/dev/isxander/yacl/api/ConfigCategory.java index e9755dd..19c7f72 100644 --- a/src/client/java/dev/isxander/yacl/api/ConfigCategory.java +++ b/src/client/java/dev/isxander/yacl/api/ConfigCategory.java @@ -3,6 +3,7 @@ package dev.isxander.yacl.api; import com.google.common.collect.ImmutableList; import dev.isxander.yacl.impl.ConfigCategoryImpl; import dev.isxander.yacl.impl.OptionGroupImpl; +import dev.isxander.yacl.impl.utils.YACLConstants; import net.minecraft.text.MutableText; import net.minecraft.text.Text; import org.apache.commons.lang3.Validate; @@ -76,6 +77,11 @@ public interface ConfigCategory { public Builder option(@NotNull Option option) { Validate.notNull(option, "`option` must not be null"); + if (option instanceof ListOption listOption) { + YACLConstants.LOGGER.warn("Adding list option as an option is not supported! Rerouting to group!"); + return group(listOption); + } + this.rootOptions.add(option); return this; } @@ -91,6 +97,9 @@ public interface ConfigCategory { public Builder options(@NotNull Collection> options) { Validate.notNull(options, "`options` must not be null"); + if (options.stream().anyMatch(ListOption.class::isInstance)) + throw new UnsupportedOperationException("List options must not be added as an option but a group!"); + this.rootOptions.addAll(options); return this; } diff --git a/src/client/java/dev/isxander/yacl/api/ListOption.java b/src/client/java/dev/isxander/yacl/api/ListOption.java new file mode 100644 index 0000000..895898c --- /dev/null +++ b/src/client/java/dev/isxander/yacl/api/ListOption.java @@ -0,0 +1,217 @@ +package dev.isxander.yacl.api; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import dev.isxander.yacl.impl.ListOptionImpl; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import org.apache.commons.lang3.Validate; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A list option that takes form as an option group for UX. + * You add this option through {@link ConfigCategory.Builder#group(OptionGroup)}. Do NOT add as an option. + * Users can add remove and reshuffle a list type. You can use any controller you wish, there are no dedicated + * controllers for list types. List options do not manipulate your list but get and set the list with a + * regular binding for simplicity. + * + * You may apply option flags like a normal option and collapse like a normal group, it is a merge of them both. + * Methods in this interface marked with {@link ApiStatus.Internal} should not be used, and could be subject to + * change at any time + * @param + */ +public interface ListOption extends OptionGroup, Option> { + @Override + @NotNull ImmutableList> options(); + + /** + * Class of the entry type + */ + @NotNull Class elementTypeClass(); + + @ApiStatus.Internal + ListOptionEntry insertNewEntryToTop(); + + @ApiStatus.Internal + void insertEntry(int index, ListOptionEntry entry); + + @ApiStatus.Internal + int indexOf(ListOptionEntry entry); + + @ApiStatus.Internal + void removeEntry(ListOptionEntry entry); + + @ApiStatus.Internal + void addRefreshListener(Runnable changedListener); + + static Builder createBuilder(Class typeClass) { + return new Builder<>(typeClass); + } + + class Builder { + private Text name = Text.empty(); + private final List tooltipLines = new ArrayList<>(); + private Function, Controller> controllerFunction; + private Binding> binding = null; + private final Set flags = new HashSet<>(); + private T initialValue; + private boolean collapsed = false; + private boolean available = true; + private final Class typeClass; + + private Builder(Class typeClass) { + this.typeClass = typeClass; + } + + /** + * Sets name of the list, for UX purposes, a name should always be given, + * but isn't enforced. + * + * @see ListOption#name() + */ + public Builder name(@NotNull Text name) { + Validate.notNull(name, "`name` must not be null"); + + this.name = name; + return this; + } + + /** + * Sets the tooltip to be used by the list. It is displayed like a normal + * group when you hover over the name. Entries do not allow a tooltip. + *

+ * Can be invoked twice to append more lines. + * No need to wrap the text yourself, the gui does this itself. + * + * @param tooltips text lines - merged with a new-line on {@link Builder#build()}. + */ + public Builder tooltip(@NotNull Text... tooltips) { + Validate.notEmpty(tooltips, "`tooltips` cannot be empty"); + + tooltipLines.addAll(List.of(tooltips)); + return this; + } + + /** + * Sets the value that is used when creating new entries + */ + public Builder initial(@NotNull T initialValue) { + Validate.notNull(initialValue, "`initialValue` cannot be empty"); + + this.initialValue = initialValue; + return this; + } + + /** + * Sets the controller for the option. + * This is how you interact and change the options. + * + * @see dev.isxander.yacl.gui.controllers + */ + public Builder controller(@NotNull Function, Controller> control) { + Validate.notNull(control, "`control` cannot be null"); + + this.controllerFunction = control; + return this; + } + + /** + * Sets the binding for the option. + * Used for default, getter and setter. + * + * @see Binding + */ + public Builder binding(@NotNull Binding> binding) { + Validate.notNull(binding, "`binding` cannot be null"); + + this.binding = binding; + return this; + } + + /** + * 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 + */ + public Builder binding(@NotNull List def, @NotNull Supplier<@NotNull List> getter, @NotNull Consumer<@NotNull List> 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; + } + + /** + * Sets if the option can be configured + * + * @see Option#available() + */ + public Builder available(boolean available) { + this.available = available; + return this; + } + + /** + * Adds a flag to the option. + * Upon applying changes, all flags are executed. + * {@link Option#flags()} + */ + public Builder flag(@NotNull OptionFlag... flag) { + Validate.notNull(flag, "`flag` must not be null"); + + this.flags.addAll(Arrays.asList(flag)); + return this; + } + + /** + * Adds a flag to the option. + * Upon applying changes, all flags are executed. + * {@link Option#flags()} + */ + public Builder flags(@NotNull Collection flags) { + Validate.notNull(flags, "`flags` must not be null"); + + this.flags.addAll(flags); + return this; + } + + /** + * Dictates if the group should be collapsed by default. + * If not set, it will not be collapsed by default. + * + * @see OptionGroup#collapsed() + */ + public Builder collapsed(boolean collapsible) { + this.collapsed = collapsible; + return this; + } + + public ListOption build() { + Validate.notNull(controllerFunction, "`controller` must not be null"); + Validate.notNull(binding, "`binding` must not be null"); + Validate.notNull(initialValue, "`initialValue` must not be null"); + + MutableText concatenatedTooltip = Text.empty(); + boolean first = true; + for (Text line : tooltipLines) { + if (!first) concatenatedTooltip.append("\n"); + first = false; + + concatenatedTooltip.append(line); + } + + return new ListOptionImpl<>(name, concatenatedTooltip, binding, initialValue, typeClass, controllerFunction, ImmutableSet.copyOf(flags), collapsed, available); + } + } +} diff --git a/src/client/java/dev/isxander/yacl/api/ListOptionEntry.java b/src/client/java/dev/isxander/yacl/api/ListOptionEntry.java new file mode 100644 index 0000000..e0a3424 --- /dev/null +++ b/src/client/java/dev/isxander/yacl/api/ListOptionEntry.java @@ -0,0 +1,23 @@ +package dev.isxander.yacl.api; + +import com.google.common.collect.ImmutableSet; +import org.jetbrains.annotations.NotNull; + +public interface ListOptionEntry extends Option { + ListOption parentGroup(); + + @Override + default @NotNull Class typeClass() { + return parentGroup().elementTypeClass(); + } + + @Override + default @NotNull ImmutableSet flags() { + return parentGroup().flags(); + } + + @Override + default boolean available() { + return parentGroup().available(); + } +} diff --git a/src/client/java/dev/isxander/yacl/api/Option.java b/src/client/java/dev/isxander/yacl/api/Option.java index 772c816..394723f 100644 --- a/src/client/java/dev/isxander/yacl/api/Option.java +++ b/src/client/java/dev/isxander/yacl/api/Option.java @@ -70,12 +70,6 @@ public interface Option { */ boolean changed(); - /** - * If true, modifying this option recommends a restart. - */ - @Deprecated - boolean requiresRestart(); - /** * Value in the GUI, ready to set the actual bound value or be undone. */ @@ -109,6 +103,10 @@ public interface Option { */ boolean isPendingValueDefault(); + default boolean canResetToDefault() { + return true; + } + /** * Adds a listener for when the pending value changes */ diff --git a/src/client/java/dev/isxander/yacl/api/OptionGroup.java b/src/client/java/dev/isxander/yacl/api/OptionGroup.java index 3364bdf..6cc6c7f 100644 --- a/src/client/java/dev/isxander/yacl/api/OptionGroup.java +++ b/src/client/java/dev/isxander/yacl/api/OptionGroup.java @@ -31,7 +31,7 @@ public interface OptionGroup { /** * List of all options in the group */ - @NotNull ImmutableList> options(); + @NotNull ImmutableList> options(); /** * Dictates if the group should be collapsed by default. @@ -96,6 +96,9 @@ public interface OptionGroup { public Builder option(@NotNull Option option) { Validate.notNull(option, "`option` must not be null"); + if (option instanceof ListOption) + throw new UnsupportedOperationException("List options must not be added as an option but a group!"); + this.options.add(option); return this; } @@ -109,6 +112,9 @@ public interface OptionGroup { public Builder options(@NotNull Collection> options) { Validate.notEmpty(options, "`options` must not be empty"); + if (options.stream().anyMatch(ListOption.class::isInstance)) + throw new UnsupportedOperationException("List options must not be added as an option but a group!"); + this.options.addAll(options); return this; } diff --git a/src/client/java/dev/isxander/yacl/api/utils/OptionUtils.java b/src/client/java/dev/isxander/yacl/api/utils/OptionUtils.java index ab46b5b..22032bd 100644 --- a/src/client/java/dev/isxander/yacl/api/utils/OptionUtils.java +++ b/src/client/java/dev/isxander/yacl/api/utils/OptionUtils.java @@ -1,9 +1,6 @@ package dev.isxander.yacl.api.utils; -import dev.isxander.yacl.api.ConfigCategory; -import dev.isxander.yacl.api.Option; -import dev.isxander.yacl.api.OptionGroup; -import dev.isxander.yacl.api.YetAnotherConfigLib; +import dev.isxander.yacl.api.*; import java.util.function.Consumer; import java.util.function.Function; @@ -16,9 +13,14 @@ public class OptionUtils { public static void consumeOptions(YetAnotherConfigLib yacl, Function, Boolean> consumer) { for (ConfigCategory category : yacl.categories()) { for (OptionGroup group : category.groups()) { - for (Option option : group.options()) { - if (consumer.apply(option)) return; + if (group instanceof ListOption list) { + if (consumer.apply(list)) return; + } else { + for (Option option : group.options()) { + if (consumer.apply(option)) return; + } } + } } } diff --git a/src/client/java/dev/isxander/yacl/gui/CategoryListWidget.java b/src/client/java/dev/isxander/yacl/gui/CategoryListWidget.java index e97f46c..71a8e4e 100644 --- a/src/client/java/dev/isxander/yacl/gui/CategoryListWidget.java +++ b/src/client/java/dev/isxander/yacl/gui/CategoryListWidget.java @@ -32,12 +32,6 @@ public class CategoryListWidget extends ElementListWidgetExt { private final YACLScreen yaclScreen; @@ -33,6 +29,14 @@ public class OptionListWidget extends ElementListWidgetExt listOption) { + listOption.addRefreshListener(() -> refreshListEntries(listOption, category)); + } + } + } } public void refreshOptions() { @@ -40,6 +44,7 @@ public class OptionListWidget extends ElementListWidgetExt categories = new ArrayList<>(); if (yaclScreen.getCurrentCategoryIdx() == -1) { + // -1 = no category, search in progress, so use all categories for search categories.addAll(yaclScreen.config.categories()); } else { categories.add(yaclScreen.config.categories().get(yaclScreen.getCurrentCategoryIdx())); @@ -48,19 +53,19 @@ public class OptionListWidget extends ElementListWidgetExt viewableSupplier; - GroupSeparatorEntry groupSeparatorEntry = null; + GroupSeparatorEntry groupSeparatorEntry; if (!group.isRoot()) { - groupSeparatorEntry = new GroupSeparatorEntry(group, yaclScreen); - viewableSupplier = groupSeparatorEntry::isExpanded; + groupSeparatorEntry = group instanceof ListOption listOption + ? new ListGroupSeparatorEntry(listOption, yaclScreen) + : new GroupSeparatorEntry(group, yaclScreen); addEntry(groupSeparatorEntry); } else { - viewableSupplier = () -> true; + groupSeparatorEntry = null; } List optionEntries = new ArrayList<>(); for (Option option : group.options()) { - OptionEntry entry = new OptionEntry(option, category, group, option.controller().provideWidget(yaclScreen, Dimension.ofInt(getRowLeft(), 0, getRowWidth(), 20)), viewableSupplier); + OptionEntry entry = new OptionEntry(option, category, group, groupSeparatorEntry, option.controller().provideWidget(yaclScreen, getDefaultEntryPosition())); addEntry(entry); optionEntries.add(entry); } @@ -76,6 +81,30 @@ public class OptionListWidget extends ElementListWidgetExt listOption, ConfigCategory category) { + // find group separator for group + GroupSeparatorEntry groupSeparator = super.children().stream().filter(e -> e instanceof GroupSeparatorEntry gs && gs.group == listOption).map(GroupSeparatorEntry.class::cast).findAny().orElse(null); + + if (groupSeparator == null) + return; + + for (OptionEntry entry : groupSeparator.optionEntries) + super.removeEntry(entry); + + groupSeparator.optionEntries.clear(); + Entry lastEntry = groupSeparator; + for (ListOptionEntry listOptionEntry : listOption.options()) { + OptionEntry optionEntry = new OptionEntry(listOptionEntry, category, listOption, groupSeparator, listOptionEntry.controller().provideWidget(yaclScreen, getDefaultEntryPosition())); + addEntryBelow(lastEntry, optionEntry); + groupSeparator.optionEntries.add(optionEntry); + lastEntry = optionEntry; + } + } + + public Dimension getDefaultEntryPosition() { + return Dimension.ofInt(getRowLeft(), 0, getRowWidth(), 20); + } + public void expandAllGroups() { for (Entry entry : super.children()) { if (entry instanceof GroupSeparatorEntry groupSeparatorEntry) { @@ -153,6 +182,40 @@ public class OptionListWidget extends ElementListWidgetExt { public boolean isViewable() { return true; @@ -168,25 +231,28 @@ public class OptionListWidget extends ElementListWidgetExt viewableSupplier; private final TextScaledButtonWidget resetButton; private final String categoryName; private final String groupName; - private OptionEntry(Option option, ConfigCategory category, OptionGroup group, AbstractWidget widget, Supplier viewableSupplier) { + public OptionEntry(Option option, ConfigCategory category, OptionGroup group, @Nullable GroupSeparatorEntry groupSeparatorEntry, AbstractWidget widget) { this.option = option; this.category = category; this.group = group; - this.widget = widget; - this.viewableSupplier = viewableSupplier; + this.groupSeparatorEntry = groupSeparatorEntry; + if (option instanceof ListOptionEntry listOptionEntry) + this.widget = new ListEntryWidget(yaclScreen, listOptionEntry, widget); + else this.widget = widget; this.categoryName = category.name().getString().toLowerCase(); this.groupName = group.name().getString().toLowerCase(); - if (this.widget.canReset()) { - this.widget.setDimension(this.widget.getDimension().expanded(-21, 0)); - this.resetButton = new TextScaledButtonWidget(widget.getDimension().xLimit() + 1, -50, 20, 20, 2f, Text.of("\u21BB"), button -> { + if (option.canResetToDefault() && this.widget.canReset()) { + this.widget.setDimension(this.widget.getDimension().expanded(-20, 0)); + this.resetButton = new TextScaledButtonWidget(widget.getDimension().xLimit(), -50, 20, 20, 2f, Text.of("\u21BB"), button -> { option.requestSetDefault(); }); option.addListener((opt, val) -> this.resetButton.active = !opt.isPendingValueDefault() && opt.available()); @@ -231,7 +297,7 @@ public class OptionListWidget extends ElementListWidgetExt optionEntries; + protected List optionEntries; private int y; @@ -282,10 +348,7 @@ public class OptionListWidget extends ElementListWidgetExt { - setExpanded(!isExpanded()); - recacheViewableChildren(); - }); + this.expandMinimizeButton = new LowProfileButtonWidget(0, 0, 20, 20, Text.empty(), btn -> onExpandButtonPress()); updateExpandMinimizeText(); } @@ -293,8 +356,10 @@ public class OptionListWidget extends ElementListWidgetExt listOption; + private final TextScaledButtonWidget resetListButton; + private final TooltipButtonWidget addListButton; + + private ListGroupSeparatorEntry(ListOption group, Screen screen) { + super(group, screen); + this.listOption = group; + + this.resetListButton = new TextScaledButtonWidget(getRowRight() - 20, -50, 20, 20, 2f, Text.of("\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, Text.of("+"), Text.translatable("yacl.list.add_top"), btn -> { + group.insertNewEntryToTop(); + }); + + updateExpandMinimizeText(); + minimizeIfUnavailable(); + } + + @Override + public void render(MatrixStack matrices, 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(matrices, index, y, x, entryWidth, entryHeight, mouseX, mouseY, hovered, tickDelta); + + int buttonY = expandMinimizeButton.getY(); + + resetListButton.setY(buttonY); + addListButton.setY(buttonY); + + resetListButton.render(matrices, mouseX, mouseY, tickDelta); + addListButton.render(matrices, mouseX, mouseY, tickDelta); + } + + @Override + public void postRender(MatrixStack matrices, int mouseX, int mouseY, float delta) { + minimizeIfUnavailable(); // cannot run in render because it *should* cause a ConcurrentModificationException (but doesn't) + + super.postRender(matrices, mouseX, mouseY, delta); + + addListButton.renderHoveredTooltip(matrices); + } + + private void minimizeIfUnavailable() { + if (!listOption.available() && isExpanded()) { + setExpanded(false); + recacheViewableChildren(); + } + } + + @Override + protected void updateExpandMinimizeText() { + super.updateExpandMinimizeText(); + if (resetListButton != null && addListButton != null) + resetListButton.visible = addListButton.visible = isExpanded(); + expandMinimizeButton.active = listOption == null || listOption.available(); + } + + @Override + public List children() { + return ImmutableList.of(expandMinimizeButton, addListButton, resetListButton); + } + } } diff --git a/src/client/java/dev/isxander/yacl/gui/TooltipButtonWidget.java b/src/client/java/dev/isxander/yacl/gui/TooltipButtonWidget.java index b034f4b..359e8d0 100644 --- a/src/client/java/dev/isxander/yacl/gui/TooltipButtonWidget.java +++ b/src/client/java/dev/isxander/yacl/gui/TooltipButtonWidget.java @@ -3,21 +3,26 @@ package dev.isxander.yacl.gui; import net.minecraft.client.MinecraftClient; import net.minecraft.client.font.MultilineText; import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.tooltip.Tooltip; import net.minecraft.client.gui.widget.ButtonWidget; import net.minecraft.client.util.math.MatrixStack; import net.minecraft.text.Text; -public class TooltipButtonWidget extends ButtonWidget { +public class TooltipButtonWidget extends TextScaledButtonWidget { protected final Screen screen; protected MultilineText wrappedDescription; - public TooltipButtonWidget(Screen screen, int x, int y, int width, int height, Text message, Text tooltip, PressAction onPress) { - super(x, y, width, height, message, onPress, DEFAULT_NARRATION_SUPPLIER); + public TooltipButtonWidget(Screen screen, int x, int y, int width, int height, Text message, float textScale, Text tooltip, PressAction onPress) { + super(x, y, width, height, textScale, message, onPress); this.screen = screen; setTooltip(tooltip); } + public TooltipButtonWidget(Screen screen, int x, int y, int width, int height, Text message, Text tooltip, PressAction onPress) { + this(screen, x, y, width, height, message, 1, tooltip, onPress); + } + public void renderHoveredTooltip(MatrixStack matrices) { if (isHovered()) { YACLScreen.renderMultilineTooltip(matrices, MinecraftClient.getInstance().textRenderer, wrappedDescription, getX() + width / 2, getY() - 4, getY() + height + 4, screen.width, screen.height); diff --git a/src/client/java/dev/isxander/yacl/gui/controllers/ControllerWidget.java b/src/client/java/dev/isxander/yacl/gui/controllers/ControllerWidget.java index 0f49d3d..ae54ca4 100644 --- a/src/client/java/dev/isxander/yacl/gui/controllers/ControllerWidget.java +++ b/src/client/java/dev/isxander/yacl/gui/controllers/ControllerWidget.java @@ -8,6 +8,7 @@ import dev.isxander.yacl.gui.utils.GuiUtils; import net.minecraft.client.font.MultilineText; import net.minecraft.client.gui.DrawableHelper; import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.gui.screen.narration.NarrationPart; import net.minecraft.client.util.math.MatrixStack; import net.minecraft.text.Text; import net.minecraft.util.Formatting; @@ -151,6 +152,7 @@ public abstract class ControllerWidget> extends Abstract @Override public void appendNarrations(NarrationMessageBuilder builder) { - + builder.put(NarrationPart.TITLE, control.option().name()); + builder.put(NarrationPart.HINT, control.option().tooltip()); } } diff --git a/src/client/java/dev/isxander/yacl/gui/controllers/LabelController.java b/src/client/java/dev/isxander/yacl/gui/controllers/LabelController.java index 8369680..960c950 100644 --- a/src/client/java/dev/isxander/yacl/gui/controllers/LabelController.java +++ b/src/client/java/dev/isxander/yacl/gui/controllers/LabelController.java @@ -64,7 +64,7 @@ public class LabelController implements Controller { float y = getDimension().y(); for (OrderedText text : wrappedText) { - textRenderer.drawWithShadow(matrices, text, getDimension().x(), y + getYPadding(), option().available() ? -1 : 0xFFA0A0A0); + textRenderer.drawWithShadow(matrices, text, getDimension().x() + getXPadding(), y + getYPadding(), option().available() ? -1 : 0xFFA0A0A0); y += textRenderer.fontHeight; } } @@ -123,12 +123,16 @@ public class LabelController implements Controller { return textRenderer.getTextHandler().getStyleAt(wrappedText.get(line), x); } + private int getXPadding() { + return 4; + } + private int getYPadding() { return 3; } private void updateText() { - wrappedText = textRenderer.wrapLines(formatValue(), getDimension().width()); + wrappedText = textRenderer.wrapLines(formatValue(), getDimension().width() - getXPadding() * 2); setDimension(getDimension().withHeight(wrappedText.size() * textRenderer.fontHeight + getYPadding() * 2)); } diff --git a/src/client/java/dev/isxander/yacl/gui/controllers/ListEntryWidget.java b/src/client/java/dev/isxander/yacl/gui/controllers/ListEntryWidget.java new file mode 100644 index 0000000..a548efb --- /dev/null +++ b/src/client/java/dev/isxander/yacl/gui/controllers/ListEntryWidget.java @@ -0,0 +1,132 @@ +package dev.isxander.yacl.gui.controllers; + +import com.google.common.collect.ImmutableList; +import dev.isxander.yacl.api.ListOption; +import dev.isxander.yacl.api.ListOptionEntry; +import dev.isxander.yacl.api.utils.Dimension; +import dev.isxander.yacl.gui.*; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.ParentElement; +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class ListEntryWidget extends AbstractWidget implements ParentElement { + private final TooltipButtonWidget removeButton, moveUpButton, moveDownButton; + private final AbstractWidget entryWidget; + + private final ListOption listOption; + private final ListOptionEntry listOptionEntry; + + private final String optionNameString; + + private Element focused; + private boolean dragging; + + public ListEntryWidget(YACLScreen screen, ListOptionEntry listOptionEntry, AbstractWidget entryWidget) { + super(entryWidget.getDimension()); + this.listOptionEntry = listOptionEntry; + this.listOption = listOptionEntry.parentGroup(); + this.optionNameString = listOptionEntry.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, Text.of("\u274c"), Text.translatable("yacl.list.remove"), btn -> { + listOption.removeEntry(listOptionEntry); + }); + + moveUpButton = new TooltipButtonWidget(screen, dim.x(), dim.y(), 20, 20, Text.of("\u2191"), Text.translatable("yacl.list.move_up"), btn -> { + int index = listOption.indexOf(listOptionEntry) - 1; + if (index >= 0) { + listOption.removeEntry(listOptionEntry); + listOption.insertEntry(index, listOptionEntry); + updateButtonStates(); + } + }); + + moveDownButton = new TooltipButtonWidget(screen, dim.x() + 20, dim.y(), 20, 20, Text.of("\u2193"), Text.translatable("yacl.list.move_down"), btn -> { + int index = listOption.indexOf(listOptionEntry) + 1; + if (index < listOption.options().size()) { + listOption.removeEntry(listOptionEntry); + listOption.insertEntry(index, listOptionEntry); + updateButtonStates(); + } + }); + + updateButtonStates(); + } + + @Override + public void render(MatrixStack matrices, 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(matrices, mouseX, mouseY, delta); + moveUpButton.render(matrices, mouseX, mouseY, delta); + moveDownButton.render(matrices, mouseX, mouseY, delta); + entryWidget.render(matrices, mouseX, mouseY, delta); + } + + @Override + public void postRender(MatrixStack matrices, int mouseX, int mouseY, float delta) { + removeButton.renderHoveredTooltip(matrices); + moveUpButton.renderHoveredTooltip(matrices); + moveDownButton.renderHoveredTooltip(matrices); + } + + protected void updateButtonStates() { + removeButton.active = listOption.available(); + moveUpButton.active = listOption.indexOf(listOptionEntry) > 0 && listOption.available(); + moveDownButton.active = listOption.indexOf(listOptionEntry) < listOption.options().size() - 1 && listOption.available(); + } + + @Override + public void unfocus() { + entryWidget.unfocus(); + } + + @Override + public void appendNarrations(NarrationMessageBuilder builder) { + entryWidget.appendNarrations(builder); + } + + @Override + public boolean matchesSearch(String query) { + return optionNameString.contains(query.toLowerCase()); + } + + @Override + public 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 Element getFocused() { + return focused; + } + + @Override + public void setFocused(@Nullable Element focused) { + this.focused = focused; + } +} diff --git a/src/client/java/dev/isxander/yacl/gui/controllers/slider/SliderControllerElement.java b/src/client/java/dev/isxander/yacl/gui/controllers/slider/SliderControllerElement.java index c78e0eb..ea4e262 100644 --- a/src/client/java/dev/isxander/yacl/gui/controllers/slider/SliderControllerElement.java +++ b/src/client/java/dev/isxander/yacl/gui/controllers/slider/SliderControllerElement.java @@ -141,7 +141,11 @@ public class SliderControllerElement extends ControllerWidget dim) { super.setDimension(dim); - sliderBounds = Dimension.ofInt(dim.xLimit() - getXPadding() - getThumbWidth() / 2 - dim.width() / 3, dim.centerY() - 5, dim.width() / 3, 10); + int trackWidth = dim.width() / 3; + if (optionNameString.isEmpty()) + trackWidth = dim.width() / 2; + + sliderBounds = Dimension.ofInt(dim.xLimit() - getXPadding() - getThumbWidth() / 2 - trackWidth, dim.centerY() - 5, trackWidth, 10); } protected int getThumbX() { diff --git a/src/client/java/dev/isxander/yacl/gui/controllers/string/StringControllerElement.java b/src/client/java/dev/isxander/yacl/gui/controllers/string/StringControllerElement.java index ef70341..da33aec 100644 --- a/src/client/java/dev/isxander/yacl/gui/controllers/string/StringControllerElement.java +++ b/src/client/java/dev/isxander/yacl/gui/controllers/string/StringControllerElement.java @@ -49,7 +49,7 @@ public class StringControllerElement extends ControllerWidget maxWidth) { string = string.substring(0, Math.max(string.length() - 1 - (firstIter ? 1 : suffix.length() + 1), 0)).trim(); diff --git a/src/client/java/dev/isxander/yacl/impl/ButtonOptionImpl.java b/src/client/java/dev/isxander/yacl/impl/ButtonOptionImpl.java index dcb9c7a..f526d42 100644 --- a/src/client/java/dev/isxander/yacl/impl/ButtonOptionImpl.java +++ b/src/client/java/dev/isxander/yacl/impl/ButtonOptionImpl.java @@ -78,11 +78,6 @@ public class ButtonOptionImpl implements ButtonOption { return ImmutableSet.of(); } - @Override - public boolean requiresRestart() { - return false; - } - @Override public boolean changed() { return false; diff --git a/src/client/java/dev/isxander/yacl/impl/ListOptionEntryImpl.java b/src/client/java/dev/isxander/yacl/impl/ListOptionEntryImpl.java new file mode 100644 index 0000000..dc7aa88 --- /dev/null +++ b/src/client/java/dev/isxander/yacl/impl/ListOptionEntryImpl.java @@ -0,0 +1,122 @@ +package dev.isxander.yacl.impl; + +import dev.isxander.yacl.api.*; +import net.minecraft.text.Text; +import org.jetbrains.annotations.NotNull; + +import java.util.function.BiConsumer; +import java.util.function.Function; + +public class ListOptionEntryImpl implements ListOptionEntry { + private final ListOptionImpl group; + + private T value; + + private final Binding binding; + private final Controller controller; + + public ListOptionEntryImpl(ListOptionImpl group, T initialValue, @NotNull Function, Controller> controlGetter) { + this.group = group; + this.value = initialValue; + this.binding = new EntryBinding(); + this.controller = controlGetter.apply(this); + } + + @Override + public @NotNull Text name() { + return Text.empty(); + } + + @Override + public @NotNull Text tooltip() { + return Text.empty(); + } + + @Override + public @NotNull Controller controller() { + return controller; + } + + @Override + public @NotNull Binding binding() { + return binding; + } + + @Override + public boolean available() { + return parentGroup().available(); + } + + @Override + public void setAvailable(boolean available) { + + } + + @Override + public ListOption parentGroup() { + return group; + } + + @Override + public boolean changed() { + return false; + } + + @Override + public @NotNull T pendingValue() { + return value; + } + + @Override + public void requestSet(T value) { + binding.setValue(value); + } + + @Override + public boolean applyValue() { + return false; + } + + @Override + public void forgetPendingValue() { + + } + + @Override + public void requestSetDefault() { + + } + + @Override + public boolean isPendingValueDefault() { + return false; + } + + @Override + public boolean canResetToDefault() { + return false; + } + + @Override + public void addListener(BiConsumer, T> changedListener) { + + } + + private class EntryBinding implements Binding { + @Override + public void setValue(T newValue) { + value = newValue; + group.callListeners(); + } + + @Override + public T getValue() { + return value; + } + + @Override + public T defaultValue() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/client/java/dev/isxander/yacl/impl/ListOptionImpl.java b/src/client/java/dev/isxander/yacl/impl/ListOptionImpl.java new file mode 100644 index 0000000..128d3e7 --- /dev/null +++ b/src/client/java/dev/isxander/yacl/impl/ListOptionImpl.java @@ -0,0 +1,208 @@ +package dev.isxander.yacl.impl; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import dev.isxander.yacl.api.*; +import net.minecraft.text.Text; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class ListOptionImpl implements ListOption { + private final Text name; + private final Text tooltip; + private final Binding> binding; + private final T initialValue; + private final List> entries; + private final boolean collapsed; + private boolean available; + private final Class typeClass; + private final ImmutableSet flags; + private final EntryFactory entryFactory; + private final List>, List>> listeners; + private final List refreshListeners; + + public ListOptionImpl(@NotNull Text name, @NotNull Text tooltip, @NotNull Binding> binding, @NotNull T initialValue, @NotNull Class typeClass, @NotNull Function, Controller> controllerFunction, ImmutableSet flags, boolean collapsed, boolean available) { + this.name = name; + this.tooltip = tooltip; + this.binding = binding; + this.initialValue = initialValue; + this.entryFactory = new EntryFactory(controllerFunction); + this.entries = createEntries(binding().getValue()); + this.collapsed = collapsed; + this.typeClass = typeClass; + this.flags = flags; + this.available = available; + this.listeners = new ArrayList<>(); + this.refreshListeners = new ArrayList<>(); + callListeners(); + } + + @Override + public @NotNull Text name() { + return this.name; + } + + @Override + public @NotNull Text tooltip() { + return this.tooltip; + } + + @Override + public @NotNull ImmutableList> options() { + return ImmutableList.copyOf(entries); + } + + @Override + public @NotNull Controller> controller() { + throw new UnsupportedOperationException(); + } + + @Override + public @NotNull Binding> binding() { + return binding; + } + + @Override + public @NotNull Class> typeClass() { + throw new UnsupportedOperationException(); + } + + @Override + public @NotNull Class elementTypeClass() { + return typeClass; + } + + @Override + public boolean collapsed() { + return collapsed; + } + + @Override + public @NotNull ImmutableSet flags() { + return flags; + } + + @Override + public ImmutableList pendingValue() { + return ImmutableList.copyOf(entries.stream().map(Option::pendingValue).toList()); + } + + @Override + public void insertEntry(int index, ListOptionEntry entry) { + entries.add(index, (ListOptionEntry) entry); + onRefresh(); + } + + @Override + public ListOptionEntry insertNewEntryToTop() { + ListOptionEntry newEntry = entryFactory.create(initialValue); + entries.add(0, newEntry); + onRefresh(); + return newEntry; + } + + @Override + public void removeEntry(ListOptionEntry entry) { + entries.remove(entry); + onRefresh(); + } + + @Override + public int indexOf(ListOptionEntry entry) { + return entries.indexOf(entry); + } + + @Override + public void requestSet(List value) { + entries.clear(); + entries.addAll(createEntries(value)); + onRefresh(); + listeners.forEach(listener -> listener.accept(this, value)); + } + + @Override + public boolean changed() { + return !binding().getValue().equals(pendingValue()); + } + + @Override + public boolean applyValue() { + if (changed()) { + binding().setValue(pendingValue()); + return true; + } + return false; + } + + @Override + public void forgetPendingValue() { + requestSet(binding().getValue()); + } + + @Override + public void requestSetDefault() { + requestSet(binding().defaultValue()); + } + + @Override + public boolean isPendingValueDefault() { + return binding().defaultValue().equals(pendingValue()); + } + + @Override + public boolean available() { + return available; + } + + @Override + public void setAvailable(boolean available) { + this.available = available; + } + + @Override + public void addListener(BiConsumer>, List> changedListener) { + this.listeners.add(changedListener); + } + + @Override + public void addRefreshListener(Runnable changedListener) { + this.refreshListeners.add(changedListener); + } + + @Override + public boolean isRoot() { + return false; + } + + private List> createEntries(Collection values) { + return values.stream().map(entryFactory::create).collect(Collectors.toList()); + } + + void callListeners() { + List pendingValue = pendingValue(); + this.listeners.forEach(listener -> listener.accept(this, pendingValue)); + } + + private void onRefresh() { + refreshListeners.forEach(Runnable::run); + callListeners(); + } + + private class EntryFactory { + private final Function, Controller> controllerFunction; + + private EntryFactory(Function, Controller> controllerFunction) { + this.controllerFunction = controllerFunction; + } + + public ListOptionEntry create(T initialValue) { + return new ListOptionEntryImpl<>(ListOptionImpl.this, initialValue, controllerFunction); + } + } +} diff --git a/src/client/java/dev/isxander/yacl/impl/OptionGroupImpl.java b/src/client/java/dev/isxander/yacl/impl/OptionGroupImpl.java index 58bc96b..02ef04c 100644 --- a/src/client/java/dev/isxander/yacl/impl/OptionGroupImpl.java +++ b/src/client/java/dev/isxander/yacl/impl/OptionGroupImpl.java @@ -6,5 +6,5 @@ import dev.isxander.yacl.api.OptionGroup; import net.minecraft.text.Text; import org.jetbrains.annotations.NotNull; -public record OptionGroupImpl(@NotNull Text name, @NotNull Text tooltip, ImmutableList> options, boolean collapsed, boolean isRoot) implements OptionGroup { +public record OptionGroupImpl(@NotNull Text name, @NotNull Text tooltip, ImmutableList> options, boolean collapsed, boolean isRoot) implements OptionGroup { } diff --git a/src/client/java/dev/isxander/yacl/impl/OptionImpl.java b/src/client/java/dev/isxander/yacl/impl/OptionImpl.java index 90158c7..0cc156f 100644 --- a/src/client/java/dev/isxander/yacl/impl/OptionImpl.java +++ b/src/client/java/dev/isxander/yacl/impl/OptionImpl.java @@ -92,11 +92,6 @@ public class OptionImpl implements Option { return flags; } - @Override - public boolean requiresRestart() { - return flags.contains(OptionFlag.GAME_RESTART); - } - @Override public boolean changed() { return !binding().getValue().equals(pendingValue); diff --git a/src/client/java/dev/isxander/yacl/impl/YetAnotherConfigLibImpl.java b/src/client/java/dev/isxander/yacl/impl/YetAnotherConfigLibImpl.java index eb23eac..380929c 100644 --- a/src/client/java/dev/isxander/yacl/impl/YetAnotherConfigLibImpl.java +++ b/src/client/java/dev/isxander/yacl/impl/YetAnotherConfigLibImpl.java @@ -8,12 +8,77 @@ import dev.isxander.yacl.impl.utils.YACLConstants; import net.minecraft.client.gui.screen.Screen; import net.minecraft.text.Text; +import java.util.Objects; import java.util.function.Consumer; -public record YetAnotherConfigLibImpl(Text title, ImmutableList categories, Runnable saveFunction, Consumer initConsumer) implements YetAnotherConfigLib { +public final class YetAnotherConfigLibImpl implements YetAnotherConfigLib { + private final Text title; + private final ImmutableList categories; + private final Runnable saveFunction; + private final Consumer initConsumer; + + private boolean generated = false; + + public YetAnotherConfigLibImpl(Text title, ImmutableList categories, Runnable saveFunction, Consumer initConsumer) { + this.title = title; + this.categories = categories; + this.saveFunction = saveFunction; + this.initConsumer = initConsumer; + } + @Override public Screen generateScreen(Screen parent) { + if (generated) + throw new UnsupportedOperationException("To prevent memory leaks, you should only generate a Screen once per instance. Please re-build the instance to generate another GUI."); + YACLConstants.LOGGER.info("Generating YACL screen"); + generated = true; return new YACLScreen(this, parent); } + + @Override + public Text title() { + return title; + } + + @Override + public ImmutableList categories() { + return categories; + } + + @Override + public Runnable saveFunction() { + return saveFunction; + } + + @Override + public Consumer initConsumer() { + return initConsumer; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (YetAnotherConfigLibImpl) obj; + return Objects.equals(this.title, that.title) && + Objects.equals(this.categories, that.categories) && + Objects.equals(this.saveFunction, that.saveFunction) && + Objects.equals(this.initConsumer, that.initConsumer); + } + + @Override + public int hashCode() { + return Objects.hash(title, categories, saveFunction, initConsumer); + } + + @Override + public String toString() { + return "YetAnotherConfigLibImpl[" + + "title=" + title + ", " + + "categories=" + categories + ", " + + "saveFunction=" + saveFunction + ", " + + "initConsumer=" + initConsumer + ']'; + } + } diff --git a/src/main/resources/assets/yet-another-config-lib/lang/en_us.json b/src/main/resources/assets/yet-another-config-lib/lang/en_us.json index 17972a1..292864f 100644 --- a/src/main/resources/assets/yet-another-config-lib/lang/en_us.json +++ b/src/main/resources/assets/yet-another-config-lib/lang/en_us.json @@ -16,6 +16,11 @@ "yacl.gui.save_before_exit": "Save before exiting!", "yacl.gui.save_before_exit.tooltip": "Save or cancel to exit the GUI.", + "yacl.list.move_up": "Move up", + "yacl.list.move_down": "Move down", + "yacl.list.remove": "Remove", + "yacl.list.add_top": "New entry", + "yacl.restart.title": "Config requires restart!", "yacl.restart.message": "One or more options needs you to restart the game to apply the changes.", "yacl.restart.yes": "Close Minecraft", diff --git a/src/testmod/java/dev/isxander/yacl/test/config/ExampleConfig.java b/src/testmod/java/dev/isxander/yacl/test/config/ExampleConfig.java index c7ea46c..5d864da 100644 --- a/src/testmod/java/dev/isxander/yacl/test/config/ExampleConfig.java +++ b/src/testmod/java/dev/isxander/yacl/test/config/ExampleConfig.java @@ -5,6 +5,7 @@ import dev.isxander.yacl.config.ConfigInstance; import dev.isxander.yacl.config.GsonConfigInstance; import java.awt.*; +import java.util.List; import java.nio.file.Path; public class ExampleConfig { @@ -25,6 +26,9 @@ public class ExampleConfig { @ConfigEntry public long longField = 5; @ConfigEntry public Alphabet enumOption = Alphabet.A; + @ConfigEntry public List stringList = List.of("This is quite cool.", "You can add multiple items!", "And it is integrated so well into Option groups!"); + @ConfigEntry public List intList = List.of(1, 2, 3); + @ConfigEntry public boolean groupTestRoot = false; @ConfigEntry public boolean groupTestFirstGroup = false; @ConfigEntry public boolean groupTestFirstGroup2 = false; diff --git a/src/testmod/java/dev/isxander/yacl/test/config/GuiTest.java b/src/testmod/java/dev/isxander/yacl/test/config/GuiTest.java index 7295f2c..4965150 100644 --- a/src/testmod/java/dev/isxander/yacl/test/config/GuiTest.java +++ b/src/testmod/java/dev/isxander/yacl/test/config/GuiTest.java @@ -22,7 +22,8 @@ import net.minecraft.text.ClickEvent; import net.minecraft.text.HoverEvent; import net.minecraft.text.Text; -import java.awt.*; +import java.awt.Color; +import java.util.List; public class GuiTest { public static Screen getModConfigScreenFactory(Screen parent) { @@ -245,6 +246,36 @@ public class GuiTest { .build()) .build()) .build()) + .category(ConfigCategory.createBuilder() + .name(Text.of("List Test")) + .group(ListOption.createBuilder(String.class) + .name(Text.of("String List")) + .binding( + defaults.stringList, + () -> config.stringList, + val -> config.stringList = val + ) + .controller(StringController::new) + .initial("") + .build()) + .group(ListOption.createBuilder(Integer.class) + .name(Text.of("Slider List")) + .binding( + defaults.intList, + () -> config.intList, + val -> config.intList = val + ) + .controller(opt -> new IntegerSliderController(opt, 0, 10, 1)) + .initial(0) + .available(false) + .build()) + .group(ListOption.createBuilder(Text.class) + .name(Text.of("Useless Label List")) + .binding(Binding.immutable(List.of(Text.of("It's quite impressive that literally every single controller works, without problem.")))) + .controller(LabelController::new) + .initial(Text.of("Initial label")) + .build()) + .build()) .category(PlaceholderCategory.createBuilder() .name(Text.of("Placeholder Category")) .screen((client, yaclScreen) -> new RequireRestartScreen(yaclScreen)) -- cgit