Terminal User Interface framework for Java that ports the Charmbracelet ecosystem (Bubble Tea, Bubbles, Lipgloss, Harmonica) from Go, enabling developers to build interactive CLI applications using The Elm Architecture pattern.
Bubbles is TUI4J's collection of pre-built UI components for creating rich terminal interfaces. All components implement the Model interface and can be used standalone or composed within larger applications. This document covers all available components with complete API signatures, configuration options, and usage examples.
Animated loading spinner component.
package com.williamcallahan.tui4j.compat.bubbles.spinner;
class Spinner implements Model {
// Constructors
Spinner();
Spinner(SpinnerType type);
// Configuration
void setType(SpinnerType type);
Spinner setStyle(Style style);
// Model implementation
Command init();
UpdateResult<Spinner> update(Message msg);
String view();
// State accessors
int id();
int tag();
int frame();
}
enum SpinnerType {
Line, Dot, MiniDot, Jump, Pulse, Points,
Globe, Moon, Monkey, Meter, Hamburger
}Basic Usage:
// Create default spinner
Spinner spinner = new Spinner();
// Create with specific type
Spinner dotSpinner = new Spinner(SpinnerType.Dot);
// Style the spinner
spinner.setStyle(Style.newStyle()
.foreground("#00FF00")
.bold());
// Use in a model
class LoadingModel implements Model {
private final Spinner spinner;
private final String message;
public LoadingModel() {
this.spinner = new Spinner(SpinnerType.Dot);
this.message = "Loading...";
}
@Override
public Command init() {
return spinner.init();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
// Update spinner
UpdateResult<Spinner> spinnerResult = spinner.update(msg);
return UpdateResult.from(
new LoadingModel(spinnerResult.model(), this.message),
spinnerResult.command()
);
}
@Override
public String view() {
return spinner.view() + " " + message;
}
}Spinner Types:
Line - Rotating line (- \ | /)Dot - Animated dotsMiniDot - Smaller dot animationJump - Jumping animationPulse - Pulsing circlePoints - Animated pointsGlobe - Globe rotationMoon - Moon phasesMonkey - Monkey animationMeter - Meter barsHamburger - Hamburger menu animationIntegration Patterns:
// Spinner with timeout
class TimedLoadingModel implements Model {
private final Spinner spinner;
private final long startTime;
@Override
public Command init() {
return Command.batch(
spinner.init(),
Command.tick(Duration.ofSeconds(5), id -> new TimeoutMessage())
);
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof TimeoutMessage) {
return UpdateResult.from(new ErrorModel("Timeout"));
}
UpdateResult<Spinner> spinnerResult = spinner.update(msg);
return UpdateResult.from(
new TimedLoadingModel(spinnerResult.model(), this.startTime),
spinnerResult.command()
);
}
}Progress bar component with customizable appearance.
package com.williamcallahan.tui4j.compat.bubbles.progress;
class Progress implements Model {
// Constructor
Progress();
// Builder methods
Progress withWidth(int width);
Progress withFull(char full);
Progress withEmpty(char empty);
Progress withFullColor(String color);
Progress withEmptyColor(String color);
Progress withPercentage(boolean show);
Progress withPercentFormat(String format);
// Model implementation
Command init();
UpdateResult<Progress> update(Message msg);
String view();
// State accessors
double percent();
// Message factory
SetPercentMessage setPercent(double percent);
}Basic Usage:
// Create and configure progress bar
Progress progress = new Progress()
.withWidth(40)
.withFull('█')
.withEmpty('░')
.withFullColor("#00FF00")
.withEmptyColor("#333333")
.withPercentage(true)
.withPercentFormat("%.1f%%");
// Use in a model
class DownloadModel implements Model {
private final Progress progress;
private final double downloadPercent;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof DownloadProgressMessage download) {
// Update progress
UpdateResult<Progress> progressResult = progress.update(
progress.setPercent(download.percent())
);
return UpdateResult.from(
new DownloadModel(progressResult.model(), download.percent())
);
}
return UpdateResult.from(this);
}
@Override
public String view() {
return "Downloading:\n" + progress.view();
}
}Configuration Options:
// Default ASCII progress bar
Progress simpleProgress = new Progress()
.withWidth(30)
.withPercentage(true);
// Custom characters
Progress customProgress = new Progress()
.withWidth(50)
.withFull('▰')
.withEmpty('▱')
.withFullColor("#00AAFF")
.withEmptyColor("#666666");
// Hide percentage
Progress noPercentProgress = new Progress()
.withWidth(40)
.withPercentage(false);
// Custom percentage format
Progress formattedProgress = new Progress()
.withWidth(40)
.withPercentage(true)
.withPercentFormat("Progress: %.0f%%");Integration Example:
class MultiStepProgress implements Model {
private final Progress progress;
private final int currentStep;
private final int totalSteps;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof StepCompleteMessage) {
int newStep = currentStep + 1;
double percent = (double) newStep / totalSteps;
UpdateResult<Progress> progressResult = progress.update(
progress.setPercent(percent)
);
if (newStep >= totalSteps) {
return UpdateResult.from(
new CompleteModel(),
Command.none()
);
}
return UpdateResult.from(
new MultiStepProgress(progressResult.model(), newStep, totalSteps)
);
}
return UpdateResult.from(this);
}
@Override
public String view() {
return String.format("Step %d of %d\n%s",
currentStep, totalSteps, progress.view());
}
}Single-line text input field with cursor, placeholder, and echo modes.
package com.williamcallahan.tui4j.compat.bubbles.textinput;
class TextInput implements Model {
// Constructor
TextInput();
// Builder methods
TextInput withPrompt(String prompt);
TextInput withPlaceholder(String placeholder);
TextInput withValue(String value);
TextInput withCharLimit(int limit);
TextInput withEchoMode(EchoMode mode);
TextInput withEchoCharacter(char ch);
TextInput withWidth(int width);
// Model implementation
Command init();
UpdateResult<TextInput> update(Message msg);
String view();
// State accessors and mutators
String value();
void setValue(String value);
void focus();
void blur();
boolean focused();
}
enum EchoMode {
EchoNormal, // Show characters normally
EchoPassword, // Show as password characters (*)
EchoNone // Don't show characters
}Basic Usage:
// Create basic text input
TextInput input = new TextInput()
.withPrompt("Name: ")
.withPlaceholder("Enter your name")
.withWidth(40);
input.focus(); // Focus the input
// Use in a model
class FormModel implements Model {
private final TextInput nameInput;
@Override
public Command init() {
return nameInput.init();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
if (key.type() == KeyType.KeyEnter) {
// Submit form
String name = nameInput.value();
return UpdateResult.from(
new SubmittedModel(name),
Command.none()
);
}
}
// Delegate to input
UpdateResult<TextInput> inputResult = nameInput.update(msg);
return UpdateResult.from(
new FormModel(inputResult.model()),
inputResult.command()
);
}
@Override
public String view() {
return "Enter your information:\n\n" + nameInput.view();
}
}Configuration Examples:
// Password input
TextInput passwordInput = new TextInput()
.withPrompt("Password: ")
.withEchoMode(EchoMode.EchoPassword)
.withEchoCharacter('*')
.withWidth(30);
// Character limit
TextInput limitedInput = new TextInput()
.withPrompt("Code: ")
.withCharLimit(6)
.withWidth(20);
// Pre-filled value
TextInput prefilledInput = new TextInput()
.withPrompt("Email: ")
.withValue("user@example.com")
.withWidth(50);
// No echo (invisible input)
TextInput invisibleInput = new TextInput()
.withPrompt("Secret: ")
.withEchoMode(EchoMode.EchoNone)
.withWidth(30);Multi-Field Form:
class MultiFieldForm implements Model {
private final TextInput usernameInput;
private final TextInput emailInput;
private final TextInput passwordInput;
private final int focusedField; // 0, 1, or 2
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Tab to switch fields
if (key.type() == KeyType.KeyTab) {
int newFocus = (focusedField + 1) % 3;
usernameInput.blur();
emailInput.blur();
passwordInput.blur();
switch (newFocus) {
case 0 -> usernameInput.focus();
case 1 -> emailInput.focus();
case 2 -> passwordInput.focus();
}
return UpdateResult.from(
new MultiFieldForm(usernameInput, emailInput, passwordInput, newFocus)
);
}
// Enter to submit
if (key.type() == KeyType.KeyEnter) {
return UpdateResult.from(new SubmittedForm(
usernameInput.value(),
emailInput.value(),
passwordInput.value()
));
}
}
// Delegate to focused field
TextInput focusedInput = switch (focusedField) {
case 0 -> usernameInput;
case 1 -> emailInput;
case 2 -> passwordInput;
default -> usernameInput;
};
UpdateResult<TextInput> result = focusedInput.update(msg);
// Create new model with updated input
TextInput newUsername = focusedField == 0 ? result.model() : usernameInput;
TextInput newEmail = focusedField == 1 ? result.model() : emailInput;
TextInput newPassword = focusedField == 2 ? result.model() : passwordInput;
return UpdateResult.from(
new MultiFieldForm(newUsername, newEmail, newPassword, focusedField),
result.command()
);
}
@Override
public String view() {
return "Registration Form\n\n" +
usernameInput.view() + "\n" +
emailInput.view() + "\n" +
passwordInput.view() + "\n\n" +
"Press Tab to switch fields, Enter to submit";
}
}Multi-line text editor with line numbers, scrolling, and keyboard navigation.
package com.williamcallahan.tui4j.compat.bubbles.textarea;
class Textarea implements Model {
// Constructor
Textarea();
// Builder methods
Textarea withValue(String value);
Textarea withPrompt(String prompt);
Textarea withPlaceholder(String placeholder);
Textarea withWidth(int width);
Textarea withHeight(int height);
Textarea withMaxWidth(int maxWidth);
Textarea withMaxHeight(int maxHeight);
Textarea withCharLimit(int limit);
Textarea withShowLineNumbers(boolean show);
Textarea withFocused(boolean focused);
Textarea withStyle(Style style);
Textarea withKeyMap(KeyMap keyMap);
// Model implementation
Command init();
UpdateResult<Textarea> update(Message msg);
String view();
// State accessors and mutators
String value();
void setValue(String value);
int cursorLine();
int cursorColumn();
void focus();
void blur();
}Basic Usage:
// Create textarea
Textarea textarea = new Textarea()
.withWidth(60)
.withHeight(10)
.withPlaceholder("Enter your text here...")
.withShowLineNumbers(true);
textarea.focus();
// Use in a model
class EditorModel implements Model {
private final Textarea textarea;
@Override
public Command init() {
return textarea.init();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Ctrl+S to save
if (key.type() == KeyType.KeyCtrlS) {
String content = textarea.value();
return UpdateResult.from(
this,
Command.batch(
saveToFile(content),
Command.println("Saved!")
)
);
}
// Ctrl+Q to quit
if (key.type() == KeyType.KeyCtrlQ) {
return UpdateResult.from(this, Command.quit());
}
}
// Delegate to textarea
UpdateResult<Textarea> textareaResult = textarea.update(msg);
return UpdateResult.from(
new EditorModel(textareaResult.model()),
textareaResult.command()
);
}
@Override
public String view() {
Style headerStyle = Style.newStyle()
.width(60)
.background("#0066CC")
.foreground("#FFFFFF")
.padding(0, 1);
Style footerStyle = Style.newStyle()
.width(60)
.foreground("#666666")
.padding(0, 1);
String header = headerStyle.render("Text Editor");
String footer = footerStyle.render(
String.format("Line %d, Col %d | Ctrl+S: Save | Ctrl+Q: Quit",
textarea.cursorLine(), textarea.cursorColumn())
);
return VerticalJoinDecorator.join(
Position.Left,
header,
textarea.view(),
footer
);
}
}Configuration Examples:
// Simple textarea without line numbers
Textarea simple = new Textarea()
.withWidth(50)
.withHeight(8)
.withShowLineNumbers(false);
// Large textarea with character limit
Textarea limited = new Textarea()
.withWidth(80)
.withHeight(20)
.withCharLimit(1000)
.withShowLineNumbers(true);
// Styled textarea
Textarea styled = new Textarea()
.withWidth(60)
.withHeight(12)
.withStyle(Style.newStyle()
.border(StandardBorder.roundedBorder())
.borderForeground("#00FF00")
.padding(1));
// Pre-filled content
Textarea prefilled = new Textarea()
.withWidth(70)
.withHeight(15)
.withValue("Line 1\nLine 2\nLine 3")
.withShowLineNumbers(true);Advanced Usage - Code Editor:
class CodeEditorModel implements Model {
private final Textarea textarea;
private final String filename;
private final boolean modified;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Save with Ctrl+S
if (key.type() == KeyType.KeyCtrlS) {
Command saveCmd = saveFile(filename, textarea.value());
return UpdateResult.from(
new CodeEditorModel(textarea, filename, false),
saveCmd
);
}
// Check for modifications
UpdateResult<Textarea> result = textarea.update(msg);
boolean isModified = !result.model().value().equals(textarea.value());
return UpdateResult.from(
new CodeEditorModel(result.model(), filename, isModified),
result.command()
);
}
UpdateResult<Textarea> result = textarea.update(msg);
return UpdateResult.from(
new CodeEditorModel(result.model(), filename, modified),
result.command()
);
}
@Override
public String view() {
String title = filename + (modified ? " *" : "");
Style titleStyle = Style.newStyle()
.bold()
.foreground("#FFFFFF")
.background("#0066CC")
.width(80)
.padding(0, 2);
String titleBar = titleStyle.render(title);
String editorView = textarea.view();
String statusBar = String.format(
"Ln %d, Col %d | %d chars",
textarea.cursorLine(),
textarea.cursorColumn(),
textarea.value().length()
);
return VerticalJoinDecorator.join(
Position.Left,
titleBar,
editorView,
statusBar
);
}
}Textarea KeyMap:
package com.williamcallahan.tui4j.compat.bubbles.textarea;
class KeyMap {
// Factory
static KeyMap defaultKeyMap();
// Key bindings (customizable)
Key moveUp;
Key moveDown;
Key moveLeft;
Key moveRight;
Key lineStart;
Key lineEnd;
Key deleteCharacterBackward;
Key deleteCharacterForward;
Key deleteWordBackward;
Key deleteWordForward;
Key deleteLine;
Key insertNewline;
Key paste;
}Textarea Styles:
package com.williamcallahan.tui4j.compat.bubbles.textarea;
class Styles {
Style base;
Style placeholder;
Style cursor;
Style lineNumber;
Style selectedLineNumber;
Style prompt;
Style endOfBuffer;
}Custom KeyMap Usage:
// Create custom key bindings
KeyMap customKeyMap = KeyMap.defaultKeyMap();
customKeyMap.moveUp = new Key(KeyType.KeyCtrlP); // Ctrl+P for up
customKeyMap.moveDown = new Key(KeyType.KeyCtrlN); // Ctrl+N for down
customKeyMap.lineStart = new Key(KeyType.KeyCtrlA); // Ctrl+A for line start
customKeyMap.lineEnd = new Key(KeyType.KeyCtrlE); // Ctrl+E for line end
Textarea textarea = new Textarea()
.withKeyMap(customKeyMap);Custom Styles Usage:
// Create custom styles
Styles customStyles = new Styles();
customStyles.base = Style.newStyle().foreground("#FFFFFF");
customStyles.placeholder = Style.newStyle().foreground("#666666").italic();
customStyles.cursor = Style.newStyle().background("#00FF00");
customStyles.lineNumber = Style.newStyle().foreground("#888888");
customStyles.selectedLineNumber = Style.newStyle().foreground("#FFFF00").bold();
customStyles.prompt = Style.newStyle().foreground("#00FFFF");
customStyles.endOfBuffer = Style.newStyle().foreground("#333333");
Textarea textarea = new Textarea()
.withStyles(customStyles);Selectable list with filtering, pagination, and customizable rendering.
package com.williamcallahan.tui4j.compat.bubbles.list;
class List implements Model {
// Constructors
List(Item[] items, int width, int height);
List(Item[] items, ItemDelegate delegate, int width, int height);
List(ListDataSource dataSource, ItemDelegate delegate, int width, int height);
List(ListDataSource dataSource, int width, int height);
// Builder methods
List setShowHelp(boolean show);
List setShowTitle(boolean show);
List setTitle(String title);
List setShowStatusBar(boolean show);
List setShowFilter(boolean show);
List setFilteringEnabled(boolean enabled);
List setFilterState(FilterState state);
List setWidth(int width);
List setHeight(int height);
List setStyles(Styles styles);
List setKeyMap(KeyMap keyMap);
// Model implementation
Command init();
UpdateResult<List> update(Message msg);
String view();
// Navigation
Command cursorUp();
Command cursorDown();
// Selection and access
Item selectedItem();
Item itemAt(int index);
int selectedIndex();
void setSelectedIndex(int index);
// Filtering
void filteredItems(FilterFunction func);
}
interface Item {
String filterValue();
}
interface ItemDelegate {
int height(Item item);
void render(StringBuilder sb, List list, int index, Item item);
}
class DefaultItem implements Item {
DefaultItem(String title, String description);
}
class DefaultDelegate implements ItemDelegate {
// Default rendering implementation
}
enum FilterState {
Filtering, NotFiltering
}Basic Usage:
// Create items
Item[] items = new Item[] {
new DefaultItem("Option 1", "First option description"),
new DefaultItem("Option 2", "Second option description"),
new DefaultItem("Option 3", "Third option description"),
new DefaultItem("Option 4", "Fourth option description")
};
// Create list
List list = new List(items, 40, 10)
.setTitle("Select an option")
.setShowTitle(true)
.setShowStatusBar(true)
.setFilteringEnabled(true);
// Use in a model
class MenuModel implements Model {
private final List list;
@Override
public Command init() {
return list.init();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Enter to select
if (key.type() == KeyType.KeyEnter) {
Item selected = list.selectedItem();
return UpdateResult.from(
new SelectedModel(selected),
Command.none()
);
}
// Escape to cancel
if (key.type() == KeyType.KeyEsc) {
return UpdateResult.from(this, Command.quit());
}
}
// Delegate to list
UpdateResult<List> listResult = list.update(msg);
return UpdateResult.from(
new MenuModel(listResult.model()),
listResult.command()
);
}
@Override
public String view() {
return list.view();
}
}Custom Items:
// Custom item class
class FileItem implements Item {
private final String filename;
private final long size;
private final boolean isDirectory;
FileItem(String filename, long size, boolean isDirectory) {
this.filename = filename;
this.size = size;
this.isDirectory = isDirectory;
}
@Override
public String filterValue() {
return filename;
}
String filename() { return filename; }
long size() { return size; }
boolean isDirectory() { return isDirectory; }
}
// Custom delegate for rendering
class FileItemDelegate implements ItemDelegate {
@Override
public int height(Item item) {
return 1; // Single line per item
}
@Override
public void render(StringBuilder sb, List list, int index, Item item) {
if (!(item instanceof FileItem fileItem)) return;
boolean isSelected = index == list.selectedIndex();
Style itemStyle = Style.newStyle()
.foreground(isSelected ? "#000000" : "#FFFFFF")
.background(isSelected ? "#00FF00" : "#000000")
.width(list.width());
String icon = fileItem.isDirectory() ? "📁" : "📄";
String sizeStr = fileItem.isDirectory() ?
"<DIR>" :
formatSize(fileItem.size());
String line = String.format(
"%s %-30s %10s",
icon,
fileItem.filename(),
sizeStr
);
sb.append(itemStyle.render(line));
}
private String formatSize(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024) + " KB";
return (bytes / (1024 * 1024)) + " MB";
}
}
// Use custom items and delegate
FileItem[] files = loadFiles();
List fileList = new List(files, new FileItemDelegate(), 60, 15)
.setTitle("File Browser")
.setShowTitle(true)
.setFilteringEnabled(true);Filtering:
class SearchableListModel implements Model {
private final List list;
private final String searchTerm;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// '/' to start filtering
if (key.type() == KeyType.KeyRunes && key.runes()[0] == '/') {
list.setFilterState(FilterState.Filtering);
return UpdateResult.from(this);
}
}
UpdateResult<List> listResult = list.update(msg);
return UpdateResult.from(
new SearchableListModel(listResult.model(), searchTerm),
listResult.command()
);
}
}List Fuzzy Matching and Data Types:
package com.williamcallahan.tui4j.compat.bubbles.list;
// Rank - Result ranking for fuzzy matching
class Rank {
int index;
int score;
int[] matchedIndexes;
Rank(int index, int score, int[] matchedIndexes);
}
// FilteredItem - Item with ranking information
class FilteredItem {
Item item;
Rank rank;
FilteredItem(Item item, Rank rank);
}
// FetchedCurrentPageItems - Result of fetching current page
class FetchedCurrentPageItems {
List<Item> items;
int startIndex;
int endIndex;
FetchedCurrentPageItems(List<Item> items, int startIndex, int endIndex);
}
// FetchedItems - Result of fetching items
class FetchedItems {
List<Item> items;
int totalCount;
FetchedItems(List<Item> items, int totalCount);
}Fuzzy Matching Utilities:
package com.williamcallahan.tui4j.compat.bubbles.list.fuzzy;
// Match - Fuzzy match result
class Match {
String str;
int index;
int[] matchedIndexes;
Match(String str, int index, int[] matchedIndexes);
}
// FuzzyFilter - Fuzzy string matching
class FuzzyFilter {
static List<Match> filter(String pattern, List<String> candidates);
static List<Match> filterWithLimit(String pattern, List<String> candidates, int limit);
}Fuzzy Matching Usage:
// Filter items with fuzzy matching
List<Item> allItems = loadAllItems();
String searchTerm = "foo";
// Convert items to strings for matching
List<String> itemStrings = allItems.stream()
.map(Item::filterValue)
.toList();
// Perform fuzzy match
List<Match> matches = FuzzyFilter.filter(searchTerm, itemStrings);
// Create filtered items with ranks
List<FilteredItem> filteredItems = matches.stream()
.map(match -> new FilteredItem(
allItems.get(match.index),
new Rank(match.index, calculateScore(match), match.matchedIndexes)
))
.toList();
// Custom filtering with ranking
list.filteredItems((items, filter) -> {
List<String> values = items.stream()
.map(Item::filterValue)
.toList();
List<Match> results = FuzzyFilter.filterWithLimit(filter, values, 100);
return results.stream()
.map(m -> items.get(m.index))
.toList();
});Interactive table with navigation, sorting, and styling.
package com.williamcallahan.tui4j.compat.bubbles.table;
class Table implements Model {
// Factory
static Table create();
// Constructor
Table();
// Builder methods
Table columns(List<Column> columns);
Table columns(Column... columns);
Table rows(List<Row> rows);
Table rows(Row... rows);
Table withWidth(int width);
Table withHeight(int height);
Table focused(boolean focused);
// Model implementation
Command init();
UpdateResult<Table> update(Message msg);
String view();
// Navigation
void cursorUp();
void cursorDown();
void cursorLeft();
void cursorRight();
void cursorHome();
void cursorEnd();
void pageUp();
void pageDown();
// Selection and access
Row selectedRow();
Column selectedColumn();
String selectedCell();
}
class Column {
// Constructor
Column(String title);
// Builder methods
Column withWidth(int width);
Column withMaxWidth(int maxWidth);
// Accessors
String title();
int width();
int maxWidth();
}
class Row {
// Constructors
Row(String... data);
Row(List<String> data);
}Basic Usage:
// Create table
Table table = Table.create()
.columns(
new Column("Name").withWidth(20),
new Column("Age").withWidth(5),
new Column("City").withWidth(15)
)
.rows(
new Row("Alice Johnson", "30", "New York"),
new Row("Bob Smith", "25", "San Francisco"),
new Row("Charlie Brown", "35", "Seattle"),
new Row("Diana Prince", "28", "Boston")
)
.withWidth(50)
.withHeight(10)
.focused(true);
// Use in a model
class TableViewModel implements Model {
private final Table table;
@Override
public Command init() {
return table.init();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Enter to select row
if (key.type() == KeyType.KeyEnter) {
Row selected = table.selectedRow();
return UpdateResult.from(
new RowDetailsModel(selected),
Command.none()
);
}
}
// Delegate to table
UpdateResult<Table> tableResult = table.update(msg);
return UpdateResult.from(
new TableViewModel(tableResult.model()),
tableResult.command()
);
}
@Override
public String view() {
Style headerStyle = Style.newStyle()
.bold()
.foreground("#FFFFFF")
.background("#0066CC")
.padding(0, 1);
String header = headerStyle.render("User Directory");
return VerticalJoinDecorator.join(
Position.Left,
header,
table.view(),
"Use arrow keys to navigate, Enter to select"
);
}
}Dynamic Table:
class DataTableModel implements Model {
private final Table table;
private final List<DataRow> data;
public DataTableModel(List<DataRow> data) {
this.data = data;
this.table = buildTable(data);
}
private Table buildTable(List<DataRow> data) {
Table t = Table.create()
.columns(
new Column("ID").withWidth(10),
new Column("Name").withWidth(25),
new Column("Status").withWidth(15),
new Column("Date").withWidth(20)
)
.withWidth(80)
.withHeight(20)
.focused(true);
for (DataRow row : data) {
t = t.rows(new Row(
String.valueOf(row.id()),
row.name(),
row.status(),
row.date().toString()
));
}
return t;
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof DataUpdatedMessage update) {
// Rebuild table with new data
return UpdateResult.from(
new DataTableModel(update.newData())
);
}
UpdateResult<Table> tableResult = table.update(msg);
return UpdateResult.from(
new DataTableModel(tableResult.model(), data),
tableResult.command()
);
}
}Navigation Pattern:
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
switch (key.type()) {
case KeyUp -> table.cursorUp();
case KeyDown -> table.cursorDown();
case KeyLeft -> table.cursorLeft();
case KeyRight -> table.cursorRight();
case KeyHome -> table.cursorHome();
case KeyEnd -> table.cursorEnd();
case KeyPgUp -> table.pageUp();
case KeyPgDown -> table.pageDown();
}
}
UpdateResult<Table> tableResult = table.update(msg);
return UpdateResult.from(
new TableViewModel(tableResult.model()),
tableResult.command()
);
}Scrollable content viewport with mouse wheel support.
package com.williamcallahan.tui4j.compat.bubbles.viewport;
class Viewport implements Model {
// Constructors
Viewport();
Viewport(String content);
Viewport(String content, int height);
// Builder methods
Viewport withWidth(int width);
Viewport withHeight(int height);
Viewport withYOffset(int offset);
Viewport withXOffset(int offset);
Viewport withMouseWheelEnabled(boolean enabled);
Viewport withMouseWheelDelta(int delta);
Viewport withHighPerformanceRendering(boolean enabled);
Viewport withStyle(Style style);
// Model implementation
Command init();
UpdateResult<Viewport> update(Message msg);
String view();
// Content management
void setContent(String content);
String content();
// Scrolling
int yOffset();
int xOffset();
void lineDown(int n);
void lineUp(int n);
void pageDown();
void pageUp();
void halfPageDown();
void halfPageUp();
void scrollToBottom();
void scrollToTop();
}Basic Usage:
// Create viewport with content
String longContent = generateLongContent();
Viewport viewport = new Viewport(longContent)
.withWidth(60)
.withHeight(15)
.withMouseWheelEnabled(true)
.withMouseWheelDelta(3);
// Use in a model
class DocumentViewerModel implements Model {
private final Viewport viewport;
@Override
public Command init() {
return viewport.init();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
switch (key.type()) {
case KeyUp -> viewport.lineUp(1);
case KeyDown -> viewport.lineDown(1);
case KeyPgUp -> viewport.pageUp();
case KeyPgDown -> viewport.pageDown();
case KeyHome -> viewport.scrollToTop();
case KeyEnd -> viewport.scrollToBottom();
}
}
// Delegate to viewport
UpdateResult<Viewport> viewportResult = viewport.update(msg);
return UpdateResult.from(
new DocumentViewerModel(viewportResult.model()),
viewportResult.command()
);
}
@Override
public String view() {
Style containerStyle = Style.newStyle()
.border(StandardBorder.roundedBorder())
.borderForeground("#00FF00");
return containerStyle.render(viewport.view());
}
}Dynamic Content:
class LogViewerModel implements Model {
private final Viewport viewport;
private final List<String> logLines;
public LogViewerModel(List<String> logLines) {
this.logLines = logLines;
this.viewport = new Viewport(
String.join("\n", logLines),
20
).withWidth(80)
.withMouseWheelEnabled(true);
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof NewLogLineMessage newLog) {
List<String> updatedLogs = new ArrayList<>(logLines);
updatedLogs.add(newLog.line());
Viewport updatedViewport = viewport;
updatedViewport.setContent(String.join("\n", updatedLogs));
// Auto-scroll to bottom for new logs
updatedViewport.scrollToBottom();
return UpdateResult.from(
new LogViewerModel(updatedViewport, updatedLogs)
);
}
UpdateResult<Viewport> viewportResult = viewport.update(msg);
return UpdateResult.from(
new LogViewerModel(viewportResult.model(), logLines),
viewportResult.command()
);
}
}Horizontal and Vertical Scrolling:
class WideContentViewer implements Model {
private final Viewport viewport;
public WideContentViewer() {
this.viewport = new Viewport(wideContent)
.withWidth(80)
.withHeight(20)
.withXOffset(0)
.withYOffset(0)
.withMouseWheelEnabled(true);
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
switch (key.type()) {
case KeyUp -> viewport.lineUp(1);
case KeyDown -> viewport.lineDown(1);
case KeyLeft -> {
int newX = Math.max(0, viewport.xOffset() - 5);
Viewport updated = viewport.withXOffset(newX);
return UpdateResult.from(new WideContentViewer(updated));
}
case KeyRight -> {
int newX = viewport.xOffset() + 5;
Viewport updated = viewport.withXOffset(newX);
return UpdateResult.from(new WideContentViewer(updated));
}
}
}
UpdateResult<Viewport> result = viewport.update(msg);
return UpdateResult.from(
new WideContentViewer(result.model()),
result.command()
);
}
}Performance Mode:
// For very large content, enable high-performance rendering
Viewport largeViewport = new Viewport(veryLargeContent)
.withWidth(100)
.withHeight(30)
.withHighPerformanceRendering(true)
.withMouseWheelEnabled(true);Pagination component with page navigation.
package com.williamcallahan.tui4j.compat.bubbles.paginator;
class Paginator implements Model {
// Constructor
Paginator();
// Builder methods
Paginator withPageSize(int size);
Paginator withTotalPages(int total);
Paginator withType(Type type);
Paginator withKeyMap(KeyMap keyMap);
Paginator withStyles(Styles styles);
// Model implementation
Command init();
UpdateResult<Paginator> update(Message msg);
String view();
// Navigation
void pageDown();
void pageUp();
void firstPage();
void lastPage();
// State accessors
int page();
int pageSize();
int totalPages();
}
enum Type {
Arabic, // "1 / 10"
Dots // "• • ○ • •"
}Basic Usage:
// Create paginator
Paginator paginator = new Paginator()
.withPageSize(10)
.withTotalPages(5)
.withType(Type.Arabic);
// Use in a model
class PaginatedListModel implements Model {
private final Paginator paginator;
private final List<Item> allItems;
@Override
public Command init() {
return paginator.init();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
switch (key.type()) {
case KeyLeft -> paginator.pageUp();
case KeyRight -> paginator.pageDown();
case KeyHome -> paginator.firstPage();
case KeyEnd -> paginator.lastPage();
}
}
UpdateResult<Paginator> paginatorResult = paginator.update(msg);
return UpdateResult.from(
new PaginatedListModel(paginatorResult.model(), allItems),
paginatorResult.command()
);
}
@Override
public String view() {
int page = paginator.page();
int pageSize = paginator.pageSize();
int start = page * pageSize;
int end = Math.min(start + pageSize, allItems.size());
List<Item> pageItems = allItems.subList(start, end);
StringBuilder sb = new StringBuilder();
for (Item item : pageItems) {
sb.append(item.render()).append("\n");
}
sb.append("\n").append(paginator.view());
sb.append("\nUse ← → to navigate pages");
return sb.toString();
}
}Pagination Types:
// Arabic numerals (1 / 10)
Paginator arabicPaginator = new Paginator()
.withTotalPages(10)
.withType(Type.Arabic);
// Dots (• • ○ • •)
Paginator dotsPaginator = new Paginator()
.withTotalPages(10)
.withType(Type.Dots);Integration with Table:
class PaginatedTableModel implements Model {
private final Table table;
private final Paginator paginator;
private final List<DataRow> allData;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Handle pagination
if (key.type() == KeyType.KeyCtrlRight) {
paginator.pageDown();
return UpdateResult.from(rebuildWithPage(paginator.page()));
}
if (key.type() == KeyType.KeyCtrlLeft) {
paginator.pageUp();
return UpdateResult.from(rebuildWithPage(paginator.page()));
}
// Delegate table navigation
UpdateResult<Table> tableResult = table.update(msg);
return UpdateResult.from(
new PaginatedTableModel(
tableResult.model(),
paginator,
allData
),
tableResult.command()
);
}
UpdateResult<Paginator> paginatorResult = paginator.update(msg);
return UpdateResult.from(
new PaginatedTableModel(table, paginatorResult.model(), allData),
paginatorResult.command()
);
}
private PaginatedTableModel rebuildWithPage(int page) {
int pageSize = paginator.pageSize();
int start = page * pageSize;
int end = Math.min(start + pageSize, allData.size());
List<DataRow> pageData = allData.subList(start, end);
Table newTable = buildTableFromData(pageData);
return new PaginatedTableModel(newTable, paginator, allData);
}
@Override
public String view() {
return VerticalJoinDecorator.join(
Position.Left,
table.view(),
"",
paginator.view(),
"Ctrl+← / Ctrl+→: Change page | ↑ / ↓: Navigate rows"
);
}
}Stopwatch component for elapsed time tracking.
package com.williamcallahan.tui4j.compat.bubbles.stopwatch;
class Stopwatch implements Model {
// Constructors
Stopwatch();
Stopwatch(Duration interval);
// Model implementation
Command init();
UpdateResult<Stopwatch> update(Message msg);
String view();
// Control
Command start();
Command stop();
Command toggle();
Command reset();
// State accessors
boolean running();
Duration elapsed();
Duration interval();
int id();
// Configuration
void setInterval(Duration interval);
void setElapsed(Duration elapsed);
}Basic Usage:
// Create stopwatch with default 1-second interval
Stopwatch stopwatch = new Stopwatch();
// High-precision stopwatch with 100ms intervals
Stopwatch precise = new Stopwatch(Duration.ofMillis(100));
// Use in a model
class StopwatchModel implements Model {
private final Stopwatch stopwatch;
@Override
public Command init() {
return Command.batch(
stopwatch.init(),
stopwatch.start() // Start immediately
);
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Space to toggle start/stop
if (key.type() == KeyType.KeySpace) {
return UpdateResult.from(this, stopwatch.toggle());
}
// R to reset
if (key.type() == KeyType.KeyRunes && key.runes()[0] == 'r') {
return UpdateResult.from(this, stopwatch.reset());
}
}
// Delegate to stopwatch
UpdateResult<Stopwatch> result = stopwatch.update(msg);
return UpdateResult.from(
new StopwatchModel(result.model()),
result.command()
);
}
@Override
public String view() {
Style headerStyle = Style.newStyle()
.bold()
.foreground("#00FF00")
.width(40)
.alignHorizontal(Position.Center);
String status = stopwatch.running() ? "[Running]" : "[Stopped]";
return headerStyle.render(
"Stopwatch\n\n" +
stopwatch.view() + "\n\n" +
status + "\n\n" +
"Space: Start/Stop | R: Reset | Q: Quit"
);
}
}Lap Timer Pattern:
class LapTimerModel implements Model {
private final Stopwatch stopwatch;
private final List<Duration> laps;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Space to record lap
if (key.type() == KeyType.KeySpace && stopwatch.running()) {
List<Duration> newLaps = new ArrayList<>(laps);
newLaps.add(stopwatch.elapsed());
return UpdateResult.from(
new LapTimerModel(stopwatch, newLaps)
);
}
// R to reset stopwatch and clear laps
if (key.type() == KeyType.KeyRunes && key.runes()[0] == 'r') {
return UpdateResult.from(
new LapTimerModel(stopwatch, List.of()),
Command.batch(
stopwatch.reset(),
stopwatch.start()
)
);
}
// T to toggle start/stop
if (key.type() == KeyType.KeyRunes && key.runes()[0] == 't') {
return UpdateResult.from(this, stopwatch.toggle());
}
}
UpdateResult<Stopwatch> result = stopwatch.update(msg);
return UpdateResult.from(
new LapTimerModel(result.model(), laps),
result.command()
);
}
@Override
public String view() {
StringBuilder sb = new StringBuilder();
sb.append("Current Time: ").append(stopwatch.view()).append("\n");
sb.append(stopwatch.running() ? "[Running]" : "[Stopped]").append("\n\n");
if (!laps.isEmpty()) {
sb.append("Laps:\n");
for (int i = 0; i < laps.size(); i++) {
Duration lapTime = laps.get(i);
Duration splitTime = i > 0 ?
lapTime.minus(laps.get(i - 1)) : lapTime;
sb.append(String.format(" %2d. %s (split: %s)\n",
i + 1,
formatDuration(lapTime),
formatDuration(splitTime)
));
}
}
sb.append("\nSpace: Lap | T: Start/Stop | R: Reset");
return sb.toString();
}
private String formatDuration(Duration d) {
long seconds = d.getSeconds();
long millis = d.toMillisPart();
return String.format("%d.%03ds", seconds, millis);
}
}Messages:
package com.williamcallahan.tui4j.compat.bubbles.stopwatch;
record StartStopMessage(int id, boolean running) implements Message { }
record TickMessage(int id, int tag) implements Message { }
record ResetMessage(int id) implements Message { }Countdown/stopwatch timer component.
package com.williamcallahan.tui4j.compat.bubbles.timer;
class Timer implements Model {
// Constructor
Timer();
// Builder methods
Timer withDuration(Duration duration);
Timer withInterval(Duration interval);
Timer withTickInterval(Duration interval);
Timer withKeyMap(KeyMap keyMap);
Timer withStyles(Styles styles);
// Model implementation
Command init();
UpdateResult<Timer> update(Message msg);
String view();
// Control
Command start();
Command stop();
Command toggle();
void reset();
// State accessors
boolean running();
Duration elapsed();
Duration remaining();
}Basic Usage:
// Create countdown timer
Timer countdown = new Timer()
.withDuration(Duration.ofMinutes(5))
.withInterval(Duration.ofSeconds(1));
// Use in a model
class CountdownModel implements Model {
private final Timer timer;
@Override
public Command init() {
return Command.batch(
timer.init(),
timer.start() // Start immediately
);
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
UpdateResult<Timer> timerResult = timer.update(msg);
// Check if timer completed
if (timerResult.model().remaining().isZero() &&
timer.running()) {
return UpdateResult.from(
new CompletedModel(),
Command.batch(
Command.println("Time's up!"),
Command.quit()
)
);
}
return UpdateResult.from(
new CountdownModel(timerResult.model()),
timerResult.command()
);
}
@Override
public String view() {
Style timerStyle = Style.newStyle()
.bold()
.foreground("#00FF00")
.width(40)
.alignHorizontal(Position.Center);
return timerStyle.render(
"Countdown Timer\n\n" +
timer.view() + "\n\n" +
"Press Space to pause/resume, R to reset, Q to quit"
);
}
}Stopwatch Mode:
// Create stopwatch (no duration limit)
Timer stopwatch = new Timer()
.withInterval(Duration.ofMillis(100));
class StopwatchModel implements Model {
private final Timer timer;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
if (key.type() == KeyType.KeySpace) {
// Toggle start/stop
Command toggleCmd = timer.toggle();
return UpdateResult.from(this, toggleCmd);
}
if (key.type() == KeyType.KeyRunes && key.runes()[0] == 'r') {
timer.reset();
return UpdateResult.from(this);
}
}
UpdateResult<Timer> timerResult = timer.update(msg);
return UpdateResult.from(
new StopwatchModel(timerResult.model()),
timerResult.command()
);
}
@Override
public String view() {
Duration elapsed = timer.elapsed();
long minutes = elapsed.toMinutes();
long seconds = elapsed.minusMinutes(minutes).getSeconds();
long millis = elapsed.minusMinutes(minutes)
.minusSeconds(seconds)
.toMillis();
String timeDisplay = String.format(
"%02d:%02d.%03d",
minutes, seconds, millis
);
return String.format(
"Stopwatch\n\n%s\n\n%s",
timeDisplay,
timer.running() ? "[Running]" : "[Stopped]"
);
}
}Pomodoro Timer:
class PomodoroModel implements Model {
private final Timer timer;
private final int completedPomodoros;
private final boolean isBreak;
private static final Duration WORK_DURATION = Duration.ofMinutes(25);
private static final Duration SHORT_BREAK = Duration.ofMinutes(5);
private static final Duration LONG_BREAK = Duration.ofMinutes(15);
@Override
public UpdateResult<? extends Model> update(Message msg) {
UpdateResult<Timer> timerResult = timer.update(msg);
// Check if current session completed
if (timerResult.model().remaining().isZero() && timer.running()) {
if (!isBreak) {
// Work session done, start break
int newPomodoros = completedPomodoros + 1;
Duration breakDuration = (newPomodoros % 4 == 0) ?
LONG_BREAK : SHORT_BREAK;
Timer breakTimer = new Timer()
.withDuration(breakDuration)
.withInterval(Duration.ofSeconds(1));
return UpdateResult.from(
new PomodoroModel(breakTimer, newPomodoros, true),
Command.batch(
breakTimer.start(),
Command.println("Work session complete! Take a break.")
)
);
} else {
// Break done, start new work session
Timer workTimer = new Timer()
.withDuration(WORK_DURATION)
.withInterval(Duration.ofSeconds(1));
return UpdateResult.from(
new PomodoroModel(workTimer, completedPomodoros, false),
Command.batch(
workTimer.start(),
Command.println("Break complete! Back to work.")
)
);
}
}
return UpdateResult.from(
new PomodoroModel(timerResult.model(), completedPomodoros, isBreak),
timerResult.command()
);
}
@Override
public String view() {
String phase = isBreak ? "Break Time" : "Work Time";
return String.format(
"Pomodoro Timer\n\n" +
"%s\n" +
"%s\n\n" +
"Completed: %d\n",
phase,
timer.view(),
completedPomodoros
);
}
}File system browser component.
package com.williamcallahan.tui4j.compat.bubbles.filepicker;
class FilePicker implements Model {
// Constructor
FilePicker();
// Model implementation
Command init();
UpdateResult<FilePicker> update(Message msg);
String view();
// State accessors
File selectedFile();
File currentDirectory();
}
// FilePicker Messages
class DidSelectFileMessage implements Message {
DidSelectFileMessage(String path);
String path();
}
class DidSelectDirectoryMessage implements Message {
DidSelectDirectoryMessage(String path);
String path();
}Basic Usage:
// Create file picker
FilePicker filePicker = new FilePicker();
// Use in a model
class FileSelectionModel implements Model {
private final FilePicker filePicker;
@Override
public Command init() {
return filePicker.init();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Enter to select file
if (key.type() == KeyType.KeyEnter) {
File selected = filePicker.selectedFile();
if (selected != null && selected.isFile()) {
return UpdateResult.from(
new FileOpenedModel(selected),
Command.none()
);
}
}
// Escape to cancel
if (key.type() == KeyType.KeyEsc) {
return UpdateResult.from(this, Command.quit());
}
}
// Delegate to file picker
UpdateResult<FilePicker> filePickerResult = filePicker.update(msg);
return UpdateResult.from(
new FileSelectionModel(filePickerResult.model()),
filePickerResult.command()
);
}
@Override
public String view() {
Style headerStyle = Style.newStyle()
.bold()
.foreground("#FFFFFF")
.background("#0066CC")
.padding(0, 1);
String currentDir = filePicker.currentDirectory().getAbsolutePath();
String header = headerStyle.render("Select File: " + currentDir);
return VerticalJoinDecorator.join(
Position.Left,
header,
filePicker.view(),
"Enter: Select | Esc: Cancel | ↑/↓: Navigate"
);
}
}File Operations:
class FileManagerModel implements Model {
private final FilePicker filePicker;
private final String operation; // "open", "save", "delete"
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
if (key.type() == KeyType.KeyEnter) {
File selected = filePicker.selectedFile();
return switch (operation) {
case "open" -> UpdateResult.from(
new FileOpenedModel(selected),
loadFile(selected)
);
case "save" -> UpdateResult.from(
new FileSavedModel(selected),
saveFile(selected)
);
case "delete" -> UpdateResult.from(
new ConfirmDeleteModel(selected),
Command.none()
);
default -> UpdateResult.from(this);
};
}
}
UpdateResult<FilePicker> result = filePicker.update(msg);
return UpdateResult.from(
new FileManagerModel(result.model(), operation),
result.command()
);
}
}Cursor rendering for text input components.
package com.williamcallahan.tui4j.compat.bubbles.cursor;
class Cursor {
// Mode control
void setMode(CursorMode mode);
// Rendering
String view();
}
enum CursorMode {
CursorBlink, // Blinking cursor
CursorStatic, // Solid cursor
CursorHide // Hidden cursor
}Usage:
// The Cursor class is typically used internally by TextInput and Textarea
// components. For custom text input, you can use it directly:
class CustomInputModel implements Model {
private final Cursor cursor;
private final String text;
private final int cursorPos;
public CustomInputModel() {
this.cursor = new Cursor();
cursor.setMode(CursorMode.CursorBlink);
this.text = "";
this.cursorPos = 0;
}
@Override
public String view() {
String beforeCursor = text.substring(0, cursorPos);
String afterCursor = text.substring(cursorPos);
return beforeCursor + cursor.view() + afterCursor;
}
}Cursor Modes:
// Blinking cursor (default)
cursor.setMode(CursorMode.CursorBlink);
// Static cursor (no blink)
cursor.setMode(CursorMode.CursorStatic);
// Hidden cursor
cursor.setMode(CursorMode.CursorHide);Key binding help display component.
package com.williamcallahan.tui4j.compat.bubbles.help;
class Help implements Model {
// Constructor
Help();
// Model implementation
Command init();
UpdateResult<Help> update(Message msg);
String view(KeyMap keyMap);
}
record Binding(Key keys, String help) { }Basic Usage:
// Create help component
Help help = new Help();
// Define key bindings
KeyMap keyMap = new KeyMap()
.add(new Binding(new Key(KeyType.KeyUp), "Move up"))
.add(new Binding(new Key(KeyType.KeyDown), "Move down"))
.add(new Binding(new Key(KeyType.KeyEnter), "Select"))
.add(new Binding(new Key(KeyType.KeyEsc), "Cancel"))
.add(new Binding(new Key(KeyType.KeyRunes, new char[]{'q'}), "Quit"));
// Use in a model
class HelpfulModel implements Model {
private final Help help;
private final boolean showHelp;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// F1 to toggle help
if (key.type() == KeyType.KeyF1) {
return UpdateResult.from(
new HelpfulModel(help, !showHelp)
);
}
}
return UpdateResult.from(this);
}
@Override
public String view() {
if (showHelp) {
return help.view(keyMap);
}
return "Press F1 for help\n\n" + mainContent();
}
}Advanced Help System:
class ApplicationWithHelp implements Model {
private final Help help;
private final KeyMap mainKeyMap;
private final KeyMap editKeyMap;
private final String currentMode;
@Override
public String view() {
// Show different help based on current mode
KeyMap currentKeyMap = currentMode.equals("edit") ?
editKeyMap : mainKeyMap;
Style helpStyle = Style.newStyle()
.border(StandardBorder.roundedBorder())
.borderForeground("#00FF00")
.padding(1);
String helpText = help.view(currentKeyMap);
return VerticalJoinDecorator.join(
Position.Left,
renderMainContent(),
"",
helpStyle.render(helpText)
);
}
}
// Define key maps for different modes
KeyMap mainKeyMap = new KeyMap()
.add(new Binding(new Key(KeyType.KeyUp), "Navigate up"))
.add(new Binding(new Key(KeyType.KeyDown), "Navigate down"))
.add(new Binding(new Key(KeyType.KeyEnter), "Select item"))
.add(new Binding(new Key(KeyType.KeyRunes, new char[]{'e'}), "Edit mode"))
.add(new Binding(new Key(KeyType.KeyRunes, new char[]{'q'}), "Quit"));
KeyMap editKeyMap = new KeyMap()
.add(new Binding(new Key(KeyType.KeyCtrlS), "Save"))
.add(new Binding(new Key(KeyType.KeyCtrlX), "Cut"))
.add(new Binding(new Key(KeyType.KeyCtrlC), "Copy"))
.add(new Binding(new Key(KeyType.KeyCtrlV), "Paste"))
.add(new Binding(new Key(KeyType.KeyEsc), "Exit edit mode"));class ComplexFormModel implements Model {
private final TextInput nameInput;
private final TextInput emailInput;
private final Textarea bioTextarea;
private final List countryList;
private final int focusedComponent; // 0-3
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
// Tab to switch focus
if (key.type() == KeyType.KeyTab) {
return UpdateResult.from(switchFocus());
}
// Delegate to focused component
Model focused = getFocusedComponent();
UpdateResult<?> result = focused.update(msg);
return UpdateResult.from(
updateComponent(result.model()),
result.command()
);
}
return UpdateResult.from(this);
}
@Override
public String view() {
return VerticalJoinDecorator.join(
Position.Left,
"User Profile Form",
"",
nameInput.view(),
emailInput.view(),
"Biography:",
bioTextarea.view(),
"Country:",
countryList.view(),
"",
"Tab: Next field | Enter: Submit"
);
}
}class StatefulComponentModel implements Model {
private final Spinner loadingSpinner;
private final Progress downloadProgress;
private final TextInput userInput;
private final ViewState state;
enum ViewState {
INPUT, LOADING, DOWNLOADING, COMPLETE
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
return switch (state) {
case INPUT -> handleInputState(msg);
case LOADING -> handleLoadingState(msg);
case DOWNLOADING -> handleDownloadingState(msg);
case COMPLETE -> handleCompleteState(msg);
};
}
@Override
public String view() {
return switch (state) {
case INPUT -> userInput.view();
case LOADING -> loadingSpinner.view() + " Processing...";
case DOWNLOADING -> "Downloading:\n" + downloadProgress.view();
case COMPLETE -> "Complete!";
};
}
}class ResponsiveLayout implements Model {
private final Table table;
private final List list;
private final int terminalWidth;
private final int terminalHeight;
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof WindowSizeMessage size) {
// Rebuild components with new dimensions
Table resizedTable = table
.withWidth(size.width() - 4)
.withHeight(size.height() - 10);
return UpdateResult.from(
new ResponsiveLayout(resizedTable, list, size.width(), size.height())
);
}
return UpdateResult.from(this);
}
}init() on components in your model's init() methodCommand.batch() when components need multiple commandsWindowSizeMessageInstall with Tessl CLI
npx tessl i tessl/maven-com-williamcallahan--tui4j