Example: a list manager

A complete menu that lists named profiles in a SelectList, with New and Rename screens, a validated name field and a Delete confirmation dialog.

Last updated

On this page

Complete example

A menu that manages a list of named profiles: a list to pick one from, New... and Rename... screens with a validated name field, and Delete... behind a confirmation dialog. It shows the patterns most real menus need: several screens, a selection that buttons act on, validation, and a status line.

File What it is
ProfileStore.java the list of names, kept in memory
ProfilesMenu.java the menu: a main screen, a name screen and a delete dialog
ProfilesMenuTest.java a JUnit test with FakeHost

ProfileStore.java#

src/main/java/com/example/mymod/ProfileStore.java
package com.example.mymod;

import java.util.ArrayList;
import java.util.List;

/** The profiles. Kept in memory here; save and load them as in the settings example. */
public final class ProfileStore {
    private final List<String> names = new ArrayList<>();

    public List<String> names() {
        return new ArrayList<>(names);
    }

    public boolean contains(String name) {
        return names.contains(name);
    }

    public void add(String name) {
        names.add(name);
    }

    public void rename(String oldName, String newName) {
        names.set(names.indexOf(oldName), newName);
    }

    public void delete(String name) {
        names.remove(name);
    }
}

ProfilesMenu.java#

src/main/java/com/example/mymod/ProfilesMenu.java
package com.example.mymod;

import dev.xichil.mvui.Button;
import dev.xichil.mvui.ConfirmDialog;
import dev.xichil.mvui.Label;
import dev.xichil.mvui.MenuScreen;
import dev.xichil.mvui.Screen;
import dev.xichil.mvui.ScreenFactory;
import dev.xichil.mvui.SelectList;
import dev.xichil.mvui.StatusLine;
import dev.xichil.mvui.Text;
import dev.xichil.mvui.TextInput;
import dev.xichil.mvui.UiHost;
import dev.xichil.mvui.UiTexts;

import java.util.ArrayList;
import java.util.List;

public final class ProfilesMenu implements ScreenFactory {
    private final ProfileStore store;

    public ProfilesMenu(ProfileStore store) {
        this.store = store;
    }

    @Override
    public Text buttonLabel() {
        return Text.translatable("mymod.profiles.button", "Profiles...");
    }

    @Override
    public Screen create(UiHost host) {
        StatusLine status = StatusLine.builder("status").build();

        SelectList list = SelectList.builder("profiles")
                .rows(() -> {
                    List<SelectList.Row> rows = new ArrayList<>();
                    for (String name : store.names()) {
                        rows.add(SelectList.Row.of(name, Text.literal(name)));   // names are unique
                    }
                    return rows;
                })
                .build();

        return MenuScreen.builder("main")
                .title(Text.translatable("mymod.profiles.title", "Profiles"))
                .add(Label.builder("empty")
                        .text(Text.translatable("mymod.profiles.empty", "No profiles yet. Press New to make one."))
                        .visible(() -> store.names().isEmpty())
                        .build())
                .add(list)
                .add(Button.builder("new")
                        .label(Text.translatable("mymod.profiles.new", "New..."))
                        .onPress(() -> host.open(nameScreen(host, null, status)))
                        .build())
                .add(Button.builder("rename")
                        .label(Text.translatable("mymod.profiles.rename", "Rename..."))
                        .enabled(() -> list.selected() != null)
                        .onPress(() -> host.open(nameScreen(host, list.selected(), status)))
                        .build())
                .add(Button.builder("delete")
                        .label(Text.translatable("mymod.profiles.delete", "Delete..."))
                        .enabled(() -> list.selected() != null)
                        .onPress(() -> host.open(deleteDialog(list.selected(), status)))
                        .build())
                .add(status)
                .footer(Button.builder("done").label(UiTexts.DONE).onPress(host::back).build())
                .build();
    }

