InputConfirmation.java

/*
 * Copyright 2022 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.input;

import net.morimekta.io.tty.TTYMode;
import net.morimekta.strings.chr.Char;
import net.morimekta.terminal.Terminal;

import java.io.Closeable;
import java.io.IOException;
import java.io.PrintStream;
import java.io.UncheckedIOException;

import static net.morimekta.strings.StringUtil.printableWidth;
import static net.morimekta.strings.chr.Control.CURSOR_ERASE;
import static net.morimekta.strings.chr.Control.cursorLeft;

/**
 * Handles reading a yes/no confirmation from the terminal in raw mode.
 */
public class InputConfirmation implements Closeable {
    /**
     * Constructor for simple confirmation.
     *
     * @param terminal     Terminal to use.
     * @param confirmation Message to print.
     */
    public InputConfirmation(Terminal terminal, String confirmation) {
        this(terminal, confirmation, null);
    }

    /**
     * Constructor for complete line-input.
     *
     * @param terminal     Terminal to use.
     * @param confirmation Message to print.
     * @param defaultValue Default value to return on e.g. 'enter'.
     */
    public InputConfirmation(Terminal terminal, String confirmation, Boolean defaultValue) {
        this.terminal = terminal.withMode(TTYMode.RAW);
        this.out = terminal.rawOut();
        this.confirmation = confirmation;
        this.defaultValue = defaultValue;
    }

    @Override
    public void close() {
        terminal.close();
    }

    /**
     * @return Run confirmation and return value as boolean.
     */
    public boolean getAsBoolean() {
        terminal.printWriter().append(confirmation).flush();

        try {
            for (; ; ) {
                Char c = terminal.charReader().read();
                if (c == null) {
                    throw new IOException("End of stream.");
                }
                handleInterrupt(c);
                if (isConfirmation(c)) {
                    printConfirmation(true);
                    return true;
                } else if (isRejection(c)) {
                    printConfirmation(false);
                    return false;
                } else if (isDefault(c)) {
                    if (defaultValue != null) {
                        printConfirmation(defaultValue);
                        return defaultValue;
                    } else {
                        // ignore this letter if no default value exists.
                        continue;
                    }
                }
                printMessage(String.format("'%s' is not valid input.", c.asString()));
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /**
     * Handle backspace. These are not control sequences, so must be handled separately
     * from those.
     *
     * @param c The char instance.
     * @return True if default value should be used.
     */
    protected boolean isDefault(Char c) {
        return c.codepoint() == '\n' || c.codepoint() == ' ';
    }

    /**
     * @param c The char instance.
     * @return True if confirmation is rejected.
     */
    protected boolean isRejection(Char c) {
        return c.codepoint() == 'n' || c.codepoint() == Char.DEL || c.codepoint() == Char.BS;
    }

    /**
     * If the provided char indicates confirmation.
     *
     * @param c The char instance.
     * @return True if confirmation is accepted.
     */
    protected boolean isConfirmation(Char c) {
        return c.codepoint() == 'y';
    }

    /**
     * Handle user interrupts.
     *
     * @param c The char instance.
     * @throws IOException If interrupted.
     */
    protected void handleInterrupt(Char c) throws IOException {
        if (c.codepoint() == Char.ESC || c.codepoint() == Char.ABR || c.codepoint() == Char.EOF) {
            printMessage("User interrupted.");
            throw new IOException("User interrupted: " + c.asString());
        }
    }

    /**
     * Print confirmation.
     *
     * @param result The result to be printed.
     */
    protected void printConfirmation(boolean result) {
        if (result) {
            printMessage("Yes.");
        } else {
            printMessage("No.");
        }
    }

    private void printMessage(String message) {
        if (printedMessage == null) {
            out.print(' ');
            out.print(message);
        } else {
            out.print(cursorLeft(printableWidth(printedMessage)));
            out.print(CURSOR_ERASE);
            out.print(message);
        }
        printedMessage = message;
    }

    private final Terminal    terminal;
    private final PrintStream out;
    private final String      confirmation;
    private final Boolean     defaultValue;
    private       String      printedMessage;
}