Add Help component for CLI usage and input validation support

- Introduced `Help` class for generating CLI help/usage blocks with options, descriptions, and customizable formatting.
- Enhanced `Prompt` and `PromptBuilder` with validation capabilities using predicates and retry messages.
- Added terminal control methods `clearScreen` and `cursorTo` with ANSI support.
- Updated examples and documentation (`Examples.md`, `Components.md`) to showcase the new features.
- Extended `Examples` class with a `helpBlock` method for demonstrating the `Help` usage.
This commit is contained in:
!verity
2026-03-14 12:00:31 +01:00
parent 21256dc2dd
commit 4b44c1d155
8 changed files with 232 additions and 12 deletions

View File

@@ -205,6 +205,27 @@ public final class Terminal {
return new Pager(DEFAULT.getSupport());
}
/** Returns a help/usage builder for CLI --help output. */
public static Help help() {
return new Help(DEFAULT.getSupport());
}
/** Clears the terminal screen (ANSI). No-op if ANSI is disabled. */
public static void clearScreen() {
if (DEFAULT.getSupport().isAnsiEnabled()) {
System.out.print(dev.jakub.terminal.core.Ansi.CLEAR_SCREEN);
System.out.flush();
}
}
/** Moves the cursor to the given 1-based row and column (ANSI). No-op if ANSI is disabled. */
public static void cursorTo(int row, int col) {
if (DEFAULT.getSupport().isAnsiEnabled()) {
System.out.print(dev.jakub.terminal.core.Ansi.cursorTo(row, col));
System.out.flush();
}
}
TerminalSupport getSupport() {
return support;
}

View File

@@ -0,0 +1,87 @@
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;
/**
* CLI help / usage block. Use via {@link Terminal#help()}.
* Formats options and descriptions for a typical --help output.
*/
public final class Help {
private static final int DEFAULT_OPTION_WIDTH = 24;
private final TerminalSupport support;
private final List<String> options = new ArrayList<>();
private final List<String> descriptions = new ArrayList<>();
private String title = "Usage";
private int optionWidth = DEFAULT_OPTION_WIDTH;
private PrintStream out = System.out;
public Help(TerminalSupport support) {
this.support = support;
}
/**
* Sets the title (e.g. "Usage" or "Options").
*/
public Help title(String title) {
this.title = title != null ? title : "";
return this;
}
/**
* Adds an option with description (e.g. "-v, --verbose" and "Enable verbose output").
*/
public Help option(String option, String description) {
options.add(option != null ? option : "");
descriptions.add(description != null ? description : "");
return this;
}
/**
* Sets the width of the option column (default 24).
*/
public Help optionWidth(int width) {
this.optionWidth = Math.max(8, width);
return this;
}
/**
* Sets output stream (default: stdout).
*/
public Help output(PrintStream out) {
this.out = out != null ? out : System.out;
return this;
}
/**
* Prints the help block to the given stream.
*/
public void print(PrintStream stream) {
if (stream == null) stream = System.out;
if (!title.isEmpty()) {
stream.println(title);
stream.println();
}
int ow = Math.max(optionWidth, 8);
for (int i = 0; i < options.size(); i++) {
String opt = options.get(i);
String desc = i < descriptions.size() ? descriptions.get(i) : "";
String padded = opt.length() <= ow ? opt + " ".repeat(ow - opt.length()) : opt;
stream.print(" " + padded + " ");
stream.println(desc);
}
}
/**
* Prints to stdout.
*/
public void print() {
print(out);
}
}

View File

@@ -61,5 +61,15 @@ public final class Ansi {
/** Clear line */
public static final String ERASE_LINE = "\u001B[2K";
/** Clear entire screen and move cursor to home (1,1) */
public static final String CLEAR_SCREEN = "\u001B[H\u001B[2J";
/**
* Cursor position (1-based row and column). ESC [ row ; col H
*/
public static String cursorTo(int row, int col) {
return "\u001B[" + Math.max(1, row) + ";" + Math.max(1, col) + "H";
}
private Ansi() {}
}

View File

