Add new terminal components and tooling settings
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -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
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -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/
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
14
.idea/discord.xml
generated
Normal file
14
.idea/discord.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="ASK" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
<option name="applicationTheme" value="default" />
|
||||||
|
<option name="iconsTheme" value="default" />
|
||||||
|
<option name="button1Title" value="" />
|
||||||
|
<option name="button1Url" value="" />
|
||||||
|
<option name="button2Title" value="" />
|
||||||
|
<option name="button2Url" value="" />
|
||||||
|
<option name="customApplicationId" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/gradle.xml
generated
Normal file
17
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleHome" value="" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/misc.xml
generated
Normal file
10
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="FrameworkDetectionExcludesConfiguration">
|
||||||
|
<file type="web" url="file://$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="corretto-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
32
build.gradle.kts
Normal file
32
build.gradle.kts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
id("java")
|
||||||
|
id("java-library")
|
||||||
|
id("application")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named<JavaExec>("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()
|
||||||
|
}
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||||
234
gradlew
vendored
Normal file
234
gradlew
vendored
Normal file
@@ -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" "$@"
|
||||||
89
gradlew.bat
vendored
Normal file
89
gradlew.bat
vendored
Normal file
@@ -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
|
||||||
1
settings.gradle.kts
Normal file
1
settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "terminal-ui"
|
||||||
215
src/main/java/dev/jakub/terminal/Terminal.java
Normal file
215
src/main/java/dev/jakub/terminal/Terminal.java
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package dev.jakub.terminal;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.components.*;
|
||||||
|
import dev.jakub.terminal.core.Color;
|
||||||
|
import dev.jakub.terminal.core.StyledText;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
import dev.jakub.terminal.interactive.Confirm;
|
||||||
|
import dev.jakub.terminal.interactive.Menu;
|
||||||
|
import dev.jakub.terminal.interactive.Pager;
|
||||||
|
import dev.jakub.terminal.interactive.SelectList;
|
||||||
|
import dev.jakub.terminal.internal.PromptBuilder;
|
||||||
|
import dev.jakub.terminal.live.Dashboard;
|
||||||
|
import dev.jakub.terminal.live.ProgressBarBuilder;
|
||||||
|
import dev.jakub.terminal.live.SpinnerBuilder;
|
||||||
|
|
||||||
|
public final class Terminal {
|
||||||
|
|
||||||
|
private static final TerminalSupport DEFAULT_SUPPORT = new TerminalSupport();
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Terminal instance with auto-detected capabilities.
|
||||||
|
*/
|
||||||
|
public Terminal() {
|
||||||
|
this.support = DEFAULT_SUPPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Terminal instance with the given support (e.g. for testing).
|
||||||
|
*/
|
||||||
|
public Terminal(TerminalSupport support) {
|
||||||
|
this.support = support != null ? support : DEFAULT_SUPPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared default Terminal instance.
|
||||||
|
*/
|
||||||
|
private static final Terminal DEFAULT = new Terminal();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new table builder using default terminal support.
|
||||||
|
*/
|
||||||
|
public static Table table() {
|
||||||
|
return DEFAULT.tableInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new table builder with the given terminal support (e.g. for testing).
|
||||||
|
*/
|
||||||
|
public static Table table(TerminalSupport support) {
|
||||||
|
return new Table(support != null ? support : DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a styled text builder for the given string. Chain with
|
||||||
|
* {@link StyledText#color(Color)}, {@link StyledText#bold()}, etc.,
|
||||||
|
* then {@link StyledText#print()} or {@link StyledText#println()}.
|
||||||
|
*/
|
||||||
|
public static StyledText print(String text) {
|
||||||
|
return new StyledText(text, DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a progress bar builder.
|
||||||
|
*/
|
||||||
|
public static ProgressBarBuilder progressBar() {
|
||||||
|
return new ProgressBarBuilder(DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a spinner builder. Call {@link SpinnerBuilder#start()} to run.
|
||||||
|
*/
|
||||||
|
public static SpinnerBuilder spinner() {
|
||||||
|
return new SpinnerBuilder(DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an interactive menu builder. Call {@link Menu#select()} to show and get choice.
|
||||||
|
*/
|
||||||
|
public static Menu menu() {
|
||||||
|
return new Menu(DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a select list (arrow keys Up/Down + Enter, or number + Enter). Call {@link SelectList#select()}.
|
||||||
|
*/
|
||||||
|
public static SelectList selectList() {
|
||||||
|
return new SelectList(DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a horizontal rule/separator builder.
|
||||||
|
*/
|
||||||
|
public static Rule rule() {
|
||||||
|
return new Rule(DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a key-value list builder (aligned labels).
|
||||||
|
*/
|
||||||
|
public static KeyValue keyValue() {
|
||||||
|
return new KeyValue(DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a box builder (text in a frame).
|
||||||
|
*/
|
||||||
|
public static Box box() {
|
||||||
|
return new Box(DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a tree builder. Use {@link Tree#node(String)} then {@link Tree.TreeBuilder#child(String)} / {@link Tree.TreeBuilder#end()}. */
|
||||||
|
public static Tree tree() {
|
||||||
|
return new Tree(DEFAULT.getSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a multi-column layout (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/main/java/dev/jakub/terminal/components/Badge.java
Normal file
61
src/main/java/dev/jakub/terminal/components/Badge.java
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.Color;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colored bracket label (notification badge). Use via {@link Terminal#badge(String, Color)}.
|
||||||
|
* Chainable like StyledText: .println() / .print().
|
||||||
|
*/
|
||||||
|
public final class Badge {
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
private final Color color;
|
||||||
|
private final TerminalSupport support;
|
||||||
|
|
||||||
|
public Badge(String label, Color color, TerminalSupport support) {
|
||||||
|
this.label = label != null ? label : "";
|
||||||
|
this.color = color != null ? color : Color.WHITE;
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the badge to the given stream (no newline).
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
String text = "[" + label + "]";
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(color.ansiCode());
|
||||||
|
out.print(text);
|
||||||
|
out.print(Ansi.RESET);
|
||||||
|
} else {
|
||||||
|
out.print(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the badge to stdout (no newline).
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the badge followed by a newline.
|
||||||
|
*/
|
||||||
|
public void println(PrintStream out) {
|
||||||
|
print(out);
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the badge to stdout followed by a newline.
|
||||||
|
*/
|
||||||
|
public void println() {
|
||||||
|
println(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/main/java/dev/jakub/terminal/components/Box.java
Normal file
105
src/main/java/dev/jakub/terminal/components/Box.java
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text in a box/frame. Use via {@link Terminal#box()}.
|
||||||
|
*/
|
||||||
|
public final class Box {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<String> lines = new ArrayList<>();
|
||||||
|
private String title = "";
|
||||||
|
private String borderChar = "|";
|
||||||
|
private String cornerChar = "+";
|
||||||
|
private String horizontalChar = "-";
|
||||||
|
|
||||||
|
public Box(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets an optional title (shown in the top border).
|
||||||
|
*/
|
||||||
|
public Box title(String title) {
|
||||||
|
this.title = title != null ? title : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a line of content.
|
||||||
|
*/
|
||||||
|
public Box line(String text) {
|
||||||
|
lines.add(text != null ? text : "");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds multiple lines.
|
||||||
|
*/
|
||||||
|
public Box lines(String... texts) {
|
||||||
|
for (String t : texts) {
|
||||||
|
lines.add(t != null ? t : "");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the box to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
int maxLen = lines.stream().mapToInt(this::displayWidth).max().orElse(0);
|
||||||
|
if (title.length() > 0) {
|
||||||
|
maxLen = Math.max(maxLen, displayWidth(title) + 2);
|
||||||
|
}
|
||||||
|
maxLen = Math.max(2, Math.min(maxLen, support.getWidth() - 4));
|
||||||
|
int totalWidth = maxLen + 2;
|
||||||
|
|
||||||
|
String topBorder = cornerChar + (horizontalChar.repeat(totalWidth - 2)) + cornerChar;
|
||||||
|
if (title.length() > 0) {
|
||||||
|
String t = " " + title + " ";
|
||||||
|
int tw = displayWidth(t);
|
||||||
|
if (tw <= totalWidth - 2) {
|
||||||
|
topBorder = cornerChar + horizontalChar + t + horizontalChar.repeat(totalWidth - 2 - tw) + cornerChar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.println(Ansi.BOLD + topBorder + Ansi.RESET);
|
||||||
|
} else {
|
||||||
|
out.println(topBorder);
|
||||||
|
}
|
||||||
|
for (String line : lines) {
|
||||||
|
String padded = pad(line, maxLen);
|
||||||
|
out.println(borderChar + " " + padded + " " + borderChar);
|
||||||
|
}
|
||||||
|
out.println(support.isAnsiEnabled() ? Ansi.BOLD + cornerChar + horizontalChar.repeat(totalWidth - 2) + cornerChar + Ansi.RESET : cornerChar + horizontalChar.repeat(totalWidth - 2) + cornerChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int displayWidth(String s) {
|
||||||
|
int len = 0;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
len += (c >= 0x1100 && Character.getType(c) == Character.OTHER_LETTER) ? 2 : 1;
|
||||||
|
}
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pad(String s, int width) {
|
||||||
|
int w = displayWidth(s);
|
||||||
|
if (w >= width) return s;
|
||||||
|
return s + " ".repeat(width - w);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/dev/jakub/terminal/components/Breadcrumb.java
Normal file
65
src/main/java/dev/jakub/terminal/components/Breadcrumb.java
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadcrumb path. Use via {@link Terminal#breadcrumb()}.
|
||||||
|
* Last crumb bold with ANSI. Configurable separator.
|
||||||
|
*/
|
||||||
|
public final class Breadcrumb {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<String> crumbs = new ArrayList<>();
|
||||||
|
private String separator = " > ";
|
||||||
|
|
||||||
|
public Breadcrumb(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a crumb.
|
||||||
|
*/
|
||||||
|
public Breadcrumb crumb(String label) {
|
||||||
|
crumbs.add(label != null ? label : "");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the separator (default " > ").
|
||||||
|
*/
|
||||||
|
public Breadcrumb separator(String sep) {
|
||||||
|
this.separator = sep != null ? sep : " ";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the breadcrumb to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
for (int i = 0; i < crumbs.size(); i++) {
|
||||||
|
if (i > 0) out.print(separator);
|
||||||
|
boolean last = (i == crumbs.size() - 1);
|
||||||
|
if (last && support.isAnsiEnabled()) {
|
||||||
|
out.print(Ansi.BOLD);
|
||||||
|
}
|
||||||
|
out.print(crumbs.get(i));
|
||||||
|
if (last && support.isAnsiEnabled()) {
|
||||||
|
out.print(Ansi.RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/main/java/dev/jakub/terminal/components/Chart.java
Normal file
85
src/main/java/dev/jakub/terminal/components/Chart.java
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASCII bar chart / sparkline. Use via {@link Terminal#chart()}.
|
||||||
|
* Vertical bars using ▁▂▃▄▅▆▇█; ASCII fallback: |. Auto-scale to height and width.
|
||||||
|
*/
|
||||||
|
public final class Chart {
|
||||||
|
|
||||||
|
private static final String UTF8_BARS = " ▁▂▃▄▅▆▇█";
|
||||||
|
private static final String ASCII_BAR = "|";
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<Double> data = new ArrayList<>();
|
||||||
|
private int height = 5;
|
||||||
|
|
||||||
|
public Chart(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds data points (varargs).
|
||||||
|
*/
|
||||||
|
public Chart data(double... values) {
|
||||||
|
if (values != null) {
|
||||||
|
for (double v : values) data.add(v);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets chart height in lines (default 5).
|
||||||
|
*/
|
||||||
|
public Chart height(int h) {
|
||||||
|
this.height = Math.max(1, Math.min(h, 20));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the chart to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
if (data.isEmpty()) return;
|
||||||
|
double min = data.stream().min(Double::compareTo).orElse(0.0);
|
||||||
|
double max = data.stream().max(Double::compareTo).orElse(1.0);
|
||||||
|
double range = max - min;
|
||||||
|
if (range == 0) range = 1;
|
||||||
|
boolean ascii = !support.isUtf8Symbols();
|
||||||
|
int n = data.size();
|
||||||
|
double[] barHeights = new double[n];
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
barHeights[i] = ((data.get(i) - min) / range) * height;
|
||||||
|
}
|
||||||
|
for (int row = height; row >= 0; row--) {
|
||||||
|
double yVal = min + (range * row) / height;
|
||||||
|
String label = row == height ? String.format("%6.0f", max) : (row == 0 ? String.format("%6.0f", min) : String.format("%6.0f", yVal));
|
||||||
|
StringBuilder line = new StringBuilder();
|
||||||
|
line.append(label).append(" | ");
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
boolean fill = barHeights[i] >= row;
|
||||||
|
if (ascii) {
|
||||||
|
line.append(fill ? ASCII_BAR : " ");
|
||||||
|
} else {
|
||||||
|
int idx = (int) Math.round((barHeights[i] / height) * (UTF8_BARS.length() - 1));
|
||||||
|
idx = Math.max(0, Math.min(idx, UTF8_BARS.length() - 1));
|
||||||
|
line.append(fill ? UTF8_BARS.charAt(idx) : " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.println(line.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/main/java/dev/jakub/terminal/components/CodeBlock.java
Normal file
94
src/main/java/dev/jakub/terminal/components/CodeBlock.java
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code block with optional syntax highlighting. Use via {@link Terminal#code(String)}.
|
||||||
|
* Box around content. Optional line numbers. Keywords for java, json, xml, bash.
|
||||||
|
*/
|
||||||
|
public final class CodeBlock {
|
||||||
|
|
||||||
|
private static final Set<String> JAVA_KW = Set.of("public", "private", "protected", "class", "interface", "extends", "implements", "return", "void", "int", "long", "boolean", "if", "else", "for", "while", "new", "null", "true", "false", "import", "package", "static", "final", "String");
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final String language;
|
||||||
|
private final List<String> lines = new ArrayList<>();
|
||||||
|
private boolean lineNumbers;
|
||||||
|
|
||||||
|
public CodeBlock(String language, TerminalSupport support) {
|
||||||
|
this.language = language != null ? language.toLowerCase() : "";
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a line of code.
|
||||||
|
*/
|
||||||
|
public CodeBlock line(String text) {
|
||||||
|
lines.add(text != null ? text : "");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables line numbers.
|
||||||
|
*/
|
||||||
|
public CodeBlock lineNumbers() {
|
||||||
|
this.lineNumbers = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the code block to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
if (lines.isEmpty()) return;
|
||||||
|
int numWidth = lineNumbers ? String.valueOf(lines.size()).length() : 0;
|
||||||
|
int contentWidth = 0;
|
||||||
|
for (String s : lines) contentWidth = Math.max(contentWidth, s.length());
|
||||||
|
contentWidth = Math.min(contentWidth, support.getWidth() - 6 - numWidth);
|
||||||
|
int totalWidth = numWidth + contentWidth + 4;
|
||||||
|
String border = "+" + "-".repeat(totalWidth - 2) + "+";
|
||||||
|
if (support.isAnsiEnabled()) out.println(Ansi.BOLD + border + Ansi.RESET);
|
||||||
|
for (int i = 0; i < lines.size(); i++) {
|
||||||
|
String raw = lines.get(i);
|
||||||
|
String content = raw.length() > contentWidth ? raw.substring(0, contentWidth) : raw;
|
||||||
|
content = highlight(content, language);
|
||||||
|
String numPart = lineNumbers ? padLeft(String.valueOf(i + 1), numWidth) + " | " : "";
|
||||||
|
out.println("| " + numPart + padRight(content, contentWidth) + " |");
|
||||||
|
}
|
||||||
|
if (support.isAnsiEnabled()) out.println(Ansi.BOLD + border + Ansi.RESET);
|
||||||
|
else out.println(border);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String highlight(String line, String lang) {
|
||||||
|
if (!support.isAnsiEnabled()) return line;
|
||||||
|
if ("java".equals(lang)) {
|
||||||
|
for (String kw : JAVA_KW) {
|
||||||
|
line = line.replaceAll("\\b(" + Pattern.quote(kw) + ")\\b", Ansi.FG_BLUE + "$1" + Ansi.RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String padLeft(String s, int w) {
|
||||||
|
return s.length() >= w ? s : " ".repeat(w - s.length()) + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String padRight(String s, int w) {
|
||||||
|
return s.length() >= w ? s : s + " ".repeat(w - s.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main/java/dev/jakub/terminal/components/Columns.java
Normal file
104
src/main/java/dev/jakub/terminal/components/Columns.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-column layout. Use via {@link Terminal#columns()}.
|
||||||
|
* Distributes 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<String> 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<List<String>> columnLines = new ArrayList<>();
|
||||||
|
int maxLines = 0;
|
||||||
|
for (String text : columnTexts) {
|
||||||
|
if (columnLines.size() >= n) break;
|
||||||
|
String[] lines = (text != null ? text : "").split("\\n", -1);
|
||||||
|
List<String> padded = new ArrayList<>();
|
||||||
|
for (String line : lines) {
|
||||||
|
if (displayWidth(line) > colWidth) {
|
||||||
|
int cut = 0;
|
||||||
|
int dw = 0;
|
||||||
|
for (int i = 0; i < line.length() && dw < colWidth; i++) {
|
||||||
|
dw += (line.charAt(i) >= 0x1100 && Character.getType(line.charAt(i)) == Character.OTHER_LETTER) ? 2 : 1;
|
||||||
|
cut = i + 1;
|
||||||
|
}
|
||||||
|
line = line.substring(0, cut);
|
||||||
|
}
|
||||||
|
padded.add(padRight(line, colWidth));
|
||||||
|
}
|
||||||
|
columnLines.add(padded);
|
||||||
|
if (padded.size() > maxLines) maxLines = padded.size();
|
||||||
|
}
|
||||||
|
while (columnLines.size() < n) {
|
||||||
|
columnLines.add(new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int row = 0; row < maxLines; row++) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int c = 0; c < n; c++) {
|
||||||
|
if (c > 0) sb.append(COL_SEP);
|
||||||
|
List<String> lines = columnLines.get(c);
|
||||||
|
String line = row < lines.size() ? lines.get(row) : padRight("", colWidth);
|
||||||
|
sb.append(line);
|
||||||
|
}
|
||||||
|
out.println(sb.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int displayWidth(String s) {
|
||||||
|
int w = 0;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
w += (s.charAt(i) >= 0x1100 && Character.getType(s.charAt(i)) == Character.OTHER_LETTER) ? 2 : 1;
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String padRight(String s, int width) {
|
||||||
|
int w = displayWidth(s);
|
||||||
|
if (w >= width) return s;
|
||||||
|
return s + " ".repeat(width - w);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/main/java/dev/jakub/terminal/components/Diff.java
Normal file
80
src/main/java/dev/jakub/terminal/components/Diff.java
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line-by-line diff viewer. Use via {@link Terminal#diff()}.
|
||||||
|
* Red for removed, green for added.
|
||||||
|
*/
|
||||||
|
public final class Diff {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private String before = "";
|
||||||
|
private String after = "";
|
||||||
|
|
||||||
|
public Diff(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the "before" (old) text. Removed lines will be shown in red with "-".
|
||||||
|
*/
|
||||||
|
public Diff before(String text) {
|
||||||
|
this.before = text != null ? text : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the "after" (new) text. Added lines will be shown in green with "+".
|
||||||
|
*/
|
||||||
|
public Diff after(String text) {
|
||||||
|
this.after = text != null ? text : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints a simple line-by-line diff to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
String[] a = before.split("\\n", -1);
|
||||||
|
String[] b = after.split("\\n", -1);
|
||||||
|
int i = 0, j = 0;
|
||||||
|
while (i < a.length || j < b.length) {
|
||||||
|
if (i < a.length && j < b.length && a[i].equals(b[j])) {
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.FG_WHITE);
|
||||||
|
out.println(" " + a[i]);
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (j < b.length && (i >= a.length || !containsAt(a, b[j], i))) {
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.FG_GREEN);
|
||||||
|
out.println("+ " + b[j]);
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
|
||||||
|
j++;
|
||||||
|
} else if (i < a.length) {
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.FG_RED);
|
||||||
|
out.println("- " + a[i]);
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsAt(String[] a, String line, int from) {
|
||||||
|
for (int k = from; k < a.length; k++) {
|
||||||
|
if (a[k].equals(line)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/main/java/dev/jakub/terminal/components/Heatmap.java
Normal file
82
src/main/java/dev/jakub/terminal/components/Heatmap.java
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2D heatmap: values mapped to intensity blocks. Use via {@link Terminal#heatmap()}.
|
||||||
|
* UTF-8: space, ░, ▒, ▓, █. ASCII fallback: ., +, # etc. Auto-scale to max value.
|
||||||
|
*/
|
||||||
|
public final class Heatmap {
|
||||||
|
|
||||||
|
private static final String[] UTF8_BLOCKS = {" ", "░", "▒", "▓", "█"};
|
||||||
|
private static final String[] ASCII_BLOCKS = {".", "-", "+", "#", "@"};
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<Row> rows = new ArrayList<>();
|
||||||
|
private int globalMax = -1;
|
||||||
|
|
||||||
|
public Heatmap(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a row with label and numeric values.
|
||||||
|
*/
|
||||||
|
public Heatmap row(String label, int... values) {
|
||||||
|
if (label == null) label = "";
|
||||||
|
int[] v = values != null ? values : new int[0];
|
||||||
|
for (int x : v) {
|
||||||
|
if (x > globalMax) globalMax = x;
|
||||||
|
}
|
||||||
|
rows.add(new Row(label, v));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the heatmap to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
if (rows.isEmpty()) return;
|
||||||
|
int maxVal = globalMax <= 0 ? 1 : globalMax;
|
||||||
|
boolean ascii = !support.isUtf8Symbols();
|
||||||
|
String[] blocks = ascii ? ASCII_BLOCKS : UTF8_BLOCKS;
|
||||||
|
int labelWidth = 0;
|
||||||
|
for (Row r : rows) {
|
||||||
|
if (r.label.length() > labelWidth) labelWidth = r.label.length();
|
||||||
|
}
|
||||||
|
labelWidth = Math.max(3, labelWidth);
|
||||||
|
for (Row r : rows) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String pad = r.label.length() < labelWidth ? " ".repeat(labelWidth - r.label.length()) : "";
|
||||||
|
sb.append(pad).append(r.label).append(" ");
|
||||||
|
for (int i = 0; i < r.values.length; i++) {
|
||||||
|
int idx = (int) Math.round((r.values[i] * (blocks.length - 1)) / (double) maxVal);
|
||||||
|
idx = Math.max(0, Math.min(idx, blocks.length - 1));
|
||||||
|
sb.append(blocks[idx]);
|
||||||
|
}
|
||||||
|
out.println(sb.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Row {
|
||||||
|
final String label;
|
||||||
|
final int[] values;
|
||||||
|
|
||||||
|
Row(String label, int[] values) {
|
||||||
|
this.label = label;
|
||||||
|
this.values = values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/main/java/dev/jakub/terminal/components/KeyValue.java
Normal file
87
src/main/java/dev/jakub/terminal/components/KeyValue.java
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key-value list with aligned labels. Use via {@link Terminal#keyValue()}.
|
||||||
|
*/
|
||||||
|
public final class KeyValue {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<String> keys = new ArrayList<>();
|
||||||
|
private final List<String> values = new ArrayList<>();
|
||||||
|
private String separator = ": ";
|
||||||
|
private int labelWidth = -1;
|
||||||
|
|
||||||
|
public KeyValue(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a row (label, value).
|
||||||
|
*/
|
||||||
|
public KeyValue row(String key, String value) {
|
||||||
|
keys.add(key != null ? key : "");
|
||||||
|
values.add(value != null ? value : "");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the separator between key and value (default ": ").
|
||||||
|
*/
|
||||||
|
public KeyValue separator(String sep) {
|
||||||
|
this.separator = sep != null ? sep : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a fixed width for the key column (auto if not set).
|
||||||
|
*/
|
||||||
|
public KeyValue labelWidth(int width) {
|
||||||
|
this.labelWidth = width;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the key-value block to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
if (keys.isEmpty()) return;
|
||||||
|
int kw = labelWidth >= 0 ? labelWidth : keys.stream().mapToInt(this::displayWidth).max().orElse(0);
|
||||||
|
kw = Math.max(1, kw);
|
||||||
|
for (int i = 0; i < keys.size(); i++) {
|
||||||
|
String k = keys.get(i);
|
||||||
|
String v = i < values.size() ? values.get(i) : "";
|
||||||
|
int pad = kw - displayWidth(k);
|
||||||
|
String padded = k + (pad > 0 ? " ".repeat(pad) : "");
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(Ansi.BOLD + padded + Ansi.RESET + separator + v);
|
||||||
|
} else {
|
||||||
|
out.print(padded + separator + v);
|
||||||
|
}
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int displayWidth(String s) {
|
||||||
|
int len = 0;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
len += (c >= 0x1100 && Character.getType(c) == Character.OTHER_LETTER) ? 2 : 1;
|
||||||
|
}
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/main/java/dev/jakub/terminal/components/Log.java
Normal file
119
src/main/java/dev/jakub/terminal/components/Log.java
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log viewer with level labels and colors. Use via {@link Terminal#log()}.
|
||||||
|
* INFO=cyan, WARN=yellow, ERROR=red, DEBUG=gray.
|
||||||
|
*/
|
||||||
|
public final class Log {
|
||||||
|
|
||||||
|
private static final int LABEL_WIDTH = 6;
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<Entry> entries = new ArrayList<>();
|
||||||
|
private boolean withTimestamp;
|
||||||
|
|
||||||
|
public Log(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an INFO line (cyan).
|
||||||
|
*/
|
||||||
|
public Log info(String message) {
|
||||||
|
entries.add(new Entry(Level.INFO, message));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a WARN line (yellow).
|
||||||
|
*/
|
||||||
|
public Log warn(String message) {
|
||||||
|
entries.add(new Entry(Level.WARN, message));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an ERROR line (red).
|
||||||
|
*/
|
||||||
|
public Log error(String message) {
|
||||||
|
entries.add(new Entry(Level.ERROR, message));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a DEBUG line (gray).
|
||||||
|
*/
|
||||||
|
public Log debug(String message) {
|
||||||
|
entries.add(new Entry(Level.DEBUG, message));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables timestamp prefix on each line.
|
||||||
|
*/
|
||||||
|
public Log withTimestamp() {
|
||||||
|
this.withTimestamp = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints all log entries to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
|
||||||
|
for (Entry e : entries) {
|
||||||
|
String prefix = withTimestamp ? "[" + fmt.format(Instant.now()) + "] " : "";
|
||||||
|
String label = "[" + e.level.name() + "]";
|
||||||
|
while (label.length() < LABEL_WIDTH) label += " ";
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(e.level.ansi);
|
||||||
|
}
|
||||||
|
out.print(prefix + label + " " + e.message);
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(Ansi.RESET);
|
||||||
|
}
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Entry {
|
||||||
|
final Level level;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
Entry(Level level, String message) {
|
||||||
|
this.level = level;
|
||||||
|
this.message = message != null ? message : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Level {
|
||||||
|
INFO(Ansi.FG_CYAN),
|
||||||
|
WARN(Ansi.FG_YELLOW),
|
||||||
|
ERROR(Ansi.FG_RED),
|
||||||
|
DEBUG(Ansi.FG_BRIGHT_BLACK);
|
||||||
|
|
||||||
|
final String ansi;
|
||||||
|
|
||||||
|
Level(String ansi) {
|
||||||
|
this.ansi = ansi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/dev/jakub/terminal/components/Markdown.java
Normal file
65
src/main/java/dev/jakub/terminal/components/Markdown.java
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Markdown renderer for terminal. Use via {@link Terminal#markdown(String)}.
|
||||||
|
* Supports # headings, **bold**, *italic*, `code`, --- rules, - list.
|
||||||
|
*/
|
||||||
|
public final class Markdown {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final String source;
|
||||||
|
|
||||||
|
public Markdown(String source, TerminalSupport support) {
|
||||||
|
this.source = source != null ? source : "";
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders and prints to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
String[] lines = source.split("\\n", -1);
|
||||||
|
for (String line : lines) {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.startsWith("#")) {
|
||||||
|
int level = 0;
|
||||||
|
while (level < line.length() && line.charAt(level) == '#') level++;
|
||||||
|
String rest = line.substring(level).trim();
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.BOLD);
|
||||||
|
out.println(rest);
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
|
||||||
|
} else if (line.equals("---") || line.startsWith("---")) {
|
||||||
|
String rule = support.isUtf8Symbols() ? "─" : "-";
|
||||||
|
out.println(rule.repeat(Math.min(40, support.getWidth())));
|
||||||
|
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||||
|
String bullet = support.isUtf8Symbols() ? " • " : " - ";
|
||||||
|
out.println(bullet + renderInline(line.substring(2)));
|
||||||
|
} else {
|
||||||
|
out.println(renderInline(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String renderInline(String s) {
|
||||||
|
if (!support.isAnsiEnabled()) {
|
||||||
|
return s.replaceAll("\\*\\*(.+?)\\*\\*", "$1").replaceAll("\\*(.+?)\\*", "$1").replaceAll("`(.+?)`", "$1");
|
||||||
|
}
|
||||||
|
s = s.replaceAll("\\*\\*(.+?)\\*\\*", Ansi.BOLD + "$1" + Ansi.RESET);
|
||||||
|
s = s.replaceAll("\\*(.+?)\\*", Ansi.ITALIC + "$1" + Ansi.RESET);
|
||||||
|
s = s.replaceAll("`(.+?)`", Ansi.FG_CYAN + "$1" + Ansi.RESET);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/main/java/dev/jakub/terminal/components/Rule.java
Normal file
70
src/main/java/dev/jakub/terminal/components/Rule.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizontal rule/separator line. Use via {@link Terminal#rule()}.
|
||||||
|
*/
|
||||||
|
public final class Rule {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final int width;
|
||||||
|
private char character = '-';
|
||||||
|
private String prefix = "";
|
||||||
|
private String suffix = "";
|
||||||
|
|
||||||
|
public Rule(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
this.width = Math.min(support.getWidth(), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the given character for the line (e.g. '-', '=', '*').
|
||||||
|
*/
|
||||||
|
public Rule character(char c) {
|
||||||
|
this.character = c;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses '=' for the line.
|
||||||
|
*/
|
||||||
|
public Rule doubles() {
|
||||||
|
return character('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional text before the line.
|
||||||
|
*/
|
||||||
|
public Rule prefix(String prefix) {
|
||||||
|
this.prefix = prefix != null ? prefix : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional text after the line.
|
||||||
|
*/
|
||||||
|
public Rule suffix(String suffix) {
|
||||||
|
this.suffix = suffix != null ? suffix : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the rule to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
int lineLen = Math.max(0, width - prefix.length() - suffix.length());
|
||||||
|
String line = prefix + String.valueOf(character).repeat(Math.max(0, lineLen)) + suffix;
|
||||||
|
out.println(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the rule to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/main/java/dev/jakub/terminal/components/Steps.java
Normal file
108
src/main/java/dev/jakub/terminal/components/Steps.java
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step / wizard display. Use via {@link Terminal#steps()}.
|
||||||
|
* Status: DONE, RUNNING, PENDING, FAILED. Icons with ASCII fallback.
|
||||||
|
*/
|
||||||
|
public final class Steps {
|
||||||
|
|
||||||
|
private static final String ICON_DONE_UTF8 = "✅";
|
||||||
|
private static final String ICON_RUNNING_UTF8 = "⏳";
|
||||||
|
private static final String ICON_PENDING_UTF8 = "○";
|
||||||
|
private static final String ICON_FAILED_UTF8 = "❌";
|
||||||
|
private static final String ICON_DONE_ASCII = "[x]";
|
||||||
|
private static final String ICON_RUNNING_ASCII = "[~]";
|
||||||
|
private static final String ICON_PENDING_ASCII = "[ ]";
|
||||||
|
private static final String ICON_FAILED_ASCII = "[!]";
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<StepEntry> steps = new ArrayList<>();
|
||||||
|
|
||||||
|
public Steps(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a step with status.
|
||||||
|
*/
|
||||||
|
public Steps step(String label, Status status) {
|
||||||
|
steps.add(new StepEntry(label != null ? label : "", status != null ? status : Status.PENDING));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints all steps to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
boolean ascii = !support.isUtf8Symbols();
|
||||||
|
for (StepEntry e : steps) {
|
||||||
|
String icon = icon(e.status, ascii);
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(colorFor(e.status));
|
||||||
|
}
|
||||||
|
out.print(icon + " ");
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(Ansi.RESET);
|
||||||
|
}
|
||||||
|
out.println(e.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String icon(Status s, boolean ascii) {
|
||||||
|
if (ascii) {
|
||||||
|
return switch (s) {
|
||||||
|
case DONE -> ICON_DONE_ASCII;
|
||||||
|
case RUNNING -> ICON_RUNNING_ASCII;
|
||||||
|
case PENDING -> ICON_PENDING_ASCII;
|
||||||
|
case FAILED -> ICON_FAILED_ASCII;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return switch (s) {
|
||||||
|
case DONE -> ICON_DONE_UTF8;
|
||||||
|
case RUNNING -> ICON_RUNNING_UTF8;
|
||||||
|
case PENDING -> ICON_PENDING_UTF8;
|
||||||
|
case FAILED -> ICON_FAILED_UTF8;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String colorFor(Status s) {
|
||||||
|
return switch (s) {
|
||||||
|
case DONE -> Ansi.FG_GREEN;
|
||||||
|
case RUNNING -> Ansi.FG_YELLOW;
|
||||||
|
case PENDING -> Ansi.FG_WHITE;
|
||||||
|
case FAILED -> Ansi.FG_RED;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class StepEntry {
|
||||||
|
final String label;
|
||||||
|
final Status status;
|
||||||
|
|
||||||
|
StepEntry(String label, Status status) {
|
||||||
|
this.label = label;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step status for {@link Steps}.
|
||||||
|
*/
|
||||||
|
public enum Status {
|
||||||
|
DONE, RUNNING, PENDING, FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/main/java/dev/jakub/terminal/components/SysInfo.java
Normal file
53
src/main/java/dev/jakub/terminal/components/SysInfo.java
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System info widget. Use via {@link Terminal#sysinfo()}.
|
||||||
|
* OS, JVM, CPU, RAM from System.getProperty() and Runtime.getRuntime().
|
||||||
|
*/
|
||||||
|
public final class SysInfo {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
|
||||||
|
public SysInfo(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints system info as a key-value block to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
Runtime rt = Runtime.getRuntime();
|
||||||
|
long maxMem = rt.maxMemory() == Long.MAX_VALUE ? rt.totalMemory() : rt.maxMemory();
|
||||||
|
long usedMem = rt.totalMemory() - rt.freeMemory();
|
||||||
|
String os = System.getProperty("os.name", "?") + " " + System.getProperty("os.version", "");
|
||||||
|
String jvm = System.getProperty("java.vm.name", "?") + " " + System.getProperty("java.version", "");
|
||||||
|
int cores = rt.availableProcessors();
|
||||||
|
String ram = formatBytes(usedMem) + " / " + formatBytes(maxMem) + " used";
|
||||||
|
|
||||||
|
KeyValue kv = new KeyValue(support);
|
||||||
|
kv.row("OS", os.trim());
|
||||||
|
kv.row("JVM", jvm.trim());
|
||||||
|
kv.row("CPU", cores + " cores");
|
||||||
|
kv.row("RAM", ram);
|
||||||
|
kv.print(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatBytes(long bytes) {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
|
||||||
|
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/main/java/dev/jakub/terminal/components/Table.java
Normal file
141
src/main/java/dev/jakub/terminal/components/Table.java
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for ASCII tables. Use via {@link Terminal#table()}.
|
||||||
|
*/
|
||||||
|
public final class Table {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<String> header = new ArrayList<>();
|
||||||
|
private final List<List<String>> rows = new ArrayList<>();
|
||||||
|
private String borderChar = "|";
|
||||||
|
private String separatorChar = "-";
|
||||||
|
private String cornerChar = "+";
|
||||||
|
|
||||||
|
public Table(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the table header columns.
|
||||||
|
*/
|
||||||
|
public Table header(String... columns) {
|
||||||
|
header.clear();
|
||||||
|
for (String c : columns) {
|
||||||
|
header.add(c != null ? c : "");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a data row.
|
||||||
|
*/
|
||||||
|
public Table row(String... cells) {
|
||||||
|
List<String> row = new ArrayList<>();
|
||||||
|
for (String c : cells) {
|
||||||
|
row.add(c != null ? c : "");
|
||||||
|
}
|
||||||
|
rows.add(row);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the table to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
int cols = header.isEmpty() ? (rows.isEmpty() ? 0 : rows.get(0).size()) : header.size();
|
||||||
|
if (cols == 0) return;
|
||||||
|
|
||||||
|
int[] widths = new int[cols];
|
||||||
|
for (int i = 0; i < header.size() && i < cols; i++) {
|
||||||
|
widths[i] = Math.max(widths[i], cellWidth(header.get(i)));
|
||||||
|
}
|
||||||
|
for (List<String> row : rows) {
|
||||||
|
for (int i = 0; i < row.size() && i < cols; i++) {
|
||||||
|
widths[i] = Math.max(widths[i], cellWidth(row.get(i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < cols; i++) {
|
||||||
|
widths[i] = Math.max(1, widths[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sep = new StringBuilder(cornerChar);
|
||||||
|
for (int w : widths) {
|
||||||
|
sep.append(repeat(separatorChar, w + 2)).append(cornerChar);
|
||||||
|
}
|
||||||
|
String sepLine = sep.toString();
|
||||||
|
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.println(Ansi.BOLD + sepLine + Ansi.RESET);
|
||||||
|
} else {
|
||||||
|
out.println(sepLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!header.isEmpty()) {
|
||||||
|
printRow(out, header, widths, true);
|
||||||
|
out.println(sepLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (List<String> row : rows) {
|
||||||
|
List<String> padded = new ArrayList<>();
|
||||||
|
for (int i = 0; i < cols; i++) {
|
||||||
|
padded.add(i < row.size() ? row.get(i) : "");
|
||||||
|
}
|
||||||
|
printRow(out, padded, widths, false);
|
||||||
|
}
|
||||||
|
out.println(sepLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the table to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void printRow(PrintStream out, List<String> row, int[] widths, boolean bold) {
|
||||||
|
if (support.isAnsiEnabled() && bold) {
|
||||||
|
out.print(Ansi.BOLD);
|
||||||
|
}
|
||||||
|
out.print(borderChar);
|
||||||
|
for (int i = 0; i < row.size(); i++) {
|
||||||
|
String cell = row.get(i);
|
||||||
|
int w = i < widths.length ? widths[i] : cellWidth(cell);
|
||||||
|
out.print(" ");
|
||||||
|
out.print(pad(cell, w));
|
||||||
|
out.print(" ");
|
||||||
|
out.print(borderChar);
|
||||||
|
}
|
||||||
|
out.println();
|
||||||
|
if (support.isAnsiEnabled() && bold) {
|
||||||
|
out.print(Ansi.RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int cellWidth(String s) {
|
||||||
|
int len = 0;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
len += (c >= 0x1100 && Character.getType(c) == Character.OTHER_LETTER) ? 2 : 1;
|
||||||
|
}
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pad(String s, int width) {
|
||||||
|
int w = cellWidth(s);
|
||||||
|
if (w >= width) return s;
|
||||||
|
return s + " ".repeat(width - w);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String repeat(String s, int n) {
|
||||||
|
return s.repeat(Math.max(0, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/main/java/dev/jakub/terminal/components/TableOfContents.java
Normal file
110
src/main/java/dev/jakub/terminal/components/TableOfContents.java
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table of contents. Use via {@link Terminal#toc()}.
|
||||||
|
* Optional sub-sections. Renders with numbers and rule.
|
||||||
|
*/
|
||||||
|
public final class TableOfContents {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<Section> sections = new ArrayList<>();
|
||||||
|
private static final String TITLE = "Table of Contents";
|
||||||
|
|
||||||
|
public TableOfContents(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a section. Returns a sub-builder for optional sub-sections.
|
||||||
|
*/
|
||||||
|
public TocSection section(String title) {
|
||||||
|
Section s = new Section(title != null ? title : "", new ArrayList<>());
|
||||||
|
sections.add(s);
|
||||||
|
return new TocSection(s, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the TOC to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
if (support.isAnsiEnabled()) out.println(Ansi.BOLD + TITLE + Ansi.RESET);
|
||||||
|
else out.println(TITLE);
|
||||||
|
String ruleChar = support.isUtf8Symbols() ? "─" : "-";
|
||||||
|
out.println(ruleChar.repeat(Math.min(TITLE.length(), support.getWidth())));
|
||||||
|
int num = 1;
|
||||||
|
for (Section s : sections) {
|
||||||
|
out.println(" " + num + ". " + s.title);
|
||||||
|
for (String sub : s.subs) {
|
||||||
|
out.println(" - " + sub);
|
||||||
|
}
|
||||||
|
num++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Section {
|
||||||
|
final String title;
|
||||||
|
final List<String> subs;
|
||||||
|
|
||||||
|
Section(String title, List<String> subs) {
|
||||||
|
this.title = title;
|
||||||
|
this.subs = subs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder for adding sub-sections to a TOC section.
|
||||||
|
*/
|
||||||
|
public static final class TocSection {
|
||||||
|
private final Section section;
|
||||||
|
private final TableOfContents toc;
|
||||||
|
|
||||||
|
TocSection(Section section, TableOfContents toc) {
|
||||||
|
this.section = section;
|
||||||
|
this.toc = toc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a sub-section.
|
||||||
|
*/
|
||||||
|
public TocSection sub(String title) {
|
||||||
|
section.subs.add(title != null ? title : "");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds another top-level section. Returns builder for that section.
|
||||||
|
*/
|
||||||
|
public TocSection section(String title) {
|
||||||
|
return toc.section(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the TOC.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
toc.print(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
toc.print();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/main/java/dev/jakub/terminal/components/Timeline.java
Normal file
76
src/main/java/dev/jakub/terminal/components/Timeline.java
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical timeline with events. Use via {@link Terminal#timeline()}.
|
||||||
|
* Dots and connectors; with ANSI dots colored cyan/green.
|
||||||
|
*/
|
||||||
|
public final class Timeline {
|
||||||
|
|
||||||
|
private static final String DOT_UTF8 = "●";
|
||||||
|
private static final String PIPE_UTF8 = "│";
|
||||||
|
private static final String DOT_ASCII = "*";
|
||||||
|
private static final String PIPE_ASCII = "|";
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<Event> events = new ArrayList<>();
|
||||||
|
|
||||||
|
public Timeline(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an event (label + description).
|
||||||
|
*/
|
||||||
|
public Timeline event(String label, String description) {
|
||||||
|
events.add(new Event(label != null ? label : "", description != null ? description : ""));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the timeline to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
boolean ascii = !support.isUtf8Symbols();
|
||||||
|
String dot = ascii ? DOT_ASCII : DOT_UTF8;
|
||||||
|
String pipe = ascii ? PIPE_ASCII : PIPE_UTF8;
|
||||||
|
for (int i = 0; i < events.size(); i++) {
|
||||||
|
Event e = events.get(i);
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(i == events.size() - 1 ? Ansi.FG_GREEN : Ansi.FG_CYAN);
|
||||||
|
}
|
||||||
|
out.print(dot + " ");
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
|
||||||
|
out.println(e.label + " " + e.description);
|
||||||
|
if (i < events.size() - 1) {
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.FG_CYAN);
|
||||||
|
out.println(pipe);
|
||||||
|
if (support.isAnsiEnabled()) out.print(Ansi.RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Event {
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
Event(String label, String description) {
|
||||||
|
this.label = label;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/main/java/dev/jakub/terminal/components/Tree.java
Normal file
116
src/main/java/dev/jakub/terminal/components/Tree.java
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package dev.jakub.terminal.components;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
import dev.jakub.terminal.internal.TreeNode;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree view for directory-like structures. Use via {@link Terminal#tree()}.
|
||||||
|
* Supports unlimited nesting. Use {@link #node(String)} for root, then
|
||||||
|
* {@link TreeBuilder#child(String)} and {@link TreeBuilder#end()} to build.
|
||||||
|
*/
|
||||||
|
public final class Tree {
|
||||||
|
|
||||||
|
private static final String UTF8_BRANCH = "├── ";
|
||||||
|
private static final String UTF8_LAST = "└── ";
|
||||||
|
private static final String UTF8_PIPE = "│ ";
|
||||||
|
private static final String ASCII_BRANCH = "+-- ";
|
||||||
|
private static final String ASCII_LAST = "\\-- ";
|
||||||
|
private static final String ASCII_PIPE = "| ";
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private TreeNode root;
|
||||||
|
|
||||||
|
public Tree(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the root node label and returns a builder for adding children.
|
||||||
|
* Use {@link TreeBuilder#child(String)} to add children and {@link TreeBuilder#end()} to go back to parent.
|
||||||
|
*/
|
||||||
|
public TreeBuilder node(String label) {
|
||||||
|
this.root = new TreeNode(label);
|
||||||
|
return new TreeBuilder(root, null, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the tree to the given stream.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
if (root == null) return;
|
||||||
|
boolean useAscii = !support.isUtf8Symbols();
|
||||||
|
String branch = useAscii ? ASCII_BRANCH : UTF8_BRANCH;
|
||||||
|
String last = useAscii ? ASCII_LAST : UTF8_LAST;
|
||||||
|
String pipe = useAscii ? ASCII_PIPE : UTF8_PIPE;
|
||||||
|
out.println(root.label);
|
||||||
|
printChildren(out, root.children, "", branch, last, pipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the tree to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void printChildren(PrintStream out, List<TreeNode> children, String prefix, String branch, String last, String pipe) {
|
||||||
|
for (int i = 0; i < children.size(); i++) {
|
||||||
|
TreeNode n = children.get(i);
|
||||||
|
boolean isLast = (i == children.size() - 1);
|
||||||
|
String conn = isLast ? last : branch;
|
||||||
|
out.println(prefix + conn + n.label);
|
||||||
|
String nextPrefix = prefix + (isLast ? " " : pipe);
|
||||||
|
printChildren(out, n.children, nextPrefix, branch, last, pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalSupport getSupport() {
|
||||||
|
return support;
|
||||||
|
}
|
||||||
|
|
||||||
|
TreeNode getRoot() {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder for adding children to a tree node. {@link #child(String)} adds a child and returns a builder for it;
|
||||||
|
* {@link #end()} returns the parent builder (or the tree when at root).
|
||||||
|
*/
|
||||||
|
public static final class TreeBuilder {
|
||||||
|
private final TreeNode node;
|
||||||
|
private final TreeBuilder parent;
|
||||||
|
private final Tree tree;
|
||||||
|
|
||||||
|
TreeBuilder(TreeNode node, TreeBuilder parent, Tree tree) {
|
||||||
|
this.node = node;
|
||||||
|
this.parent = parent;
|
||||||
|
this.tree = tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a child and returns a builder for that child (for nesting). */
|
||||||
|
public TreeBuilder child(String label) {
|
||||||
|
TreeNode child = new TreeNode(label);
|
||||||
|
node.children.add(child);
|
||||||
|
return new TreeBuilder(child, this, tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the parent builder, or this when at root (so you can call {@link #child(String)} or {@link #print()}). */
|
||||||
|
public TreeBuilder end() {
|
||||||
|
return parent != null ? parent : this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the tree. */
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
tree.print(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the tree to stdout. */
|
||||||
|
public void print() {
|
||||||
|
tree.print();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/dev/jakub/terminal/core/Ansi.java
Normal file
65
src/main/java/dev/jakub/terminal/core/Ansi.java
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package dev.jakub.terminal.core;
|
||||||
|
|
||||||
|
public final class Ansi {
|
||||||
|
|
||||||
|
/** Reset all attributes */
|
||||||
|
public static final String RESET = "\u001B[0m";
|
||||||
|
|
||||||
|
/** Bold/bright */
|
||||||
|
public static final String BOLD = "\u001B[1m";
|
||||||
|
/** Dim */
|
||||||
|
public static final String DIM = "\u001B[2m";
|
||||||
|
/** Italic */
|
||||||
|
public static final String ITALIC = "\u001B[3m";
|
||||||
|
/** Underline */
|
||||||
|
public static final String UNDERLINE = "\u001B[4m";
|
||||||
|
|
||||||
|
/** Black foreground */
|
||||||
|
public static final String FG_BLACK = "\u001B[30m";
|
||||||
|
/** Red foreground */
|
||||||
|
public static final String FG_RED = "\u001B[31m";
|
||||||
|
/** Green foreground */
|
||||||
|
public static final String FG_GREEN = "\u001B[32m";
|
||||||
|
/** Yellow foreground */
|
||||||
|
public static final String FG_YELLOW = "\u001B[33m";
|
||||||
|
/** Blue foreground */
|
||||||
|
public static final String FG_BLUE = "\u001B[34m";
|
||||||
|
/** Magenta foreground */
|
||||||
|
public static final String FG_MAGENTA = "\u001B[35m";
|
||||||
|
/** Cyan foreground */
|
||||||
|
public static final String FG_CYAN = "\u001B[36m";
|
||||||
|
/** White foreground */
|
||||||
|
public static final String FG_WHITE = "\u001B[37m";
|
||||||
|
|
||||||
|
/** Bright black (gray) foreground */
|
||||||
|
public static final String FG_BRIGHT_BLACK = "\u001B[90m";
|
||||||
|
/** Bright red foreground */
|
||||||
|
public static final String FG_BRIGHT_RED = "\u001B[91m";
|
||||||
|
/** Bright green foreground */
|
||||||
|
public static final String FG_BRIGHT_GREEN = "\u001B[92m";
|
||||||
|
/** Bright yellow foreground */
|
||||||
|
public static final String FG_BRIGHT_YELLOW = "\u001B[93m";
|
||||||
|
/** Bright blue foreground */
|
||||||
|
public static final String FG_BRIGHT_BLUE = "\u001B[94m";
|
||||||
|
/** Bright magenta foreground */
|
||||||
|
public static final String FG_BRIGHT_MAGENTA = "\u001B[95m";
|
||||||
|
/** Bright cyan foreground */
|
||||||
|
public static final String FG_BRIGHT_CYAN = "\u001B[96m";
|
||||||
|
/** Bright white foreground */
|
||||||
|
public static final String FG_BRIGHT_WHITE = "\u001B[97m";
|
||||||
|
|
||||||
|
/** Hide cursor */
|
||||||
|
public static final String HIDE_CURSOR = "\u001B[?25l";
|
||||||
|
/** Show cursor */
|
||||||
|
public static final String SHOW_CURSOR = "\u001B[?25h";
|
||||||
|
/** Move cursor up one line */
|
||||||
|
public static final String CURSOR_UP = "\u001B[1A";
|
||||||
|
/** Move cursor down one line */
|
||||||
|
public static final String CURSOR_DOWN = "\u001B[1B";
|
||||||
|
/** Carriage return (start of line) */
|
||||||
|
public static final String CARRIAGE_RETURN = "\r";
|
||||||
|
/** Clear line */
|
||||||
|
public static final String ERASE_LINE = "\u001B[2K";
|
||||||
|
|
||||||
|
private Ansi() {}
|
||||||
|
}
|
||||||
38
src/main/java/dev/jakub/terminal/core/Color.java
Normal file
38
src/main/java/dev/jakub/terminal/core/Color.java
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package dev.jakub.terminal.core;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard terminal colors for use with {@link Terminal#print(String)} and other components.
|
||||||
|
*/
|
||||||
|
public enum Color {
|
||||||
|
BLACK(Ansi.FG_BLACK),
|
||||||
|
RED(Ansi.FG_RED),
|
||||||
|
GREEN(Ansi.FG_GREEN),
|
||||||
|
YELLOW(Ansi.FG_YELLOW),
|
||||||
|
BLUE(Ansi.FG_BLUE),
|
||||||
|
MAGENTA(Ansi.FG_MAGENTA),
|
||||||
|
CYAN(Ansi.FG_CYAN),
|
||||||
|
WHITE(Ansi.FG_WHITE),
|
||||||
|
BRIGHT_BLACK(Ansi.FG_BRIGHT_BLACK),
|
||||||
|
BRIGHT_RED(Ansi.FG_BRIGHT_RED),
|
||||||
|
BRIGHT_GREEN(Ansi.FG_BRIGHT_GREEN),
|
||||||
|
BRIGHT_YELLOW(Ansi.FG_BRIGHT_YELLOW),
|
||||||
|
BRIGHT_BLUE(Ansi.FG_BRIGHT_BLUE),
|
||||||
|
BRIGHT_MAGENTA(Ansi.FG_BRIGHT_MAGENTA),
|
||||||
|
BRIGHT_CYAN(Ansi.FG_BRIGHT_CYAN),
|
||||||
|
BRIGHT_WHITE(Ansi.FG_BRIGHT_WHITE);
|
||||||
|
|
||||||
|
private final String ansiCode;
|
||||||
|
|
||||||
|
Color(String ansiCode) {
|
||||||
|
this.ansiCode = ansiCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ANSI escape sequence for this color (foreground).
|
||||||
|
*/
|
||||||
|
public String ansiCode() {
|
||||||
|
return ansiCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/main/java/dev/jakub/terminal/core/StyledText.java
Normal file
96
src/main/java/dev/jakub/terminal/core/StyledText.java
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package dev.jakub.terminal.core;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for colored and styled terminal output.
|
||||||
|
* Use via {@link Terminal#print(String)}.
|
||||||
|
*/
|
||||||
|
public final class StyledText {
|
||||||
|
|
||||||
|
private final String text;
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private Color color;
|
||||||
|
private boolean bold;
|
||||||
|
private boolean dim;
|
||||||
|
private boolean underline;
|
||||||
|
|
||||||
|
public StyledText(String text, TerminalSupport support) {
|
||||||
|
this.text = text != null ? text : "";
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the given foreground color.
|
||||||
|
*/
|
||||||
|
public StyledText color(Color color) {
|
||||||
|
this.color = color;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies bold styling.
|
||||||
|
*/
|
||||||
|
public StyledText bold() {
|
||||||
|
this.bold = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies dim styling.
|
||||||
|
*/
|
||||||
|
public StyledText dim() {
|
||||||
|
this.dim = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies underline styling.
|
||||||
|
*/
|
||||||
|
public StyledText underline() {
|
||||||
|
this.underline = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the styled text to the given stream without a newline.
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (bold) sb.append(Ansi.BOLD);
|
||||||
|
if (dim) sb.append(Ansi.DIM);
|
||||||
|
if (underline) sb.append(Ansi.UNDERLINE);
|
||||||
|
if (color != null) sb.append(color.ansiCode());
|
||||||
|
sb.append(text);
|
||||||
|
sb.append(Ansi.RESET);
|
||||||
|
out.print(sb.toString());
|
||||||
|
} else {
|
||||||
|
out.print(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the styled text to stdout without a newline.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the styled text to the given stream followed by a newline.
|
||||||
|
*/
|
||||||
|
public void println(PrintStream out) {
|
||||||
|
print(out);
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the styled text to stdout followed by a newline.
|
||||||
|
*/
|
||||||
|
public void println() {
|
||||||
|
println(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/main/java/dev/jakub/terminal/core/TerminalSupport.java
Normal file
110
src/main/java/dev/jakub/terminal/core/TerminalSupport.java
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package dev.jakub.terminal.core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects terminal capabilities: width and ANSI support.
|
||||||
|
* Graceful fallback when ANSI is not supported (e.g. Windows CMD).
|
||||||
|
*/
|
||||||
|
public final class TerminalSupport {
|
||||||
|
|
||||||
|
private static final int DEFAULT_WIDTH = 80;
|
||||||
|
private static final String COLUMNS_ENV = "COLUMNS";
|
||||||
|
private static final String TERM_ENV = "TERM";
|
||||||
|
private static final String ANSI_PROP = "dev.jakub.terminal.ansi";
|
||||||
|
private static final String UTF8_PROP = "dev.jakub.terminal.utf8";
|
||||||
|
|
||||||
|
private final boolean ansiEnabled;
|
||||||
|
private final int width;
|
||||||
|
private final boolean utf8Symbols;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates support with auto-detected capabilities.
|
||||||
|
*/
|
||||||
|
public TerminalSupport() {
|
||||||
|
this.ansiEnabled = detectAnsiSupport();
|
||||||
|
this.width = detectTerminalWidth();
|
||||||
|
this.utf8Symbols = detectUtf8Symbols();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates support with explicit ANSI and width (for testing).
|
||||||
|
*/
|
||||||
|
public TerminalSupport(boolean ansiEnabled, int width) {
|
||||||
|
this.ansiEnabled = ansiEnabled;
|
||||||
|
this.width = Math.max(10, width);
|
||||||
|
this.utf8Symbols = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates support with explicit ANSI, width and UTF-8 symbols (for testing).
|
||||||
|
*/
|
||||||
|
public TerminalSupport(boolean ansiEnabled, int width, boolean utf8Symbols) {
|
||||||
|
this.ansiEnabled = ansiEnabled;
|
||||||
|
this.width = Math.max(10, width);
|
||||||
|
this.utf8Symbols = utf8Symbols;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean detectUtf8Symbols() {
|
||||||
|
String force = System.getProperty(UTF8_PROP);
|
||||||
|
if ("true".equalsIgnoreCase(force)) return true;
|
||||||
|
if ("false".equalsIgnoreCase(force)) return false;
|
||||||
|
String os = System.getProperty("os.name", "").toLowerCase();
|
||||||
|
if (os.contains("win")) return false;
|
||||||
|
String encoding = System.getProperty("sun.stdout.encoding", System.getProperty("file.encoding", ""));
|
||||||
|
return encoding.toUpperCase().contains("UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean detectAnsiSupport() {
|
||||||
|
String force = System.getProperty(ANSI_PROP);
|
||||||
|
if ("true".equalsIgnoreCase(force)) return true;
|
||||||
|
if ("false".equalsIgnoreCase(force)) return false;
|
||||||
|
|
||||||
|
String os = System.getProperty("os.name", "").toLowerCase();
|
||||||
|
if (os.contains("win")) {
|
||||||
|
String ver = System.getProperty("os.version", "");
|
||||||
|
try {
|
||||||
|
int major = Integer.parseInt(ver.split("\\.")[0]);
|
||||||
|
if (major >= 10) return true;
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (System.console() != null) {
|
||||||
|
String term = System.getenv(TERM_ENV);
|
||||||
|
return term != null && !term.isEmpty() && !term.equalsIgnoreCase("dumb");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int detectTerminalWidth() {
|
||||||
|
String columns = System.getenv(COLUMNS_ENV);
|
||||||
|
if (columns != null && !columns.isEmpty()) {
|
||||||
|
try {
|
||||||
|
int w = Integer.parseInt(columns.trim());
|
||||||
|
if (w > 0) return w;
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
return DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether ANSI escape codes should be emitted.
|
||||||
|
*/
|
||||||
|
public boolean isAnsiEnabled() {
|
||||||
|
return ansiEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detected or configured terminal width in columns.
|
||||||
|
*/
|
||||||
|
public int getWidth() {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to use UTF-8 symbols (box-drawing, emoji, blocks). When false, ASCII fallback is used
|
||||||
|
* so output looks correct in Windows CMD/PowerShell without UTF-8 encoding.
|
||||||
|
*/
|
||||||
|
public boolean isUtf8Symbols() {
|
||||||
|
return utf8Symbols;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/main/java/dev/jakub/terminal/interactive/Confirm.java
Normal file
87
src/main/java/dev/jakub/terminal/interactive/Confirm.java
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package dev.jakub.terminal.interactive;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yes/No confirm dialog. Use via {@link Terminal#confirm(String)}.
|
||||||
|
* [Y/n] or [y/N] based on default. Injectable in/out for tests.
|
||||||
|
*/
|
||||||
|
public final class Confirm {
|
||||||
|
|
||||||
|
private final String message;
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private boolean defaultYes = true;
|
||||||
|
private PrintStream out = System.out;
|
||||||
|
private InputStream in = System.in;
|
||||||
|
|
||||||
|
public Confirm(String message, TerminalSupport support) {
|
||||||
|
this.message = message != null ? message : "";
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets default to No ([y/N]).
|
||||||
|
*/
|
||||||
|
public Confirm defaultNo() {
|
||||||
|
this.defaultYes = false;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets default to Yes ([Y/n]). This is the default.
|
||||||
|
*/
|
||||||
|
public Confirm defaultYes() {
|
||||||
|
this.defaultYes = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets output stream.
|
||||||
|
*/
|
||||||
|
public Confirm output(PrintStream out) {
|
||||||
|
this.out = out != null ? out : System.out;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets input stream (for tests).
|
||||||
|
*/
|
||||||
|
public Confirm input(InputStream in) {
|
||||||
|
this.in = in != null ? in : System.in;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts and returns true for yes, false for no.
|
||||||
|
*/
|
||||||
|
public boolean ask() {
|
||||||
|
String prompt = defaultYes ? " [Y/n]: " : " [y/N]: ";
|
||||||
|
out.print(message + prompt);
|
||||||
|
out.flush();
|
||||||
|
try (Scanner scan = new Scanner(in)) {
|
||||||
|
if (!scan.hasNextLine()) return defaultYes;
|
||||||
|
String line = scan.nextLine().trim().toLowerCase();
|
||||||
|
if (line.isEmpty()) return defaultYes;
|
||||||
|
return "y".equals(line) || "yes".equals(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the given scanner for one line. Use when sharing one scanner (e.g. demo).
|
||||||
|
*/
|
||||||
|
public boolean ask(Scanner sharedScanner) {
|
||||||
|
if (sharedScanner == null) return ask();
|
||||||
|
String prompt = defaultYes ? " [Y/n]: " : " [y/N]: ";
|
||||||
|
out.print(message + prompt);
|
||||||
|
out.flush();
|
||||||
|
if (!sharedScanner.hasNextLine()) return defaultYes;
|
||||||
|
String line = sharedScanner.nextLine().trim().toLowerCase();
|
||||||
|
if (line.isEmpty()) return defaultYes;
|
||||||
|
return "y".equals(line) || "yes".equals(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main/java/dev/jakub/terminal/interactive/Menu.java
Normal file
104
src/main/java/dev/jakub/terminal/interactive/Menu.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.jakub.terminal.interactive;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for interactive terminal menus. Use via {@link Terminal#menu()}.
|
||||||
|
*/
|
||||||
|
public final class Menu {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<String> options = new ArrayList<>();
|
||||||
|
private String title = "Select an option";
|
||||||
|
private PrintStream out = System.out;
|
||||||
|
private java.io.InputStream in = System.in;
|
||||||
|
|
||||||
|
public Menu(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the menu title.
|
||||||
|
*/
|
||||||
|
public Menu title(String title) {
|
||||||
|
this.title = title != null ? title : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an option. Order is preserved.
|
||||||
|
*/
|
||||||
|
public Menu option(String option) {
|
||||||
|
options.add(option != null ? option : "");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the output stream (default: stdout).
|
||||||
|
*/
|
||||||
|
public Menu output(PrintStream out) {
|
||||||
|
this.out = out != null ? out : System.out;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the input stream (default: stdin). Useful for testing.
|
||||||
|
*/
|
||||||
|
public Menu input(java.io.InputStream in) {
|
||||||
|
this.in = in != null ? in : System.in;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the menu and blocks until the user selects an option.
|
||||||
|
* Returns the selected option string, or the first option if only one,
|
||||||
|
* or null if no options or read fails.
|
||||||
|
*/
|
||||||
|
public String select() {
|
||||||
|
if (options.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (options.size() == 1) {
|
||||||
|
return options.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.println(Ansi.BOLD + title + Ansi.RESET);
|
||||||
|
out.println();
|
||||||
|
} else {
|
||||||
|
out.println(title);
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < options.size(); i++) {
|
||||||
|
out.println(" " + (i + 1) + ". " + options.get(i));
|
||||||
|
}
|
||||||
|
out.println();
|
||||||
|
out.print("Enter choice (1-" + options.size() + "): ");
|
||||||
|
out.flush();
|
||||||
|
|
||||||
|
try (Scanner scanner = new Scanner(in)) {
|
||||||
|
while (scanner.hasNextLine()) {
|
||||||
|
String line = scanner.nextLine();
|
||||||
|
if (line == null) continue;
|
||||||
|
line = line.trim();
|
||||||
|
try {
|
||||||
|
int choice = Integer.parseInt(line);
|
||||||
|
if (choice >= 1 && choice <= options.size()) {
|
||||||
|
return options.get(choice - 1);
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
out.print("Invalid. Enter choice (1-" + options.size() + "): ");
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/main/java/dev/jakub/terminal/interactive/Pager.java
Normal file
143
src/main/java/dev/jakub/terminal/interactive/Pager.java
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package dev.jakub.terminal.interactive;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pager for long lists. Use via {@link Terminal#pager()}.
|
||||||
|
* Interactive: Enter/Space/Down/Right = next page, Up/Left = previous, 'q' = quit.
|
||||||
|
*/
|
||||||
|
public final class Pager {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<String> lines = new ArrayList<>();
|
||||||
|
private int pageSize = 20;
|
||||||
|
private PrintStream out = System.out;
|
||||||
|
private InputStream in = System.in;
|
||||||
|
|
||||||
|
public Pager(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the lines to display.
|
||||||
|
*/
|
||||||
|
public Pager lines(List<String> lines) {
|
||||||
|
this.lines.clear();
|
||||||
|
if (lines != null) {
|
||||||
|
this.lines.addAll(lines);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the lines (varargs).
|
||||||
|
*/
|
||||||
|
public Pager lines(String... lines) {
|
||||||
|
this.lines.clear();
|
||||||
|
if (lines != null) {
|
||||||
|
for (String s : lines) this.lines.add(s != null ? s : "");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets page size (default 20).
|
||||||
|
*/
|
||||||
|
public Pager pageSize(int size) {
|
||||||
|
this.pageSize = Math.max(1, size);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets output stream.
|
||||||
|
*/
|
||||||
|
public Pager output(PrintStream out) {
|
||||||
|
this.out = out != null ? out : System.out;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets input stream (for tests).
|
||||||
|
*/
|
||||||
|
public Pager input(InputStream in) {
|
||||||
|
this.in = in != null ? in : System.in;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows all lines at once (no interaction).
|
||||||
|
*/
|
||||||
|
public void print(PrintStream out) {
|
||||||
|
for (String line : lines) {
|
||||||
|
out.println(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive paging: Enter/Space/Down/Right = next, Up/Left = previous, 'q' = quit.
|
||||||
|
*/
|
||||||
|
public void interactive() {
|
||||||
|
int totalPages = Math.max(1, (lines.size() + pageSize - 1) / pageSize);
|
||||||
|
int page = 0;
|
||||||
|
while (true) {
|
||||||
|
int from = page * pageSize;
|
||||||
|
int to = Math.min(from + pageSize, lines.size());
|
||||||
|
for (int i = from; i < to; i++) {
|
||||||
|
out.println(lines.get(i));
|
||||||
|
}
|
||||||
|
out.println();
|
||||||
|
out.print("[Page " + (page + 1) + " / " + totalPages + "] Enter/Space/Down=next, Up=prev, q=quit: ");
|
||||||
|
out.flush();
|
||||||
|
|
||||||
|
switch (readKey(in)) {
|
||||||
|
case NEXT:
|
||||||
|
page++;
|
||||||
|
if (page >= totalPages) return;
|
||||||
|
break;
|
||||||
|
case PREV:
|
||||||
|
if (page > 0) page--;
|
||||||
|
break;
|
||||||
|
case QUIT:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum KeyAction { NEXT, PREV, QUIT }
|
||||||
|
|
||||||
|
private static KeyAction readKey(InputStream in) {
|
||||||
|
try {
|
||||||
|
int c = in.read();
|
||||||
|
if (c == -1) return KeyAction.QUIT;
|
||||||
|
if (c == 'q' || c == 'Q') return KeyAction.QUIT;
|
||||||
|
if (c == ' ' || c == '\n' || c == '\r') return KeyAction.NEXT;
|
||||||
|
if (c == '\u001b') {
|
||||||
|
if (in.read() != '[') return KeyAction.NEXT;
|
||||||
|
while (true) {
|
||||||
|
int b = in.read();
|
||||||
|
if (b == -1) return KeyAction.QUIT;
|
||||||
|
if (b == 'A') return KeyAction.PREV;
|
||||||
|
if (b == 'B' || b == 'C') return KeyAction.NEXT;
|
||||||
|
if (b == 'D') return KeyAction.PREV;
|
||||||
|
if (b >= 0x40) return KeyAction.NEXT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return KeyAction.NEXT;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return KeyAction.QUIT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints all to stdout.
|
||||||
|
*/
|
||||||
|
public void print() {
|
||||||
|
print(System.out);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/main/java/dev/jakub/terminal/interactive/Prompt.java
Normal file
89
src/main/java/dev/jakub/terminal/interactive/Prompt.java
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package dev.jakub.terminal.interactive;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input prompt for reading user input. Use via {@link Terminal#prompt(String)}.
|
||||||
|
* Supports masked input for passwords. Injectable input/output for tests.
|
||||||
|
*/
|
||||||
|
public final class Prompt {
|
||||||
|
|
||||||
|
private final String message;
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private boolean masked;
|
||||||
|
private PrintStream out = System.out;
|
||||||
|
private InputStream in = System.in;
|
||||||
|
|
||||||
|
public Prompt(String message, TerminalSupport support) {
|
||||||
|
this.message = message != null ? message : "";
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides input (e.g. for passwords). Shows * per character or blank.
|
||||||
|
*/
|
||||||
|
public Prompt masked() {
|
||||||
|
this.masked = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the output stream (default: stdout).
|
||||||
|
*/
|
||||||
|
public Prompt output(PrintStream out) {
|
||||||
|
this.out = out != null ? out : System.out;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the input stream (default: stdin). Useful for tests.
|
||||||
|
*/
|
||||||
|
public Prompt input(InputStream in) {
|
||||||
|
this.in = in != null ? in : System.in;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts and returns the entered string. Blocks until a line is read.
|
||||||
|
*/
|
||||||
|
public String ask() {
|
||||||
|
out.print(message);
|
||||||
|
out.flush();
|
||||||
|
if (masked) {
|
||||||
|
return readMasked();
|
||||||
|
}
|
||||||
|
try (Scanner scan = new Scanner(in)) {
|
||||||
|
return scan.hasNextLine() ? scan.nextLine() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts and returns the next line from the given scanner. Use this when
|
||||||
|
* sharing one scanner (e.g. in a demo) so input blocks correctly.
|
||||||
|
*/
|
||||||
|
public String ask(Scanner sharedScanner) {
|
||||||
|
if (sharedScanner == null) return ask();
|
||||||
|
out.print(message);
|
||||||
|
out.flush();
|
||||||
|
if (masked && System.console() != null) {
|
||||||
|
char[] chars = System.console().readPassword();
|
||||||
|
return chars != null ? new String(chars) : "";
|
||||||
|
}
|
||||||
|
return sharedScanner.hasNextLine() ? sharedScanner.nextLine() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readMasked() {
|
||||||
|
if (System.console() != null) {
|
||||||
|
char[] chars = System.console().readPassword();
|
||||||
|
return chars != null ? new String(chars) : "";
|
||||||
|
}
|
||||||
|
try (Scanner scan = new Scanner(in)) {
|
||||||
|
return scan.hasNextLine() ? scan.nextLine() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/main/java/dev/jakub/terminal/interactive/SelectList.java
Normal file
205
src/main/java/dev/jakub/terminal/interactive/SelectList.java
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package dev.jakub.terminal.interactive;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Scanner;
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive select list: options with one highlighted. Navigate with Up/Down arrow keys
|
||||||
|
* and Enter to confirm (when terminal supports raw input), or type the option number and Enter.
|
||||||
|
* Use via {@link Terminal#selectList()}.
|
||||||
|
*/
|
||||||
|
public final class SelectList {
|
||||||
|
|
||||||
|
private static final long FALLBACK_MS = 2500L;
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<String> options = new ArrayList<>();
|
||||||
|
private String title = "Select an option";
|
||||||
|
private PrintStream out = System.out;
|
||||||
|
private InputStream in = System.in;
|
||||||
|
|
||||||
|
public SelectList(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SelectList title(String title) {
|
||||||
|
this.title = title != null ? title : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SelectList option(String option) {
|
||||||
|
options.add(option != null ? option : "");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SelectList output(PrintStream out) {
|
||||||
|
this.out = out != null ? out : System.out;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SelectList input(InputStream in) {
|
||||||
|
this.in = in != null ? in : System.in;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the list and blocks until the user selects. Returns the selected option, or null if empty.
|
||||||
|
*/
|
||||||
|
public String select() {
|
||||||
|
if (options.isEmpty()) return null;
|
||||||
|
if (options.size() == 1) return options.get(0);
|
||||||
|
|
||||||
|
boolean ansi = support.isAnsiEnabled();
|
||||||
|
if (ansi) out.print(Ansi.HIDE_CURSOR);
|
||||||
|
try {
|
||||||
|
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
|
||||||
|
Thread reader = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
while (!Thread.currentThread().isInterrupted()) {
|
||||||
|
if (in.available() > 0) {
|
||||||
|
int b = in.read();
|
||||||
|
if (b == -1) break;
|
||||||
|
queue.offer(b);
|
||||||
|
} else {
|
||||||
|
Thread.sleep(30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}, "select-list-reader");
|
||||||
|
reader.setDaemon(true);
|
||||||
|
reader.start();
|
||||||
|
|
||||||
|
int current = 0;
|
||||||
|
long firstInputAt = -1;
|
||||||
|
StringBuilder numInput = new StringBuilder();
|
||||||
|
|
||||||
|
draw(current, ansi);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
Integer b = queue.poll(FALLBACK_MS, TimeUnit.MILLISECONDS);
|
||||||
|
if (b == null) {
|
||||||
|
reader.interrupt();
|
||||||
|
return selectWithScanner(current, ansi);
|
||||||
|
}
|
||||||
|
if (firstInputAt < 0) firstInputAt = System.currentTimeMillis();
|
||||||
|
|
||||||
|
if (b == 13 || b == 10) {
|
||||||
|
if (numInput.length() > 0) {
|
||||||
|
try {
|
||||||
|
int n = Integer.parseInt(numInput.toString());
|
||||||
|
if (n >= 1 && n <= options.size()) return options.get(n - 1);
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
return options.get(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b >= '1' && b <= '9') {
|
||||||
|
numInput.append((char) b.intValue());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows: 224 (or 0) then 72=Up, 80=Down, 75=Left, 77=Right
|
||||||
|
if (b == 224 || b == 0) {
|
||||||
|
Integer b2 = queue.poll(150, TimeUnit.MILLISECONDS);
|
||||||
|
if (b2 != null) {
|
||||||
|
if (b2 == 72) {
|
||||||
|
current = (current - 1 + options.size()) % options.size();
|
||||||
|
numInput.setLength(0);
|
||||||
|
redraw(current, ansi);
|
||||||
|
} else if (b2 == 80) {
|
||||||
|
current = (current + 1) % options.size();
|
||||||
|
numInput.setLength(0);
|
||||||
|
redraw(current, ansi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANSI: ESC [ A (Up) / ESC [ B (Down). Zuerst sofortige Bytes aus der Queue holen.
|
||||||
|
if (b == 27) {
|
||||||
|
Integer b2 = queue.poll(0, TimeUnit.MILLISECONDS);
|
||||||
|
if (b2 == null) b2 = queue.poll(120, TimeUnit.MILLISECONDS);
|
||||||
|
Integer b3 = null;
|
||||||
|
if (b2 != null && (b2 == 91 || b2 == 79)) {
|
||||||
|
b3 = queue.poll(0, TimeUnit.MILLISECONDS);
|
||||||
|
if (b3 == null) b3 = queue.poll(120, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
if (b3 != null) {
|
||||||
|
if (b3 == 65) {
|
||||||
|
current = (current - 1 + options.size()) % options.size();
|
||||||
|
numInput.setLength(0);
|
||||||
|
redraw(current, ansi);
|
||||||
|
} else if (b3 == 66) {
|
||||||
|
current = (current + 1) % options.size();
|
||||||
|
numInput.setLength(0);
|
||||||
|
redraw(current, ansi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return options.get(0);
|
||||||
|
} finally {
|
||||||
|
if (ansi) out.print(Ansi.SHOW_CURSOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String selectWithScanner(int current, boolean ansi) {
|
||||||
|
out.print("\nEnter number (1-" + options.size() + ") or Enter for current: ");
|
||||||
|
out.flush();
|
||||||
|
try (Scanner scanner = new Scanner(in)) {
|
||||||
|
if (scanner.hasNextLine()) {
|
||||||
|
String line = scanner.nextLine();
|
||||||
|
if (line != null) line = line.trim();
|
||||||
|
if (line == null || line.isEmpty()) return options.get(current);
|
||||||
|
try {
|
||||||
|
int n = Integer.parseInt(line);
|
||||||
|
if (n >= 1 && n <= options.size()) return options.get(n - 1);
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options.get(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void draw(int current, boolean ansi) {
|
||||||
|
if (ansi) out.print(Ansi.BOLD + title + Ansi.RESET + "\n\n");
|
||||||
|
else out.println(title + "\n");
|
||||||
|
for (int i = 0; i < options.size(); i++) {
|
||||||
|
String line = (i + 1) + ". " + options.get(i);
|
||||||
|
if (i == current) {
|
||||||
|
if (ansi) out.print(Ansi.BOLD + "> " + line + Ansi.RESET + "\n");
|
||||||
|
else out.println("> " + line);
|
||||||
|
} else {
|
||||||
|
if (ansi) out.print(" " + line + "\n");
|
||||||
|
else out.println(" " + line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.print("\n(Up/Down: move, Enter: select, or type number): ");
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void redraw(int current, boolean ansi) {
|
||||||
|
int lines = options.size() + 3;
|
||||||
|
if (ansi) {
|
||||||
|
for (int i = 0; i < lines; i++) out.print(Ansi.CURSOR_UP);
|
||||||
|
for (int i = 0; i < lines; i++) {
|
||||||
|
out.print(Ansi.ERASE_LINE);
|
||||||
|
if (i < lines - 1) out.print(Ansi.CURSOR_DOWN);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < lines; i++) out.print(Ansi.CURSOR_UP);
|
||||||
|
}
|
||||||
|
draw(current, ansi);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/main/java/dev/jakub/terminal/internal/PromptBuilder.java
Normal file
75
src/main/java/dev/jakub/terminal/internal/PromptBuilder.java
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package dev.jakub.terminal.internal;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
import dev.jakub.terminal.interactive.Prompt;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for {@link Prompt}. Use via {@link Terminal#prompt(String)}.
|
||||||
|
* Chain {@link #masked()} then {@link #ask()}.
|
||||||
|
*/
|
||||||
|
public final class PromptBuilder {
|
||||||
|
|
||||||
|
private final String message;
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private boolean masked;
|
||||||
|
private PrintStream output = System.out;
|
||||||
|
private InputStream input = System.in;
|
||||||
|
|
||||||
|
public PromptBuilder(String message, TerminalSupport support) {
|
||||||
|
this.message = message != null ? message : "";
|
||||||
|
this.support = support != null ? support : new TerminalSupport();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides input (e.g. for passwords).
|
||||||
|
*/
|
||||||
|
public PromptBuilder masked() {
|
||||||
|
this.masked = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets output stream (default: stdout).
|
||||||
|
*/
|
||||||
|
public PromptBuilder output(PrintStream out) {
|
||||||
|
this.output = out != null ? out : System.out;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets input stream (default: stdin). For tests.
|
||||||
|
*/
|
||||||
|
public PromptBuilder input(InputStream in) {
|
||||||
|
this.input = in != null ? in : System.in;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts and returns the entered string.
|
||||||
|
*/
|
||||||
|
public String ask() {
|
||||||
|
Prompt p = new Prompt(message, support);
|
||||||
|
if (masked) p.masked();
|
||||||
|
p.output(output);
|
||||||
|
p.input(input);
|
||||||
|
return p.ask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts and returns the next line from the given scanner. Use one shared
|
||||||
|
* scanner so input blocks until the user types (avoids EOF when run from IDE).
|
||||||
|
*/
|
||||||
|
public String ask(Scanner sharedScanner) {
|
||||||
|
if (sharedScanner == null) return ask();
|
||||||
|
Prompt p = new Prompt(message, support);
|
||||||
|
if (masked) p.masked();
|
||||||
|
p.output(output);
|
||||||
|
p.input(input);
|
||||||
|
return p.ask(sharedScanner);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main/java/dev/jakub/terminal/internal/TreeNode.java
Normal file
16
src/main/java/dev/jakub/terminal/internal/TreeNode.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.jakub.terminal.internal;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.components.Tree;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Node for {@link Tree}. */
|
||||||
|
public final class TreeNode {
|
||||||
|
public final String label;
|
||||||
|
public final List<TreeNode> children = new ArrayList<>();
|
||||||
|
|
||||||
|
public TreeNode(String label) {
|
||||||
|
this.label = label != null ? label : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/main/java/dev/jakub/terminal/live/Dashboard.java
Normal file
118
src/main/java/dev/jakub/terminal/live/Dashboard.java
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package dev.jakub.terminal.live;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live dashboard that refreshes widgets at an interval. Use via {@link Terminal#dashboard()}.
|
||||||
|
* Clears screen and re-renders. Thread-safe. Call {@link #stop()} to end.
|
||||||
|
*/
|
||||||
|
public final class Dashboard {
|
||||||
|
|
||||||
|
private static final String CLEAR = "\u001B[2J\u001B[H";
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final List<Widget> widgets = new ArrayList<>();
|
||||||
|
private long intervalMs = 1000;
|
||||||
|
private PrintStream out = System.out;
|
||||||
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
private Thread thread;
|
||||||
|
|
||||||
|
public Dashboard(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets refresh interval (e.g. 1, SECONDS).
|
||||||
|
*/
|
||||||
|
public Dashboard refreshEvery(long amount, TimeUnit unit) {
|
||||||
|
this.intervalMs = unit.toMillis(amount);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a widget. The supplier returns a component that has print(PrintStream); we capture its output.
|
||||||
|
*/
|
||||||
|
public Dashboard widget(String title, Supplier<Object> widgetSupplier) {
|
||||||
|
widgets.add(new Widget(title, widgetSupplier));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the dashboard in a background thread. Stops with {@link #stop()}.
|
||||||
|
*/
|
||||||
|
public void start() {
|
||||||
|
if (!running.compareAndSet(false, true)) return;
|
||||||
|
thread = new Thread(this::run, "terminal-ui-dashboard");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the dashboard.
|
||||||
|
*/
|
||||||
|
public void stop() {
|
||||||
|
running.set(false);
|
||||||
|
if (thread != null) {
|
||||||
|
try {
|
||||||
|
thread.join(5000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run() {
|
||||||
|
while (running.get()) {
|
||||||
|
try {
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(CLEAR);
|
||||||
|
}
|
||||||
|
for (Widget w : widgets) {
|
||||||
|
if (w.title != null && !w.title.isEmpty()) {
|
||||||
|
out.println("--- " + w.title + " ---");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Object comp = w.supplier.get();
|
||||||
|
if (comp != null) {
|
||||||
|
capturePrint(comp);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
out.println("Error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
out.println("Dashboard error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(intervalMs);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void capturePrint(Object comp) throws Exception {
|
||||||
|
java.lang.reflect.Method m = comp.getClass().getMethod("print", PrintStream.class);
|
||||||
|
m.invoke(comp, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Widget {
|
||||||
|
final String title;
|
||||||
|
final Supplier<Object> supplier;
|
||||||
|
|
||||||
|
Widget(String title, Supplier<Object> supplier) {
|
||||||
|
this.title = title;
|
||||||
|
this.supplier = supplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/main/java/dev/jakub/terminal/live/ProgressBar.java
Normal file
157
src/main/java/dev/jakub/terminal/live/ProgressBar.java
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package dev.jakub.terminal.live;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-safe progress bar. Use via {@link Terminal#progressBar()}.
|
||||||
|
*/
|
||||||
|
public final class ProgressBar {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual style for the progress bar fill.
|
||||||
|
*/
|
||||||
|
public enum Style {
|
||||||
|
/** Block characters: [████████░░░░] */
|
||||||
|
BLOCK("█", "░"),
|
||||||
|
/** Equals: [========----] */
|
||||||
|
EQUALS("=", "-"),
|
||||||
|
/** Hash: [######## ] */
|
||||||
|
HASH("#", " "),
|
||||||
|
/** Arrow: [>>>>>>------] */
|
||||||
|
ARROW(">", "-");
|
||||||
|
|
||||||
|
private final String filled;
|
||||||
|
private final String empty;
|
||||||
|
|
||||||
|
Style(String filled, String empty) {
|
||||||
|
this.filled = filled;
|
||||||
|
this.empty = empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final long total;
|
||||||
|
private final int width;
|
||||||
|
private final Style style;
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final AtomicLong current = new AtomicLong(0);
|
||||||
|
private final ReentrantLock printLock = new ReentrantLock();
|
||||||
|
private final PrintStream out;
|
||||||
|
private boolean printed;
|
||||||
|
private String prefix = "";
|
||||||
|
private String suffix = "";
|
||||||
|
|
||||||
|
public ProgressBar(long total, int width, Style style, TerminalSupport support, PrintStream out) {
|
||||||
|
this.total = Math.max(1, total);
|
||||||
|
this.width = Math.max(10, Math.min(width, 200));
|
||||||
|
this.style = style != null ? style : Style.BLOCK;
|
||||||
|
this.support = support;
|
||||||
|
this.out = out != null ? out : System.out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets optional text before the bar.
|
||||||
|
*/
|
||||||
|
public ProgressBar prefix(String prefix) {
|
||||||
|
this.prefix = prefix != null ? prefix : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets optional text after the bar (e.g. percentage).
|
||||||
|
*/
|
||||||
|
public ProgressBar suffix(String suffix) {
|
||||||
|
this.suffix = suffix != null ? suffix : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the current value and redraws the bar. Thread-safe.
|
||||||
|
*/
|
||||||
|
public void update(long value) {
|
||||||
|
current.set(Math.max(0, Math.min(value, total)));
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments the current value by one and redraws. Thread-safe.
|
||||||
|
*/
|
||||||
|
public void increment() {
|
||||||
|
long next = current.updateAndGet(v -> Math.min(v + 1, total));
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current progress value.
|
||||||
|
*/
|
||||||
|
public long getCurrent() {
|
||||||
|
return current.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total (max) value.
|
||||||
|
*/
|
||||||
|
public long getTotal() {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void redraw() {
|
||||||
|
printLock.lock();
|
||||||
|
try {
|
||||||
|
long cur = current.get();
|
||||||
|
int filledCount = (int) ((cur * width) / total);
|
||||||
|
filledCount = Math.max(0, Math.min(filledCount, width));
|
||||||
|
String filledCh = support.isUtf8Symbols() ? style.filled : asciiFilled();
|
||||||
|
String emptyCh = support.isUtf8Symbols() ? style.empty : asciiEmpty();
|
||||||
|
String filled = filledCh.repeat(filledCount);
|
||||||
|
String empty = emptyCh.repeat(width - filledCount);
|
||||||
|
String bar = "[" + filled + empty + "]";
|
||||||
|
int pct = total > 0 ? (int) (100 * cur / total) : 0;
|
||||||
|
String line = prefix + bar + " " + pct + "%" + suffix;
|
||||||
|
if (printed) {
|
||||||
|
out.print(Ansi.CARRIAGE_RETURN);
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(Ansi.ERASE_LINE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.print(line);
|
||||||
|
printed = true;
|
||||||
|
} finally {
|
||||||
|
printLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String asciiFilled() {
|
||||||
|
return switch (style) {
|
||||||
|
case BLOCK -> "#";
|
||||||
|
case EQUALS -> "=";
|
||||||
|
case HASH -> "#";
|
||||||
|
case ARROW -> ">";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String asciiEmpty() {
|
||||||
|
return switch (style) {
|
||||||
|
case BLOCK, HASH -> " ";
|
||||||
|
case EQUALS, ARROW -> "-";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes the bar at 100% and prints a newline.
|
||||||
|
*/
|
||||||
|
public void complete() {
|
||||||
|
update(total);
|
||||||
|
printLock.lock();
|
||||||
|
try {
|
||||||
|
out.println();
|
||||||
|
} finally {
|
||||||
|
printLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/main/java/dev/jakub/terminal/live/Spinner.java
Normal file
103
src/main/java/dev/jakub/terminal/live/Spinner.java
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package dev.jakub.terminal.live;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.Ansi;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-safe spinner for indeterminate progress. Use via {@link Terminal#spinner()}.
|
||||||
|
*/
|
||||||
|
public final class Spinner {
|
||||||
|
|
||||||
|
/** Braille-style frames (UTF-8). */
|
||||||
|
private static final String[] FRAMES_UTF8 = {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"};
|
||||||
|
/** ASCII fallback for Windows CMD / no UTF-8. */
|
||||||
|
private static final String[] FRAMES_ASCII = {"|", "/", "-", "\\"};
|
||||||
|
|
||||||
|
private final String message;
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private final PrintStream out;
|
||||||
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
private final ReentrantLock lock = new ReentrantLock();
|
||||||
|
private Thread thread;
|
||||||
|
private int frameIndex;
|
||||||
|
|
||||||
|
public Spinner(String message, TerminalSupport support, PrintStream out) {
|
||||||
|
this.message = message != null ? message : "";
|
||||||
|
this.support = support;
|
||||||
|
this.out = out != null ? out : System.out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the spinner in a background thread. Idempotent.
|
||||||
|
*/
|
||||||
|
public Spinner start() {
|
||||||
|
if (!running.compareAndSet(false, true)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
thread = new Thread(this::run, "terminal-ui-spinner");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.start();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop(String message) {
|
||||||
|
if (!running.compareAndSet(true, false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (thread != null) {
|
||||||
|
try {
|
||||||
|
thread.join(2000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
out.print(Ansi.CARRIAGE_RETURN);
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(Ansi.ERASE_LINE);
|
||||||
|
}
|
||||||
|
if (message != null && !message.isEmpty()) {
|
||||||
|
out.println(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the spinner without a final message.
|
||||||
|
*/
|
||||||
|
public void stop() {
|
||||||
|
stop(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run() {
|
||||||
|
String[] frames = support.isUtf8Symbols() ? FRAMES_UTF8 : FRAMES_ASCII;
|
||||||
|
while (running.get()) {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
String frame = frames[frameIndex % frames.length];
|
||||||
|
frameIndex++;
|
||||||
|
out.print(Ansi.CARRIAGE_RETURN);
|
||||||
|
if (support.isAnsiEnabled()) {
|
||||||
|
out.print(Ansi.ERASE_LINE);
|
||||||
|
}
|
||||||
|
out.print(frame + " " + message);
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(support.isAnsiEnabled() ? 80 : 120);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/main/java/dev/jakub/terminal/live/SpinnerBuilder.java
Normal file
45
src/main/java/dev/jakub/terminal/live/SpinnerBuilder.java
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package dev.jakub.terminal.live;
|
||||||
|
|
||||||
|
import dev.jakub.terminal.Terminal;
|
||||||
|
import dev.jakub.terminal.core.TerminalSupport;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for {@link Spinner}. Use via {@link Terminal#spinner()}.
|
||||||
|
*/
|
||||||
|
public final class SpinnerBuilder {
|
||||||
|
|
||||||
|
private final TerminalSupport support;
|
||||||
|
private String message = "Loading...";
|
||||||
|
private PrintStream out = System.out;
|
||||||
|
|
||||||
|
public SpinnerBuilder(TerminalSupport support) {
|
||||||
|
this.support = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the message shown next to the spinner.
|
||||||
|
*/
|
||||||
|
public SpinnerBuilder message(String message) {
|
||||||
|
this.message = message != null ? message : "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the output stream (default: stdout).
|
||||||
|
*/
|
||||||
|
public SpinnerBuilder output(PrintStream out) {
|
||||||
|
this.out = out != null ? out : System.out;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and starts the spinner. Returns the running Spinner instance.
|
||||||
|
*/
|
||||||
|
public Spinner start() {
|
||||||
|
Spinner s = new Spinner(message, support, out);
|
||||||
|
s.start();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/test/java/dev/jakub/terminal/AnsiTest.java
Normal file
38
src/test/java/dev/jakub/terminal/AnsiTest.java
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/test/java/dev/jakub/terminal/MenuTest.java
Normal file
66
src/test/java/dev/jakub/terminal/MenuTest.java
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/test/java/dev/jakub/terminal/ProgressBarTest.java
Normal file
105
src/test/java/dev/jakub/terminal/ProgressBarTest.java
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/test/java/dev/jakub/terminal/SelectListTest.java
Normal file
45
src/test/java/dev/jakub/terminal/SelectListTest.java
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/test/java/dev/jakub/terminal/SpinnerTest.java
Normal file
53
src/test/java/dev/jakub/terminal/SpinnerTest.java
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/test/java/dev/jakub/terminal/StyledTextTest.java
Normal file
53
src/test/java/dev/jakub/terminal/StyledTextTest.java
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/test/java/dev/jakub/terminal/TableTest.java
Normal file
63
src/test/java/dev/jakub/terminal/TableTest.java
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/test/java/dev/jakub/terminal/TerminalSupportTest.java
Normal file
29
src/test/java/dev/jakub/terminal/TerminalSupportTest.java
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/test/java/dev/jakub/terminal/TerminalTest.java
Normal file
75
src/test/java/dev/jakub/terminal/TerminalTest.java
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user