Add new terminal components and tooling settings

This commit is contained in:
!verity
2026-03-13 18:30:23 +01:00
commit 26de5ef958
58 changed files with 4423 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
package dev.jakub.terminal;
import dev.jakub.terminal.components.*;
import dev.jakub.terminal.core.Color;
import dev.jakub.terminal.core.StyledText;
import dev.jakub.terminal.core.TerminalSupport;
import dev.jakub.terminal.interactive.Confirm;
import dev.jakub.terminal.interactive.Menu;
import dev.jakub.terminal.interactive.Pager;
import dev.jakub.terminal.interactive.SelectList;
import dev.jakub.terminal.internal.PromptBuilder;
import dev.jakub.terminal.live.Dashboard;
import dev.jakub.terminal.live.ProgressBarBuilder;
import dev.jakub.terminal.live.SpinnerBuilder;
public final class Terminal {
private static final TerminalSupport DEFAULT_SUPPORT = new TerminalSupport();
private final TerminalSupport support;
/**
* Creates a Terminal instance with auto-detected capabilities.
*/
public Terminal() {
this.support = DEFAULT_SUPPORT;
}
/**
* Creates a Terminal instance with the given support (e.g. for testing).
*/
public Terminal(TerminalSupport support) {
this.support = support != null ? support : DEFAULT_SUPPORT;
}
/**
* Shared default Terminal instance.
*/
private static final Terminal DEFAULT = new Terminal();
/**
* Returns a new table builder using default terminal support.
*/
public static Table table() {
return DEFAULT.tableInternal();
}
/**
* Returns a new table builder with the given terminal support (e.g. for testing).
*/
public static Table table(TerminalSupport support) {
return new Table(support != null ? support : DEFAULT.getSupport());
}
/**
* Returns a styled text builder for the given string. Chain with
* {@link StyledText#color(Color)}, {@link StyledText#bold()}, etc.,
* then {@link StyledText#print()} or {@link StyledText#println()}.
*/
public static StyledText print(String text) {
return new StyledText(text, DEFAULT.getSupport());
}
/**
* Returns a progress bar builder.
*/
public static ProgressBarBuilder progressBar() {
return new ProgressBarBuilder(DEFAULT.getSupport());
}
/**
* Returns a spinner builder. Call {@link SpinnerBuilder#start()} to run.
*/
public static SpinnerBuilder spinner() {
return new SpinnerBuilder(DEFAULT.getSupport());
}
/**
* Returns an interactive menu builder. Call {@link Menu#select()} to show and get choice.
*/
public static Menu menu() {
return new Menu(DEFAULT.getSupport());
}
/**
* Returns a select list (arrow keys Up/Down + Enter, or number + Enter). Call {@link SelectList#select()}.
*/
public static SelectList selectList() {
return new SelectList(DEFAULT.getSupport());
}
/**
* Returns a horizontal rule/separator builder.
*/
public static Rule rule() {
return new Rule(DEFAULT.getSupport());
}
/**
* Returns a key-value list builder (aligned labels).
*/
public static KeyValue keyValue() {
return new KeyValue(DEFAULT.getSupport());
}
/**
* Returns a box builder (text in a frame).
*/
public static Box box() {
return new Box(DEFAULT.getSupport());
}
/** Returns a tree builder. Use {@link Tree#node(String)} then {@link Tree.TreeBuilder#child(String)} / {@link Tree.TreeBuilder#end()}. */
public static Tree tree() {
return new Tree(DEFAULT.getSupport());
}
/** Returns a multi-column layout (24 columns). */
public static Columns columns() {
return new Columns(DEFAULT.getSupport());
}
/** Returns a prompt builder. Chain {@link PromptBuilder#masked()} then {@link PromptBuilder#ask()}. */
public static PromptBuilder prompt(String message) {
return new PromptBuilder(message, DEFAULT.getSupport());
}
/** Returns a colored badge. Chain {@link Badge#print()} or {@link Badge#println()}. */
public static Badge badge(String label, Color color) {
return new Badge(label, color, DEFAULT.getSupport());
}
/** Returns a diff viewer (before/after). */
public static Diff diff() {
return new Diff(DEFAULT.getSupport());
}
/** Returns a log viewer (info, warn, error, debug). */
public static Log log() {
return new Log(DEFAULT.getSupport());
}
/** Returns a timeline builder. */
public static Timeline timeline() {
return new Timeline(DEFAULT.getSupport());
}
/** Returns a heatmap builder. */
public static Heatmap heatmap() {
return new Heatmap(DEFAULT.getSupport());
}
/** Returns an ASCII chart / sparkline builder. */
public static Chart chart() {
return new Chart(DEFAULT.getSupport());
}
/** Returns a yes/no confirm dialog. */
public static Confirm confirm(String message) {
return new Confirm(message, DEFAULT.getSupport());
}
/** Returns a steps/wizard display builder. */
public static Steps steps() {
return new Steps(DEFAULT.getSupport());
}
/** Returns a breadcrumb builder. */
public static Breadcrumb breadcrumb() {
return new Breadcrumb(DEFAULT.getSupport());
}
/** Prints a notification (SUCCESS, WARNING, ERROR, INFO). */
public static void notify(String message, Notification.Type type) {
new Notification(message, type, DEFAULT.getSupport()).print();
}
/** Returns a code block builder (optional syntax highlighting). */
public static CodeBlock code(String language) {
return new CodeBlock(language, DEFAULT.getSupport());
}
/** Returns a system info widget. */
public static SysInfo sysinfo() {
return new SysInfo(DEFAULT.getSupport());
}
/** Returns a lightweight Markdown renderer. */
public static Markdown markdown(String source) {
return new Markdown(source, DEFAULT.getSupport());
}
/** Returns a table-of-contents builder. */
public static TableOfContents toc() {
return new TableOfContents(DEFAULT.getSupport());
}
/** Returns a live dashboard (refresh with widgets). */
public static Dashboard dashboard() {
return new Dashboard(DEFAULT.getSupport());
}
/** Returns a pager for long lists. */
public static Pager pager() {
return new Pager(DEFAULT.getSupport());
}
TerminalSupport getSupport() {
return support;
}
Table tableInternal() {
return new Table(support);
}
}

View File

@@ -0,0 +1,61 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.Color;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
/**
* Colored bracket label (notification badge). Use via {@link Terminal#badge(String, Color)}.
* Chainable like StyledText: .println() / .print().
*/
public final class Badge {
private final String label;
private final Color color;
private final TerminalSupport support;
public Badge(String label, Color color, TerminalSupport support) {
this.label = label != null ? label : "";
this.color = color != null ? color : Color.WHITE;
this.support = support;
}
/**
* Prints the badge to the given stream (no newline).
*/
public void print(PrintStream out) {
String text = "[" + label + "]";
if (support.isAnsiEnabled()) {
out.print(color.ansiCode());
out.print(text);
out.print(Ansi.RESET);
} else {
out.print(text);
}
}
/**
* Prints the badge to stdout (no newline).
*/
public void print() {
print(System.out);
}
/**
* Prints the badge followed by a newline.
*/
public void println(PrintStream out) {
print(out);
out.println();
}
/**
* Prints the badge to stdout followed by a newline.
*/
public void println() {
println(System.out);
}
}

View File

