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.
TUI4J provides comprehensive text processing utilities for handling ANSI escape sequences, calculating display widths, wrapping, truncating, and processing Unicode grapheme clusters.
Calculate the display width of text containing ANSI codes.
package com.williamcallahan.tui4j.ansi;
class TextWidth {
// Static method to measure display width
static int measureCellWidth(String input);
}Usage:
// Text with ANSI codes
String styled = "\u001b[31mHello\u001b[0m";
// Width ignoring ANSI codes (counts cell width, ignores escape sequences)
int width = TextWidth.measureCellWidth(styled); // Returns 5 (length of "Hello")
// Plain text width
int plainWidth = TextWidth.measureCellWidth("Hello"); // Returns 5Wrap text to specified width while preserving ANSI codes.
package com.williamcallahan.tui4j.ansi;
class TextWrapper {
// Constructor
TextWrapper();
// Instance methods for wrapping
String wrap(String text, int limit);
String wrap(String text, int limit, boolean preserveStyle);
}Usage:
String longText = "This is a very long line of text that needs to be wrapped.";
// Create wrapper instance
TextWrapper wrapper = new TextWrapper();
// Wrap at word boundaries
String wrapped = wrapper.wrap(longText, 20);
// Wrap with style preservation across lines
String styledWrap = wrapper.wrap(longText, 20, true);Example Output:
String text = "The quick brown fox jumps over the lazy dog";
TextWrapper wrapper = new TextWrapper();
String wrapped = wrapper.wrap(text, 20);
// Output (wrapped at word boundaries):
// "The quick brown fox "
// "jumps over the lazy "
// "dog"Truncate text to specified width with optional tail.
package com.williamcallahan.tui4j.ansi;
class Truncate {
static String truncate(String text, int width);
static String truncate(String text, int width, String tail);
}Usage:
String longText = "This is a very long string that needs truncation";
// Truncate without tail
String truncated = Truncate.truncate(longText, 20);
// "This is a very long "
// Truncate with ellipsis
String withTail = Truncate.truncate(longText, 20, "...");
// "This is a very lo..."
// Truncate with custom tail
String custom = Truncate.truncate(longText, 20, " [more]");
// "This is a [more]"Process Unicode grapheme clusters correctly.
package com.williamcallahan.tui4j.ansi;
class GraphemeCluster {
static List<String> clusters(String text);
}Usage:
// Text with emoji and combining characters
String text = "Hello 👨👩👧👦 World";
// Get grapheme clusters
List<String> clusters = GraphemeCluster.clusters(text);
// Correctly handles multi-codepoint graphemes
// Each cluster is a visually atomic unit
for (String cluster : clusters) {
System.out.println(cluster);
}The com.williamcallahan.tui4j.compat.x.ansi package provides additional ANSI string operations.
General ANSI string operations.
package com.williamcallahan.tui4j.compat.x.ansi;
class Ansi {
static String strip(String text);
static int length(String text);
static String truncate(String text, int maxLength);
static String truncate(String text, int maxLength, String tail);
}Usage:
String styled = "\u001b[31;1mRed Bold Text\u001b[0m";
// Remove ANSI codes
String plain = Ansi.strip(styled); // "Red Bold Text"
// Get visual length (ignoring ANSI)
int len = Ansi.length(styled); // 13
// Truncate with ANSI awareness
String trunc = Ansi.truncate(styled, 10); // Preserves color codesCalculate string display width.
package com.williamcallahan.tui4j.compat.x.ansi;
class StringWidth {
static int width(String text);
}Usage:
// ASCII text
int width1 = StringWidth.width("Hello"); // 5
// Text with wide characters (CJK)
int width2 = StringWidth.width("你好"); // 4 (each character is 2 columns wide)
// Text with ANSI codes
int width3 = StringWidth.width("\u001b[31mHello\u001b[0m"); // 5Remove ANSI escape sequences.
package com.williamcallahan.tui4j.compat.x.ansi;
class Strip {
static String strip(String text);
}Usage:
String styled = "\u001b[31mRed\u001b[0m \u001b[1mBold\u001b[0m";
String plain = Strip.strip(styled); // "Red Bold"Word wrapping with ANSI support.
package com.williamcallahan.tui4j.compat.x.ansi;
class WordWrap {
static String wrap(String text, int width);
static String wrap(String text, int width, String breakpoint);
}Usage:
String text = "The quick brown fox jumps over the lazy dog";
// Wrap at word boundaries
String wrapped = WordWrap.wrap(text, 20);
// Wrap with custom breakpoint characters
String custom = WordWrap.wrap(text, 20, " -"); // Break on space or hyphenHard wrapping (break anywhere).
package com.williamcallahan.tui4j.compat.x.ansi;
class HardWrap {
static String wrap(String text, int width);
}Usage:
String text = "verylongwordwithoutspaces";
String wrapped = HardWrap.wrap(text, 10);
// "verylongwo"
// "rdwithouts"
// "paces"Cut/slice ANSI strings.
package com.williamcallahan.tui4j.compat.x.ansi;
class Cut {
static String cut(String text, int start, int end);
}Usage:
String styled = "\u001b[31mRed Text\u001b[0m with normal text";
// Extract substring preserving ANSI codes
String substring = Cut.cut(styled, 0, 8); // Gets "Red Text" with color codes
String substring2 = Cut.cut(styled, 9, 20); // Gets "with norm" without codesLow-level ANSI parsing using state machine.
Parser states for ANSI sequences.
package com.williamcallahan.tui4j.ansi;
enum State {
Ground,
Escape,
EscapeIntermediate,
CsiEntry,
CsiParam,
CsiIntermediate,
CsiIgnore,
DcsEntry,
DcsParam,
DcsIntermediate,
DcsPassthrough,
DcsIgnore,
OscString,
SosPmApcString,
Utf8
}Parser actions.
package com.williamcallahan.tui4j.ansi;
enum Action {
None,
Print,
Execute,
Ignore,
Collect,
Param,
EscDispatch,
CsiDispatch,
Hook,
Put,
Unhook,
OscStart,
OscPut,
OscEnd
}Character codes and ranges.
package com.williamcallahan.tui4j.ansi;
enum Code {
C0_Start,
C0_End,
C1_Start,
C1_End,
Intermediate_Start,
Intermediate_End,
// ... many more codes
}Ensure text fits in terminal width:
String displayText(String text, int terminalWidth) {
int width = TextWidth.measureCellWidth(text);
if (width > terminalWidth) {
return Truncate.truncate(text, terminalWidth - 3, "...");
}
return text;
}Wrap text with padding considerations:
Style contentStyle = Style.newStyle()
.width(40)
.padding(1, 2);
String text = "Long text that needs wrapping";
// Account for padding in wrap width
int contentWidth = 40 - 4; // Subtract horizontal padding
TextWrapper wrapper = new TextWrapper();
String wrapped = wrapper.wrap(text, contentWidth);
String output = contentStyle.render(wrapped);Align multiple lines of text:
String[] lines = text.split("\n");
int maxWidth = Arrays.stream(lines)
.mapToInt(TextWidth::width)
.max()
.orElse(0);
// Center align each line
String aligned = Arrays.stream(lines)
.map(line -> {
int width = TextWidth.measureCellWidth(line);
int padding = (maxWidth - width) / 2;
return " ".repeat(padding) + line;
})
.collect(Collectors.joining("\n"));Remove ANSI codes before comparing strings:
boolean equalContent(String styled1, String styled2) {
String plain1 = Strip.strip(styled1);
String plain2 = Strip.strip(styled2);
return plain1.equals(plain2);
}Truncate while preserving ANSI styling:
String styledText = Style.newStyle()
.foreground("#FF0000")
.bold()
.render("Long text to truncate");
// Truncate preserving ANSI codes
String truncated = Ansi.truncate(styledText, 20, "...");
// Result: "\u001b[31;1mLong text to trun...\u001b[0m"@Override
public String view() {
int terminalWidth = this.width; // From WindowSizeMessage
String content = "Your long text content here...";
// Wrap to terminal width with margin
String wrapped = WordWrap.wrap(content, terminalWidth - 4);
return Style.newStyle()
.padding(0, 2)
.render(wrapped);
}String calculateBox(String content, int maxWidth) {
// Split into lines
String[] lines = content.split("\n");
// Calculate required width
int contentWidth = Arrays.stream(lines)
.mapToInt(TextWidth::width)
.max()
.orElse(0);
// Constrain to max width
int boxWidth = Math.min(contentWidth + 4, maxWidth); // +4 for padding
// Wrap if needed
if (contentWidth + 4 > maxWidth) {
int wrapWidth = maxWidth - 4;
TextWrapper wrapper = new TextWrapper();
content = Arrays.stream(lines)
.map(line -> wrapper.wrap(line, wrapWidth))
.collect(Collectors.joining("\n"));
}
return Style.newStyle()
.width(boxWidth)
.padding(1, 2)
.border(StandardBorder.normalBorder())
.render(content);
}Width Calculation: TextWidth.measureCellWidth() is optimized but avoid calling repeatedly in tight loops
Caching: Cache wrapped/truncated text when terminal size doesn't change:
private String cachedWrapped;
private int lastWidth;
TextWrapper wrapper = new TextWrapper();
String getWrapped(String text, int width) {
if (width != lastWidth) {
cachedWrapped = wrapper.wrap(text, width);
lastWidth = width;
}
return cachedWrapped;
}Lazy Wrapping: Only wrap text when actually displayed:
@Override
public String view() {
// Wrap here, not in update()
TextWrapper wrapper = new TextWrapper();
return wrapper.wrap(content, terminalWidth);
}Batch Processing: Process multiple lines together when possible:
TextWrapper wrapper = new TextWrapper();
String wrappedContent = Arrays.stream(lines)
.map(line -> wrapper.wrap(line, width))
.collect(Collectors.joining("\n"));TUI4J's text utilities correctly handle:
// All handled correctly
String text1 = "Hello 世界"; // Wide CJK characters
String text2 = "café"; // Combining accent
String text3 = "👨👩👧👦"; // Multi-codepoint emoji
String text4 = "n̈ï̈ẅ̈"; // Multiple combining marks
int w1 = TextWidth.measureCellWidth(text1); // Correct width with CJK
int w2 = TextWidth.measureCellWidth(text2); // Correct width with combining
int w3 = TextWidth.measureCellWidth(text3); // Correct emoji width
int w4 = TextWidth.measureCellWidth(text4); // Correct combining marks widthInstall with Tessl CLI
npx tessl i tessl/maven-com-williamcallahan--tui4j