InputPassword.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.input;

import net.morimekta.io.tty.TTYMode;
import net.morimekta.strings.chr.Char;
import net.morimekta.strings.chr.Control;
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.chr.Control.CURSOR_ERASE;

/**
 * Class that handled reading a password from terminal.
 * <p>
 * It will not print out the password (hidden input) on the terminal,
 * but may print out the length of it and current position based
 * on char replacement. The char replacement must be a single char, or
 * an empty string.
 * <p>
 * See {@link InputLine} for details on input controls.
 */
public class InputPassword implements Closeable {
    /**
     * Constructor for simple password input. It will not show any char
     * replacement for the password as it is written, which is the default
     * and safest method to input a password.
     *
     * @param terminal Terminal to use.
     * @param message  Message to print.
     */
    public InputPassword(Terminal terminal,
                         String message) {
        this(terminal, message, "");
    }

    /**
     * Constructor for password input with specified password input. This
     * will show an input string, like if the password was written on screen,
     * except each char is replaced with the charReplacement string instead.
     *
     * @param terminal        Terminal to use.
     * @param message         Message to print.
     * @param charReplacement The character replacement string, e.g. "*".
     */
    public InputPassword(Terminal terminal,
                         String message,
                         String charReplacement) {
        this.terminal = terminal.withMode(TTYMode.RAW);
        this.writer = terminal.rawOut();
        this.message = message;
        this.charReplacement = charReplacement;
    }

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

    /**
     * Read password from terminal.
     *
     * @return The resulting line.
     */
    public String readPassword() {
        this.before = "";
        this.after = "";

        terminal.printWriter().append(message).append(": ").flush();

        try {
            for (; ; ) {
                Char c = terminal.charReader().read();
                if (c == null) {
                    throw new IOException("End of input.");
                }

                int ch = c.codepoint();

                if (ch == Char.CR) {
                    return before + after;
                }

                if (ch == Char.DEL || ch == Char.BS) {
                    // backspace...
                    if (!before.isEmpty()) {
                        before = before.substring(0, before.length() - 1);
                        printInputLine();
                    }
                    continue;
                }

                if (c instanceof Control) {
                    if (c.equals(Control.DELETE)) {
                        if (!after.isEmpty()) {
                            after = after.substring(1);
                        }
                    } else if (c.equals(Control.LEFT)) {
                        if (!before.isEmpty()) {
                            after = before.charAt(before.length() - 1) + after;
                            before = before.substring(0, before.length() - 1);
                        }
                    } else if (c.equals(Control.HOME)) {
                        after = before + after;
                        before = "";
                    } else if (c.equals(Control.RIGHT)) {
                        if (!after.isEmpty()) {
                            before = before + after.charAt(0);
                            after = after.substring(1);
                        }
                    } else if (c.equals(Control.END)) {
                        before = before + after;
                        after = "";
                    } else {
                        // Silently ignore unknown control chars.
                        continue;
                    }
                    printInputLine();
                    continue;
                }

                if (ch == Char.ESC || ch == Char.ABR || ch == Char.EOF) {
                    throw new IOException("User interrupted: " + c.asString());
                }

                if (ch < 0x20) {
                    // Silently ignore unknown ASCII control characters.
                    continue;
                }

                before = before + c;
                if (after.isEmpty()) {
                    writer.print(charReplacement);
                } else {
                    printInputLine();
                }
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private void printInputLine() {
        if (!charReplacement.isEmpty()) {
            writer.print('\r');
            writer.print(CURSOR_ERASE);
            writer.print(message);
            writer.print(": ");

            String bf = charReplacement.repeat(before.length());
            writer.print(bf);
            if (!after.isEmpty()) {
                String af = charReplacement.repeat(after.length());
                writer.print(af);
                writer.print(Control.cursorLeft(after.length()));
            }
        }
    }

    private final Terminal    terminal;
    private final PrintStream writer;
    private final String      message;
    private final String      charReplacement;

    private String before;
    private String after;
}