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();
        }
    }
}