From 4b44c1d1557381525d3fe691c4708798166f8f79 Mon Sep 17 00:00:00 2001 From: !verity Date: Sat, 14 Mar 2026 12:00:31 +0100 Subject: [PATCH] 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. --- docs/Components.md | 10 ++- docs/Examples.md | 27 ++++++ .../java/dev/jakub/terminal/Terminal.java | 21 +++++ .../dev/jakub/terminal/components/Help.java | 87 +++++++++++++++++++ .../java/dev/jakub/terminal/core/Ansi.java | 10 +++ .../dev/jakub/terminal/example/Examples.java | 12 +++ .../jakub/terminal/interactive/Prompt.java | 44 ++++++++-- .../terminal/internal/PromptBuilder.java | 33 +++++-- 8 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 src/main/java/dev/jakub/terminal/components/Help.java diff --git a/docs/Components.md b/docs/Components.md index 3e02551..6de3728 100644 --- a/docs/Components.md +++ b/docs/Components.md @@ -26,11 +26,12 @@ Overview of what terminal-ui provides. All entry points are on `Terminal.*`. | API | Description | |-----|-------------| -| `Terminal.prompt(String)` | Text input (`.ask()`, `.masked().ask()`) | +| `Terminal.prompt(String)` | Text input (`.ask()`, `.masked().ask()`, `.validate(Predicate).retryMessage(String)`) | | `Terminal.confirm(String)` | Y/n confirmation | | `Terminal.menu()` | Numbered menu (`.option()`, `.select()`) | | `Terminal.selectList()` | List with arrow keys + Enter | | `Terminal.pager()` | Paged output (Enter/arrows/q) | +| `Terminal.help()` | CLI usage block (`.option(opt, desc)`, `.title()`) | ## Live / progress @@ -50,6 +51,13 @@ Overview of what terminal-ui provides. All entry points are on `Terminal.*`. | `Terminal.heatmap()` | Heatmap | | `Terminal.chart()` | Charts | +## Terminal control (ANSI) + +| API | Description | +|-----|-------------| +| `Terminal.clearScreen()` | Clear entire screen | +| `Terminal.cursorTo(row, col)` | Move cursor (1-based) | + ## Other | API | Description | diff --git a/docs/Examples.md b/docs/Examples.md index 5032ee5..b7ae145 100644 --- a/docs/Examples.md +++ b/docs/Examples.md @@ -108,6 +108,13 @@ String name = Terminal.prompt("Name: ").ask(); String secret = Terminal.prompt("Password: ").masked().ask(); // With shared scanner (e.g. in a loop): String line = Terminal.prompt("Input: ").ask(sharedScanner); + +// With validation (asks again until input matches): +String age = Terminal.prompt("Age: ").validate(s -> s.matches("\\d+")).ask(); +String email = Terminal.prompt("Email: ") + .validate(s -> s.contains("@")) + .retryMessage("Invalid email, try again: ") + .ask(); ``` ## Interactive: Confirm @@ -259,6 +266,26 @@ Terminal.code("java") .print(System.out); ``` +## Help (CLI usage) + +```java +Terminal.help() + .title("Options") + .option("-v, --verbose", "Enable verbose output") + .option("-h, --help", "Show this help") + .option("--file ", "Input file path") + .print(System.out); +``` + +## Clear screen & cursor + +```java +Terminal.clearScreen(); // Clear terminal (ANSI) +Terminal.cursorTo(1, 1); // Move cursor to row 1, col 1 (1-based) +``` + +No-op when ANSI is disabled. + ## SysInfo ```java diff --git a/src/main/java/dev/jakub/terminal/Terminal.java b/src/main/java/dev/jakub/terminal/Terminal.java index 7c3d40f..eefdcce 100644 --- a/src/main/java/dev/jakub/terminal/Terminal.java +++ b/src/main/java/dev/jakub/terminal/Terminal.java @@ -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; } diff --git a/src/main/java/dev/jakub/terminal/components/Help.java b/src/main/java/dev/jakub/terminal/components/Help.java new file mode 100644 index 0000000..4f141e7 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Help.java @@ -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 options = new ArrayList<>(); + private final List 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/core/Ansi.java b/src/main/java/dev/jakub/terminal/core/Ansi.java index 32c6d90..e0a1365 100644 --- a/src/main/java/dev/jakub/terminal/core/Ansi.java +++ b/src/main/java/dev/jakub/terminal/core/Ansi.java @@ -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() {} } diff --git a/src/main/java/dev/jakub/terminal/example/Examples.java b/src/main/java/dev/jakub/terminal/example/Examples.java index 29e80aa..b6baed5 100644 --- a/src/main/java/dev/jakub/terminal/example/Examples.java +++ b/src/main/java/dev/jakub/terminal/example/Examples.java @@ -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 ", "Input file path") + .print(System.out); + System.out.println(); + } + static void log() { Terminal.print("=== Log ===").color(Color.CYAN).bold().println(); Terminal.log() diff --git a/src/main/java/dev/jakub/terminal/interactive/Prompt.java b/src/main/java/dev/jakub/terminal/interactive/Prompt.java index 58e06ab..3e59a8a 100644 --- a/src/main/java/dev/jakub/terminal/interactive/Prompt.java +++ b/src/main/java/dev/jakub/terminal/interactive/Prompt.java @@ -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 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 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() { diff --git a/src/main/java/dev/jakub/terminal/internal/PromptBuilder.java b/src/main/java/dev/jakub/terminal/internal/PromptBuilder.java index e0de7f7..cdc85e5 100644 --- a/src/main/java/dev/jakub/terminal/internal/PromptBuilder.java +++ b/src/main/java/dev/jakub/terminal/internal/PromptBuilder.java @@ -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 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 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; } }