jterm: Building a Terminal UI Toolkit in Pure Java

July 31, 2026

Every BBS needs a terminal. But not just any terminal — a fast one, with per-character styling, double-buffered rendering, and a widget library that makes building full-screen applications bearable. That’s what jterm is: a pure-Java terminal UI toolkit with zero native dependencies.

The Three-Layer Architecture

Terminal (raw ANSI/VT100) → Screen (double buffer + diff) → GUI (widgets + layout)

Each layer has one job:

  • Terminal — raw I/O. Reads keystrokes, writes ANSI escape sequences, handles raw mode and terminal resize events.
  • Screen — double-buffered rendering. Maintains a front buffer (what’s on screen) and a back buffer (what’s being drawn). On refresh, diffs the two and only sends the changed cells.
  • GUI — widgets and layout managers. Labels, tables, text boxes, panels, menus. The stuff you actually want to use.

This separation matters. You can use jterm at any layer — raw terminal access for a door game, screen buffering for a custom renderer, or the full widget library for a typical BBS screen.

Layer 1: The Terminal

The terminal layer handles raw ANSI I/O. It reads input through a blocking queue, writes escape sequences through a buffered output stream, and listens for terminal resize events:

public class AnsiTerminal implements Terminal {
    private final OutputStream out;
    private final InputDecoder decoder;
    private final LinkedBlockingQueue<KeyStroke> inputQueue = new LinkedBlockingQueue<>(256);

    public AnsiTerminal() throws IOException {
        this.out = new BufferedOutputStream(System.out, 4096);
        this.decoder = new InputDecoder(System.in);
        this.lastSize = queryTerminalSize();
        this.originalStty = captureStty();
        startReaderThread();
    }
}

The InputDecoder translates raw bytes into KeyStroke objects — handling multi-byte escape sequences for arrow keys, function keys, and modifier combinations. Input is read on a virtual thread (Java 26) and pushed to a blocking queue, so consumer code can poll without blocking the I/O layer.

The terminal also supports SocketTerminal — the same interface, but backed by a network socket instead of System.in/out. This is what makes jterm work for both local terminal apps and remote BBS sessions over Telnet or SSH.

Layer 2: The Screen

The screen layer is where the performance lives. Two ScreenBuffer objects — front and back — hold the complete state of every cell on the terminal. On refresh, the screen diffs the buffers and sends only the changed cells:

public class ScreenBuffer {
    private final int columns;
    private final int rows;
    private final TextCell[] cells;

    public ScreenBuffer(TerminalSize size) {
        this.columns = size.columns();
        this.rows = size.rows();
        this.cells = new TextCell[columns * rows];
        Arrays.fill(cells, TextCell.EMPTY);
    }

    public synchronized List<CellDiff> diffFrom(ScreenBuffer other) {
        List<CellDiff> diffs = new ArrayList<>();
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < columns; c++) {
                TextCell a = getCell(c, r);
                TextCell b = other.getCell(c, r);
                if (!a.equals(b)) {
                    diffs.add(new CellDiff(c, r, a));
                }
            }
        }
        return diffs;
    }
}

A flat array for cache efficiency. Synchronized access for thread safety. And the diff is just a list of (column, row, newCell) tuples — the renderer walks that list and emits the minimum ANSI to update the screen.

On an 80×24 terminal, a full redraw is 1,920 cells. But a typical screen update — a new chat message, a cursor move, a table row change — touches maybe 5-10 cells. The diff engine means jterm sends those 10 cells instead of all 1,920. Over a slow Telnet connection, that’s the difference between instant and sluggish.

TextCell: Immutable Per-Character Styling

Every cell is a TextCell record — immutable, safe to share between buffers, with full ANSI styling:

public record TextCell(
        String character,      // String for multi-codepoint support (emoji, CJK)
        Color fg,               // foreground color
        Color bg,               // background color
        EnumSet<SGR> modifiers   // bold, italic, underline, blink, reverse
) {
    public static final TextCell EMPTY = new TextCell(" ",
            AnsiColor.DEFAULT, AnsiColor.DEFAULT, EnumSet.noneOf(SGR.class));
}

Colors support three levels: ANSI 16-color (the classics), 256-color (xterm extended), and 24-bit RGB (truecolor). The renderer automatically batches SGR state changes — if 50 consecutive cells are all bright green, it sends one SGR set, not 50.

The character field is a String, not a char, because some Unicode codepoints are multi-character (emoji, combining marks). TextCell also detects double-width characters (CJK ideographs, Hangul) so the renderer can account for them in column layout.

Layer 3: Widgets

jterm ships with a full widget library:

Display widgets: Label, Button, Separator, ProgressBar Input widgets: TextBox, TextArea, CheckBox, RadioButton, RadioGroup Data widgets: ListBox, Table, DataGrid (with TableModel and ListModel abstractions) Container widgets: Panel (with Border and Borders), Menu, MenuBar, MenuItem, MenuPanel Chart widgets: Chart (line, bar, sparkline), ChartSeries, ChartAxisConfig

Each widget extends AbstractComponent, which handles the rendering lifecycle: bounds calculation, invalidation, and repaint scheduling. Widgets are composed into containers with layout managers:

public interface LayoutManager {
    void layout(Container container, TerminalSize size);
}

// LinearLayout — stack components vertically or horizontally
// BorderLayout — center + north/south/east/west regions
// GridLayout — rows × columns grid

Layout Managers

Three layout managers cover most terminal UI needs:

LinearLayout — stacks components vertically or horizontally with optional spacing. This is the workhorse for most BBS screens.

BorderLayout — divides space into center, north, south, east, and west regions. Good for screens with a fixed header, footer, and scrollable body.

GridLayout — arranges components in a rows × columns grid. Useful for data-dense screens like stock screeners or admin panels.

The Animation Engine

jterm includes 30+ animated backgrounds and transition effects — the kind of eye candy that makes a BBS feel alive:

Animated backgrounds: MatrixRain, Starfield, Aurora, PlasmaWash, LavaLamp, Snowfall, OceanWaves, LightningStorm, Fireworks, DNAHelix, CircuitBoard, MandelbrotZoom, TerrainFlyover, MoirePatterns, VoronoiCells, Spirograph, TickerTape, CandlestickField, MarketDepth, PortfolioHeatmap, PacketFlow, PhosphorDecay, RainStorm, Scanlines, DvdLogo

Transition effects: SlideTransition, FadeTransition, DissolveTransition, WipeTransition, TypewriterEffect

Each animation runs on a AnimationTimer with configurable FPS. The AnimationManager handles lifecycle — start, stop, pause, and cleanup. Animations render to the same ScreenBuffer as widgets, so they compose cleanly with regular UI elements.

This is what makes Phosphor BBS screens look like this:

screen "Login" {
    layout {
        banner { ansi = "welcome.ans" }
        spacer 2
        input { label = "Login: ", value = username, on_submit { val -> password_field.focus() } }
        input { label = "Password: ", value = password, on_submit { val -> attempt_login() } }
        statusbar { left = "${session_count} users online", right = "[Enter] Login" }
    }
}

The ansi = "welcome.ans" renders a full ANSI art banner. The animated background plays behind it. The screen transition slides in from the left. All from jterm’s animation and rendering layers, driven by Phosphor’s declarative widget tree.

The Sprite System

For game developers, jterm includes a sprite system — Sprite, SpriteSheet, SpriteRenderer, ParticleEffect, and AnimatedText. These render directly to the screen buffer with per-frame diffing, so animated sprites don’t force full redraws.

This is what powers Phosphor’s door games. A dungeon crawler with moving entities, particle effects on combat, and animated text on level-up — all rendered through the same double-buffered screen layer.

Zero Native Dependencies

jterm is pure Java. No JNI, no native libraries, no platform-specific builds. It works on any xterm-compatible terminal — macOS Terminal, iTerm2, Linux xterm, Windows Terminal, even Telnet clients.

The only external interaction is stty for raw mode (disabling echo and line buffering), and that’s handled with a simple ProcessBuilder call that captures and restores the original terminal state.

Why Not Just Use Existing Libraries?

Libraries like lanterna and Jexer exist for Java terminal UI. They’re good. But jterm was built for a specific use case: a BBS that needs:

  1. Network terminalsSocketTerminal makes the same widget library work over Telnet/SSH, not just local terminals
  2. Double-buffered diff rendering — essential for slow remote connections where full redraws are unacceptable
  3. Animation support — BBS systems need visual flair; lanterna and Jexer don’t ship with animated backgrounds
  4. Phosphor integration — the @PhosphorWidget annotation system lets plugins extend the widget library without touching jterm core

The Stack

jterm is the foundation. Phosphor builds on it. The BBS builds on Phosphor. Three layers of abstraction, each clean enough to use independently:

jterm (terminal UI toolkit) → Phosphor (BBS programming language) → Phosphor BBS (the BBS)

You can use jterm alone for any terminal application. You can use Phosphor with jterm for any BBS. And the Phosphor BBS ties it all together with networking, database, and federation.

That’s the stack. Pure Java, from the terminal cell to the federated sync envelope.