    /** Asks for a name: for a new profile if {@code oldName} is null, otherwise to rename {@code oldName}. */
    private Screen nameScreen(UiHost host, String oldName, StatusLine status) {
        TextInput name = TextInput.builder("name")
                .label(Text.translatable("mymod.profiles.name", "Name"))
                .initial(oldName == null ? "" : oldName)
                .maxLength(32)
                .validator(s -> nameError(s.trim(), oldName))
                .build();

        Button save = Button.builder("save")
                .label(UiTexts.DONE)
                .enabled(name::isValid)
                .onPress(() -> {
                    String newName = name.value().trim();
                    if (oldName == null) {
                        store.add(newName);
                        status.show(StatusLine.Kind.SUCCESS,
                                Text.translatable("mymod.profiles.created", "Created %s", Text.literal(newName)));
                    } else {
                        store.rename(oldName, newName);
                        status.show(StatusLine.Kind.SUCCESS,
                                Text.translatable("mymod.profiles.renamed", "Renamed to %s", Text.literal(newName)));
                    }
                    host.back();
                })
                .build();

        return MenuScreen.builder(oldName == null ? "new" : "rename")
                .title(oldName == null
                        ? Text.translatable("mymod.profiles.new.title", "New profile")
                        : Text.translatable("mymod.profiles.rename.title", "Rename %s", Text.literal(oldName)))
                .add(name)
                .footer(save)
                .footer(Button.builder("cancel").label(UiTexts.CANCEL).onPress(host::back).build())
                .build();
    }

    /** Why {@code name} can't be used, or null if it can. Renaming to the same name is fine. */
    private Text nameError(String name, String oldName) {
        if (name.isEmpty()) {
            return Text.translatable("mymod.profiles.name.empty", "Enter a name");
        }
        if (!name.equals(oldName) && store.contains(name)) {
            return Text.translatable("mymod.profiles.name.taken", "There is already a profile called %s",
                    Text.literal(name));
        }
        return null;
    }

    private Screen deleteDialog(String name, StatusLine status) {
        return ConfirmDialog.builder("delete.confirm")
                .title(Text.translatable("mymod.profiles.delete.title", "Delete %s?", Text.literal(name)))
                .message(Text.translatable("mymod.profiles.delete.message", "This can't be undone."))
                .yesLabel(Text.translatable("mymod.profiles.delete.yes", "Delete"))
                .noLabel(UiTexts.CANCEL)
                .onYes(() -> {
                    store.delete(name);
                    status.show(StatusLine.Kind.SUCCESS,
                            Text.translatable("mymod.profiles.deleted", "Deleted %s", Text.literal(name)));
                })
                .build();
    }
}

ProfilesMenuTest.java#

src/test/java/com/example/mymod/ProfilesMenuTest.java
package com.example.mymod;

import dev.xichil.mvui.fake.FakeHost;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.Arrays;
import java.util.Collections;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ProfilesMenuTest {
    private ProfileStore store;
    private FakeHost host;

    @BeforeEach
    void open() {
        store = new ProfileStore();
        host = new FakeHost();
        host.open(new ProfilesMenu(store).create(host));
    }

    @Test
    void createsAProfile() {
        assertTrue(host.render().contains("[label empty] No profiles yet."));
        host.click("new");
        assertEquals("new", host.current().id());
        host.type("name", "  Survival ").click("save");
        assertEquals("main", host.current().id());
        assertEquals(Collections.singletonList("Survival"), store.names());
        assertTrue(host.render().contains("[status status] SUCCESS Created Survival"));
    }

    @Test
    void refusesADuplicateName() {
        store.add("Survival");
        host.refresh();
        host.click("new").type("name", "Survival");
        assertTrue(host.render().contains("(error: There is already a profile called Survival)"));
        assertThrows(IllegalStateException.class, () -> host.click("save"));   // disabled
    }

    @Test
    void renamesAndDeletesTheSelectedProfile() {
        store.add("Survival");
        store.add("Creative");
        host.refresh();
        assertThrows(IllegalStateException.class, () -> host.click("rename"));  // nothing selected

        host.select("profiles", "Creative").click("rename");
        host.type("name", "Building").click("save");
        assertEquals(Arrays.asList("Survival", "Building"), store.names());

        host.select("profiles", "Survival").click("delete");
        assertEquals("delete.confirm", host.current().id());
        host.click("yes");
        assertEquals(Collections.singletonList("Building"), store.names());
    }
}

How it works#