Example: a settings menu
A complete settings menu with a toggle, a cycle, a slider, a collapsible section, a key binding, a color, a properties file and a JUnit test.
A complete settings menu for a mod: an on/off switch, a mode, a volume slider and an Advanced section with
a key binding, a color and a number. The settings are saved to config/mymod.properties when the player
presses Done. Everything below compiles against the API only, so it is the same code for every loader and
version.
It has four files:
| File | What it is |
|---|---|
MyConfig.java |
the values, and loading and saving them |
MyMenu.java |
the menu |
MyMod.java |
loads the config and registers the menu; each loader's entry class calls init() |
MyMenuTest.java |
a JUnit test with FakeHost |
MyConfig.java#
package com.example.mymod;
import dev.xichil.mvui.ColorPicker;
import dev.xichil.mvui.Key;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
/** The mod's settings. The menu reads and writes these fields; nothing else stores them. */
public final class MyConfig {
public enum Mode {
FAST("fast", "Fast"), BALANCED("balanced", "Balanced"), FANCY("fancy", "Fancy");
final String key;
final String english;
Mode(String key, String english) {
this.key = key;
this.english = english;
}
}
public boolean enabled = true;
public Mode mode = Mode.BALANCED;
public int volume = 80;
public boolean advancedOpen = false;
public Key zoomKey = Key.keyboard(90); // Z
public int highlightColor = 0xFF8800;
public int radius = 16;
/** Reads the file; missing or unreadable values keep their defaults. */
public static MyConfig load(Path file) {
MyConfig c = new MyConfig();
Properties p = new Properties();
if (Files.exists(file)) {
try (InputStream in = Files.newInputStream(file)) {
p.load(in);
} catch (IOException e) {
return c;
}
}
c.enabled = Boolean.parseBoolean(p.getProperty("enabled", String.valueOf(c.enabled)));
c.mode = parseMode(p.getProperty("mode"), c.mode);
c.volume = parseInt(p.getProperty("volume"), c.volume);
c.advancedOpen = Boolean.parseBoolean(p.getProperty("advancedOpen", String.valueOf(c.advancedOpen)));
Key key = Key.fromName(p.getProperty("zoomKey"));
c.zoomKey = key != null ? key : c.zoomKey;
Integer color = ColorPicker.parseHex(p.getProperty("highlightColor", ""), false);
c.highlightColor = color != null ? color : c.highlightColor;
c.radius = parseInt(p.getProperty("radius"), c.radius);
return c;
}
public void save(Path file) throws IOException {
Properties p = new Properties();
p.setProperty("enabled", String.valueOf(enabled));
p.setProperty("mode", mode.name());
p.setProperty("volume", String.valueOf(volume));
p.setProperty("advancedOpen", String.valueOf(advancedOpen));
p.setProperty("zoomKey", zoomKey.name()); // "key.keyboard.z"
p.setProperty("highlightColor", String.format("#%06X", highlightColor));
p.setProperty("radius", String.valueOf(radius));
Files.createDirectories(file.toAbsolutePath().getParent());
try (OutputStream out = Files.newOutputStream(file)) {
p.store(out, "My Mod settings");
}
}
private static Mode parseMode(String text, Mode fallback) {
for (Mode m : Mode.values()) {
if (m.name().equals(text)) {
return m;
}
}
return fallback;
}
private static int parseInt(String text, int fallback) {
try {
return text == null ? fallback : Integer.parseInt(text.trim());
} catch (NumberFormatException e) {
return fallback;
}
}
}
MyMenu.java#
package com.example.mymod;
import dev.xichil.mvui.Button;
import dev.xichil.mvui.ColorPicker;
import dev.xichil.mvui.Cycle;
import dev.xichil.mvui.Keybind;
import dev.xichil.mvui.MenuScreen;
import dev.xichil.mvui.NumberField;
import dev.xichil.mvui.Screen;
import dev.xichil.mvui.ScreenFactory;
import dev.xichil.mvui.Section;
import dev.xichil.mvui.Slider;
import dev.xichil.mvui.StatusLine;
import dev.xichil.mvui.Text;
import dev.xichil.mvui.Toggle;
import dev.xichil.mvui.UiHost;
import dev.xichil.mvui.UiTexts;
import java.nio.file.Path;
import java.util.Arrays;
public final class MyMenu implements ScreenFactory {
private final MyConfig config;
private final Path file;
public MyMenu(MyConfig config, Path file) {
this.config = config;
this.file = file;
}
@Override
public Text buttonLabel() {
return Text.translatable("mymod.menu", "My Mod...");
}
@Override
public Screen create(UiHost host) {
StatusLine status = StatusLine.builder("status").build();
return MenuScreen.builder("main")
.title(Text.translatable("mymod.title", "My Mod"))
.add(Toggle.builder("enabled")
.label(Text.translatable("mymod.enabled", "Enabled"))
.value(() -> config.enabled)
.onChange(v -> config.enabled = v)
.build())
.add(Cycle.<MyConfig.Mode>builder("mode")
.label(Text.translatable("mymod.mode", "Mode"))
.values(Arrays.asList(MyConfig.Mode.values()))
.value(() -> config.mode)
.valueText(m -> Text.translatable("mymod.mode." + m.key, m.english))
.onChange(m -> config.mode = m)
.enabled(() -> config.enabled)
.build())
.add(Slider.builder("volume")
.label(Text.translatable("mymod.volume", "Volume"))
.range(0, 100).step(5)
.value(() -> config.volume)
.valueText(v -> Text.literal((int) v + "%"))
.onChange(v -> config.volume = (int) v)
.enabled(() -> config.enabled)
.build())
.add(Section.builder("advanced")
.title(Text.translatable("mymod.advanced", "Advanced"))
.expanded(config.advancedOpen)
.onToggle(open -> config.advancedOpen = open)
.add(Keybind.builder("zoomKey")
.label(Text.translatable("mymod.zoomKey", "Zoom"))
.value(() -> config.zoomKey)
.onChange(key -> config.zoomKey = key)
.build())
.add(ColorPicker.builder("highlight")
.label(Text.translatable("mymod.highlight", "Highlight"))
.value(() -> config.highlightColor)
.onChange(c -> config.highlightColor = c)
.build())
.add(NumberField.ofInt("radius")
.label(Text.translatable("mymod.radius", "Radius (blocks)"))
.initial(config.radius)
.range(1, 256)
.onChange(r -> config.radius = r)
.build())
.build())
.add(status)
.footer(Button.builder("done")
.label(UiTexts.DONE)
.onPress(() -> host.runAsync(() -> {
config.save(file);
return null;
}, (ignored, error) -> {
if (error != null) {
status.show(StatusLine.Kind.ERROR,
Text.translatable("mymod.save.failed", "Couldn't save the settings"));
} else {
host.back();
}
}))
.build())
.build();
}
}
MyMod.java#
package com.example.mymod;
import dev.xichil.mvui.MultiversionUi;
import dev.xichil.mvui.Text;
import java.nio.file.Path;
import java.nio.file.Paths;
public final class MyMod {
public static final String MOD_ID = "mymod";
private MyMod() {
}
/** Called once, on the client, by each loader's entry class. */
public static void init() {
Path file = Paths.get("config", MOD_ID + ".properties"); // in the game directory
MultiversionUi.register(MOD_ID, Text.translatable("mymod.name", "My Mod"),
new MyMenu(MyConfig.load(file), file));
}
}
Call MyMod.init() from your loader's entry class: Register your menu, or one entry
per loader family as in Build one mod for every loader.
MyMenuTest.java#
package com.example.mymod;
import dev.xichil.mvui.fake.FakeHost;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MyMenuTest {
@TempDir
Path dir;
private Path file;
private MyConfig config;
private FakeHost host;
@BeforeEach
void open() {
file = dir.resolve("mymod.properties");
config = new MyConfig();
host = new FakeHost();
host.open(new MyMenu(config, file).create(host));
}
private void assertShows(String line) {
assertTrue(host.render().contains(line), () -> "expected '" + line + "' in:\n" + host.render());
}
@Test
void turningOffGreysOutTheRest() {
host.click("enabled");
assertFalse(config.enabled);
assertShows("[cycle mode] Mode: Balanced (disabled)");
assertShows("[slider volume] Volume: 80% (disabled)");
}
@Test
void advancedStartsCollapsedAndRemembersItIsOpen() {
assertFalse(host.render().contains("[keybind zoomKey]"));
host.click("advanced");
assertTrue(config.advancedOpen);
assertShows("[keybind zoomKey] Zoom: Z");
assertShows("[color highlight] Highlight: #FF8800");
}
@Test
void doneSavesAndCloses() {
host.slide("volume", 42).click("done"); // snapped to the step: 40
assertEquals(0, host.depth());
assertEquals(40, MyConfig.load(file).volume);
}
}
How it works#
- The values live in
MyConfig. Each widget'svaluereads a field and itsonChangewrites it. Nothing is saved until Done (State and refresh). enabled(() -> config.enabled)on the mode and volume makes them follow the toggle: after a click the screen refreshes and re-reads every supplier, so they grey out at once.- The section remembers itself.
expanded(config.advancedOpen)starts it as the player left it, andonTogglestores the new state, whichsavewrites to the file. - Done saves off the main thread with
runAsync. On failure the screen stays and the status line says so; on successhost.back()closes the menu. - Keys and colors are stored as text:
Key.name()andKey.fromName, a hex string andColorPicker.parseHex. - The test needs no game.
FakeHostrenders the screen as text and drives each widget by its id.
Lang file#
The menu works without one (every Text has an English fallback). To translate it, ship
assets/mymod/lang/en_us.json (1.13+) and the older formats as described in
Translate your menu:
{
"mymod.name": "My Mod",
"mymod.menu": "My Mod...",
"mymod.title": "My Mod",
"mymod.enabled": "Enabled",
"mymod.mode": "Mode",
"mymod.mode.fast": "Fast",
"mymod.mode.balanced": "Balanced",
"mymod.mode.fancy": "Fancy",
"mymod.volume": "Volume",
"mymod.advanced": "Advanced",
"mymod.zoomKey": "Zoom",
"mymod.highlight": "Highlight",
"mymod.radius": "Radius (blocks)",
"mymod.save.failed": "Couldn't save the settings"
}