diff --git a/common/src/api/java/net/caffeinemc/mods/sodium/api/config/option/IntegerOptionControlStyle.java b/common/src/api/java/net/caffeinemc/mods/sodium/api/config/option/IntegerOptionControlStyle.java new file mode 100644 index 0000000000..67aac8e825 --- /dev/null +++ b/common/src/api/java/net/caffeinemc/mods/sodium/api/config/option/IntegerOptionControlStyle.java @@ -0,0 +1,9 @@ +package net.caffeinemc.mods.sodium.api.config.option; + +/** + * The control style used by an integer option. + */ +public enum IntegerOptionControlStyle { + SLIDER, + TEXT_BOX +} diff --git a/common/src/api/java/net/caffeinemc/mods/sodium/api/config/structure/IntegerOptionBuilder.java b/common/src/api/java/net/caffeinemc/mods/sodium/api/config/structure/IntegerOptionBuilder.java index 313ee6f079..7a042cfc42 100644 --- a/common/src/api/java/net/caffeinemc/mods/sodium/api/config/structure/IntegerOptionBuilder.java +++ b/common/src/api/java/net/caffeinemc/mods/sodium/api/config/structure/IntegerOptionBuilder.java @@ -103,6 +103,14 @@ public interface IntegerOptionBuilder extends StatefulOptionBuilder { */ IntegerOptionBuilder setValidatorProvider(Function provider, Identifier... dependencies); + /** + * Sets the control style for this integer option. + * + * @param control The control style to use. + * @return The current builder instance. + */ + IntegerOptionBuilder setControlStyle(IntegerOptionControlStyle control); + /** * Sets the value formatter for this integer option. * @@ -110,4 +118,4 @@ public interface IntegerOptionBuilder extends StatefulOptionBuilder { * @return The current builder instance. */ IntegerOptionBuilder setValueFormatter(ControlValueFormatter formatter); -} \ No newline at end of file +} diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/config/builder/IntegerOptionBuilderImpl.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/config/builder/IntegerOptionBuilderImpl.java index 4822924fae..d9d091181f 100644 --- a/common/src/main/java/net/caffeinemc/mods/sodium/client/config/builder/IntegerOptionBuilderImpl.java +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/config/builder/IntegerOptionBuilderImpl.java @@ -19,6 +19,7 @@ class IntegerOptionBuilderImpl extends StatefulOptionBuilderImpl implements IntegerOptionBuilder { private DependentValue validatorProvider; + private IntegerOptionControlStyle control = IntegerOptionControlStyle.SLIDER; private ControlValueFormatter valueFormatter; IntegerOptionBuilderImpl(Identifier id) { @@ -31,6 +32,7 @@ void validateData() { Validate.notNull(this.getValidatorProvider(), "Validator provider must be set"); Validate.notNull(this.getValueFormatter(), "Value formatter must be set"); + Validate.notNull(this.getControlStyle(), "Control style must be set"); } @Override @@ -51,6 +53,7 @@ IntegerOption build() { this.getBinding(), this.getApplyHook(), this.getValidatorProvider(), + this.getControlStyle(), this.getValueFormatter()); } @@ -70,6 +73,10 @@ DependentValue getValidatorProvider() { return getFirstNotNull(this.validatorProvider, IntegerOption::getValidatorProvider); } + IntegerOptionControlStyle getControlStyle() { + return getFirstNotNull(this.control, IntegerOption::getControlStyle); + } + ControlValueFormatter getValueFormatter() { return getFirstNotNull(this.valueFormatter, IntegerOption::getValueFormatter); } @@ -193,6 +200,12 @@ public IntegerOptionBuilder setValidatorProvider(Function { private final DependentValue validator; + private final IntegerOptionControlStyle controlStyle; private final ControlValueFormatter valueFormatter; public IntegerOption( @@ -35,10 +34,12 @@ public IntegerOption( OptionBinding binding, Consumer applyHook, DependentValue validator, + IntegerOptionControlStyle controlStyle, ControlValueFormatter valueFormatter ) { super(id, dependencies, name, enabled, storage, tooltipProvider, impact, flags, defaultValue, controlHiddenWhenDisabled, binding, applyHook); this.validator = validator; + this.controlStyle = controlStyle; this.valueFormatter = valueFormatter; } @@ -59,7 +60,10 @@ Integer validateValue(Integer value) { @Override Control createControl() { - return new SliderControl(this); + return switch (this.controlStyle) { + case SLIDER -> new SliderControl(this); + case TEXT_BOX -> new IntegerTextBoxControl(this); + }; } public SteppedValidator getSteppedValidator() { @@ -74,6 +78,10 @@ public DependentValue getValidatorProvider() { return this.validator; } + public IntegerOptionControlStyle getControlStyle() { + return this.controlStyle; + } + public ControlValueFormatter getValueFormatter() { return this.valueFormatter; } diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumConfigBuilder.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumConfigBuilder.java index fc3847f377..12c41a06d4 100644 --- a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumConfigBuilder.java +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/SodiumConfigBuilder.java @@ -10,6 +10,7 @@ import net.caffeinemc.mods.sodium.api.config.ConfigEntryPoint; import net.caffeinemc.mods.sodium.api.config.ConfigState; import net.caffeinemc.mods.sodium.api.config.StorageEventHandler; +import net.caffeinemc.mods.sodium.api.config.option.IntegerOptionControlStyle; import net.caffeinemc.mods.sodium.api.config.option.OptionFlag; import net.caffeinemc.mods.sodium.api.config.option.OptionImpact; import net.caffeinemc.mods.sodium.api.config.option.Range; @@ -47,6 +48,9 @@ public class SodiumConfigBuilder implements ConfigEntryPoint { private static final Identifier SODIUM_ICON = Identifier.fromNamespaceAndPath("sodium", "textures/gui/config-icon.png"); private static final SodiumOptions DEFAULTS = SodiumOptions.defaults(); + public static final int FRAMERATE_LIMIT_MIN = 10; + public static final int FRAMERATE_LIMIT_MAX = 1_000_000; + public static final int FRAMERATE_LIMIT_DEFAULT = 60; private final Options vanillaOpts; private final StorageEventHandler vanillaStorage; @@ -309,9 +313,10 @@ private OptionPageBuilder buildGeneralPage(ConfigBuilder builder) { .setStorageHandler(this.vanillaStorage) .setName(Component.translatable("options.framerateLimit")) .setTooltip(Component.translatable("sodium.options.fps_limit.tooltip")) - .setValueFormatter(ControlValueFormatterImpls.fpsLimit()) - .setRange(10, 260, 10) - .setDefaultValue(60) + .setValueFormatter(ControlValueFormatterImpls.fpsLimit(FRAMERATE_LIMIT_MAX)) + .setRange(FRAMERATE_LIMIT_MIN, FRAMERATE_LIMIT_MAX, 1) + .setDefaultValue(FRAMERATE_LIMIT_DEFAULT) + .setControlStyle(IntegerOptionControlStyle.TEXT_BOX) .setBinding(this.vanillaOpts.framerateLimit()::set, this.vanillaOpts.framerateLimit()::get) ) ); @@ -726,5 +731,4 @@ private OptionPageBuilder buildAdvancedPage(ConfigBuilder builder) { ); return advancedPage; } - -} \ No newline at end of file +} diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/ControlElement.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/ControlElement.java index 08a9ff3ea8..fbf3d1fa0b 100644 --- a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/ControlElement.java +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/ControlElement.java @@ -4,7 +4,7 @@ import net.caffeinemc.mods.sodium.client.gui.ColorTheme; import net.caffeinemc.mods.sodium.client.gui.Colors; import net.caffeinemc.mods.sodium.client.gui.Layout; -import net.caffeinemc.mods.sodium.client.gui.widgets.AbstractWidget; +import net.caffeinemc.mods.sodium.client.gui.widgets.AbstractParentWidget; import net.caffeinemc.mods.sodium.client.util.Dim2i; import net.minecraft.ChatFormatting; import net.minecraft.client.gui.ComponentPath; @@ -15,7 +15,7 @@ import net.minecraft.network.chat.Style; import org.jspecify.annotations.Nullable; -public abstract class ControlElement extends AbstractWidget { +public abstract class ControlElement extends AbstractParentWidget { protected final AbstractOptionList list; protected final ColorTheme theme; @@ -83,6 +83,16 @@ public int getY() { if (!this.getOption().isEnabled()) { return null; } + + if (this.children().isEmpty()) { + return ComponentPath.leaf(this); + } + return super.nextFocusPath(event); } + + @Override + public boolean isFocused() { + return super.isFocused() || this.getFocused() != null; + } } diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/ControlValueFormatterImpls.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/ControlValueFormatterImpls.java index d03ff70643..471e2cc422 100644 --- a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/ControlValueFormatterImpls.java +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/ControlValueFormatterImpls.java @@ -31,8 +31,8 @@ public static ControlValueFormatter resolution() { }; } - public static ControlValueFormatter fpsLimit() { - return (v) -> (v == 260) ? Component.translatable("options.framerateLimit.max") : Component.translatable("options.framerate", v); + public static ControlValueFormatter fpsLimit(int maximum) { + return (v) -> (v == maximum) ? Component.translatable("options.framerateLimit.max") : Component.translatable("options.framerate", v); } public static ControlValueFormatter brightness() { diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/IntegerTextBoxControl.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/IntegerTextBoxControl.java new file mode 100644 index 0000000000..557a775854 --- /dev/null +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/options/control/IntegerTextBoxControl.java @@ -0,0 +1,258 @@ +package net.caffeinemc.mods.sodium.client.gui.options.control; + +import com.mojang.blaze3d.platform.cursor.CursorTypes; +import net.caffeinemc.mods.sodium.client.config.structure.IntegerOption; +import net.caffeinemc.mods.sodium.client.config.structure.StatefulOption; +import net.caffeinemc.mods.sodium.client.gui.ColorTheme; +import net.caffeinemc.mods.sodium.client.gui.Colors; +import net.caffeinemc.mods.sodium.client.gui.Layout; +import net.caffeinemc.mods.sodium.client.util.Dim2i; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.util.Mth; +import org.jspecify.annotations.Nullable; + +import java.util.regex.Pattern; + +public class IntegerTextBoxControl implements Control { + private final IntegerOption option; + + public IntegerTextBoxControl(IntegerOption option) { + this.option = option; + } + + @Override + public ControlElement createElement(Screen screen, AbstractOptionList list, Dim2i dim, ColorTheme theme) { + return new IntegerTextBoxControlElement(list, this.option, dim, theme); + } + + @Override + public StatefulOption getOption() { + return this.option; + } + + @Override + public int getMaxWidth() { + return Layout.SLIDER_WIDTH; + } + + static class IntegerTextBoxControlElement extends StatefulControlElement { + private static final Pattern NON_DIGIT_PATTERN = Pattern.compile("[^0-9]"); + private static final int TEXT_BOX_WIDTH = Layout.SLIDER_WIDTH; + private static final int TEXT_BOX_HEIGHT = Layout.BUTTON_SHORT - 6; + + private final IntegerOption option; + private final EditBox textBox; + + private boolean updatingText; + + public IntegerTextBoxControlElement(AbstractOptionList list, IntegerOption option, Dim2i dim, ColorTheme theme) { + super(list, dim, theme); + + this.option = option; + + this.textBox = new EditBox( + this.font, + 0, + 0, + TEXT_BOX_WIDTH - (Layout.INNER_MARGIN * 2), + TEXT_BOX_HEIGHT, + option.getName()); + this.textBox.setBordered(false); + this.textBox.setMaxLength(String.valueOf(option.getSteppedValidator().max()).length()); + this.textBox.setResponder(this::setValueFromText); + this.syncTextToOption(); + this.addChild(this.textBox); + } + + @Override + public IntegerOption getOption() { + return this.option; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) { + super.extractRenderState(graphics, mouseX, mouseY, delta); + + if (!this.option.showControl() || (this.isResetOverlayActive() && this.getFocused() != this.textBox)) { + return; + } + + this.textBox.setEditable(this.option.isEnabled()); + this.textBox.setTextColor(this.option.isEnabled() ? Colors.FOREGROUND : Colors.FOREGROUND_DISABLED); + this.textBox.setTextColorUneditable(Colors.FOREGROUND_DISABLED); + + if (!this.textBox.isFocused()) { + this.syncTextToOption(); + } + + this.updateTextBoxPosition(); + + int x = this.getTextBoxX(); + int y = this.getTextBoxY(); + int borderColor = (this.isFocused() || this.isMouseOverTextBox(mouseX, mouseY)) ? this.theme.themeLighter : Colors.BACKGROUND_LIGHT; + + this.drawRect(graphics, x, y, x + TEXT_BOX_WIDTH, y + TEXT_BOX_HEIGHT, Colors.BACKGROUND_MEDIUM); + this.drawBorder(graphics, x, y, x + TEXT_BOX_WIDTH, y + TEXT_BOX_HEIGHT, borderColor); + this.textBox.extractRenderState(graphics, mouseX, mouseY, delta); + + if (this.isMouseOverTextBox(mouseX, mouseY)) { + graphics.requestCursor(CursorTypes.IBEAM); + } + } + + @Override + public int getContentWidth() { + return TEXT_BOX_WIDTH; + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (super.mouseClicked(event, doubleClick)) { + this.setFocused(false); + this.syncTextToOption(); + return true; + } + + if (this.isResetOverlayActive() || !this.option.isEnabled() || !this.option.showControl()) { + return false; + } + + this.updateTextBoxPosition(); + + if (event.button() == 0 && this.isMouseOverTextBox(event.x(), event.y())) { + this.setFocused(true); + this.textBox.mouseClicked(event, doubleClick); + return true; + } + + return false; + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (this.getFocused() != this.textBox) { + return false; + } + + if (event.isEscape() || event.isConfirmation()) { + this.setFocused(false); + return true; + } + + return this.textBox.keyPressed(event); + } + + @Override + public boolean charTyped(CharacterEvent event) { + if (this.getFocused() != this.textBox || !isDigit(event.codepoint())) { + return false; + } + + return this.textBox.charTyped(event); + } + + @Override + public void setFocused(boolean focused) { + if (focused) { + this.setFocused(this.textBox); + } else { + this.setFocused((GuiEventListener) null); + } + } + + @Override + public void setFocused(@Nullable GuiEventListener guiEventListener) { + if (guiEventListener == null && this.getFocused() == this.textBox) { + this.commitText(); + } + + super.setFocused(guiEventListener); + } + + private int getTextBoxX() { + return this.getLimitX() - TEXT_BOX_WIDTH - Layout.OPTION_TEXT_SIDE_PADDING; + } + + private int getTextBoxY() { + return this.getCenterY() - (TEXT_BOX_HEIGHT / 2); + } + + private void updateTextBoxPosition() { + this.textBox.setX(this.getTextBoxX() + Layout.INNER_MARGIN); + this.textBox.setY(this.getTextBoxY() + ((TEXT_BOX_HEIGHT - this.font.lineHeight + 1) / 2)); + } + + private boolean isMouseOverTextBox(double mouseX, double mouseY) { + int x = this.getTextBoxX(); + int y = this.getTextBoxY(); + return mouseX >= x && mouseX < x + TEXT_BOX_WIDTH && mouseY >= y && mouseY < y + TEXT_BOX_HEIGHT; + } + + private void setValueFromText(String text) { + if (this.updatingText) { + return; + } + + String sanitized = sanitize(text); + if (!sanitized.equals(text)) { + this.setText(sanitized); + return; + } + + if (sanitized.isEmpty()) { + return; + } + + this.option.modifyValue(this.getClampedValue(sanitized)); + } + + private void commitText() { + String text = this.textBox.getValue(); + if (text.isEmpty()) { + this.syncTextToOption(); + return; + } + + int value = this.getClampedValue(text); + this.option.modifyValue(value); + this.setText(String.valueOf(value)); + } + + private int getClampedValue(String text) { + var range = this.option.getSteppedValidator(); + int value; + + try { + value = Integer.parseInt(text); + } catch (NumberFormatException ignored) { + value = range.max(); + } + + return Mth.clamp(value, range.min(), range.max()); + } + + private void syncTextToOption() { + this.setText(String.valueOf(this.option.getValidatedValue())); + } + + private void setText(String text) { + this.updatingText = true; + this.textBox.setValue(text); + this.updatingText = false; + } + + private static String sanitize(String text) { + return NON_DIGIT_PATTERN.matcher(text).replaceAll(""); + } + + private static boolean isDigit(int codepoint) { + return codepoint >= '0' && codepoint <= '9'; + } + } +} diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/mixin/features/gui/FramerateLimitOptionMixin.java b/common/src/main/java/net/caffeinemc/mods/sodium/mixin/features/gui/FramerateLimitOptionMixin.java new file mode 100644 index 0000000000..76c8fb341f --- /dev/null +++ b/common/src/main/java/net/caffeinemc/mods/sodium/mixin/features/gui/FramerateLimitOptionMixin.java @@ -0,0 +1,57 @@ +package net.caffeinemc.mods.sodium.mixin.features.gui; + +import com.mojang.serialization.Codec; +import net.minecraft.client.Minecraft; +import net.minecraft.client.OptionInstance; +import net.minecraft.client.Options; +import net.minecraft.network.chat.Component; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import static net.caffeinemc.mods.sodium.client.gui.SodiumConfigBuilder.*; + +@Mixin(Options.class) +public class FramerateLimitOptionMixin { + @Mutable + @Shadow + @Final + private OptionInstance framerateLimit; + + @Inject( + method = "", + at = @At( + value = "FIELD", + target = "Lnet/minecraft/client/Options;framerateLimit:Lnet/minecraft/client/OptionInstance;", + opcode = Opcodes.PUTFIELD, + shift = At.Shift.AFTER + ) + ) + private void replaceFramerateLimitOption(Minecraft minecraft, java.io.File optionsFile, CallbackInfo ci) { + this.framerateLimit = new OptionInstance<>( + "options.framerateLimit", + OptionInstance.noTooltip(), + FramerateLimitOptionMixin::formatFramerateLimit, + new OptionInstance.IntRange(FRAMERATE_LIMIT_MIN, FRAMERATE_LIMIT_MAX), + Codec.intRange(FRAMERATE_LIMIT_MIN, FRAMERATE_LIMIT_MAX), + FRAMERATE_LIMIT_DEFAULT, + FramerateLimitOptionMixin::setFramerateLimit); + } + + private static Component formatFramerateLimit(Component caption, Integer value) { + if (value == FRAMERATE_LIMIT_MAX) { + return Options.genericValueLabel(caption, Component.translatable("options.framerateLimit.max")); + } + + return Options.genericValueLabel(caption, Component.translatable("options.framerate", value)); + } + + private static void setFramerateLimit(Integer value) { + Minecraft.getInstance().getFramerateLimitTracker().setFramerateLimit(value); + } +} diff --git a/common/src/main/resources/sodium-common.mixins.json b/common/src/main/resources/sodium-common.mixins.json index 628c405f92..c350466c5c 100644 --- a/common/src/main/resources/sodium-common.mixins.json +++ b/common/src/main/resources/sodium-common.mixins.json @@ -46,6 +46,7 @@ "core.world.map.ClientLevelMixin", "core.world.map.ClientPacketListenerMixin", "features.gui.OptionsAccessor", + "features.gui.FramerateLimitOptionMixin", "features.gui.hooks.console.GameRendererMixin", "features.gui.hooks.debug.DebugEntryMemoryMixin", "features.gui.hooks.debug.DebugScreenEntriesAccessor",