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.run();dependencies {
implementation("io.github.bfur64:menu-manager:x.x.x")
implementation("io.github.bfur64:terminal:x.x.x")
}- 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.run();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.<Integer>of(60)
.getter(config::getFps)
.setter(config::setFps)
.parser(Integer::parseInt)
.build();or direct lambda call
Integer fpsField = 50;
Property<Integer> fps =
Property.<Integer>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.run();
}
private void showOptionsMenu() {
MenuManager menu = new MenuManager(terminal, List.of(
new InputItem<>("Volume", volumeProperty),
new ActionItem("[ Back ]", true) // Returns to main menu
));
menu.run();
// Control returns here after submenu exits
}Menus can stack. Calling new MenuManager().run() 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 (Terminal terminal = Terminal.auto()) {
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.run();
}
}
// 3. Use the validated config in your game
private static void startGame(Terminal 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 set(String stringValue)- Parse and set (requires.parser())boolean isValid(T value)- Check without settingboolean isValid(String stringValue)- Parse and checkString getLatestError()- Get last validation failure message
Menu Manager uses a simple render loop that:
- Renders all items to the terminal buffer
- Draws the cursor (
>) at the current position - Blocks waiting for user input
- Processes the keystroke (arrow keys, Enter, Escape)
- Loops until an exit condition
// Simplified internal flow
while (isRunning) {
terminal.clearScreen();
// Render all items
for (Item item : menuList) {
terminal.putString(x, y, item.getDisplayName());
}
// Draw cursor
terminal.putString(0, cursorPos, ">");
terminal.flush();
// Block and process input
KeyStroke key = terminal.readInput();
handleInput(key);
}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)
- Full screen redraws: Every input triggers
clearScreen()(works for small menus) - 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: ANSI escape sequence support required
✅ Fully Supported:
- Windows Terminal (Windows 11)
- Powershell 7
- CMD.exe
- Linux xterm
- WSL2
- Termux (Android)
❓ Untested (likely works):
- macOS Terminal, iTerm2
- Other Linux terminals
Auto-detection: The library uses Terminal.auto() to detect and select a backend (JLine3 or Lanterna for Termux).
- Terminal Abstraction: Tetrue Terminal — Unified API over JLine3 and Lanterna
- Build Tool: Gradle 9.3.1
- Language: Pure Java 21+ (no Kotlin, no reflection, no annotations)
- Dependencies: Only
terminal(user must provide compatible backend)
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.
