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.
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#
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#
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#
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#
- The selection drives the buttons. Rename and Delete are
enabled(() -> list.selected() != null). When a row disappears (renamed or deleted), the list clears its selection, and the buttons grey out again on the same refresh. - One screen for New and Rename.
nameScreentakes the old name ornull. It's built fresh on every press, so the field starts from the right text each time. - Validation keeps bad names out. The validator trims the text and refuses empty and taken names; the Done
button is enabled only while
name.isValid(), so its action can trust the input. - The status line belongs to the main screen. The name screen and the dialog write to it before returning,
and the main screen shows the message when it is current again. A
ConfirmDialog'sonYesruns after the dialog has closed, so the main screen is current by then. - The "No profiles yet" label uses
visible(…), so it takes no space once there are profiles. - Row ids are the names, which the validator keeps unique. A duplicate row id would turn the menu off.
- To save the profiles, give
ProfileStoreload and save methods and callsavewithhost.runAsyncfrom the actions, as in Save and load settings.