@@ -24,6 +24,7 @@ public final class Examples {
columns();
steps();
breadcrumb();
helpBlock();
log();
logWithMinLevel();
diff();
@@ -128,6 +129,17 @@ public final class Examples {
System.out.println();
}
static void helpBlock() {
Terminal.print("=== Help (CLI usage) ===").color(Color.CYAN).bold().println();
Terminal.help()
.title("Options")
.option("-v, --verbose", "Enable verbose output")
.option("-h, --help", "Show this help")
.option("--file <path>", "Input file path")
.print(System.out);
System.out.println();
}
static void log() {
Terminal.print("=== Log ===").color(Color.CYAN).bold().println();
Terminal.log()

View File

@@ -6,6 +6,7 @@ import dev.jakub.terminal.core.TerminalSupport;
import java.io.InputStream;
import java.io.PrintStream;
import java.util.Scanner;
import java.util.function.Predicate;
/**
* Input prompt for reading user input. Use via {@link Terminal#prompt(String)}.
@@ -18,6 +19,8 @@ public final class Prompt {
private boolean masked;
private PrintStream out = System.out;
private InputStream in = System.in;
private Predicate<String> validator;
private String retryMessage = "Invalid, try again: ";
public Prompt(String message, TerminalSupport support) {
this.message = message != null ? message : "";
@@ -48,33 +51,62 @@ public final class Prompt {
return this;
}
/**
* Validates input: asks again until the predicate returns true. Null = no validation.
*/
public Prompt validate(Predicate<String> validator) {
this.validator = validator;
return this;
}
/**
* Message shown when validation fails (default: "Invalid, try again: ").
*/
public Prompt retryMessage(String retryMessage) {
this.retryMessage = retryMessage != null ? retryMessage : "Invalid, try again: ";
return this;
}
/**
* Prompts and returns the entered string. Blocks until a line is read.
* If {@link #validate(Predicate)} is set, repeats until the predicate accepts the input.
*/
public String ask() {
out.print(message);
out.flush();
if (masked) {
return readMasked();
}
try (Scanner scan = new Scanner(in)) {
return scan.hasNextLine() ? scan.nextLine() : "";
return askLoop(scan);
}
}
/**
* Prompts and returns the next line from the given scanner. Use this when
* sharing one scanner (e.g. in an interactive app) so input blocks correctly.
* If {@link #validate(Predicate)} is set, repeats until the predicate accepts the input.
*/
public String ask(Scanner sharedScanner) {
if (sharedScanner == null) return ask();
out.print(message);
out.flush();
if (masked && System.console() != null) {
out.print(message);
out.flush();
char[] chars = System.console().readPassword();
return chars != null ? new String(chars) : "";
}
return sharedScanner.hasNextLine() ? sharedScanner.nextLine() : "";
return askLoop(sharedScanner);
}
private String askLoop(Scanner scan) {
while (true) {
out.print(message);
out.flush();
if (!scan.hasNextLine()) return "";
String line = scan.nextLine();
if (line == null) line = "";
if (validator == null || validator.test(line)) return line;
out.print(retryMessage);
out.flush();
}
}
private String readMasked() {

View File

@@ -7,6 +7,7 @@ import dev.jakub.terminal.interactive.Prompt;
import java.io.InputStream;
import java.io.PrintStream;
import java.util.Scanner;
import java.util.function.Predicate;
/**
* Fluent builder for {@link Prompt}. Use via {@link Terminal#prompt(String)}.
@@ -19,6 +20,8 @@ public final class PromptBuilder {
private boolean masked;
private PrintStream output = System.out;
private InputStream input = System.in;
private Predicate<String> validator;
private String retryMessage = "Invalid, try again: ";
public PromptBuilder(String message, TerminalSupport support) {
this.message = message != null ? message : "";
@@ -49,14 +52,27 @@ public final class PromptBuilder {
return this;
}
/**
* Validates input: prompts again until the predicate returns true. Null = no validation.
*/
public PromptBuilder validate(Predicate<String> validator) {
this.validator = validator;
return this;
}
/**
* Message shown when validation fails (default: "Invalid, try again: ").
*/
public PromptBuilder retryMessage(String retryMessage) {
this.retryMessage = retryMessage != null ? retryMessage : "Invalid, try again: ";
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);
Prompt p = buildPrompt();
return p.ask();
}
@@ -66,10 +82,17 @@ public final class PromptBuilder {
*/
public String ask(Scanner sharedScanner) {
if (sharedScanner == null) return ask();
Prompt p = buildPrompt();
return p.ask(sharedScanner);
}
private Prompt buildPrompt() {
Prompt p = new Prompt(message, support);
if (masked) p.masked();
p.output(output);
p.input(input);
return p.ask(sharedScanner);
if (validator != null) p.validate(validator);
p.retryMessage(retryMessage);
return p;
}
}