A terminal text buffer implementation in Java — the core data structure used by terminal emulators to store, edit, and render text output.
When a shell prints output, something has to keep track of what character is in each cell of the screen, what colour it has, and where the cursor is. That "something" is a terminal buffer.
This library implements that data structure from scratch in Java, without any external runtime dependencies. It is meant for developers who are:
- building a terminal emulator or TUI framework,
- studying how terminal emulators work internally, or
- looking for a clean, tested reference implementation.
It is not a terminal emulator itself — it does not parse escape sequences, drive a display, or handle input events. It is the data layer only.
Be aware of these before you use it:
- Wide characters in Windows PowerShell / CMD: PowerShell replaces CJK
ideographs and many emoji with
?before they reach the JVM. Wide character support works correctly in the library (verified by the automated test suite) but cannot be tested interactively from those terminals. Use IntelliJ's built-in terminal or Windows Terminal withchcp 65001.
Requirements: Java 8+, no external runtime dependencies.
git clone https://github.com/igarbayo/terminal-text-buffer.git
cd terminal-text-buffer
./gradlew testAll 14 test classes run. The HTML report is at
build/reports/tests/test/index.html.
Windows note: if Gradle cannot find the compiler, set
org.gradle.java.homeingradle.propertiesto point to your JDK (not JRE):org.gradle.java.home=C:\\Program Files\\Java\\jdk1.8.0_261
// Create an 80x24 buffer with 500 lines of scrollback
TerminalBuffer buf = new TerminalBuffer(80, 24, 500);
// Set attributes and write
buf.setForeground(TerminalColor.RED);
buf.addStyle(TextStyle.BOLD);
buf.writeText("Hello, terminal!");
// Move the cursor and write more
buf.setCursor(0, 1);
buf.resetAttributes();
buf.writeText("Normal text on row 1");
// Read back
String line0 = buf.getLine(0); // "Hello, terminal!"
char ch = buf.getChar(0, 0); // 'H'A command-line REPL lets you poke at the buffer manually:
./gradlew runType help at the prompt to see all commands, or consult the
manual testing guide for a full walkthrough
with expected output.
| Command | What it does |
|---|---|
write <text> |
Write text at cursor position (overwrite) |
insert <text> |
Insert text at cursor, shifting content right |
fill [char] |
Fill current line with character (space if omitted) |
newline |
Insert empty line at bottom, scroll top to scrollback |
clear |
Clear screen and reset cursor to (0, 0) |
clearall |
Clear screen, scrollback, and reset cursor |
cursor <col> <row> |
Move cursor to position |
move up|down|left|right [n] |
Move cursor N steps (default 1) |
fg <COLOR> |
Set foreground color |
bg <COLOR> |
Set background color |
bold / italic / underline |
Enable text style |
nostyle <STYLE> |
Disable text style |
reset |
Reset all attributes to default |
resize <w> <h> |
Resize the buffer |
print |
Repaint the buffer |
scrollback |
Print scrollback history |
info |
Show dimensions, cursor, and current attributes |
codepoints <text> |
Show Unicode code points (debug) |
exit / quit |
Exit the REPL |
Colors: DEFAULT RED GREEN YELLOW BLUE MAGENTA CYAN WHITE BLACK and BRIGHT_* variants.
Tested on every combination in CI:
| Java version | Ubuntu | Windows |
|---|---|---|
| 11 | Passing | Passing |
| 17 | Passing | Passing |
| 21 | Passing | Passing |
Wide character support (CJK + emoji) is verified by the automated test suite on all platforms. Interactive display of wide characters requires a Unicode-aware terminal — see Known Limitations.
Gradle is picking up a JRE instead of a JDK. Set org.gradle.java.home in
gradle.properties to point to a JDK directory (the one that contains bin/javac):
org.gradle.java.home=C:\\Program Files\\Java\\jdk1.8.0_261Or set JAVA_HOME in your terminal before running Gradle:
$env:JAVA_HOME = "C:\Program Files\Java\jdk1.8.0_261"
./gradlew testPowerShell and CMD do not forward CJK ideographs or many emoji to the JVM — they
replace them with ? before the Java process ever receives the input. This is an
OS-level limitation. The library itself handles wide characters correctly (verified
by the automated test suite). To test interactively, use:
- IntelliJ IDEA's built-in terminal
- Windows Terminal with
chcp 65001(UTF-8 code page)
Gradle cached the last run. Force a fresh run with:
./gradlew cleanTest testThe class was compiled with a newer JDK than the one running it. Make sure the
JDK used to build (JAVA_HOME) and the JDK used to run are the same version, or
at least that the runtime is at least Java 8.
┌───────────────────────────────────────────────────┐
│ TerminalBuffer (public API) │
│ │
│ cursor: CursorPosition │
│ attrs: CellAttributes (fg, bg, styles) │
│ │
│ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ Screen │ │ Scrollback │ │
│ │ Row[height] │ │ Row[] circular │ │
│ │ (mutable, editable) │ │ (read-only; │ │
│ │ │ │ max size cap) │ │
│ │ row 0 = top line │ │ -1 = newest │ │
│ │ row h-1 = bottom │ │ -n = oldest │ │
│ └──────────────────────┘ └──────────────────┘ │
│ │ │ │
│ └──────────┬───────────────┘ │
│ │ │
│ Row ──┤ Cell[] (fixed width) │
│ │ │
│ Cell ──┤ char + CellAttributes │
│ │ + CellType │
│ │ (NORMAL/WIDE_LEFT/ │
│ │ WIDE_RIGHT) │
└──────────────────────┴────────────────────────────┘
CellAttributes = TerminalColor (fg) + TerminalColor (bg) + Set<TextStyle>
TerminalColor = DEFAULT | BLACK | RED | GREEN | YELLOW | BLUE |
MAGENTA | CYAN | WHITE | BRIGHT_* variants (17 total)
TextStyle = BOLD | ITALIC | UNDERLINE
When insertEmptyLine() is called (or the cursor reaches the bottom), the top
screen row is snapshotted (copy-constructor) and pushed to the back of the
scrollback deque. If the deque is at capacity the oldest entry is evicted from
the front.
TerminalColor (enum) — 16 standard colors + DEFAULT (17 values)
TextStyle (enum) — BOLD, ITALIC, UNDERLINE
CellAttributes (immutable) — fg color + bg color + EnumSet<TextStyle>
Cell (immutable) — int codePoint + CellAttributes + CellType (NORMAL/WIDE_LEFT/WIDE_RIGHT)
Row (package-private) — mutable fixed-width array of Cells
CursorPosition (immutable) — (col, row) value object
TerminalBuffer (public) — the main API class
For the full rationale and alternatives considered for each decision, see ARCHITECTURE_DECISIONS.md.
Cell and CellAttributes are immutable value objects. This makes it safe to
share Cell.EMPTY everywhere without defensive copying, and ensures that
scrollback history cannot be accidentally modified after a row is pushed there.
Row is mutable (package-private) — writing a character is O(1) instead of
creating a new row array on every keystroke. When a row is pushed into
scrollback, a copy-constructor snapshot is taken (new Row(original)), so
future mutations to the screen row do not corrupt history.
TerminalColor is a 17-value enum (DEFAULT + 16 standard ANSI colors).
TextStyle flags use EnumSet<TextStyle>, backed by a long bitmask internally
— compact, type-safe, and no custom bitfield arithmetic needed.
A fixed-size Row[] circular buffer with sbHead (index of the oldest entry)
and sbCount (number of live entries). Push and eviction are O(1); random
access by index is also O(1) via scrollback[(sbHead + sbCount + n) % maxScrollback].
- Screen: row
[0, height-1], where row 0 is the top visible line. - Scrollback: row
[-scrollbackSize, -1], where row -1 is the most recently scrolled-off line and-scrollbackSizeis the oldest.
This signed-integer convention unifies all content-access methods under a
single int row parameter without needing a separate type or flag.
Uses the placeholder cell strategy (same as xterm, VTE, and most real terminal emulators):
- A wide glyph at column
csetscells[c] = WIDE_LEFTandcells[c+1] = WIDE_RIGHT. WIDE_RIGHTstores'\0'and is skipped intoContentString().- Writing over either half of a wide pair automatically clears both halves.
- A wide char that does not fit at the right edge is skipped.
Detection uses Unicode block ranges directly (not Character.getType(), which
is too restrictive for some ranges).
When the last character is written to column width-1, the cursor stays there
and pendingWrap = true is set internally. The next writeText call resolves
the wrap first — advances to column 0 of the next row (inserting a new line if
at the bottom) — then writes. This matches xterm behaviour.
Inserts at the cursor, shifting existing row content right. Overflow flows to subsequent rows recursively:
- Capture content from cursor column to end of row.
- Merge:
combined = [new cells] + [existing]. - Write the first
(width - cursorCol)cells back. - If overflow contains actual content, recurse at column 0 of the next row.
- If overflow reaches the last row,
insertEmptyLine()is called — the top row scrolls into scrollback.
getLine(row)— content with trailing spaces trimmed. Convenient for tests.getRawLine(row)— fixed-width string (alwayswidthcharacters). For width-sensitive rendering.
resize(newWidth, newHeight) handles four cases:
- Wider: rows padded with
Cell.EMPTYat the right. - Narrower: rows truncated at the new width.
- Taller: new empty rows appended at the bottom.
- Shorter: rows removed from the top enter scrollback.
-
insertTextcursor position after overflow: Cursor arithmetic is based on total character count relative to the cursor start. Consistent in the current implementation; could be refined for complex multi-line inserts. -
hasActualContenttrailing spaces: Spaces from empty rows are suppressed to avoid infinite blank-cell propagation. Intentional trailing spaces at the end of a full row would be dropped.
- Add
Bidi(bidirectional text) support for RTL languages. - Add
SGR(Select Graphic Rendition) parsing to process ANSI escape sequences directly. - Add alternate screen buffer support (used by fullscreen TUI apps like vim).
Built using TDD (Test-Driven Development) in a bottom-up order:
- Value objects (
CellAttributes,Cell) — tested in isolation first Row— the storage primitiveTerminalBufferskeleton with cursor and dimensions- Attribute state management
clearScreen/clearAll+ basic content accesswriteTextwith pending-wrap flagfillLineinsertEmptyLine+ scrollbackinsertTextwith push-down wrapping- Content access edge cases (
getLine,getAllContent) - Wide character support
- Resize
Commits are signed with PGP. Branch strategy follows Git Flow — see GOVERNANCE.md for details.
Contributions, questions and feedback are welcome. Please read CONTRIBUTING.md before opening a pull request.
This project is licensed under the MIT License — see LICENSE for the full text.
The choice of MIT reflects the academic nature of this project: maximum freedom to study, adapt, and reuse, with no restrictions on how you use it.
Third-party component licenses are listed in COMPONENTS_LICENSE.md.

