Property<Integer> volume = Property.of(50)
.require(v -> v >= 0 && v <= 100, "Volume must be 0-100")
.parser(Integer::parseInt)
.build();
MenuManager menu = new MenuManager(terminal, List.of(
new StaticText("== Settings =="),
new InputItem<>("Volume", volume, "%"),
new ToggleItem("Mute", Property.of(false).build()),
new ActionItem("[ Save ]", () -> System.out.println("Saved!"))
));
menu.start();dependencies {
implementation("io.github.bfur64:menu-manager:0.9.0")
implementation("io.github.bfur64:tetrue-terminal:2.4.3")
}<dependencies>
<dependency>
<groupId>io.github.bfur64</groupId>
<artifactId>menu-manager</artifactId>
<version>0.9.0</version>
</dependency>
<dependency>
<groupId>io.github.bfur64</groupId>
<artifactId>tetrue-terminal</artifactId>
<version>2.4.3</version>
</dependency>
</dependencies>- Declarative terminal menu composition
- Generic mutable
Property<T>state system - Built-in validation and parsing pipelines
- Editable menu items with inline error handling
- Dynamic rendering support
- Custom keybind editing
- Zero reflection
- Pure Java
List<Item> items = List.of(
new StaticText("== Settings =="),
new EditableItem<>("Gravity", gravity),
new ToggleItem("Fullscreen", fullscreen),
new ActionItem("[ Start Game ]", this::startGame),
new ActionItem("[ Exit ]", true)
);
MenuManager menu = new MenuManager(terminal, items);
menu.start();Property<T> is the core mutable state abstraction used throughout the menu system.
A property can define:
- Current value storage
- Validation rules
- Error messages
- String parsing logic (Optional)
- Custom getters/setters (Optional)
public static Property<Integer> gravity = Property.of(500)
.require(value -> value >= 50, "Gravity must be at least 50 ms")
.require(value -> value <= 2000, "Gravity must be less than 2000 ms")
.parser(Integer::parseInt)
.build();Editable menu items (e.g. InputItem, KeyInputItem, ToggleItem) automatically use these validators and error messages.
Custom getters and setters allow properties to bind directly to external state instead of using internally managed storage.
Define a class for external state
class Config {
int fps = 60;
public int getFps() { ... }
public void setFps(int fps) { ... }
}
Config config = new Config();Usage
Property<Integer> fps = Property.of(60)
.getter(config::getFps)
.setter(config::setFps)
.parser(Integer::parseInt)
.build();or direct lambda call
Integer fpsField = 50;
Property<Integer> fps = Property.of(60)
.getter(() -> fpsField )
.setter(fps -> {fpsField = fps; })
.build();public static Property<KeyStroke> rotateLeftKey = Property.of(new KeyStroke('q')).build();
public static Property<KeyStroke> rotateRightKey = Property.of(new KeyStroke('e')).build();private void showMainMenu() {
MenuManager menu = new MenuManager(terminal, List.of(
new ActionItem("[ Options ]", this::showOptionsMenu),
new ActionItem("[ Exit ]", true)
));
menu.start();
}
private void showOptionsMenu() {
MenuManager menu = new MenuManager(terminal, List.of(
new InputItem<>("Volume", volumeProperty),
new ActionItem("[ Back ]", true) // Returns to main menu
));
menu.start();
// Control returns here after submenu exits
}Menus can stack. Calling new MenuManager().start() inside an ActionItem creates a submenu.
// 1. Define your config with Properties
class GameConfig {
static Property<Integer> difficulty = Property.of(5)
.require(d -> d >= 1 && d <= 10, "Difficulty must be 1-10")
.parser(Integer::parseInt)
.build();
static Property<KeyStroke> jumpKey = Property.of(new KeyStroke(' '))
.build();
static Property<Boolean> soundEnabled = Property.of(true)
.build();
}
// 2. Build your menu
public static void main(String[] args) throws IOException {
try (TerminalBackend terminal = BufferedTerminal.auto()) {
terminal.start();
MenuManager menu = new MenuManager(terminal, List.of(
new LineBreak(),
new StaticText("== Game Settings =="),
new LineBreak(),
new InputItem<>("Difficulty", ": ", GameConfig.difficulty),
new KeyInputItem("Jump Key", GameConfig.jumpKey),
new ToggleItem("Sound", GameConfig.soundEnabled),
new LineBreak(),
new ActionItem("[ Start Game ]", () -> startGame(terminal)),
new ActionItem("[ Exit ]", true)
));
menu.start();
}
}
// 3. Use the validated config in your game
private static void startGame(TerminalBackend terminal) {
int difficulty = GameConfig.difficulty.get(); // Guaranteed valid
KeyStroke jump = GameConfig.jumpKey.get();
// Game loop
while (true) {
KeyStroke input = terminal.readInput();
if (input.equals(jump)) {
player.jump();
}
}
}When a user enters invalid input, Menu Manager:
- Catches the error from the Property validator
- Displays the error message in red below the item
- Prompts "Press Any Key To Continue"
- Returns to editing without losing the menu state
Property<Integer> age = Property.of(18)
.require(a -> a >= 18, "Must be 18 or older")
.require(a -> a <= 100, "Really?")
.parser(Integer::parseInt)
.build();
new InputItem<>("Age", age);
// User types "15" and presses Enter:
// → Screen shows: "Must be 18 or older" (in red)
// → Waits for any key press
// → Returns to menu (value unchanged)| Item Type | Purpose | Constructor Example |
|---|---|---|
StaticText |
Non-interactive labels | new StaticText("== Header ==") |
LineBreak |
Visual spacing | new LineBreak() |
InputItem<T> |
User-editable values with validation | new InputItem<>("Name", property) |
InputItem<T> (with suffix) |
Editable with units | new InputItem<>("Speed", property, "km/h") |
ToggleItem |
Boolean on/off switch | new ToggleItem("Enabled", boolProperty) |
ActionItem |
Executes callback on select | new ActionItem("[ Start ]", this::start) |
ActionItem (exit) |
Exits menu after action | new ActionItem("[ Save ]", this::save, true) |
KeyInputItem |
Keybind editor | new KeyInputItem("Jump", keyProperty) |
DynamicText<T> |
Auto-updating display | new DynamicText<>("FPS: ", fps::get) |
// InputItem variants
new InputItem<>("Name", property)
new InputItem<>("Name", ": ", property) // Custom separator
new InputItem<>("Speed", property, "km/h") // With suffix
new InputItem<>("Speed", " = ", property, "km/h") // Both
// ActionItem variants
new ActionItem("[ Start ]", callback) // Regular action
new ActionItem("[ Exit ]", true) // Exit menu after
new ActionItem("[ Save ]", this::save, true) // Both
// DynamicText variants
new DynamicText<>("Value: ", supplier)
new DynamicText<>("Price: ", "$", supplier) // With prefixProperty.of(initialValue)
.require(predicate) // Validation without message (uses default)
.require(predicate, "Error message") // With custom error
.parser(String::parseMethod) // String → T conversion
.getter(supplier) // Read from external state
.setter(consumer) // Write to external state
.build()Key Methods:
T get()- Retrieve current valuevoid set(T value)- Update value (throws if invalid)void setFromString(String stringValue)- Parse and set (requires.parser())boolean isValid(T value)- Check without settingboolean isValidFromString(String stringValue)- Parse and checkString getLatestError()- Get last validation failure message
Menu Manager uses a simple render loop that:
- Gets user input (if any)
- Calls
MenuRendererto print state changes - Loops (with a cap of 60fps) using
LockSupportuntil an exit condition
// Simplified internal flow
while (isRunning) {
// Checks for input
KeyStroke keyStroke = terminal.pollInput();
if (keyStroke == null) {
keyStroke = UNKNOWN_KEY;
}
// Delegates updating to `MenuRenderer` via a method call within `MenuManager`
update(keyStroke);
// Checks if `MenuManager` should exit based on a boolean method of a `SelectableItem`
if (itemSelected != null &&
itemSelected instanceof SelectableItem selectableItem &&
selectableItem.shouldExit()
) {
exit();
}
}Editable Items:
When you select an InputItem, it:
- Highlights the item name with inverted colors
- Enters an inline editing mode (separate input loop)
- Validates each Enter press using the Property's rules
- Shows validation errors in red below the item
- Returns to the main menu on Escape or successful validation
Cursor Navigation:
The cursor automatically skips non-selectable items (like StaticText and LineBreak), wrapping at boundaries.
Parser errors:
If parsing fails (e.g., user types "abc" for an Integer property), the default error is "Unexpected Input"
- Single-threaded rendering: The menu blocks the main thread during
run() - No mouse support: Keyboard navigation only (arrow keys, Enter, Escape)
- No scrolling: All menu items must fit on screen simultaneously
- Static item lists: Menu structure is immutable after construction (use
DynamicTextfor updating values) - Terminal dependent: Requires ANSI color support and proper keystroke detection
- No nested validation: Property validators are single-level predicates
- Java 21 or higher
- Terminal Abstraction: Tetrue Terminal Lanterna-like abstraction layer for JLine4 and Lanterna
- Build Tool: Gradle 9.3.1
- Language: Pure Java 21+ (no Kotlin, no reflection, no annotations)
- Dependencies: User must provide Tetrue Terminal
Terminal
I built Menu Manager while creating a terminal-based Tetris clone, and realized this can be spun-off into its own library. Which I did, and have been promptly updating.
Development takes place on the dev branch
See CONTRIBUTING.md for contribution guidelines and pull request workflow

