Terminal.java
/*
* Copyright 2021 Terminal Utils Authors
*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* http://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.
*/
package net.morimekta.terminal;
import net.morimekta.io.tty.TTY;
import net.morimekta.io.tty.TTYMode;
import net.morimekta.io.tty.TTYModeSwitcher;
import net.morimekta.strings.chr.Char;
import net.morimekta.strings.chr.CharReader;
import net.morimekta.terminal.input.InputConfirmation;
import net.morimekta.terminal.input.InputLine;
import java.io.BufferedOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import static java.util.Objects.requireNonNull;
import static net.morimekta.strings.StringUtil.isNotEmpty;
/**
* Terminal interface. It sets proper TTY mode and reads complex characters
* from the input, and writes lines dependent on terminal mode.
*/
public class Terminal implements Closeable {
/**
* Construct a default terminal.
*/
public Terminal() {
this(new TTY());
}
/**
* Construct a default terminal.
*
* @param tty The terminal device.
*/
public Terminal(TTY tty) {
this(tty,
System.in,
System.out,
tty.getCurrentMode(),
null);
}
/**
* Construct a terminal with given mode.
*
* @param mode The terminal mode.
* @throws UncheckedIOException If unable to set TTY mode.
*/
public Terminal(TTYMode mode) {
this(new TTY(), mode);
}
/**
* Construct a terminal with a terminal mode and custom line printer.
*
* @param tty The terminal device.
* @param mode The terminal mode.
* @throws UncheckedIOException If unable to set TTY mode.
*/
public Terminal(TTY tty, TTYMode mode) {
this(tty,
System.in,
System.out,
mode,
mode == tty.getCurrentMode() ? switcher(tty, mode) : null);
}
/**
* Constructor visible for testing.
*
* @param tty The terminal device.
* @param in The input stream.
* @param out The output stream.
* @param mode TTY mode.
* @param switcher TTY mode switcher.
*/
public Terminal(TTY tty,
InputStream in,
PrintStream out,
TTYMode mode,
TTYModeSwitcher switcher) {
this.tty = tty;
this.in = in;
this.out = out;
this.mode = mode;
this.switcher = switcher;
this.charCount = 0;
this.reader = new CharReader(in);
this.writer = new TerminalPrintWriter();
this.stream = new TerminalPrintStream();
this.lp = (mode == TTYMode.RAW)
? this::lpRaw
: this::lpCooked;
}
/**
* @return Character reader to read from input.
*/
public CharReader charReader() {
return reader;
}
/**
* @return Print stream to write raw to standard out.
*/
public PrintStream rawOut() {
return out;
}
/**
* Get a print writer that should behave as close to a direct writer. The
* various println methods are repurposed to behave according to the TTY
* mode.
* <p>
* Use this writer to actually utilize the output directly. Use this when
* you know and handle the TTY mode correctly.
*
* @return The output print writer.
*/
public PrintWriter printWriter() {
return writer;
}
/**
* Get a print stream that writes to the terminal according to the output
* mode of the terminal. Handy for e.g. printing stack traces etc. while in
* raw mode.
*
* @return A wrapping print stream.
*/
public PrintStream printStream() {
return stream;
}
/**
* Get a line printer that prints lines to the terminal output according to
* terminal mode.
*
* @return The line printer.
*/
public LinePrinter lp() {
return lp;
}
/**
* @return Get the terminal device.
*/
public TTY tty() {
return tty;
}
/**
* Get a terminal with the given TTY mode. This will also reset the console
* position, so it starts on a new line, if anything was already written.
* The created terminal should be closed before this is used again.
*
* @param mode Required mode.
* @return The terminal.
*/
public Terminal withMode(TTYMode mode) {
finish();
return new Terminal(
tty,
in,
out,
mode,
mode != this.mode ? switcher(tty, mode) : null
);
}
/**
* Make a user confirmation. E.g.:
* <code>boolean really = term.confirm("Do you o'Really?");</code>
* <p>
* Will print out "<code>Do you o'Really? [y/n]: </code>". If the user press
* 'y' will pass (return true), if 'n', and 'backspace' will return false.
* Enter is considered invalid input. Invalid characters will print a short
* error message.
*
* @param what What to confirm. Basically the message before '[Y/n]'.
* @return Confirmation result.
*/
public boolean confirm(String what) {
String message = what + " [y/n]:";
try (var confirm = new InputConfirmation(this, message)) {
return confirm.getAsBoolean();
}
}
/**
* Make a user confirmation. E.g.:
* <code>boolean really = term.confirm("Do you o'Really?", false);</code>
* <p>
* Will print out "<code>Do you o'Really? [y/N]: </code>". If the user press
* 'y' will pass (return true), if 'n', and 'backspace' will return false.
* Enter will return the default value. Invalid characters will print a
* short error message.
*
* @param what What to confirm. Basically the message before '[Y/n]'.
* @param def the default response on 'enter'.
* @return Confirmation result.
*/
public boolean confirm(String what, boolean def) {
String message = what + " [" + (def ? "Y/n" : "y/N") + "]:";
try (var confirm = new InputConfirmation(this, message, def)) {
return confirm.getAsBoolean();
}
}
/**
* Show a "press any key to continue" message. If the user interrupts, an
* exception is thrown, any other key just returns.
*
* @param message Message shown when waiting.
*/
public void pressToContinue(String message) {
try (var confirm = new InputConfirmation(this, message) {
@Override
protected boolean isConfirmation(Char c) {
return true;
}
@Override
protected void printConfirmation(boolean result) {
}
}) {
confirm.getAsBoolean();
}
}
/**
* Read a line from terminal.
*
* @param message The message to be shown before line input.
* @return The read line.
* @throws UncheckedIOException if interrupted or reached end of input.
*/
public String readLine(String message) {
try (var input = new InputLine(this, message)) {
return input.readLine();
}
}
/**
* Execute callable, which may not be interruptable by itself, but listen to terminal input and abort the task if
* CTRL-C is pressed.
*
* @param exec The executor to run task on.
* @param callable The callable function.
* @param <T> The return type of the callable.
* @return The result of the callable.
* @throws IOException If aborted or read failure.
* @throws InterruptedException If interrupted while waiting.
* @throws ExecutionException If execution failed.
*/
public <T> T executeAbortable(ExecutorService exec, Callable<T> callable)
throws IOException, InterruptedException, ExecutionException {
Future<T> task = exec.submit(callable);
waitAbortable(task);
return task.get();
}
/**
* Execute runnable, which may not be interruptable by itself, but listen to terminal input and abort the task if
* CTRL-C is pressed.
*
* @param exec The executor to run task on.
* @param callable The runnable function.
* @throws IOException If aborted or read failure.
* @throws InterruptedException If interrupted while waiting.
* @throws ExecutionException If execution failed.
*/
public void executeAbortable(ExecutorService exec, Runnable callable)
throws IOException, InterruptedException, ExecutionException {
Future<?> task = exec.submit(callable);
waitAbortable(task);
task.get();
}
/**
* Wait for future task to be done or canceled. React to terminal induced
* abort (ctrl-C) and cancel the task if so. Note that this method will
* basically swallow all user input and
*
* @param task The task to wait for.
* @param <T> The task generic type.
* @throws IOException On aborted or read failure.
* @throws InterruptedException On thread interrupted.
*/
public <T> void waitAbortable(Future<T> task) throws IOException, InterruptedException {
while (!task.isDone()) {
Char c = reader.readIfAvailable();
if (c != null && (c.codepoint() == Char.ABR || c.codepoint() == Char.ESC)) {
task.cancel(true);
throw new IOException("Aborted with '" + c.asString() + "'");
}
sleep(79L);
}
}
/**
* Finish the current set of lines and continue below.
*/
public void finish() {
writer.flush();
stream.flush();
if (charCount > 0) {
if (mode == TTYMode.RAW) {
out.write('\r');
}
out.write('\n');
charCount = 0;
}
out.flush();
}
@Override
public void close() {
finish();
try {
if (switcher != null) {
switcher.close();
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* Sleep a specified number of milliseconds. This method is exposed to help
* with testing.
*
* @param millis Millis to sleep.
* @throws InterruptedException If sleep was interrupted.
*/
protected void sleep(long millis) throws InterruptedException {
Thread.sleep(millis);
}
private void lpCooked(String message) {
try {
if (message != null) {
out.write(message.getBytes(StandardCharsets.UTF_8));
}
out.write('\n');
charCount = 0;
out.flush();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private void lpRaw(String message) {
try {
if (charCount > 0) {
out.write('\r');
out.write('\n');
}
charCount = 0;
if (isNotEmpty(message)) {
out.write(message.getBytes(StandardCharsets.UTF_8));
++charCount;
}
out.flush();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private final TTYMode mode;
private final TTYModeSwitcher switcher;
private final InputStream in;
private final PrintStream out;
private final CharReader reader;
private final PrintWriter writer;
private final PrintStream stream;
private final LinePrinter lp;
private final TTY tty;
private int charCount;
private static TTYModeSwitcher switcher(TTY tty, TTYMode mode) {
try {
return new TTYModeSwitcher(tty, mode);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* Print writer that tracks character output and delegates line
* printing to the terminal's line printer.
*/
private class TerminalPrintWriter extends PrintWriter {
private TerminalPrintWriter() {
super(Terminal.this.out, true, StandardCharsets.UTF_8);
}
// --- Fluent ---
@Override
public PrintWriter format(String format, Object... args) {
return format(Locale.US, format, args);
}
@Override
public PrintWriter format(Locale l, String format, Object... args) {
write(String.format(l, format, args));
return this;
}
@Override
public PrintWriter printf(String format, Object... args) {
return format(Locale.US, format, args);
}
@Override
public PrintWriter printf(Locale l, String format, Object... args) {
return format(l, format, args);
}
@Override
public PrintWriter append(char c) {
print(c);
return this;
}
@Override
public PrintWriter append(CharSequence csq) {
if (csq == null) {
csq = "null";
}
write(csq.toString());
return this;
}
@Override
public PrintWriter append(CharSequence csq, int start, int end) {
if (csq == null) {
csq = "null";
}
return append(csq.subSequence(start, end));
}
// --- No Return Value ---
@Override
public void write(int c) {
if (c != '\n') {
++charCount;
} else {
charCount = 0;
}
super.write(c);
}
@Override
public void write(char[] buf, int off, int len) {
if (len > 0) {
if (buf[off + len - 1] != '\n') {
++charCount;
} else {
charCount = 0;
}
super.write(buf, off, len);
}
}
@Override
public void write(String s, int off, int len) {
if (len > 0) {
if (s.charAt(off + len - 1) != '\n') {
++charCount;
} else {
charCount = 0;
}
super.write(s, off, len);
}
}
@Override
public void write(String s) {
write(s, 0, s.length());
}
@Override
public void write(char[] buf) {
write(buf, 0, buf.length);
}
@Override
public void println() {
lp.println(null);
}
@Override
public void println(int x) {
lp.println(String.valueOf(x));
}
@Override
public void println(char x) {
lp.println(String.valueOf(x));
}
@Override
public void println(long x) {
lp.println(String.valueOf(x));
}
@Override
public void println(float x) {
lp.println(String.valueOf(x));
}
@Override
public void println(double x) {
lp.println(String.valueOf(x));
}
@Override
public void println(char[] x) {
lp.println(new String(x));
}
@Override
public void println(boolean x) {
lp.println(String.valueOf(x));
}
@Override
public void println(Object x) {
lp.println(String.valueOf(x));
}
@Override
public void println(String x) {
lp.println(x);
}
@Override
public void print(boolean b) {
write(String.valueOf(b));
}
@Override
public void print(char c) {
write(String.valueOf(c));
}
@Override
public void print(int i) {
write(String.valueOf(i));
}
@Override
public void print(long l) {
write(String.valueOf(l));
}
@Override
public void print(float f) {
write(String.valueOf(f));
}
@Override
public void print(double d) {
write(String.valueOf(d));
}
@Override
public void print(char[] s) {
write(new String(s));
}
@Override
public void print(String s) {
write(s);
}
@Override
public void print(Object obj) {
write(String.valueOf(obj));
}
}
/**
* Print stream that delegates line breaks to the terminal's line
* printer based on the current TTY mode.
*/
private class TerminalPrintStream extends PrintStream {
private TerminalPrintStream() {
super(new BufferedOutputStream(Terminal.this.out),
true,
StandardCharsets.UTF_8);
}
@Override
public void write(int i) {
if (i == Char.LF) {
lp.println(null);
super.flush();
} else {
super.write(i);
++charCount;
}
}
@Override
public void write(byte[] bytes, int off, int len) {
requireNonNull(bytes, "bytes == null");
for (int i = off; i < off + len; ++i) {
this.write(Byte.toUnsignedInt(bytes[i]));
}
super.flush();
}
}
}