CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-com-williamcallahan--tui4j

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.

Overview
Eval results
Files

bubbles-components.mddocs/

Bubbles UI Components

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.

Spinner

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 dots
  • MiniDot - Smaller dot animation
  • Jump - Jumping animation
  • Pulse - Pulsing circle
  • Points - Animated points
  • Globe - Globe rotation
  • Moon - Moon phases
  • Monkey - Monkey animation
  • Meter - Meter bars
  • Hamburger - Hamburger menu animation

Integration 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

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());
    }
}

TextInput

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";
    }
}

Textarea

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);

List

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();
});

Table

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()
    );
}

Viewport

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);

Paginator

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

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 { }

Timer

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
        );
    }
}

FilePicker

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

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);

Help

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"));

Component Integration Patterns

Composing Multiple Components

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"
        );
    }
}

Component State Management

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!";
        };
    }
}

Responsive Component Layout

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);
    }
}

Best Practices

  1. Component Lifecycle: Always call init() on components in your model's init() method
  2. Message Delegation: Delegate unhandled messages to child components
  3. Immutability: Components return new instances on update - always use the returned model
  4. Command Batching: Use Command.batch() when components need multiple commands
  5. Focus Management: Manually manage focus for multi-component forms
  6. Responsive Design: Update component dimensions on WindowSizeMessage
  7. State Composition: Keep component state in parent model constructors
  8. Error Handling: Check for null returns from selection methods
  9. Performance: Use high-performance mode for large viewports
  10. Accessibility: Always provide help text and keyboard shortcuts

Install with Tessl CLI

npx tessl i tessl/maven-com-williamcallahan--tui4j@0.3.0

docs

animation.md

bubbles-components.md

bubbletea-core.md

index.md

input-handling.md

lipgloss-styling.md

utilities.md

tile.json