# 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.

URL: https://xichil.dev/docs/xis-mvui/v1/examples/settings-menu/ · Updated: 2026-11-06 · Applies to: Fabric, Quilt, Legacy Fabric, Forge, NeoForge, MC 1.7.10 - 26.3

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

```java title="src/main/java/com/example/mymod/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

```java title="src/main/java/com/example/mymod/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

```java title="src/main/java/com/example/mymod/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](https://xichil.dev/docs/xis-mvui/v1/guides/registering/index.md), or one entry
per loader family as in [Build one mod for every loader](https://xichil.dev/docs/xis-mvui/v1/guides/multiversion-mod/index.md).

## MyMenuTest.java

```java title="src/test/java/com/example/mymod/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's `value` reads a field and its `onChange` writes it. Nothing
  is saved until Done ([State and refresh](https://xichil.dev/docs/xis-mvui/v1/concepts/state-and-refresh/index.md)).
- **`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, and
  `onToggle` stores the new state, which `save` writes to the file.
- **Done saves off the main thread** with `runAsync`. On failure the screen stays and the status line says so;
  on success `host.back()` closes the menu.
- **Keys and colors are stored as text**: `Key.name()` and `Key.fromName`, a hex string and
  `ColorPicker.parseHex`.
- **The test needs no game.** `FakeHost` renders 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](https://xichil.dev/docs/xis-mvui/v1/guides/translations/index.md):

```json title="src/main/resources/assets/mymod/lang/en_us.json"
{
  "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"
}
```
