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

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

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

```java title="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

```java title="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

```java title="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

- **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.** `nameScreen` takes the old name or `null`. 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`'s `onYes` runs 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 `ProfileStore` load and save methods and call `save` with `host.runAsync` from the
  actions, as in [Save and load settings](https://xichil.dev/docs/xis-mvui/v1/guides/saving-config/index.md).
