Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Config API and Options Screen Redesign #2837

Open
wants to merge 31 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
61db95c
New game GUI experiment
IMS212 Oct 23, 2024
4b68c3b
Add WIP config api with IMS' redesign of the options page and contari…
douira Oct 24, 2024
5b18c79
scrollabe page list
douira Oct 24, 2024
94793b0
allow users to set their own theme colors,
douira Oct 25, 2024
328d230
add api to add custom screens, use a color theme builder instead of a…
douira Oct 25, 2024
b66afea
move important members of option builder subinterfaces more useful
douira Oct 26, 2024
ef1df63
refactor: store dimensions in AbstractWidget
douira Oct 26, 2024
b627ad3
new: scrollable option list
douira Oct 27, 2024
fe9b2a6
fix: move sliders and tickboxes when sccrolling
douira Oct 27, 2024
720ecbd
add api method for formatting version instead of setting a new one
douira Oct 27, 2024
d5ba7d0
fix crash not working correctly
douira Oct 27, 2024
7706825
move layout constants into separate class,
douira Oct 27, 2024
1742e41
constrain tooltip to screen
douira Oct 27, 2024
1189d81
fix tooltip after rebase
douira Nov 1, 2024
3cf0729
improve config failure crash message
douira Nov 1, 2024
dc1332e
add default implementation to early register entrypoint so users don'…
douira Nov 1, 2024
cc0fbcb
truncate mod header subtitles if too long
douira Nov 1, 2024
384a818
add usage documentation, update todos
douira Nov 1, 2024
9d65347
add api methods to add button that redirects to an external page
douira Nov 2, 2024
c18e6c8
allow gui scale to be changed by holding down control and scrolling,
douira Nov 2, 2024
406d5ba
add ability for mods to override other mods' options with new options
douira Nov 2, 2024
8e701ca
cleanup
douira Nov 3, 2024
fe70c25
implement dynamic value constraints in control elements, cleanup cont…
douira Nov 3, 2024
a3a5b84
add annotation-based entrypoint loading on neoforge, refactored how m…
douira Nov 3, 2024
0ad3f83
Fix cycling controls not playing a sound when activated using the key…
haykam821 Nov 4, 2024
6bfbe53
allow changing fullscreen resolution on macOS too
douira Nov 4, 2024
fea1c5c
new: make tooltips scrollable
KingContaria Nov 18, 2024
8a9b25a
consolidate more colors into the color class
douira Nov 19, 2024
56f687b
misc cleanup and refactor scrollbar widget to extend abstract widget
douira Nov 19, 2024
d104ff1
add arrow to toolbar at hovered element, moved tooltip logic into own…
douira Nov 19, 2024
be9bb64
update todos
douira Nov 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.caffeinemc.mods.sodium.api.config;

import net.caffeinemc.mods.sodium.api.config.structure.ConfigBuilder;