@@ -0,0 +1,105 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Text in a box/frame. Use via {@link Terminal#box()}.
*/
public final class Box {
private final TerminalSupport support;
private final List<String> lines = new ArrayList<>();
private String title = "";
private String borderChar = "|";
private String cornerChar = "+";
private String horizontalChar = "-";
public Box(TerminalSupport support) {
this.support = support;
}
/**
* Sets an optional title (shown in the top border).
*/
public Box title(String title) {
this.title = title != null ? title : "";
return this;
}
/**
* Adds a line of content.
*/
public Box line(String text) {
lines.add(text != null ? text : "");
return this;
}
/**
* Adds multiple lines.
*/
public Box lines(String... texts) {
for (String t : texts) {
lines.add(t != null ? t : "");
}
return this;
}
/**
* Prints the box to the given stream.
*/
public void print(PrintStream out) {
int maxLen = lines.stream().mapToInt(this::displayWidth).max().orElse(0);
if (title.length() > 0) {
maxLen = Math.max(maxLen, displayWidth(title) + 2);
}
maxLen = Math.max(2, Math.min(maxLen, support.getWidth() - 4));
int totalWidth = maxLen + 2;
String topBorder = cornerChar + (horizontalChar.repeat(totalWidth - 2)) + cornerChar;
if (title.length() > 0) {
String t = " " + title + " ";
int tw = displayWidth(t);
if (tw <= totalWidth - 2) {
topBorder = cornerChar + horizontalChar + t + horizontalChar.repeat(totalWidth - 2 - tw) + cornerChar;
}
}
if (support.isAnsiEnabled()) {
out.println(Ansi.BOLD + topBorder + Ansi.RESET);
} else {
out.println(topBorder);
}
for (String line : lines) {
String padded = pad(line, maxLen);
out.println(borderChar + " " + padded + " " + borderChar);
}
out.println(support.isAnsiEnabled() ? Ansi.BOLD + cornerChar + horizontalChar.repeat(totalWidth - 2) + cornerChar + Ansi.RESET : cornerChar + horizontalChar.repeat(totalWidth - 2) + cornerChar);
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
private int displayWidth(String s) {
int len = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
len += (c >= 0x1100 && Character.getType(c) == Character.OTHER_LETTER) ? 2 : 1;
}
return len;
}
private String pad(String s, int width) {
int w = displayWidth(s);
if (w >= width) return s;
return s + " ".repeat(width - w);
}
}

View File

@@ -0,0 +1,65 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Breadcrumb path. Use via {@link Terminal#breadcrumb()}.
* Last crumb bold with ANSI. Configurable separator.
*/
public final class Breadcrumb {
private final TerminalSupport support;
private final List<String> crumbs = new ArrayList<>();
private String separator = " > ";
public Breadcrumb(TerminalSupport support) {
this.support = support;
}
/**
* Adds a crumb.
*/
public Breadcrumb crumb(String label) {
crumbs.add(label != null ? label : "");
return this;
}
/**
* Sets the separator (default " > ").
*/
public Breadcrumb separator(String sep) {
this.separator = sep != null ? sep : " ";
return this;
}
/**
* Prints the breadcrumb to the given stream.
*/
public void print(PrintStream out) {
for (int i = 0; i < crumbs.size(); i++) {
if (i > 0) out.print(separator);
boolean last = (i == crumbs.size() - 1);
if (last && support.isAnsiEnabled()) {
out.print(Ansi.BOLD);
}
out.print(crumbs.get(i));
if (last && support.isAnsiEnabled()) {
out.print(Ansi.RESET);
}
}
out.println();
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,85 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* ASCII bar chart / sparkline. Use via {@link Terminal#chart()}.
* Vertical bars using ▁▂▃▄▅▆▇█; ASCII fallback: |. Auto-scale to height and width.
*/
public final class Chart {
private static final String UTF8_BARS = " ▁▂▃▄▅▆▇█";
private static final String ASCII_BAR = "|";
private final TerminalSupport support;
private final List<Double> data = new ArrayList<>();
private int height = 5;
public Chart(TerminalSupport support) {
this.support = support;
}
/**
* Adds data points (varargs).
*/
public Chart data(double... values) {
if (values != null) {
for (double v : values) data.add(v);
}
return this;
}
/**
* Sets chart height in lines (default 5).
*/
public Chart height(int h) {
this.height = Math.max(1, Math.min(h, 20));
return this;
}
/**
* Prints the chart to the given stream.
*/
public void print(PrintStream out) {
if (data.isEmpty()) return;
double min = data.stream().min(Double::compareTo).orElse(0.0);
double max = data.stream().max(Double::compareTo).orElse(1.0);
double range = max - min;
if (range == 0) range = 1;
boolean ascii = !support.isUtf8Symbols();
int n = data.size();
double[] barHeights = new double[n];
for (int i = 0; i < n; i++) {
barHeights[i] = ((data.get(i) - min) / range) * height;
}
for (int row = height; row >= 0; row--) {
double yVal = min + (range * row) / height;
String label = row == height ? String.format("%6.0f", max) : (row == 0 ? String.format("%6.0f", min) : String.format("%6.0f", yVal));
StringBuilder line = new StringBuilder();
line.append(label).append(" | ");
for (int i = 0; i < n; i++) {
boolean fill = barHeights[i] >= row;
if (ascii) {
line.append(fill ? ASCII_BAR : " ");
} else {
int idx = (int) Math.round((barHeights[i] / height) * (UTF8_BARS.length() - 1));
idx = Math.max(0, Math.min(idx, UTF8_BARS.length() - 1));
line.append(fill ? UTF8_BARS.charAt(idx) : " ");
}
}
out.println(line.toString());
}
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,94 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Code block with optional syntax highlighting. Use via {@link Terminal#code(String)}.
* Box around content. Optional line numbers. Keywords for java, json, xml, bash.
*/
public final class CodeBlock {
private static final Set<String> JAVA_KW = Set.of("public", "private", "protected", "class", "interface", "extends", "implements", "return", "void", "int", "long", "boolean", "if", "else", "for", "while", "new", "null", "true", "false", "import", "package", "static", "final", "String");
private final TerminalSupport support;
private final String language;
private final List<String> lines = new ArrayList<>();
private boolean lineNumbers;
public CodeBlock(String language, TerminalSupport support) {
this.language = language != null ? language.toLowerCase() : "";
this.support = support;
}
/**
* Adds a line of code.
*/
public CodeBlock line(String text) {
lines.add(text != null ? text : "");
return this;
}
/**
* Enables line numbers.
*/
public CodeBlock lineNumbers() {
this.lineNumbers = true;
return this;
}
/**
* Prints the code block to the given stream.
*/
public void print(PrintStream out) {
if (lines.isEmpty()) return;
int numWidth = lineNumbers ? String.valueOf(lines.size()).length() : 0;
int contentWidth = 0;
for (String s : lines) contentWidth = Math.max(contentWidth, s.length());
contentWidth = Math.min(contentWidth, support.getWidth() - 6 - numWidth);
int totalWidth = numWidth + contentWidth + 4;
String border = "+" + "-".repeat(totalWidth - 2) + "+";
if (support.isAnsiEnabled()) out.println(Ansi.BOLD + border + Ansi.RESET);
for (int i = 0; i < lines.size(); i++) {
String raw = lines.get(i);
String content = raw.length() > contentWidth ? raw.substring(0, contentWidth) : raw;
content = highlight(content, language);
String numPart = lineNumbers ? padLeft(String.valueOf(i + 1), numWidth) + " | " : "";
out.println("| " + numPart + padRight(content, contentWidth) + " |");
}
if (support.isAnsiEnabled()) out.println(Ansi.BOLD + border + Ansi.RESET);
else out.println(border);
}
private String highlight(String line, String lang) {
if (!support.isAnsiEnabled()) return line;
if ("java".equals(lang)) {
for (String kw : JAVA_KW) {
line = line.replaceAll("\\b(" + Pattern.quote(kw) + ")\\b", Ansi.FG_BLUE + "$1" + Ansi.RESET);
}
}
return line;
}
private static String padLeft(String s, int w) {
return s.length() >= w ? s : " ".repeat(w - s.length()) + s;
}
private static String padRight(String s, int w) {
return s.length() >= w ? s : s + " ".repeat(w - s.length());
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,104 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Multi-column layout. Use via {@link Terminal#columns()}.
* Distributes 24 columns evenly across terminal width.
*/
public final class Columns {
private static final int MAX_COLUMNS = 4;
private static final int MIN_COLUMNS = 2;
private static final String COL_SEP = " ";
private final TerminalSupport support;
private final List<String> columnTexts = new ArrayList<>();
public Columns(TerminalSupport support) {
this.support = support;
}
/**
* Adds a column. Order is preserved. Supports 24 columns.
*/
public Columns column(String text) {
if (columnTexts.size() < MAX_COLUMNS) {
columnTexts.add(text != null ? text : "");
}
return this;
}
/**
* Prints columns side by side, evenly distributed across terminal width.
*/
public void print(PrintStream out) {
int n = Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, columnTexts.size()));
if (n == 0) return;
int width = support.getWidth();
int sepLen = COL_SEP.length() * (n - 1);
int colWidth = Math.max(8, (width - sepLen) / n);
List<List<String>> columnLines = new ArrayList<>();
int maxLines = 0;
for (String text : columnTexts) {
if (columnLines.size() >= n) break;
String[] lines = (text != null ? text : "").split("\\n", -1);
List<String> padded = new ArrayList<>();
for (String line : lines) {
if (displayWidth(line) > colWidth) {
int cut = 0;
int dw = 0;
for (int i = 0; i < line.length() && dw < colWidth; i++) {
dw += (line.charAt(i) >= 0x1100 && Character.getType(line.charAt(i)) == Character.OTHER_LETTER) ? 2 : 1;
cut = i + 1;
}
line = line.substring(0, cut);
}
padded.add(padRight(line, colWidth));
}
columnLines.add(padded);
if (padded.size() > maxLines) maxLines = padded.size();
}
while (columnLines.size() < n) {
columnLines.add(new ArrayList<>());
}
for (int row = 0; row < maxLines; row++) {
StringBuilder sb = new StringBuilder();
for (int c = 0; c < n; c++) {
if (c > 0) sb.append(COL_SEP);
List<String> lines = columnLines.get(c);
String line = row < lines.size() ? lines.get(row) : padRight("", colWidth);
sb.append(line);
}
out.println(sb.toString());
}
}
private int displayWidth(String s) {
int w = 0;
for (int i = 0; i < s.length(); i++) {
w += (s.charAt(i) >= 0x1100 && Character.getType(s.charAt(i)) == Character.OTHER_LETTER) ? 2 : 1;
}
return w;
}
private String padRight(String s, int width) {
int w = displayWidth(s);
if (w >= width) return s;
return s + " ".repeat(width - w);
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,80 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
/**
* Line-by-line diff viewer. Use via {@link Terminal#diff()}.
* Red for removed, green for added.
*/
public final class Diff {
private final TerminalSupport support;
private String before = "";
private String after = "";
public Diff(TerminalSupport support) {
this.support = support;
}
/**
* Sets the "before" (old) text. Removed lines will be shown in red with "-".
*/
public Diff before(String text) {
this.before = text != null ? text : "";
return this;
}
/**
* Sets the "after" (new) text. Added lines will be shown in green with "+".
*/
public Diff after(String text) {
this.after = text != null ? text : "";
return this;
}
/**
* Prints a simple line-by-line diff to the given stream.
*/
public void print(PrintStream out) {
String[] a = before.split("\\n", -1);
String[] b = after.split("\\n", -1);
int i = 0, j = 0;
while (i < a.length || j < b.length) {
if (i < a.length && j < b.length && a[i].equals(b[j])) {
if (support.isAnsiEnabled()) out.print(Ansi.FG_WHITE);
out.println(" " + a[i]);
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
i++;
j++;
} else if (j < b.length && (i >= a.length || !containsAt(a, b[j], i))) {
if (support.isAnsiEnabled()) out.print(Ansi.FG_GREEN);
out.println("+ " + b[j]);
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
j++;
} else if (i < a.length) {
if (support.isAnsiEnabled()) out.print(Ansi.FG_RED);
out.println("- " + a[i]);
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
i++;
}
}
}
private boolean containsAt(String[] a, String line, int from) {
for (int k = from; k < a.length; k++) {
if (a[k].equals(line)) return true;
}
return false;
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,82 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* 2D heatmap: values mapped to intensity blocks. Use via {@link Terminal#heatmap()}.
* UTF-8: space, ░, ▒, ▓, █. ASCII fallback: ., +, # etc. Auto-scale to max value.
*/
public final class Heatmap {
private static final String[] UTF8_BLOCKS = {" ", "", "", "", ""};
private static final String[] ASCII_BLOCKS = {".", "-", "+", "#", "@"};
private final TerminalSupport support;
private final List<Row> rows = new ArrayList<>();
private int globalMax = -1;
public Heatmap(TerminalSupport support) {
this.support = support;
}
/**
* Adds a row with label and numeric values.
*/
public Heatmap row(String label, int... values) {
if (label == null) label = "";
int[] v = values != null ? values : new int[0];
for (int x : v) {
if (x > globalMax) globalMax = x;
}
rows.add(new Row(label, v));
return this;
}
/**
* Prints the heatmap to the given stream.
*/
public void print(PrintStream out) {
if (rows.isEmpty()) return;
int maxVal = globalMax <= 0 ? 1 : globalMax;
boolean ascii = !support.isUtf8Symbols();
String[] blocks = ascii ? ASCII_BLOCKS : UTF8_BLOCKS;
int labelWidth = 0;
for (Row r : rows) {
if (r.label.length() > labelWidth) labelWidth = r.label.length();
}
labelWidth = Math.max(3, labelWidth);
for (Row r : rows) {
StringBuilder sb = new StringBuilder();
String pad = r.label.length() < labelWidth ? " ".repeat(labelWidth - r.label.length()) : "";
sb.append(pad).append(r.label).append(" ");
for (int i = 0; i < r.values.length; i++) {
int idx = (int) Math.round((r.values[i] * (blocks.length - 1)) / (double) maxVal);
idx = Math.max(0, Math.min(idx, blocks.length - 1));
sb.append(blocks[idx]);
}
out.println(sb.toString());
}
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
private static final class Row {
final String label;
final int[] values;
Row(String label, int[] values) {
this.label = label;
this.values = values;
}
}
}

View File

@@ -0,0 +1,87 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Key-value list with aligned labels. Use via {@link Terminal#keyValue()}.
*/
public final class KeyValue {
private final TerminalSupport support;
private final List<String> keys = new ArrayList<>();
private final List<String> values = new ArrayList<>();
private String separator = ": ";
private int labelWidth = -1;
public KeyValue(TerminalSupport support) {
this.support = support;
}
/**
* Adds a row (label, value).
*/
public KeyValue row(String key, String value) {
keys.add(key != null ? key : "");
values.add(value != null ? value : "");
return this;
}
/**
* Sets the separator between key and value (default ": ").
*/
public KeyValue separator(String sep) {
this.separator = sep != null ? sep : "";
return this;
}
/**
* Sets a fixed width for the key column (auto if not set).
*/
public KeyValue labelWidth(int width) {
this.labelWidth = width;
return this;
}
/**
* Prints the key-value block to the given stream.
*/
public void print(PrintStream out) {
if (keys.isEmpty()) return;
int kw = labelWidth >= 0 ? labelWidth : keys.stream().mapToInt(this::displayWidth).max().orElse(0);
kw = Math.max(1, kw);
for (int i = 0; i < keys.size(); i++) {
String k = keys.get(i);
String v = i < values.size() ? values.get(i) : "";
int pad = kw - displayWidth(k);
String padded = k + (pad > 0 ? " ".repeat(pad) : "");
if (support.isAnsiEnabled()) {
out.print(Ansi.BOLD + padded + Ansi.RESET + separator + v);
} else {
out.print(padded + separator + v);
}
out.println();
}
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
private int displayWidth(String s) {
int len = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
len += (c >= 0x1100 && Character.getType(c) == Character.OTHER_LETTER) ? 2 : 1;
}
return len;
}
}

View File

@@ -0,0 +1,119 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* Log viewer with level labels and colors. Use via {@link Terminal#log()}.
* INFO=cyan, WARN=yellow, ERROR=red, DEBUG=gray.
*/
public final class Log {
private static final int LABEL_WIDTH = 6;
private final TerminalSupport support;
private final List<Entry> entries = new ArrayList<>();
private boolean withTimestamp;
public Log(TerminalSupport support) {
this.support = support;
}
/**
* Adds an INFO line (cyan).
*/
public Log info(String message) {
entries.add(new Entry(Level.INFO, message));
return this;
}
/**
* Adds a WARN line (yellow).
*/
public Log warn(String message) {
entries.add(new Entry(Level.WARN, message));
return this;
}
/**
* Adds an ERROR line (red).
*/
public Log error(String message) {
entries.add(new Entry(Level.ERROR, message));
return this;
}
/**
* Adds a DEBUG line (gray).
*/
public Log debug(String message) {
entries.add(new Entry(Level.DEBUG, message));
return this;
}
/**
* Enables timestamp prefix on each line.
*/
public Log withTimestamp() {
this.withTimestamp = true;
return this;
}
/**
* Prints all log entries to the given stream.
*/
public void print(PrintStream out) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
for (Entry e : entries) {
String prefix = withTimestamp ? "[" + fmt.format(Instant.now()) + "] " : "";
String label = "[" + e.level.name() + "]";
while (label.length() < LABEL_WIDTH) label += " ";
if (support.isAnsiEnabled()) {
out.print(e.level.ansi);
}
out.print(prefix + label + " " + e.message);
if (support.isAnsiEnabled()) {
out.print(Ansi.RESET);
}
out.println();
}
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
private static final class Entry {
final Level level;
final String message;
Entry(Level level, String message) {
this.level = level;
this.message = message != null ? message : "";
}
}
private enum Level {
INFO(Ansi.FG_CYAN),
WARN(Ansi.FG_YELLOW),
ERROR(Ansi.FG_RED),
DEBUG(Ansi.FG_BRIGHT_BLACK);
final String ansi;
Level(String ansi) {
this.ansi = ansi;
}
}
}

View File

@@ -0,0 +1,65 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
/**
* Lightweight Markdown renderer for terminal. Use via {@link Terminal#markdown(String)}.
* Supports # headings, **bold**, *italic*, `code`, --- rules, - list.
*/
public final class Markdown {
private final TerminalSupport support;
private final String source;
public Markdown(String source, TerminalSupport support) {
this.source = source != null ? source : "";
this.support = support;
}
/**
* Renders and prints to the given stream.
*/
public void print(PrintStream out) {
String[] lines = source.split("\\n", -1);
for (String line : lines) {
line = line.trim();
if (line.startsWith("#")) {
int level = 0;
while (level < line.length() && line.charAt(level) == '#') level++;
String rest = line.substring(level).trim();
if (support.isAnsiEnabled()) out.print(Ansi.BOLD);
out.println(rest);
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
} else if (line.equals("---") || line.startsWith("---")) {
String rule = support.isUtf8Symbols() ? "" : "-";
out.println(rule.repeat(Math.min(40, support.getWidth())));
} else if (line.startsWith("- ") || line.startsWith("* ")) {
String bullet = support.isUtf8Symbols() ? "" : " - ";
out.println(bullet + renderInline(line.substring(2)));
} else {
out.println(renderInline(line));
}
}
}
private String renderInline(String s) {
if (!support.isAnsiEnabled()) {
return s.replaceAll("\\*\\*(.+?)\\*\\*", "$1").replaceAll("\\*(.+?)\\*", "$1").replaceAll("`(.+?)`", "$1");
}
s = s.replaceAll("\\*\\*(.+?)\\*\\*", Ansi.BOLD + "$1" + Ansi.RESET);
s = s.replaceAll("\\*(.+?)\\*", Ansi.ITALIC + "$1" + Ansi.RESET);
s = s.replaceAll("`(.+?)`", Ansi.FG_CYAN + "$1" + Ansi.RESET);
return s;
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,80 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
/**
* Single notification line. Use via {@link Terminal#notify(String, Notification.Type)}.
* Each type has icon + color. Prints immediately (no stack).
*/
public final class Notification {
private final String message;
private final Type type;
private final TerminalSupport support;
public Notification(String message, Type type, TerminalSupport support) {
this.message = message != null ? message : "";
this.type = type != null ? type : Type.INFO;
this.support = support;
}
/**
* Prints the notification to the given stream (with newline).
*/
public void print(PrintStream out) {
String icon = icon(this.type);
if (support.isAnsiEnabled()) {
out.print(color(type));
}
out.print(icon + " " + message);
if (support.isAnsiEnabled()) {
out.print(Ansi.RESET);
}
out.println();
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
private String icon(Type t) {
boolean ascii = !support.isUtf8Symbols();
if (!ascii) {
return switch (t) {
case SUCCESS -> "";
case WARNING -> "⚠️";
case ERROR -> "";
case INFO -> "";
};
}
return switch (t) {
case SUCCESS -> "[OK]";
case WARNING -> "[!]";
case ERROR -> "[X]";
case INFO -> "[i]";
};
}
private static String color(Type t) {
return switch (t) {
case SUCCESS -> Ansi.FG_GREEN;
case WARNING -> Ansi.FG_YELLOW;
case ERROR -> Ansi.FG_RED;
case INFO -> Ansi.FG_CYAN;
};
}
/**
* Notification type.
*/
public enum Type {
SUCCESS, WARNING, ERROR, INFO
}
}

View File

@@ -0,0 +1,70 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
/**
* A horizontal rule/separator line. Use via {@link Terminal#rule()}.
*/
public final class Rule {
private final TerminalSupport support;
private final int width;
private char character = '-';
private String prefix = "";
private String suffix = "";
public Rule(TerminalSupport support) {
this.support = support;
this.width = Math.min(support.getWidth(), 120);
}
/**
* Uses the given character for the line (e.g. '-', '=', '*').
*/
public Rule character(char c) {
this.character = c;
return this;
}
/**
* Uses '=' for the line.
*/
public Rule doubles() {
return character('=');
}
/**
* Optional text before the line.
*/
public Rule prefix(String prefix) {
this.prefix = prefix != null ? prefix : "";
return this;
}
/**
* Optional text after the line.
*/
public Rule suffix(String suffix) {
this.suffix = suffix != null ? suffix : "";
return this;
}
/**
* Prints the rule to the given stream.
*/
public void print(PrintStream out) {
int lineLen = Math.max(0, width - prefix.length() - suffix.length());
String line = prefix + String.valueOf(character).repeat(Math.max(0, lineLen)) + suffix;
out.println(line);
}
/**
* Prints the rule to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,108 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Step / wizard display. Use via {@link Terminal#steps()}.
* Status: DONE, RUNNING, PENDING, FAILED. Icons with ASCII fallback.
*/
public final class Steps {
private static final String ICON_DONE_UTF8 = "";
private static final String ICON_RUNNING_UTF8 = "";
private static final String ICON_PENDING_UTF8 = "";
private static final String ICON_FAILED_UTF8 = "";
private static final String ICON_DONE_ASCII = "[x]";
private static final String ICON_RUNNING_ASCII = "[~]";
private static final String ICON_PENDING_ASCII = "[ ]";
private static final String ICON_FAILED_ASCII = "[!]";
private final TerminalSupport support;
private final List<StepEntry> steps = new ArrayList<>();
public Steps(TerminalSupport support) {
this.support = support;
}
/**
* Adds a step with status.
*/
public Steps step(String label, Status status) {
steps.add(new StepEntry(label != null ? label : "", status != null ? status : Status.PENDING));
return this;
}
/**
* Prints all steps to the given stream.
*/
public void print(PrintStream out) {
boolean ascii = !support.isUtf8Symbols();
for (StepEntry e : steps) {
String icon = icon(e.status, ascii);
if (support.isAnsiEnabled()) {
out.print(colorFor(e.status));
}
out.print(icon + " ");
if (support.isAnsiEnabled()) {
out.print(Ansi.RESET);
}
out.println(e.label);
}
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
private static String icon(Status s, boolean ascii) {
if (ascii) {
return switch (s) {
case DONE -> ICON_DONE_ASCII;
case RUNNING -> ICON_RUNNING_ASCII;
case PENDING -> ICON_PENDING_ASCII;
case FAILED -> ICON_FAILED_ASCII;
};
}
return switch (s) {
case DONE -> ICON_DONE_UTF8;
case RUNNING -> ICON_RUNNING_UTF8;
case PENDING -> ICON_PENDING_UTF8;
case FAILED -> ICON_FAILED_UTF8;
};
}
private static String colorFor(Status s) {
return switch (s) {
case DONE -> Ansi.FG_GREEN;
case RUNNING -> Ansi.FG_YELLOW;
case PENDING -> Ansi.FG_WHITE;
case FAILED -> Ansi.FG_RED;
};
}
private static final class StepEntry {
final String label;
final Status status;
StepEntry(String label, Status status) {
this.label = label;
this.status = status;
}
}
/**
* Step status for {@link Steps}.
*/
public enum Status {
DONE, RUNNING, PENDING, FAILED
}
}

View File

@@ -0,0 +1,53 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
/**
* System info widget. Use via {@link Terminal#sysinfo()}.
* OS, JVM, CPU, RAM from System.getProperty() and Runtime.getRuntime().
*/
public final class SysInfo {
private final TerminalSupport support;
public SysInfo(TerminalSupport support) {
this.support = support;
}
/**
* Prints system info as a key-value block to the given stream.
*/
public void print(PrintStream out) {
Runtime rt = Runtime.getRuntime();
long maxMem = rt.maxMemory() == Long.MAX_VALUE ? rt.totalMemory() : rt.maxMemory();
long usedMem = rt.totalMemory() - rt.freeMemory();
String os = System.getProperty("os.name", "?") + " " + System.getProperty("os.version", "");
String jvm = System.getProperty("java.vm.name", "?") + " " + System.getProperty("java.version", "");
int cores = rt.availableProcessors();
String ram = formatBytes(usedMem) + " / " + formatBytes(maxMem) + " used";
KeyValue kv = new KeyValue(support);
kv.row("OS", os.trim());
kv.row("JVM", jvm.trim());
kv.row("CPU", cores + " cores");
kv.row("RAM", ram);
kv.print(out);
}
private static String formatBytes(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,141 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Fluent builder for ASCII tables. Use via {@link Terminal#table()}.
*/
public final class Table {
private final TerminalSupport support;
private final List<String> header = new ArrayList<>();
private final List<List<String>> rows = new ArrayList<>();
private String borderChar = "|";
private String separatorChar = "-";
private String cornerChar = "+";
public Table(TerminalSupport support) {
this.support = support;
}
/**
* Sets the table header columns.
*/
public Table header(String... columns) {
header.clear();
for (String c : columns) {
header.add(c != null ? c : "");
}
return this;
}
/**
* Adds a data row.
*/
public Table row(String... cells) {
List<String> row = new ArrayList<>();
for (String c : cells) {
row.add(c != null ? c : "");
}
rows.add(row);
return this;
}
/**
* Prints the table to the given stream.
*/
public void print(PrintStream out) {
int cols = header.isEmpty() ? (rows.isEmpty() ? 0 : rows.get(0).size()) : header.size();
if (cols == 0) return;
int[] widths = new int[cols];
for (int i = 0; i < header.size() && i < cols; i++) {
widths[i] = Math.max(widths[i], cellWidth(header.get(i)));
}
for (List<String> row : rows) {
for (int i = 0; i < row.size() && i < cols; i++) {
widths[i] = Math.max(widths[i], cellWidth(row.get(i)));
}
}
for (int i = 0; i < cols; i++) {
widths[i] = Math.max(1, widths[i]);
}
StringBuilder sep = new StringBuilder(cornerChar);
for (int w : widths) {
sep.append(repeat(separatorChar, w + 2)).append(cornerChar);
}
String sepLine = sep.toString();
if (support.isAnsiEnabled()) {
out.println(Ansi.BOLD + sepLine + Ansi.RESET);
} else {
out.println(sepLine);
}
if (!header.isEmpty()) {
printRow(out, header, widths, true);
out.println(sepLine);
}
for (List<String> row : rows) {
List<String> padded = new ArrayList<>();
for (int i = 0; i < cols; i++) {
padded.add(i < row.size() ? row.get(i) : "");
}
printRow(out, padded, widths, false);
}
out.println(sepLine);
}
/**
* Prints the table to stdout.
*/
public void print() {
print(System.out);
}
private void printRow(PrintStream out, List<String> row, int[] widths, boolean bold) {
if (support.isAnsiEnabled() && bold) {
out.print(Ansi.BOLD);
}
out.print(borderChar);
for (int i = 0; i < row.size(); i++) {
String cell = row.get(i);
int w = i < widths.length ? widths[i] : cellWidth(cell);
out.print(" ");
out.print(pad(cell, w));
out.print(" ");
out.print(borderChar);
}
out.println();
if (support.isAnsiEnabled() && bold) {
out.print(Ansi.RESET);
}
}
private static int cellWidth(String s) {
int len = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
len += (c >= 0x1100 && Character.getType(c) == Character.OTHER_LETTER) ? 2 : 1;
}
return len;
}
private static String pad(String s, int width) {
int w = cellWidth(s);
if (w >= width) return s;
return s + " ".repeat(width - w);
}
private static String repeat(String s, int n) {
return s.repeat(Math.max(0, n));
}
}

View File

@@ -0,0 +1,110 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Table of contents. Use via {@link Terminal#toc()}.
* Optional sub-sections. Renders with numbers and rule.
*/
public final class TableOfContents {
private final TerminalSupport support;
private final List<Section> sections = new ArrayList<>();
private static final String TITLE = "Table of Contents";
public TableOfContents(TerminalSupport support) {
this.support = support;
}
/**
* Adds a section. Returns a sub-builder for optional sub-sections.
*/
public TocSection section(String title) {
Section s = new Section(title != null ? title : "", new ArrayList<>());
sections.add(s);
return new TocSection(s, this);
}
/**
* Prints the TOC to the given stream.
*/
public void print(PrintStream out) {
if (support.isAnsiEnabled()) out.println(Ansi.BOLD + TITLE + Ansi.RESET);
else out.println(TITLE);
String ruleChar = support.isUtf8Symbols() ? "" : "-";
out.println(ruleChar.repeat(Math.min(TITLE.length(), support.getWidth())));
int num = 1;
for (Section s : sections) {
out.println(" " + num + ". " + s.title);
for (String sub : s.subs) {
out.println(" - " + sub);
}
num++;
}
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
static final class Section {
final String title;
final List<String> subs;
Section(String title, List<String> subs) {
this.title = title;
this.subs = subs;
}
}
/**
* Builder for adding sub-sections to a TOC section.
*/
public static final class TocSection {
private final Section section;
private final TableOfContents toc;
TocSection(Section section, TableOfContents toc) {
this.section = section;
this.toc = toc;
}
/**
* Adds a sub-section.
*/
public TocSection sub(String title) {
section.subs.add(title != null ? title : "");
return this;
}
/**
* Adds another top-level section. Returns builder for that section.
*/
public TocSection section(String title) {
return toc.section(title);
}
/**
* Prints the TOC.
*/
public void print(PrintStream out) {
toc.print(out);
}
/**
* Prints to stdout.
*/
public void print() {
toc.print();
}
}
}

View File

@@ -0,0 +1,76 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Vertical timeline with events. Use via {@link Terminal#timeline()}.
* Dots and connectors; with ANSI dots colored cyan/green.
*/
public final class Timeline {
private static final String DOT_UTF8 = "";
private static final String PIPE_UTF8 = "";
private static final String DOT_ASCII = "*";
private static final String PIPE_ASCII = "|";
private final TerminalSupport support;
private final List<Event> events = new ArrayList<>();
public Timeline(TerminalSupport support) {
this.support = support;
}
/**
* Adds an event (label + description).
*/
public Timeline event(String label, String description) {
events.add(new Event(label != null ? label : "", description != null ? description : ""));
return this;
}
/**
* Prints the timeline to the given stream.
*/
public void print(PrintStream out) {
boolean ascii = !support.isUtf8Symbols();
String dot = ascii ? DOT_ASCII : DOT_UTF8;
String pipe = ascii ? PIPE_ASCII : PIPE_UTF8;
for (int i = 0; i < events.size(); i++) {
Event e = events.get(i);
if (support.isAnsiEnabled()) {
out.print(i == events.size() - 1 ? Ansi.FG_GREEN : Ansi.FG_CYAN);
}
out.print(dot + " ");
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
out.println(e.label + " " + e.description);
if (i < events.size() - 1) {
if (support.isAnsiEnabled()) out.print(Ansi.FG_CYAN);
out.println(pipe);
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
}
}
}
/**
* Prints to stdout.
*/
public void print() {
print(System.out);
}
private static final class Event {
final String label;
final String description;
Event(String label, String description) {
this.label = label;
this.description = description;
}
}
}

View File

@@ -0,0 +1,116 @@
package dev.jakub.terminal.components;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import dev.jakub.terminal.internal.TreeNode;
import java.io.PrintStream;
import java.util.List;
/**
* Tree view for directory-like structures. Use via {@link Terminal#tree()}.
* Supports unlimited nesting. Use {@link #node(String)} for root, then
* {@link TreeBuilder#child(String)} and {@link TreeBuilder#end()} to build.
*/
public final class Tree {
private static final String UTF8_BRANCH = "├── ";
private static final String UTF8_LAST = "└── ";
private static final String UTF8_PIPE = "";
private static final String ASCII_BRANCH = "+-- ";
private static final String ASCII_LAST = "\\-- ";
private static final String ASCII_PIPE = "| ";
private final TerminalSupport support;
private TreeNode root;
public Tree(TerminalSupport support) {
this.support = support;
}
/**
* Sets the root node label and returns a builder for adding children.
* Use {@link TreeBuilder#child(String)} to add children and {@link TreeBuilder#end()} to go back to parent.
*/
public TreeBuilder node(String label) {
this.root = new TreeNode(label);
return new TreeBuilder(root, null, this);
}
/**
* Prints the tree to the given stream.
*/
public void print(PrintStream out) {
if (root == null) return;
boolean useAscii = !support.isUtf8Symbols();
String branch = useAscii ? ASCII_BRANCH : UTF8_BRANCH;
String last = useAscii ? ASCII_LAST : UTF8_LAST;
String pipe = useAscii ? ASCII_PIPE : UTF8_PIPE;
out.println(root.label);
printChildren(out, root.children, "", branch, last, pipe);
}
/**
* Prints the tree to stdout.
*/
public void print() {
print(System.out);
}
private void printChildren(PrintStream out, List<TreeNode> children, String prefix, String branch, String last, String pipe) {
for (int i = 0; i < children.size(); i++) {
TreeNode n = children.get(i);
boolean isLast = (i == children.size() - 1);
String conn = isLast ? last : branch;
out.println(prefix + conn + n.label);
String nextPrefix = prefix + (isLast ? " " : pipe);
printChildren(out, n.children, nextPrefix, branch, last, pipe);
}
}
TerminalSupport getSupport() {
return support;
}
TreeNode getRoot() {
return root;
}
/**
* Builder for adding children to a tree node. {@link #child(String)} adds a child and returns a builder for it;
* {@link #end()} returns the parent builder (or the tree when at root).
*/
public static final class TreeBuilder {
private final TreeNode node;
private final TreeBuilder parent;
private final Tree tree;
TreeBuilder(TreeNode node, TreeBuilder parent, Tree tree) {
this.node = node;
this.parent = parent;
this.tree = tree;
}
/** Adds a child and returns a builder for that child (for nesting). */
public TreeBuilder child(String label) {
TreeNode child = new TreeNode(label);
node.children.add(child);
return new TreeBuilder(child, this, tree);
}
/** Returns the parent builder, or this when at root (so you can call {@link #child(String)} or {@link #print()}). */
public TreeBuilder end() {
return parent != null ? parent : this;
}
/** Prints the tree. */
public void print(PrintStream out) {
tree.print(out);
}
/** Prints the tree to stdout. */
public void print() {
tree.print();
}
}
}

View File

@@ -0,0 +1,65 @@
package dev.jakub.terminal.core;
public final class Ansi {
/** Reset all attributes */
public static final String RESET = "\u001B[0m";
/** Bold/bright */
public static final String BOLD = "\u001B[1m";
/** Dim */
public static final String DIM = "\u001B[2m";
/** Italic */
public static final String ITALIC = "\u001B[3m";
/** Underline */
public static final String UNDERLINE = "\u001B[4m";
/** Black foreground */
public static final String FG_BLACK = "\u001B[30m";
/** Red foreground */
public static final String FG_RED = "\u001B[31m";
/** Green foreground */
public static final String FG_GREEN = "\u001B[32m";
/** Yellow foreground */
public static final String FG_YELLOW = "\u001B[33m";
/** Blue foreground */
public static final String FG_BLUE = "\u001B[34m";
/** Magenta foreground */
public static final String FG_MAGENTA = "\u001B[35m";
/** Cyan foreground */
public static final String FG_CYAN = "\u001B[36m";
/** White foreground */
public static final String FG_WHITE = "\u001B[37m";
/** Bright black (gray) foreground */
public static final String FG_BRIGHT_BLACK = "\u001B[90m";
/** Bright red foreground */
public static final String FG_BRIGHT_RED = "\u001B[91m";
/** Bright green foreground */
public static final String FG_BRIGHT_GREEN = "\u001B[92m";
/** Bright yellow foreground */
public static final String FG_BRIGHT_YELLOW = "\u001B[93m";
/** Bright blue foreground */
public static final String FG_BRIGHT_BLUE = "\u001B[94m";
/** Bright magenta foreground */
public static final String FG_BRIGHT_MAGENTA = "\u001B[95m";
/** Bright cyan foreground */
public static final String FG_BRIGHT_CYAN = "\u001B[96m";
/** Bright white foreground */
public static final String FG_BRIGHT_WHITE = "\u001B[97m";
/** Hide cursor */
public static final String HIDE_CURSOR = "\u001B[?25l";
/** Show cursor */
public static final String SHOW_CURSOR = "\u001B[?25h";
/** Move cursor up one line */
public static final String CURSOR_UP = "\u001B[1A";
/** Move cursor down one line */
public static final String CURSOR_DOWN = "\u001B[1B";
/** Carriage return (start of line) */
public static final String CARRIAGE_RETURN = "\r";
/** Clear line */
public static final String ERASE_LINE = "\u001B[2K";
private Ansi() {}
}

View File

@@ -0,0 +1,38 @@
package dev.jakub.terminal.core;
import dev.jakub.terminal.Terminal;
/**
* Standard terminal colors for use with {@link Terminal#print(String)} and other components.
*/
public enum Color {
BLACK(Ansi.FG_BLACK),
RED(Ansi.FG_RED),
GREEN(Ansi.FG_GREEN),
YELLOW(Ansi.FG_YELLOW),
BLUE(Ansi.FG_BLUE),
MAGENTA(Ansi.FG_MAGENTA),
CYAN(Ansi.FG_CYAN),
WHITE(Ansi.FG_WHITE),
BRIGHT_BLACK(Ansi.FG_BRIGHT_BLACK),
BRIGHT_RED(Ansi.FG_BRIGHT_RED),
BRIGHT_GREEN(Ansi.FG_BRIGHT_GREEN),
BRIGHT_YELLOW(Ansi.FG_BRIGHT_YELLOW),
BRIGHT_BLUE(Ansi.FG_BRIGHT_BLUE),
BRIGHT_MAGENTA(Ansi.FG_BRIGHT_MAGENTA),
BRIGHT_CYAN(Ansi.FG_BRIGHT_CYAN),
BRIGHT_WHITE(Ansi.FG_BRIGHT_WHITE);
private final String ansiCode;
Color(String ansiCode) {
this.ansiCode = ansiCode;
}
/**
* Returns the ANSI escape sequence for this color (foreground).
*/
public String ansiCode() {
return ansiCode;
}
}

View File

@@ -0,0 +1,96 @@
package dev.jakub.terminal.core;
import dev.jakub.terminal.Terminal;
import java.io.PrintStream;
/**
* Fluent builder for colored and styled terminal output.
* Use via {@link Terminal#print(String)}.
*/
public final class StyledText {
private final String text;
private final TerminalSupport support;
private Color color;
private boolean bold;
private boolean dim;
private boolean underline;
public StyledText(String text, TerminalSupport support) {
this.text = text != null ? text : "";
this.support = support;
}
/**
* Applies the given foreground color.
*/
public StyledText color(Color color) {
this.color = color;
return this;
}
/**
* Applies bold styling.
*/
public StyledText bold() {
this.bold = true;
return this;
}
/**
* Applies dim styling.
*/
public StyledText dim() {
this.dim = true;
return this;
}
/**
* Applies underline styling.
*/
public StyledText underline() {
this.underline = true;
return this;
}
/**
* Writes the styled text to the given stream without a newline.
*/
public void print(PrintStream out) {
if (support.isAnsiEnabled()) {
StringBuilder sb = new StringBuilder();
if (bold) sb.append(Ansi.BOLD);
if (dim) sb.append(Ansi.DIM);
if (underline) sb.append(Ansi.UNDERLINE);
if (color != null) sb.append(color.ansiCode());
sb.append(text);
sb.append(Ansi.RESET);
out.print(sb.toString());
} else {
out.print(text);
}
}
/**
* Writes the styled text to stdout without a newline.
*/
public void print() {
print(System.out);
}
/**
* Writes the styled text to the given stream followed by a newline.
*/
public void println(PrintStream out) {
print(out);
out.println();
}
/**
* Writes the styled text to stdout followed by a newline.
*/
public void println() {
println(System.out);
}
}

View File

@@ -0,0 +1,110 @@
package dev.jakub.terminal.core;
/**
* Detects terminal capabilities: width and ANSI support.
* Graceful fallback when ANSI is not supported (e.g. Windows CMD).
*/
public final class TerminalSupport {
private static final int DEFAULT_WIDTH = 80;
private static final String COLUMNS_ENV = "COLUMNS";
private static final String TERM_ENV = "TERM";
private static final String ANSI_PROP = "dev.jakub.terminal.ansi";
private static final String UTF8_PROP = "dev.jakub.terminal.utf8";
private final boolean ansiEnabled;
private final int width;
private final boolean utf8Symbols;
/**
* Creates support with auto-detected capabilities.
*/
public TerminalSupport() {
this.ansiEnabled = detectAnsiSupport();
this.width = detectTerminalWidth();
this.utf8Symbols = detectUtf8Symbols();
}
/**
* Creates support with explicit ANSI and width (for testing).
*/
public TerminalSupport(boolean ansiEnabled, int width) {
this.ansiEnabled = ansiEnabled;
this.width = Math.max(10, width);
this.utf8Symbols = false;
}
/**
* Creates support with explicit ANSI, width and UTF-8 symbols (for testing).
*/
public TerminalSupport(boolean ansiEnabled, int width, boolean utf8Symbols) {
this.ansiEnabled = ansiEnabled;
this.width = Math.max(10, width);
this.utf8Symbols = utf8Symbols;
}
private static boolean detectUtf8Symbols() {
String force = System.getProperty(UTF8_PROP);
if ("true".equalsIgnoreCase(force)) return true;
if ("false".equalsIgnoreCase(force)) return false;
String os = System.getProperty("os.name", "").toLowerCase();
if (os.contains("win")) return false;
String encoding = System.getProperty("sun.stdout.encoding", System.getProperty("file.encoding", ""));
return encoding.toUpperCase().contains("UTF-8");
}
private static boolean detectAnsiSupport() {
String force = System.getProperty(ANSI_PROP);
if ("true".equalsIgnoreCase(force)) return true;
if ("false".equalsIgnoreCase(force)) return false;
String os = System.getProperty("os.name", "").toLowerCase();
if (os.contains("win")) {
String ver = System.getProperty("os.version", "");
try {
int major = Integer.parseInt(ver.split("\\.")[0]);
if (major >= 10) return true;
} catch (Exception ignored) {}
return false;
}
if (System.console() != null) {
String term = System.getenv(TERM_ENV);
return term != null && !term.isEmpty() && !term.equalsIgnoreCase("dumb");
}
return false;
}
private static int detectTerminalWidth() {
String columns = System.getenv(COLUMNS_ENV);
if (columns != null && !columns.isEmpty()) {
try {
int w = Integer.parseInt(columns.trim());
if (w > 0) return w;
} catch (NumberFormatException ignored) {}
}
return DEFAULT_WIDTH;
}
/**
* Whether ANSI escape codes should be emitted.
*/
public boolean isAnsiEnabled() {
return ansiEnabled;
}
/**
* Detected or configured terminal width in columns.
*/
public int getWidth() {
return width;
}
/**
* Whether to use UTF-8 symbols (box-drawing, emoji, blocks). When false, ASCII fallback is used
* so output looks correct in Windows CMD/PowerShell without UTF-8 encoding.
*/
public boolean isUtf8Symbols() {
return utf8Symbols;
}
}

View File

@@ -0,0 +1,87 @@
package dev.jakub.terminal.interactive;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.InputStream;
import java.io.PrintStream;
import java.util.Scanner;
/**
* Yes/No confirm dialog. Use via {@link Terminal#confirm(String)}.
* [Y/n] or [y/N] based on default. Injectable in/out for tests.
*/
public final class Confirm {
private final String message;
private final TerminalSupport support;
private boolean defaultYes = true;
private PrintStream out = System.out;
private InputStream in = System.in;
public Confirm(String message, TerminalSupport support) {
this.message = message != null ? message : "";
this.support = support;
}
/**
* Sets default to No ([y/N]).
*/
public Confirm defaultNo() {
this.defaultYes = false;
return this;
}
/**
* Sets default to Yes ([Y/n]). This is the default.
*/
public Confirm defaultYes() {
this.defaultYes = true;
return this;
}
/**
* Sets output stream.
*/
public Confirm output(PrintStream out) {
this.out = out != null ? out : System.out;
return this;
}
/**
* Sets input stream (for tests).
*/
public Confirm input(InputStream in) {
this.in = in != null ? in : System.in;
return this;
}
/**
* Prompts and returns true for yes, false for no.
*/
public boolean ask() {
String prompt = defaultYes ? " [Y/n]: " : " [y/N]: ";
out.print(message + prompt);
out.flush();
try (Scanner scan = new Scanner(in)) {
if (!scan.hasNextLine()) return defaultYes;
String line = scan.nextLine().trim().toLowerCase();
if (line.isEmpty()) return defaultYes;
return "y".equals(line) || "yes".equals(line);
}
}
/**
* Uses the given scanner for one line. Use when sharing one scanner (e.g. demo).
*/
public boolean ask(Scanner sharedScanner) {
if (sharedScanner == null) return ask();
String prompt = defaultYes ? " [Y/n]: " : " [y/N]: ";
out.print(message + prompt);
out.flush();
if (!sharedScanner.hasNextLine()) return defaultYes;
String line = sharedScanner.nextLine().trim().toLowerCase();
if (line.isEmpty()) return defaultYes;
return "y".equals(line) || "yes".equals(line);
}
}

View File

@@ -0,0 +1,104 @@
package dev.jakub.terminal.interactive;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
/**
* Fluent builder for interactive terminal menus. Use via {@link Terminal#menu()}.
*/
public final class Menu {
private final TerminalSupport support;
private final List<String> options = new ArrayList<>();
private String title = "Select an option";
private PrintStream out = System.out;
private java.io.InputStream in = System.in;
public Menu(TerminalSupport support) {
this.support = support;
}
/**
* Sets the menu title.
*/
public Menu title(String title) {
this.title = title != null ? title : "";
return this;
}
/**
* Adds an option. Order is preserved.
*/
public Menu option(String option) {
options.add(option != null ? option : "");
return this;
}
/**
* Sets the output stream (default: stdout).
*/
public Menu output(PrintStream out) {
this.out = out != null ? out : System.out;
return this;
}
/**
* Sets the input stream (default: stdin). Useful for testing.
*/
public Menu input(java.io.InputStream in) {
this.in = in != null ? in : System.in;
return this;
}
/**
* Displays the menu and blocks until the user selects an option.
* Returns the selected option string, or the first option if only one,
* or null if no options or read fails.
*/
public String select() {
if (options.isEmpty()) {
return null;
}
if (options.size() == 1) {
return options.get(0);
}
if (support.isAnsiEnabled()) {
out.println(Ansi.BOLD + title + Ansi.RESET);
out.println();
} else {
out.println(title);
out.println();
}
for (int i = 0; i < options.size(); i++) {
out.println(" " + (i + 1) + ". " + options.get(i));
}
out.println();
out.print("Enter choice (1-" + options.size() + "): ");
out.flush();
try (Scanner scanner = new Scanner(in)) {
while (scanner.hasNextLine()) {
String line = scanner.nextLine();
if (line == null) continue;
line = line.trim();
try {
int choice = Integer.parseInt(line);
if (choice >= 1 && choice <= options.size()) {
return options.get(choice - 1);
}
} catch (NumberFormatException ignored) {}
out.print("Invalid. Enter choice (1-" + options.size() + "): ");
out.flush();
}
}
return null;
}
}

View File

@@ -0,0 +1,143 @@
package dev.jakub.terminal.interactive;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.InputStream;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
/**
* Pager for long lists. Use via {@link Terminal#pager()}.
* Interactive: Enter/Space/Down/Right = next page, Up/Left = previous, 'q' = quit.
*/
public final class Pager {
private final TerminalSupport support;
private final List<String> lines = new ArrayList<>();
private int pageSize = 20;
private PrintStream out = System.out;
private InputStream in = System.in;
public Pager(TerminalSupport support) {
this.support = support;
}
/**
* Sets the lines to display.
*/
public Pager lines(List<String> lines) {
this.lines.clear();
if (lines != null) {
this.lines.addAll(lines);
}
return this;
}
/**
* Sets the lines (varargs).
*/
public Pager lines(String... lines) {
this.lines.clear();
if (lines != null) {
for (String s : lines) this.lines.add(s != null ? s : "");
}
return this;
}
/**
* Sets page size (default 20).
*/
public Pager pageSize(int size) {
this.pageSize = Math.max(1, size);
return this;
}
/**
* Sets output stream.
*/
public Pager output(PrintStream out) {
this.out = out != null ? out : System.out;
return this;
}
/**
* Sets input stream (for tests).
*/
public Pager input(InputStream in) {
this.in = in != null ? in : System.in;
return this;
}
/**
* Shows all lines at once (no interaction).
*/
public void print(PrintStream out) {
for (String line : lines) {
out.println(line);
}
}
/**
* Interactive paging: Enter/Space/Down/Right = next, Up/Left = previous, 'q' = quit.
*/
public void interactive() {
int totalPages = Math.max(1, (lines.size() + pageSize - 1) / pageSize);
int page = 0;
while (true) {
int from = page * pageSize;
int to = Math.min(from + pageSize, lines.size());
for (int i = from; i < to; i++) {
out.println(lines.get(i));
}
out.println();
out.print("[Page " + (page + 1) + " / " + totalPages + "] Enter/Space/Down=next, Up=prev, q=quit: ");
out.flush();
switch (readKey(in)) {
case NEXT:
page++;
if (page >= totalPages) return;
break;
case PREV:
if (page > 0) page--;
break;
case QUIT:
return;
}
}
}
private enum KeyAction { NEXT, PREV, QUIT }
private static KeyAction readKey(InputStream in) {
try {
int c = in.read();
if (c == -1) return KeyAction.QUIT;
if (c == 'q' || c == 'Q') return KeyAction.QUIT;
if (c == ' ' || c == '\n' || c == '\r') return KeyAction.NEXT;
if (c == '\u001b') {
if (in.read() != '[') return KeyAction.NEXT;
while (true) {
int b = in.read();
if (b == -1) return KeyAction.QUIT;
if (b == 'A') return KeyAction.PREV;
if (b == 'B' || b == 'C') return KeyAction.NEXT;
if (b == 'D') return KeyAction.PREV;
if (b >= 0x40) return KeyAction.NEXT;
}
}
return KeyAction.NEXT;
} catch (Exception e) {
return KeyAction.QUIT;
}
}
/**
* Prints all to stdout.
*/
public void print() {
print(System.out);
}
}

View File

@@ -0,0 +1,89 @@
package dev.jakub.terminal.interactive;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.InputStream;
import java.io.PrintStream;
import java.util.Scanner;
/**
* Input prompt for reading user input. Use via {@link Terminal#prompt(String)}.
* Supports masked input for passwords. Injectable input/output for tests.
*/
public final class Prompt {
private final String message;
private final TerminalSupport support;
private boolean masked;
private PrintStream out = System.out;
private InputStream in = System.in;
public Prompt(String message, TerminalSupport support) {
this.message = message != null ? message : "";
this.support = support;
}
/**
* Hides input (e.g. for passwords). Shows * per character or blank.
*/
public Prompt masked() {
this.masked = true;
return this;
}
/**
* Sets the output stream (default: stdout).
*/
public Prompt output(PrintStream out) {
this.out = out != null ? out : System.out;
return this;
}
/**
* Sets the input stream (default: stdin). Useful for tests.
*/
public Prompt input(InputStream in) {
this.in = in != null ? in : System.in;
return this;
}
/**
* Prompts and returns the entered string. Blocks until a line is read.
*/
public String ask() {
out.print(message);
out.flush();
if (masked) {
return readMasked();
}
try (Scanner scan = new Scanner(in)) {
return scan.hasNextLine() ? scan.nextLine() : "";
}
}
/**
* Prompts and returns the next line from the given scanner. Use this when
* sharing one scanner (e.g. in a demo) so input blocks correctly.
*/
public String ask(Scanner sharedScanner) {
if (sharedScanner == null) return ask();
out.print(message);
out.flush();
if (masked && System.console() != null) {
char[] chars = System.console().readPassword();
return chars != null ? new String(chars) : "";
}
return sharedScanner.hasNextLine() ? sharedScanner.nextLine() : "";
}
private String readMasked() {
if (System.console() != null) {
char[] chars = System.console().readPassword();
return chars != null ? new String(chars) : "";
}
try (Scanner scan = new Scanner(in)) {
return scan.hasNextLine() ? scan.nextLine() : "";
}
}
}

View File

@@ -0,0 +1,205 @@
package dev.jakub.terminal.interactive;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.InputStream;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
/**
* Interactive select list: options with one highlighted. Navigate with Up/Down arrow keys
* and Enter to confirm (when terminal supports raw input), or type the option number and Enter.
* Use via {@link Terminal#selectList()}.
*/
public final class SelectList {
private static final long FALLBACK_MS = 2500L;
private final TerminalSupport support;
private final List<String> options = new ArrayList<>();
private String title = "Select an option";
private PrintStream out = System.out;
private InputStream in = System.in;
public SelectList(TerminalSupport support) {
this.support = support;
}
public SelectList title(String title) {
this.title = title != null ? title : "";
return this;
}
public SelectList option(String option) {
options.add(option != null ? option : "");
return this;
}
public SelectList output(PrintStream out) {
this.out = out != null ? out : System.out;
return this;
}
public SelectList input(InputStream in) {
this.in = in != null ? in : System.in;
return this;
}
/**
* Shows the list and blocks until the user selects. Returns the selected option, or null if empty.
*/
public String select() {
if (options.isEmpty()) return null;
if (options.size() == 1) return options.get(0);
boolean ansi = support.isAnsiEnabled();
if (ansi) out.print(Ansi.HIDE_CURSOR);
try {
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
Thread reader = new Thread(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
if (in.available() > 0) {
int b = in.read();
if (b == -1) break;
queue.offer(b);
} else {
Thread.sleep(30);
}
}
} catch (Exception ignored) {}
}, "select-list-reader");
reader.setDaemon(true);
reader.start();
int current = 0;
long firstInputAt = -1;
StringBuilder numInput = new StringBuilder();
draw(current, ansi);
while (true) {
Integer b = queue.poll(FALLBACK_MS, TimeUnit.MILLISECONDS);
if (b == null) {
reader.interrupt();
return selectWithScanner(current, ansi);
}
if (firstInputAt < 0) firstInputAt = System.currentTimeMillis();
if (b == 13 || b == 10) {
if (numInput.length() > 0) {
try {
int n = Integer.parseInt(numInput.toString());
if (n >= 1 && n <= options.size()) return options.get(n - 1);
} catch (NumberFormatException ignored) {}
}
return options.get(current);
}
if (b >= '1' && b <= '9') {
numInput.append((char) b.intValue());
continue;
}
// Windows: 224 (or 0) then 72=Up, 80=Down, 75=Left, 77=Right
if (b == 224 || b == 0) {
Integer b2 = queue.poll(150, TimeUnit.MILLISECONDS);
if (b2 != null) {
if (b2 == 72) {
current = (current - 1 + options.size()) % options.size();
numInput.setLength(0);
redraw(current, ansi);
} else if (b2 == 80) {
current = (current + 1) % options.size();
numInput.setLength(0);
redraw(current, ansi);
}
}
continue;
}
// ANSI: ESC [ A (Up) / ESC [ B (Down). Zuerst sofortige Bytes aus der Queue holen.
if (b == 27) {
Integer b2 = queue.poll(0, TimeUnit.MILLISECONDS);
if (b2 == null) b2 = queue.poll(120, TimeUnit.MILLISECONDS);
Integer b3 = null;
if (b2 != null && (b2 == 91 || b2 == 79)) {
b3 = queue.poll(0, TimeUnit.MILLISECONDS);
if (b3 == null) b3 = queue.poll(120, TimeUnit.MILLISECONDS);
}
if (b3 != null) {
if (b3 == 65) {
current = (current - 1 + options.size()) % options.size();
numInput.setLength(0);
redraw(current, ansi);
} else if (b3 == 66) {
current = (current + 1) % options.size();
numInput.setLength(0);
redraw(current, ansi);
}
}
continue;
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return options.get(0);
} finally {
if (ansi) out.print(Ansi.SHOW_CURSOR);
}
}
private String selectWithScanner(int current, boolean ansi) {
out.print("\nEnter number (1-" + options.size() + ") or Enter for current: ");
out.flush();
try (Scanner scanner = new Scanner(in)) {
if (scanner.hasNextLine()) {
String line = scanner.nextLine();
if (line != null) line = line.trim();
if (line == null || line.isEmpty()) return options.get(current);
try {
int n = Integer.parseInt(line);
if (n >= 1 && n <= options.size()) return options.get(n - 1);
} catch (NumberFormatException ignored) {}
}
}
return options.get(current);
}
private void draw(int current, boolean ansi) {
if (ansi) out.print(Ansi.BOLD + title + Ansi.RESET + "\n\n");
else out.println(title + "\n");
for (int i = 0; i < options.size(); i++) {
String line = (i + 1) + ". " + options.get(i);
if (i == current) {
if (ansi) out.print(Ansi.BOLD + "> " + line + Ansi.RESET + "\n");
else out.println("> " + line);
} else {
if (ansi) out.print(" " + line + "\n");
else out.println(" " + line);
}
}
out.print("\n(Up/Down: move, Enter: select, or type number): ");
out.flush();
}
private void redraw(int current, boolean ansi) {
int lines = options.size() + 3;
if (ansi) {
for (int i = 0; i < lines; i++) out.print(Ansi.CURSOR_UP);
for (int i = 0; i < lines; i++) {
out.print(Ansi.ERASE_LINE);
if (i < lines - 1) out.print(Ansi.CURSOR_DOWN);
}
for (int i = 0; i < lines; i++) out.print(Ansi.CURSOR_UP);
}
draw(current, ansi);
}
}

View File

@@ -0,0 +1,75 @@
package dev.jakub.terminal.internal;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import dev.jakub.terminal.interactive.Prompt;
import java.io.InputStream;
import java.io.PrintStream;
import java.util.Scanner;
/**
* Fluent builder for {@link Prompt}. Use via {@link Terminal#prompt(String)}.
* Chain {@link #masked()} then {@link #ask()}.
*/
public final class PromptBuilder {
private final String message;
private final TerminalSupport support;
private boolean masked;
private PrintStream output = System.out;
private InputStream input = System.in;
public PromptBuilder(String message, TerminalSupport support) {
this.message = message != null ? message : "";
this.support = support != null ? support : new TerminalSupport();
}
/**
* Hides input (e.g. for passwords).
*/
public PromptBuilder masked() {
this.masked = true;
return this;
}
/**
* Sets output stream (default: stdout).
*/
public PromptBuilder output(PrintStream out) {
this.output = out != null ? out : System.out;
return this;
}
/**
* Sets input stream (default: stdin). For tests.
*/
public PromptBuilder input(InputStream in) {
this.input = in != null ? in : System.in;
return this;
}
/**
* Prompts and returns the entered string.
*/
public String ask() {
Prompt p = new Prompt(message, support);
if (masked) p.masked();
p.output(output);
p.input(input);
return p.ask();
}
/**
* Prompts and returns the next line from the given scanner. Use one shared
* scanner so input blocks until the user types (avoids EOF when run from IDE).
*/
public String ask(Scanner sharedScanner) {
if (sharedScanner == null) return ask();
Prompt p = new Prompt(message, support);
if (masked) p.masked();
p.output(output);
p.input(input);
return p.ask(sharedScanner);
}
}

View File

@@ -0,0 +1,16 @@
package dev.jakub.terminal.internal;
import dev.jakub.terminal.components.Tree;
import java.util.ArrayList;
import java.util.List;
/** Node for {@link Tree}. */
public final class TreeNode {
public final String label;
public final List<TreeNode> children = new ArrayList<>();
public TreeNode(String label) {
this.label = label != null ? label : "";
}
}

View File

@@ -0,0 +1,118 @@
package dev.jakub.terminal.live;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
/**
* Live dashboard that refreshes widgets at an interval. Use via {@link Terminal#dashboard()}.
* Clears screen and re-renders. Thread-safe. Call {@link #stop()} to end.
*/
public final class Dashboard {
private static final String CLEAR = "\u001B[2J\u001B[H";
private final TerminalSupport support;
private final List<Widget> widgets = new ArrayList<>();
private long intervalMs = 1000;
private PrintStream out = System.out;
private final AtomicBoolean running = new AtomicBoolean(false);
private Thread thread;
public Dashboard(TerminalSupport support) {
this.support = support;
}
/**
* Sets refresh interval (e.g. 1, SECONDS).
*/
public Dashboard refreshEvery(long amount, TimeUnit unit) {
this.intervalMs = unit.toMillis(amount);
return this;
}
/**
* Adds a widget. The supplier returns a component that has print(PrintStream); we capture its output.
*/
public Dashboard widget(String title, Supplier<Object> widgetSupplier) {
widgets.add(new Widget(title, widgetSupplier));
return this;
}
/**
* Starts the dashboard in a background thread. Stops with {@link #stop()}.
*/
public void start() {
if (!running.compareAndSet(false, true)) return;
thread = new Thread(this::run, "terminal-ui-dashboard");
thread.setDaemon(true);
thread.start();
}
/**
* Stops the dashboard.
*/
public void stop() {
running.set(false);
if (thread != null) {
try {
thread.join(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
private void run() {
while (running.get()) {
try {
if (support.isAnsiEnabled()) {
out.print(CLEAR);
}
for (Widget w : widgets) {
if (w.title != null && !w.title.isEmpty()) {
out.println("--- " + w.title + " ---");
}
try {
Object comp = w.supplier.get();
if (comp != null) {
capturePrint(comp);
}
} catch (Exception e) {
out.println("Error: " + e.getMessage());
}
out.println();
}
} catch (Exception e) {
out.println("Dashboard error: " + e.getMessage());
}
try {
Thread.sleep(intervalMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
private void capturePrint(Object comp) throws Exception {
java.lang.reflect.Method m = comp.getClass().getMethod("print", PrintStream.class);
m.invoke(comp, out);
}
private static final class Widget {
final String title;
final Supplier<Object> supplier;
Widget(String title, Supplier<Object> supplier) {
this.title = title;
this.supplier = supplier;
}
}
}

View File

@@ -0,0 +1,157 @@
package dev.jakub.terminal.live;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
/**
* Thread-safe progress bar. Use via {@link Terminal#progressBar()}.
*/
public final class ProgressBar {
/**
* Visual style for the progress bar fill.
*/
public enum Style {
/** Block characters: [████████░░░░] */
BLOCK("", ""),
/** Equals: [========----] */
EQUALS("=", "-"),
/** Hash: [######## ] */
HASH("#", " "),
/** Arrow: [>>>>>>------] */
ARROW(">", "-");
private final String filled;
private final String empty;
Style(String filled, String empty) {
this.filled = filled;
this.empty = empty;
}
}
private final long total;
private final int width;
private final Style style;
private final TerminalSupport support;
private final AtomicLong current = new AtomicLong(0);
private final ReentrantLock printLock = new ReentrantLock();
private final PrintStream out;
private boolean printed;
private String prefix = "";
private String suffix = "";
public ProgressBar(long total, int width, Style style, TerminalSupport support, PrintStream out) {
this.total = Math.max(1, total);
this.width = Math.max(10, Math.min(width, 200));
this.style = style != null ? style : Style.BLOCK;
this.support = support;
this.out = out != null ? out : System.out;
}
/**
* Sets optional text before the bar.
*/
public ProgressBar prefix(String prefix) {
this.prefix = prefix != null ? prefix : "";
return this;
}
/**
* Sets optional text after the bar (e.g. percentage).
*/
public ProgressBar suffix(String suffix) {
this.suffix = suffix != null ? suffix : "";
return this;
}
/**
* Updates the current value and redraws the bar. Thread-safe.
*/
public void update(long value) {
current.set(Math.max(0, Math.min(value, total)));
redraw();
}
/**
* Increments the current value by one and redraws. Thread-safe.
*/
public void increment() {
long next = current.updateAndGet(v -> Math.min(v + 1, total));
redraw();
}
/**
* Returns the current progress value.
*/
public long getCurrent() {
return current.get();
}
/**
* Returns the total (max) value.
*/
public long getTotal() {
return total;
}
private void redraw() {
printLock.lock();
try {
long cur = current.get();
int filledCount = (int) ((cur * width) / total);
filledCount = Math.max(0, Math.min(filledCount, width));
String filledCh = support.isUtf8Symbols() ? style.filled : asciiFilled();
String emptyCh = support.isUtf8Symbols() ? style.empty : asciiEmpty();
String filled = filledCh.repeat(filledCount);
String empty = emptyCh.repeat(width - filledCount);
String bar = "[" + filled + empty + "]";
int pct = total > 0 ? (int) (100 * cur / total) : 0;
String line = prefix + bar + " " + pct + "%" + suffix;
if (printed) {
out.print(Ansi.CARRIAGE_RETURN);
if (support.isAnsiEnabled()) {
out.print(Ansi.ERASE_LINE);
}
}
out.print(line);
printed = true;
} finally {
printLock.unlock();
}
}
private String asciiFilled() {
return switch (style) {
case BLOCK -> "#";
case EQUALS -> "=";
case HASH -> "#";
case ARROW -> ">";
};
}
private String asciiEmpty() {
return switch (style) {
case BLOCK, HASH -> " ";
case EQUALS, ARROW -> "-";
};
}
/**
* Completes the bar at 100% and prints a newline.
*/
public void complete() {
update(total);
printLock.lock();
try {
out.println();
} finally {
printLock.unlock();
}
}
}

View File

@@ -0,0 +1,61 @@
package dev.jakub.terminal.live;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
/**
* Fluent builder for {@link ProgressBar}. Use via {@link Terminal#progressBar()}.
*/
public final class ProgressBarBuilder {
private final TerminalSupport support;
private long total = 100;
private int width = 40;
private ProgressBar.Style style = ProgressBar.Style.BLOCK;
private PrintStream out = System.out;
public ProgressBarBuilder(TerminalSupport support) {
this.support = support;
}
/**
* Sets the total (maximum) value for the progress bar.
*/
public ProgressBarBuilder total(long total) {
this.total = Math.max(1, total);
return this;
}
/**
* Sets the width in characters of the bar (excluding brackets and suffix).
*/
public ProgressBarBuilder width(int width) {
this.width = Math.max(10, Math.min(width, 200));
return this;
}
/**
* Sets the visual style of the bar (e.g. BLOCK for [████░░░]).
*/
public ProgressBarBuilder style(ProgressBar.Style style) {
this.style = style != null ? style : ProgressBar.Style.BLOCK;
return this;
}
/**
* Sets the output stream (default: stdout).
*/
public ProgressBarBuilder output(PrintStream out) {
this.out = out != null ? out : System.out;
return this;
}
/**
* Builds the progress bar instance.
*/
public ProgressBar build() {
return new ProgressBar(total, width, style, support, out);
}
}

View File

@@ -0,0 +1,103 @@
package dev.jakub.terminal.live;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.Ansi;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/**
* Thread-safe spinner for indeterminate progress. Use via {@link Terminal#spinner()}.
*/
public final class Spinner {
/** Braille-style frames (UTF-8). */
private static final String[] FRAMES_UTF8 = {"", "", "", "", "", "", "", "", "", ""};
/** ASCII fallback for Windows CMD / no UTF-8. */
private static final String[] FRAMES_ASCII = {"|", "/", "-", "\\"};
private final String message;
private final TerminalSupport support;
private final PrintStream out;
private final AtomicBoolean running = new AtomicBoolean(false);
private final ReentrantLock lock = new ReentrantLock();
private Thread thread;
private int frameIndex;
public Spinner(String message, TerminalSupport support, PrintStream out) {
this.message = message != null ? message : "";
this.support = support;
this.out = out != null ? out : System.out;
}
/**
* Starts the spinner in a background thread. Idempotent.
*/
public Spinner start() {
if (!running.compareAndSet(false, true)) {
return this;
}
thread = new Thread(this::run, "terminal-ui-spinner");
thread.setDaemon(true);
thread.start();
return this;
}
public void stop(String message) {
if (!running.compareAndSet(true, false)) {
return;
}
if (thread != null) {
try {
thread.join(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
lock.lock();
try {
out.print(Ansi.CARRIAGE_RETURN);
if (support.isAnsiEnabled()) {
out.print(Ansi.ERASE_LINE);
}
if (message != null && !message.isEmpty()) {
out.println(message);
}
} finally {
lock.unlock();
}
}
/**
* Stops the spinner without a final message.
*/
public void stop() {
stop(null);
}
private void run() {
String[] frames = support.isUtf8Symbols() ? FRAMES_UTF8 : FRAMES_ASCII;
while (running.get()) {
lock.lock();
try {
String frame = frames[frameIndex % frames.length];
frameIndex++;
out.print(Ansi.CARRIAGE_RETURN);
if (support.isAnsiEnabled()) {
out.print(Ansi.ERASE_LINE);
}
out.print(frame + " " + message);
} finally {
lock.unlock();
}
try {
Thread.sleep(support.isAnsiEnabled() ? 80 : 120);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}

View File

@@ -0,0 +1,45 @@
package dev.jakub.terminal.live;
import dev.jakub.terminal.Terminal;
import dev.jakub.terminal.core.TerminalSupport;
import java.io.PrintStream;
/**
* Fluent builder for {@link Spinner}. Use via {@link Terminal#spinner()}.
*/
public final class SpinnerBuilder {
private final TerminalSupport support;
private String message = "Loading...";
private PrintStream out = System.out;
public SpinnerBuilder(TerminalSupport support) {
this.support = support;
}
/**
* Sets the message shown next to the spinner.
*/
public SpinnerBuilder message(String message) {
this.message = message != null ? message : "";
return this;
}
/**
* Sets the output stream (default: stdout).
*/
public SpinnerBuilder output(PrintStream out) {
this.out = out != null ? out : System.out;
return this;
}
/**
* Builds and starts the spinner. Returns the running Spinner instance.
*/
public Spinner start() {
Spinner s = new Spinner(message, support, out);
s.start();
return s;
}
}