commit 26de5ef958c1c8ef76fb3e1dc133cdbed4e929fe Author: !verity Date: Fri Mar 13 18:30:23 2026 +0100 Add new terminal components and tooling settings diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fac4d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..104c42f --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..2a65317 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e1c3f7e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c7d6eeb --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("java") + id("java-library") + id("application") +} + +tasks.named("run") { + jvmArgs("-Ddev.jakub.terminal.ansi=true", "-Dfile.encoding=UTF-8") +} + + +group = "dev.jakub.terminal" +version = "1.0-SNAPSHOT" + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(platform("org.junit:junit-bom:5.10.0")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5f6867c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Mar 13 15:14:45 CET 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..29baa85 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "terminal-ui" \ No newline at end of file diff --git a/src/main/java/dev/jakub/terminal/Terminal.java b/src/main/java/dev/jakub/terminal/Terminal.java new file mode 100644 index 0000000..7c3d40f --- /dev/null +++ b/src/main/java/dev/jakub/terminal/Terminal.java @@ -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 (2–4 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Badge.java b/src/main/java/dev/jakub/terminal/components/Badge.java new file mode 100644 index 0000000..9cb9c18 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Badge.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Box.java b/src/main/java/dev/jakub/terminal/components/Box.java new file mode 100644 index 0000000..96746df --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Box.java @@ -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 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Breadcrumb.java b/src/main/java/dev/jakub/terminal/components/Breadcrumb.java new file mode 100644 index 0000000..5094d27 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Breadcrumb.java @@ -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 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Chart.java b/src/main/java/dev/jakub/terminal/components/Chart.java new file mode 100644 index 0000000..83fb267 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Chart.java @@ -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 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/CodeBlock.java b/src/main/java/dev/jakub/terminal/components/CodeBlock.java new file mode 100644 index 0000000..dca482a --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/CodeBlock.java @@ -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 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 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Columns.java b/src/main/java/dev/jakub/terminal/components/Columns.java new file mode 100644 index 0000000..8343e53 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Columns.java @@ -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 2–4 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 columnTexts = new ArrayList<>(); + + public Columns(TerminalSupport support) { + this.support = support; + } + + /** + * Adds a column. Order is preserved. Supports 2–4 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> columnLines = new ArrayList<>(); + int maxLines = 0; + for (String text : columnTexts) { + if (columnLines.size() >= n) break; + String[] lines = (text != null ? text : "").split("\\n", -1); + List 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 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Diff.java b/src/main/java/dev/jakub/terminal/components/Diff.java new file mode 100644 index 0000000..e0e4af7 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Diff.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Heatmap.java b/src/main/java/dev/jakub/terminal/components/Heatmap.java new file mode 100644 index 0000000..e8d917d --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Heatmap.java @@ -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 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; + } + } +} diff --git a/src/main/java/dev/jakub/terminal/components/KeyValue.java b/src/main/java/dev/jakub/terminal/components/KeyValue.java new file mode 100644 index 0000000..64a6d60 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/KeyValue.java @@ -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 keys = new ArrayList<>(); + private final List 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; + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Log.java b/src/main/java/dev/jakub/terminal/components/Log.java new file mode 100644 index 0000000..a2f58fe --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Log.java @@ -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 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; + } + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Markdown.java b/src/main/java/dev/jakub/terminal/components/Markdown.java new file mode 100644 index 0000000..839fade --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Markdown.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Notification.java b/src/main/java/dev/jakub/terminal/components/Notification.java new file mode 100644 index 0000000..0eae53b --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Notification.java @@ -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 + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Rule.java b/src/main/java/dev/jakub/terminal/components/Rule.java new file mode 100644 index 0000000..8f7b249 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Rule.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Steps.java b/src/main/java/dev/jakub/terminal/components/Steps.java new file mode 100644 index 0000000..4564e3c --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Steps.java @@ -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 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 + } +} diff --git a/src/main/java/dev/jakub/terminal/components/SysInfo.java b/src/main/java/dev/jakub/terminal/components/SysInfo.java new file mode 100644 index 0000000..a6da284 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/SysInfo.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Table.java b/src/main/java/dev/jakub/terminal/components/Table.java new file mode 100644 index 0000000..1e05f27 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Table.java @@ -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 header = new ArrayList<>(); + private final List> 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 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 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 row : rows) { + List 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 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)); + } +} diff --git a/src/main/java/dev/jakub/terminal/components/TableOfContents.java b/src/main/java/dev/jakub/terminal/components/TableOfContents.java new file mode 100644 index 0000000..b554c21 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/TableOfContents.java @@ -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
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 subs; + + Section(String title, List 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(); + } + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Timeline.java b/src/main/java/dev/jakub/terminal/components/Timeline.java new file mode 100644 index 0000000..7763026 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Timeline.java @@ -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 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; + } + } +} diff --git a/src/main/java/dev/jakub/terminal/components/Tree.java b/src/main/java/dev/jakub/terminal/components/Tree.java new file mode 100644 index 0000000..cab3d95 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/components/Tree.java @@ -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 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(); + } + } +} diff --git a/src/main/java/dev/jakub/terminal/core/Ansi.java b/src/main/java/dev/jakub/terminal/core/Ansi.java new file mode 100644 index 0000000..32c6d90 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/core/Ansi.java @@ -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() {} +} diff --git a/src/main/java/dev/jakub/terminal/core/Color.java b/src/main/java/dev/jakub/terminal/core/Color.java new file mode 100644 index 0000000..45546bb --- /dev/null +++ b/src/main/java/dev/jakub/terminal/core/Color.java @@ -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; + } +} diff --git a/src/main/java/dev/jakub/terminal/core/StyledText.java b/src/main/java/dev/jakub/terminal/core/StyledText.java new file mode 100644 index 0000000..9d074cb --- /dev/null +++ b/src/main/java/dev/jakub/terminal/core/StyledText.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/core/TerminalSupport.java b/src/main/java/dev/jakub/terminal/core/TerminalSupport.java new file mode 100644 index 0000000..4676509 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/core/TerminalSupport.java @@ -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; + } +} diff --git a/src/main/java/dev/jakub/terminal/interactive/Confirm.java b/src/main/java/dev/jakub/terminal/interactive/Confirm.java new file mode 100644 index 0000000..57b9144 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/interactive/Confirm.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/interactive/Menu.java b/src/main/java/dev/jakub/terminal/interactive/Menu.java new file mode 100644 index 0000000..c62a23a --- /dev/null +++ b/src/main/java/dev/jakub/terminal/interactive/Menu.java @@ -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 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; + } +} diff --git a/src/main/java/dev/jakub/terminal/interactive/Pager.java b/src/main/java/dev/jakub/terminal/interactive/Pager.java new file mode 100644 index 0000000..5d3cb0b --- /dev/null +++ b/src/main/java/dev/jakub/terminal/interactive/Pager.java @@ -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 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 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/interactive/Prompt.java b/src/main/java/dev/jakub/terminal/interactive/Prompt.java new file mode 100644 index 0000000..134eec8 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/interactive/Prompt.java @@ -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() : ""; + } + } +} diff --git a/src/main/java/dev/jakub/terminal/interactive/SelectList.java b/src/main/java/dev/jakub/terminal/interactive/SelectList.java new file mode 100644 index 0000000..733956f --- /dev/null +++ b/src/main/java/dev/jakub/terminal/interactive/SelectList.java @@ -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 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 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); + } +} diff --git a/src/main/java/dev/jakub/terminal/internal/PromptBuilder.java b/src/main/java/dev/jakub/terminal/internal/PromptBuilder.java new file mode 100644 index 0000000..e0de7f7 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/internal/PromptBuilder.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/internal/TreeNode.java b/src/main/java/dev/jakub/terminal/internal/TreeNode.java new file mode 100644 index 0000000..f8787a5 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/internal/TreeNode.java @@ -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 children = new ArrayList<>(); + + public TreeNode(String label) { + this.label = label != null ? label : ""; + } +} diff --git a/src/main/java/dev/jakub/terminal/live/Dashboard.java b/src/main/java/dev/jakub/terminal/live/Dashboard.java new file mode 100644 index 0000000..e1f2f69 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/live/Dashboard.java @@ -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 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 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 supplier; + + Widget(String title, Supplier supplier) { + this.title = title; + this.supplier = supplier; + } + } +} diff --git a/src/main/java/dev/jakub/terminal/live/ProgressBar.java b/src/main/java/dev/jakub/terminal/live/ProgressBar.java new file mode 100644 index 0000000..44f8239 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/live/ProgressBar.java @@ -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(); + } + } +} diff --git a/src/main/java/dev/jakub/terminal/live/ProgressBarBuilder.java b/src/main/java/dev/jakub/terminal/live/ProgressBarBuilder.java new file mode 100644 index 0000000..76c4c70 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/live/ProgressBarBuilder.java @@ -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); + } +} diff --git a/src/main/java/dev/jakub/terminal/live/Spinner.java b/src/main/java/dev/jakub/terminal/live/Spinner.java new file mode 100644 index 0000000..5493853 --- /dev/null +++ b/src/main/java/dev/jakub/terminal/live/Spinner.java @@ -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; + } + } + } +} diff --git a/src/main/java/dev/jakub/terminal/live/SpinnerBuilder.java b/src/main/java/dev/jakub/terminal/live/SpinnerBuilder.java new file mode 100644 index 0000000..87f571c --- /dev/null +++ b/src/main/java/dev/jakub/terminal/live/SpinnerBuilder.java @@ -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; + } +} diff --git a/src/test/java/dev/jakub/terminal/AnsiTest.java b/src/test/java/dev/jakub/terminal/AnsiTest.java new file mode 100644 index 0000000..75e681a --- /dev/null +++ b/src/test/java/dev/jakub/terminal/AnsiTest.java @@ -0,0 +1,38 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.core.Ansi; +import dev.jakub.terminal.core.Color; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AnsiTest { + + @Test + void resetIsEscapeSequence() { + assertTrue(Ansi.RESET.startsWith("\u001B[")); + assertTrue(Ansi.RESET.endsWith("m")); + } + + @Test + void boldIsEscapeSequence() { + assertTrue(Ansi.BOLD.startsWith("\u001B[")); + } + + @Test + void allColorsHaveCode() { + for (Color c : Color.values()) { + assertNotNull(c.ansiCode()); + assertTrue(c.ansiCode().startsWith("\u001B[")); + } + } + + @Test + void cursorAndEraseCodesPresent() { + assertNotNull(Ansi.HIDE_CURSOR); + assertNotNull(Ansi.SHOW_CURSOR); + assertNotNull(Ansi.CURSOR_UP); + assertNotNull(Ansi.CARRIAGE_RETURN); + assertNotNull(Ansi.ERASE_LINE); + } +} diff --git a/src/test/java/dev/jakub/terminal/MenuTest.java b/src/test/java/dev/jakub/terminal/MenuTest.java new file mode 100644 index 0000000..386cc68 --- /dev/null +++ b/src/test/java/dev/jakub/terminal/MenuTest.java @@ -0,0 +1,66 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.core.TerminalSupport; +import dev.jakub.terminal.interactive.Menu; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.ByteArrayInputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class MenuTest { + + @Test + void singleOptionReturnsThatOption() { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Menu menu = new Menu(support).title("Pick one").option("Only").output(new PrintStream(baos)); + String choice = menu.select(); + assertEquals("Only", choice); + } + + @Test + void noOptionsReturnsNull() { + TerminalSupport support = new TerminalSupport(false, 80); + Menu menu = new Menu(support).title("Empty"); + assertNull(menu.select()); + } + + @Test + void selectWithInputReturnsChosenOption() { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayOutputStream outBaos = new ByteArrayOutputStream(); + ByteArrayInputStream in = new ByteArrayInputStream("2\n".getBytes(StandardCharsets.UTF_8)); + Menu menu = new Menu(support) + .title("Env") + .option("Development") + .option("Staging") + .option("Production") + .output(new PrintStream(outBaos)) + .input(in); + String choice = menu.select(); + assertEquals("Staging", choice); + } + + @Test + void invalidInputThenValidSelectsOption() { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayInputStream in = new ByteArrayInputStream("x\n99\n1\n".getBytes(StandardCharsets.UTF_8)); + Menu menu = new Menu(support) + .title("Choose") + .option("First") + .option("Second") + .input(in); + String choice = menu.select(); + assertEquals("First", choice); + } + + @Test + void staticTerminalMenuApiWorks() { + Menu m = Terminal.menu().title("Select environment").option("Dev").option("Prod"); + assertNotNull(m); + } +} diff --git a/src/test/java/dev/jakub/terminal/ProgressBarTest.java b/src/test/java/dev/jakub/terminal/ProgressBarTest.java new file mode 100644 index 0000000..fd050ff --- /dev/null +++ b/src/test/java/dev/jakub/terminal/ProgressBarTest.java @@ -0,0 +1,105 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.core.TerminalSupport; +import dev.jakub.terminal.live.ProgressBar; +import dev.jakub.terminal.live.ProgressBarBuilder; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class ProgressBarTest { + + @Test + void updateChangesCurrentValue() { + TerminalSupport support = new TerminalSupport(false, 80); + ProgressBar bar = new ProgressBar(100, 40, ProgressBar.Style.BLOCK, support, System.out); + bar.update(60); + assertEquals(60, bar.getCurrent()); + assertEquals(100, bar.getTotal()); + } + + @Test + void updateClampsToTotal() { + TerminalSupport support = new TerminalSupport(false, 80); + ProgressBar bar = new ProgressBar(100, 40, ProgressBar.Style.BLOCK, support, System.out); + bar.update(200); + assertEquals(100, bar.getCurrent()); + } + + @Test + void incrementIncrementsByOne() { + TerminalSupport support = new TerminalSupport(false, 80); + ProgressBar bar = new ProgressBar(100, 40, ProgressBar.Style.BLOCK, support, System.out); + bar.update(10); + bar.increment(); + assertEquals(11, bar.getCurrent()); + } + + @Test + void builderBuildsWithCorrectTotalAndStyle() { + TerminalSupport support = new TerminalSupport(false, 80); + ProgressBar bar = new ProgressBarBuilder(support) + .total(50) + .width(30) + .style(ProgressBar.Style.HASH) + .build(); + assertEquals(50, bar.getTotal()); + bar.update(25); + assertEquals(25, bar.getCurrent()); + } + + @Test + void outputContainsBarAndPercentage() { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ProgressBar bar = new ProgressBarBuilder(support) + .total(100) + .width(10) + .output(new PrintStream(baos)) + .build(); + bar.update(50); + bar.complete(); + String out = baos.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("[")); + assertTrue(out.contains("]")); + assertTrue(out.contains("%")); + } + + @Test + void concurrentUpdatesAreThreadSafe() throws InterruptedException { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ProgressBar bar = new ProgressBarBuilder(support) + .total(10_000) + .output(new PrintStream(baos)) + .build(); + int threads = 4; + int incrementsPerThread = 2_500; + ExecutorService exec = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + for (int t = 0; t < threads; t++) { + exec.submit(() -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + for (int i = 0; i < incrementsPerThread; i++) { + bar.increment(); + } + }); + } + start.countDown(); + exec.shutdown(); + assertTrue(exec.awaitTermination(30, TimeUnit.SECONDS)); + assertEquals(10_000, bar.getCurrent()); + } +} diff --git a/src/test/java/dev/jakub/terminal/SelectListTest.java b/src/test/java/dev/jakub/terminal/SelectListTest.java new file mode 100644 index 0000000..672bd46 --- /dev/null +++ b/src/test/java/dev/jakub/terminal/SelectListTest.java @@ -0,0 +1,45 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.core.TerminalSupport; +import dev.jakub.terminal.interactive.SelectList; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class SelectListTest { + + @Test + void emptyOptionsReturnsNull() { + SelectList list = new SelectList(new TerminalSupport(false, 80)); + assertNull(list.select()); + } + + @Test + void singleOptionReturnsIt() { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + SelectList list = new SelectList(support).option("Only").output(new PrintStream(out)).input(new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8))); + assertEquals("Only", list.select()); + } + + @Test + void numberInputSelectsOption() { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayInputStream in = new ByteArrayInputStream("2\n".getBytes(StandardCharsets.UTF_8)); + SelectList list = new SelectList(support) + .title("Pick") + .option("A") + .option("B") + .option("C") + .output(new PrintStream(out)) + .input(in); + String result = list.select(); + assertEquals("B", result); + } +} diff --git a/src/test/java/dev/jakub/terminal/SpinnerTest.java b/src/test/java/dev/jakub/terminal/SpinnerTest.java new file mode 100644 index 0000000..f36a476 --- /dev/null +++ b/src/test/java/dev/jakub/terminal/SpinnerTest.java @@ -0,0 +1,53 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.core.TerminalSupport; +import dev.jakub.terminal.live.Spinner; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class SpinnerTest { + + @Test + void startAndStopWithMessagePrintsMessage() throws InterruptedException { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Spinner spinner = new Spinner("Loading...", support, new PrintStream(baos)); + spinner.start(); + Thread.sleep(150); + spinner.stop("Done ✅"); + String out = baos.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("Loading")); + assertTrue(out.contains("Done ✅")); + } + + @Test + void stopWithoutMessageClearsLine() { + TerminalSupport support = new TerminalSupport(false, 80); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Spinner spinner = new Spinner("Wait", support, new PrintStream(baos)); + spinner.start(); + spinner.stop(null); + assertNotNull(baos.toString(StandardCharsets.UTF_8)); + } + + @Test + void stopIsIdempotent() { + TerminalSupport support = new TerminalSupport(false, 80); + Spinner spinner = new Spinner("x", support, System.out); + spinner.start(); + spinner.stop("a"); + assertDoesNotThrow(() -> spinner.stop("b")); + } + + @Test + void spinnerBuilderStartReturnsRunningSpinner() { + Spinner s = Terminal.spinner().message("Test").start(); + assertNotNull(s); + s.stop("OK"); + } +} diff --git a/src/test/java/dev/jakub/terminal/StyledTextTest.java b/src/test/java/dev/jakub/terminal/StyledTextTest.java new file mode 100644 index 0000000..b77778b --- /dev/null +++ b/src/test/java/dev/jakub/terminal/StyledTextTest.java @@ -0,0 +1,53 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.core.Color; +import dev.jakub.terminal.core.StyledText; +import dev.jakub.terminal.core.TerminalSupport; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class StyledTextTest { + + @Test + void printWithoutAnsiOutputsPlainText() { + TerminalSupport support = new TerminalSupport(false, 80); + StyledText st = new StyledText("hello", support); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + st.color(Color.GREEN).bold().print(new PrintStream(baos)); + assertEquals("hello", baos.toString(StandardCharsets.UTF_8)); + } + + @Test + void printWithAnsiOutputsEscapes() { + TerminalSupport support = new TerminalSupport(true, 80); + StyledText st = new StyledText("hi", support); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + st.color(Color.RED).bold().print(new PrintStream(baos)); + String out = baos.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("\u001B")); + assertTrue(out.contains("hi")); + } + + @Test + void printlnAppendsNewline() { + TerminalSupport support = new TerminalSupport(false, 80); + StyledText st = new StyledText("x", support); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + st.println(new PrintStream(baos)); + assertEquals("x" + System.lineSeparator(), baos.toString(StandardCharsets.UTF_8)); + } + + @Test + void nullTextTreatedAsEmpty() { + TerminalSupport support = new TerminalSupport(false, 80); + StyledText st = new StyledText(null, support); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + st.print(new PrintStream(baos)); + assertEquals("", baos.toString(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/dev/jakub/terminal/TableTest.java b/src/test/java/dev/jakub/terminal/TableTest.java new file mode 100644 index 0000000..314a9f9 --- /dev/null +++ b/src/test/java/dev/jakub/terminal/TableTest.java @@ -0,0 +1,63 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.components.Table; +import dev.jakub.terminal.core.TerminalSupport; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class TableTest { + + @Test + void tableWithHeaderAndRowsPrintsStructure() { + TerminalSupport support = new TerminalSupport(false, 80); + Table table = new Table(support).header("Name", "Status", "CPU") + .row("nginx", "✅ running", "2%") + .row("redis", "❌ stopped", "-"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + table.print(new PrintStream(baos)); + String out = baos.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("Name")); + assertTrue(out.contains("Status")); + assertTrue(out.contains("CPU")); + assertTrue(out.contains("nginx")); + assertTrue(out.contains("running")); + assertTrue(out.contains("redis")); + assertTrue(out.contains("stopped")); + assertTrue(out.contains("|")); + assertTrue(out.contains("+") || out.contains("-")); + } + + @Test + void emptyTableDoesNotThrow() { + TerminalSupport support = new TerminalSupport(false, 80); + Table table = new Table(support); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + assertDoesNotThrow(() -> table.print(new PrintStream(baos))); + } + + @Test + void headerOnlyPrintsHeaderRow() { + TerminalSupport support = new TerminalSupport(false, 80); + Table table = new Table(support).header("A", "B"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + table.print(new PrintStream(baos)); + String out = baos.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("A")); + assertTrue(out.contains("B")); + } + + @Test + void staticTerminalTableApiWorks() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Table t = Terminal.table(); + assertNotNull(t); + t.header("X", "Y").row("a", "b").print(new PrintStream(baos)); + String out = baos.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("X") && out.contains("Y") && out.contains("a") && out.contains("b")); + } +} diff --git a/src/test/java/dev/jakub/terminal/TerminalSupportTest.java b/src/test/java/dev/jakub/terminal/TerminalSupportTest.java new file mode 100644 index 0000000..6c75610 --- /dev/null +++ b/src/test/java/dev/jakub/terminal/TerminalSupportTest.java @@ -0,0 +1,29 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.core.TerminalSupport; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class TerminalSupportTest { + + @Test + void explicitSupportRespectsAnsiAndWidth() { + TerminalSupport support = new TerminalSupport(true, 120); + assertTrue(support.isAnsiEnabled()); + assertEquals(120, support.getWidth()); + } + + @Test + void widthIsClampedToMinimum() { + TerminalSupport support = new TerminalSupport(false, 5); + assertTrue(support.getWidth() >= 10); + } + + @Test + void defaultSupportHasReasonableWidth() { + TerminalSupport support = new TerminalSupport(); + assertTrue(support.getWidth() >= 10); + assertTrue(support.getWidth() <= 1000); + } +} diff --git a/src/test/java/dev/jakub/terminal/TerminalTest.java b/src/test/java/dev/jakub/terminal/TerminalTest.java new file mode 100644 index 0000000..46c4134 --- /dev/null +++ b/src/test/java/dev/jakub/terminal/TerminalTest.java @@ -0,0 +1,75 @@ +package dev.jakub.terminal; + +import dev.jakub.terminal.components.Table; +import dev.jakub.terminal.core.StyledText; +import dev.jakub.terminal.core.TerminalSupport; +import dev.jakub.terminal.interactive.Menu; +import dev.jakub.terminal.live.ProgressBar; +import dev.jakub.terminal.live.ProgressBarBuilder; +import dev.jakub.terminal.live.SpinnerBuilder; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class TerminalTest { + + @Test + void tableReturnsNewTableInstance() { + Table t = Terminal.table(); + assertNotNull(t); + } + + @Test + void printReturnsStyledText() { + StyledText st = Terminal.print("hello"); + assertNotNull(st); + } + + @Test + void progressBarReturnsBuilder() { + ProgressBarBuilder b = Terminal.progressBar(); + assertNotNull(b); + ProgressBar bar = b.total(100).width(40).style(ProgressBar.Style.BLOCK).build(); + assertNotNull(bar); + } + + @Test + void spinnerReturnsBuilder() { + SpinnerBuilder b = Terminal.spinner(); + assertNotNull(b); + } + + @Test + void menuReturnsMenuWithOptions() { + Menu m = Terminal.menu().title("T").option("A").option("B"); + assertNotNull(m); + } + + @Test + void tableWithCustomSupportUsesThatSupport() { + TerminalSupport support = new TerminalSupport(false, 80); + Table t = Terminal.table(support); + assertNotNull(t); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + t.header("A").row("b").print(new PrintStream(baos)); + assertTrue(baos.toString(StandardCharsets.UTF_8).contains("A")); + } + + @Test + void fullTableExampleFromSpec() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Terminal.table() + .header("Name", "Status", "CPU") + .row("nginx", "✅ running", "2%") + .row("redis", "❌ stopped", "-") + .print(new PrintStream(baos)); + String out = baos.toString(StandardCharsets.UTF_8); + assertTrue(out.contains("Name") && out.contains("Status") && out.contains("CPU")); + assertTrue(out.contains("nginx") && out.contains("redis")); + assertTrue(out.contains("running") && out.contains("stopped")); + } +}