public interface ConfigEntryPoint {
default void registerConfigEarly(ConfigBuilder builder) {
};

void registerConfigLate(ConfigBuilder builder);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package net.caffeinemc.mods.sodium.api.config;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ConfigEntryPointForge {
/**
* The mod id to associate this config entrypoint's "owner" with.
*
* @return the mod id
*/
String value();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package net.caffeinemc.mods.sodium.api.config;

import net.minecraft.resources.ResourceLocation;

public interface ConfigState {
boolean readBooleanOption(ResourceLocation id);

int readIntOption(ResourceLocation id);

<E extends Enum<E>> E readEnumOption(ResourceLocation id, Class<E> enumClass);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package net.caffeinemc.mods.sodium.api.config;

@FunctionalInterface
public interface StorageEventHandler {
void afterSave();
}
180 changes: 180 additions & 0 deletions common/src/api/java/net/caffeinemc/mods/sodium/api/config/USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
TODO: finalize dependency declaration to only use the API package, variant declaration?

# Usage of the Sodium Config API

The Sodium Config API lets mods add their own pages to the Video Settings screen, which Sodium replaces with its own screen.

## Scope

The Sodium Config API is intended for mods that add video settings, not as a general purpose config API. For general purpose configuration, use the platform's appropriate mod list and a config library.

As a presentation API, it does not handle loading, parsing, or saving configuration data to files. It is up to your mod to handle that on its own.

## Overview

Sodium redirects Minecraft's "Video Settings" screen to its own screen. Historically, third-party mods have mixed into Sodium to add buttons to their own settings pages or additional options to Sodium's pages.

With this API, these mods will not need to touch Sodium's internals anymore and should be able to operate independently of the GUI's implementation details. The API may not be able to cover all use cases where mods mixed into Sodium's options code, but it should cover most of the common ones.

Registration of options happens in two stages: Early and late. Early registration happens when Sodium initializes its own early options before the window is created. Late registration happens after the game launched. Most mods will only need to use late registration. These stages are independent and only options that are registered in the late stage will show up in the GUI.

## Getting Started

### Dependency on Sodium's API

Sodium publishes its api package on a maven repository that you can depend on in your buildscript.

Fabric:

```groovy
dependencies {
// ... other dependencies

modImplementation "net.caffeinemc.mods:sodium-fabric:0.6.0+mc1.21.3"
}
```

NeoForge:

```groovy
dependencies {
// ... other dependencies

implementation "net.caffeinemc.mods:sodium-neoforge:0.6.0+mc1.21.3"
}
```

### Creating an Entrypoint

Entrypoint classes that Sodium calls to run your options registration code can be declared either in your mod's metadata file, or on NeoForge with a special annotation.

#### With a Metadata Entry

Metadata-based entrypoints use the key `sodium:config_api_user` and the value is the full reference to a class that implements the `net.caffeinemc.mods.sodium.api.config.ConfigEntryPoint` interface.

Fabric `fabric.mod.json`:

```json5
{
"entrypoints": {
// ... other entrypoints

"sodium:config_api_user": [
"com.example.examplemod.ExampleModConfigBuilder"
]
}
}
```

NeoForge `neoforge.mods.toml`:
```toml
[modproperties.examplemod]
"sodium:config_api_user" = "com.example.examplemod.ExampleModConfigBuilder"
```

The implementation of the entrypoint can look something like this:

```java
package com.example.examplemod;

import net.caffeinemc.mods.sodium.api.config.ConfigEntryPoint;
import net.caffeinemc.mods.sodium.api.config.structure.ConfigBuilder;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;

public class ExampleConfigUser implements ConfigEntryPoint {
// Store your options in a separate class!
private final class OptionStorage {
private boolean exampleOption = true;

public boolean getExampleOption() {
return this.exampleOption;
}

public void setExampleOption(boolean value) {
this.exampleOption = value;
}

public void flush() {
// flush options to config file
}
}

private final OptionStorage storage = new OptionStorage();
private final Runnable handler = this.storage::flush;

@Override
public void registerConfigLate(ConfigBuilder builder) {
builder.registerOwnModOptions()
.addPage(builder.createOptionPage()
.setName(Component.literal("Example Page"))
.addOptionGroup(builder.createOptionGroup()
.setName(Component.literal("Example Group"))
.addOption(builder.createBooleanOption(ResourceLocation.parse("examplemod:example_option"))
.setName(Component.literal("Example Option")) // use translation keys here
.setTooltip(Component.literal("Example tooltip"))
.setStorageHandler(this.handler)
.setBinding(this.storage::setExampleOption, this.storage::getExampleOption)
.setDefaultValue(true)
)
)
);
}
}
```

#### NeoForge: With an Annotation

Since NeoForge has the convention of using annotations for entrypoints, this option is provided as an alternative. Any classes annotated with `@ConfigEntryPointForge("examplemod")` will be loaded as config entrypoints too. Note that the annotation must be given the mod id that should be associated as the default mod for which a config is registered with `ConfigBuilder.registerOwnModOptions`. This is necessary as it's otherwise impossible to uniquely determine which mod a class is associated with on NeoForge.

```java
import net.caffeinemc.mods.sodium.api.config.ConfigEntryPoint;
import net.caffeinemc.mods.sodium.api.config.ConfigEntryPointForge;

@ConfigEntryPointForge("examplemod")
public class ExampleConfigUser implements ConfigEntryPoint {
// class body identical to the above
}
```

### Registering Your Options

Each mod adds a page for its options, within each page there are groups of options, and each group contains a list of options. Each option has an id, a name, a tooltip, a storage handler, a binding, and a default value. There are three types of options: boolean (tickbox), integer (slider), and enum. Optionally, all types of options can be disabled, while integer and enum options can have their allowed values restricted. Those two types also require you to set a function that assigns a label to each selected value.

Some attributes of an option can be provided dynamically, meaning the returned value can depend on the state of another option. The default value, option enablement, and the allowed values can be computed dynamically. The methods for setting a dynamic value provider also require you to specify a list of dependencies, which are the resource locations of the options that the dynamic value provider reads from. Since dynamically evaluated attributes may change the state of an option, cyclic dependencies will lead to option registration failing and the game crashing.

Sodium constructs one instance of the entrypoint class, and then calls the early and late registration methods at the right time.

## API Notes

The API is largely self-explanatory and an example is provided above. Also see Sodium's own options registration for a more in-depth example of the API's usage.

### Using `ConfigBuilder` and `ModOptions`

The `ConfigBuilder` instance passed to the registration method allows quick and easy registration of a mod's own options using `ConfigBuilder.registerOwnModOptions`. The mod's id, name, version or a formatter for the existing version, and the color theme can be configured on the returned `ModOptionsBuilder`. It's also possible to register options for additional mods using `ConfigBuilder.registerModOptions`. Which mod is the "own" mod for `registerOwnModOptions` is determined by the mod that owns the metadata-based entrypoint or the mod id passed to the `@ConfigEntryPointForge("examplemod")` annotation.

Each registered mod gets its own header in the page list. The color of the header and the corresponding entries is randomly selected from a predefined list by default, but can be customized using `ModOptionsBuilder.setColorTheme`. A color theme is created either by specifying three RGB colors or a single base color with the lighter and darker colors getting derived automatically.

To simply switch to a new `Screen` when an entry in the video settings screen's page list is clicked, use `ConfigBuilder.createExternalPage` and add the returned page normally after configuring it with a name and a `Consumer<Screen>` that receives the current screen and switches to your custom screen.

### Using `OptionBuilder`

The storage handler set with `OptionBuilder.setStorageHandler` is called after changes have been made to the options through the bindings. This lets you flush the changes to the config file once, instead of every time an option is changed.

The tooltip set with `OptionBuilder.setTooltip` can optionally be a function that generates a tooltip depending on the option's current value. This is useful for enum options for which the description would be too long otherwise.

Optionally a performance impact can be specified with `OptionBuilder.setImpact` where the impact ranges from low to high (or "varies").

Flags set with `OptionBuilder.setFlags` control what things are reset when this option is applied. They include reloading chunks or reloading resource packs. See `OptionFlag` for the available values.

The default value set with `OptionBuilder.setDefaultValue`, or dynamically with `OptionBuilder.setDefaultProvider`, is used if the value returned by the binding does not fulfill the option's value constraint (in the case of a integer or enum option).

Disabling an option with `OptionBuilder.setEnabled(false)` shows the option as strikethrough and makes it non-interactive. Otherwise, especially with regard to value constraints, it will behave as usual.

The binding configured with `OptionBuilder.setBinding` is called when changes to the options have been made and are applied, or when the value no longer fulfills the option's constraints and is reset to the default value. It's also used to initially load a value during initialization.

### Using `? extends OptionBuilder`

Some of the attributes of an option are required and you must set them, or registration will fail. The concrete extensions of `OptionBuilder` for each of the option types have some additional methods for configuring type-specific things, some of which are also required. Notably, `EnumOptionBuilder.setElementNameProvider` and `IntegerOptionBuilder.setValueFormatter` are required in order to display these types of options. The method `setValueFormatter` for integer options takes a `ControlValueFormatter`, which simply formats a number as a `Component`. Many standard value formatters are provided in `ControlValueFormatterImpls` (not part of the API package).

Integer and enum options can have value constraints that restrict the set of allowed values the user can select. For integer options, a `Range` must be configured with `IntegerOptionBuilder.setRange` so that the slider's start, end, and step positions are well-defined. Enum options may be configured to only allow the selection of certain elements with `EnumOptionBuilder.setAllowedValues`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package net.caffeinemc.mods.sodium.api.config.option;

import net.minecraft.network.chat.Component;

public interface ControlValueFormatter {
Component format(int value);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package net.caffeinemc.mods.sodium.api.config.option;

import net.minecraft.network.chat.Component;

public interface NameProvider {
Component getName();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package net.caffeinemc.mods.sodium.api.config.option;

public interface OptionBinding<V> {
void save(V value);

V load();

// TODO: add shortcuts to generate for vanilla option bindings
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package net.caffeinemc.mods.sodium.client.gui.options;
package net.caffeinemc.mods.sodium.api.config.option;

public enum OptionFlag {
REQUIRES_RENDERER_RELOAD,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package net.caffeinemc.mods.sodium.client.gui.options;
package net.caffeinemc.mods.sodium.api.config.option;

import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;

public enum OptionImpact implements TextProvider {
public enum OptionImpact implements NameProvider {
LOW(ChatFormatting.GREEN, "sodium.option_impact.low"),
MEDIUM(ChatFormatting.YELLOW, "sodium.option_impact.medium"),
HIGH(ChatFormatting.GOLD, "sodium.option_impact.high"),
Expand All @@ -17,7 +17,7 @@ public enum OptionImpact implements TextProvider {
}

@Override
public Component getLocalizedName() {
public Component getName() {
return this.text;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package net.caffeinemc.mods.sodium.api.config.option;

public record Range(int min, int max, int step) {
public Range {
if (min > max) {
throw new IllegalArgumentException("Min must be less than or equal to max");
}
if (step <= 0) {
throw new IllegalArgumentException("Step must be greater than 0");
}
}

public boolean isValueValid(int value) {
return value >= this.min && value <= this.max && (value - this.min) % this.step == 0;
}

public int getSpread() {
return this.max - this.min;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package net.caffeinemc.mods.sodium.api.config.structure;

import net.caffeinemc.mods.sodium.api.config.*;
import net.caffeinemc.mods.sodium.api.config.option.OptionBinding;
import net.caffeinemc.mods.sodium.api.config.option.OptionFlag;
import net.caffeinemc.mods.sodium.api.config.option.OptionImpact;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;

import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

public interface BooleanOptionBuilder extends StatefulOptionBuilder<Boolean> {
@Override
BooleanOptionBuilder setName(Component name);

@Override
BooleanOptionBuilder setStorageHandler(StorageEventHandler storage);

@Override
BooleanOptionBuilder setTooltip(Component tooltip);

@Override
BooleanOptionBuilder setTooltip(Function<Boolean, Component> tooltip);

@Override
BooleanOptionBuilder setImpact(OptionImpact impact);

@Override
BooleanOptionBuilder setFlags(OptionFlag... flags);

@Override
BooleanOptionBuilder setDefaultValue(Boolean value);

@Override
BooleanOptionBuilder setDefaultProvider(Function<ConfigState, Boolean> provider, ResourceLocation... dependencies);

@Override
BooleanOptionBuilder setEnabled(boolean available);

@Override
BooleanOptionBuilder setEnabledProvider(Function<ConfigState, Boolean> provider, ResourceLocation... dependencies);

@Override
BooleanOptionBuilder setBinding(Consumer<Boolean> save, Supplier<Boolean> load);

@Override
BooleanOptionBuilder setBinding(OptionBinding<Boolean> binding);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package net.caffeinemc.mods.sodium.api.config.structure;

public interface ColorThemeBuilder {
ColorThemeBuilder setBaseThemeRGB(int theme);

ColorThemeBuilder setFullThemeRGB(int theme, int themeHighlight, int themeDisabled);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package net.caffeinemc.mods.sodium.api.config.structure;

import net.minecraft.resources.ResourceLocation;

public interface ConfigBuilder {
ModOptionsBuilder registerModOptions(String namespace, String name, String version);

ModOptionsBuilder registerModOptions(String namespace);

ModOptionsBuilder registerOwnModOptions();

OptionOverrideBuilder createOptionOverride();

ColorThemeBuilder createColorTheme();

OptionPageBuilder createOptionPage();

ExternalPageBuilder createExternalPage();

OptionGroupBuilder createOptionGroup();

BooleanOptionBuilder createBooleanOption(ResourceLocation id);

IntegerOptionBuilder createIntegerOption(ResourceLocation id);

<E extends Enum<E>> EnumOptionBuilder<E> createEnumOption(ResourceLocation id, Class<E> enumClass);

ExternalButtonOptionBuilder createExternalButtonOption(ResourceLocation id);
}
Loading