InputLine.java

/*
 * Copyright (c) 2016, Stein Eldar Johnsen
 *
 * 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.strings.ConsoleUtil;
import net.morimekta.strings.chr.Char;
import net.morimekta.strings.chr.Control;
import net.morimekta.terminal.LinePrinter;
import net.morimekta.terminal.Terminal;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.regex.Pattern;

/**
 * Class that handled reading a line from terminal input with
 * character and line validators, and optional tab completion.
 * <p>
 * When writing input into an app it is problematic if the app
 * crashes or exits every time you makes invalid input. This can be
 * solved with the {@link CharValidator} and {@link LineValidator}
 * interfaces. The <code>CharValidator</code> validates that any single
 * char input is valid, and will block if not. The <code>LineValidator</code>
 * is triggered when the user finishes the input and checks if the
 * line is valid as a whole, and blocks completion if not.
 * <p>
 * In addition to a {@link TabCompletion} interface may be provided that
 * can complete input based on the current content <i>before</i> the
 * cursor. If the <code>complete()</code> method returns a string, it
 * will <b>replace</b> what was before.
 */
public class InputLine {
    /**
     * Line validator interface.
     */
    @FunctionalInterface
    public interface LineValidator {
        /**
         * Validate the full line.
         *
         * @param line         The line to validate.
         * @param errorPrinter Printer to print out error messages.
         * @return True if valid, false otherwise.
         */
        boolean validate(String line, LinePrinter errorPrinter);
    }

    /**
     * Character validator interface.
     */
    @FunctionalInterface
    public interface CharValidator {
        /**
         * Validate the given char.
         *
         * @param ch           The char to validate.
         * @param errorPrinter Printer to print out error messages.
         * @return True if valid, false otherwise.
         */
        boolean validate(Char ch, LinePrinter errorPrinter);
    }

    /**
     * Tab completion interface.
     */
    @FunctionalInterface
    public interface TabCompletion {
        /**
         * Try to complete the given string.
         * <p>
         * The function should <b>only</b> print to the line printer if there
         * is <b>no</b> selected completion.
         *
         * @param before       The string to be completed. What is before the carriage.
         * @param errorPrinter Print errors or alternatives to this line printer.
         * @return the completed string, or null if no completion.
         */
        String complete(String before, LinePrinter errorPrinter);
    }

    /**
     * Constructor for simple line-input.
     *
     * @param terminal Terminal to use.
     * @param message  Message to print.
     */
    public InputLine(Terminal terminal,
                     String message) {
        this(terminal, message, null, null, null);
    }

    /**
     * Constructor for complete line-input.
     *
     * @param terminal      Terminal to use.
     * @param message       Message to print.
     * @param charValidator The character validator or null.
     * @param lineValidator The line validator or null.
     * @param tabCompletion The tab expander or null.
     */
    public InputLine(Terminal terminal,
                     String message,
                     CharValidator charValidator,
                     LineValidator lineValidator,
                     TabCompletion tabCompletion) {
        this(terminal, message, charValidator, lineValidator, tabCompletion, Pattern.compile("[-/.\\s\\\\]"));
    }

    /**
     * Constructor for complete line-input.
     *
     * @param terminal         Terminal to use.
     * @param message          Message to print.
     * @param charValidator    The character validator or null.
     * @param lineValidator    The line validator or null.
     * @param tabCompletion    The tab expander or null.
     * @param delimiterPattern Pattern matching a character that delimit 'words' that
     *                         are skipped or deleted with [ctrl-left], [ctrl-right],
     *                         [alt-w] and [alt-d].
     */
    public InputLine(Terminal terminal,
                     String message,
                     CharValidator charValidator,
                     LineValidator lineValidator,
                     TabCompletion tabCompletion,
                     Pattern delimiterPattern) {
        if (charValidator == null) {
            charValidator = (c, o) -> {
                if (c.codepoint() < 0x20 ||
                    !ConsoleUtil.isConsolePrintable(c.codepoint())) {
                    o.println("Invalid character: '" + c.asString() + "'");
                    return false;
                }
                return true;
            };
        }
        if (lineValidator == null) {
            lineValidator = (l, o) -> {
                // Accept all.
                return true;
            };
        }

        this.terminal = terminal;
        this.message = message;
        this.delimiterPattern = delimiterPattern;
        this.charValidator = charValidator;
        this.lineValidator = lineValidator;
        this.tabCompletion = tabCompletion;
    }

    /**
     * Read line from terminal.
     *
     * @return The resulting line.
     */
    public String readLine() {
        return readLine(null);
    }

    /**
     * Read line from terminal.
     *
     * @param initial The initial (default) value.
     * @return The resulting line.
     */
    public String readLine(String initial) {
        this.before = initial == null ? "" : initial;
        this.after = "";
        this.printedError = null;

        if (initial != null &&
            initial.length() > 0 &&
            !lineValidator.validate(initial, e -> {})) {
            throw new IllegalArgumentException("Invalid initial value: " + initial);
        }

        terminal.formatln("%s: %s", message, before);

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

                int ch = c.codepoint();

                if (ch == Char.CR || ch == Char.LF) {
                    String line = before + after;
                    if (lineValidator.validate(line, this::printAbove)) {
                        return line;
                    }
                    continue;
                }

                handleInterrupt(ch, c);

                if (handleTab(ch) ||
                    handleBackSpace(ch) ||
                    handleControl(c)) {
                    continue;
                }

                if (charValidator.validate(c, this::printAbove)) {
                    before = before + c;
                    printInputLine();
                }
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /**
     * Handle tab and tab completion.
     *
     * @param ch The character code point.
     * @return True if handled.
     */
    private boolean handleTab(int ch) {
        if (ch == Char.TAB && tabCompletion != null) {
            String completed = tabCompletion.complete(before, this::printAbove);
            if (completed != null) {
                before = completed;
                printInputLine();
            }
            return true;
        }

        return false;
    }

    /**
     * Handle backspace. These are not control sequences, so must be handled separately
     * from those.
     *
     * @param ch The character code point.
     * @return True if handled.
     */
    private boolean handleBackSpace(int ch) {
        if (ch == Char.DEL || ch == Char.BS) {
            // backspace...
            if (before.length() > 0) {
                before = before.substring(0, before.length() - 1);
                printInputLine();
            }
            return true;
        }

        return false;
    }

    /**
     * Handle user interrupts.
     *
     * @param ch The character code point.
     * @param c  The char instance.
     * @throws IOException If interrupted.
     */
    private void handleInterrupt(int ch, Char c) throws IOException {
        if (ch == Char.ESC || ch == Char.ABR || ch == Char.EOF) {
            throw new IOException("User interrupted: " + c.asString());
        }
    }

    /**
     * Handle control sequence chars.
     *
     * @param c The control char.
     * @return If the char was handled.
     */
    private boolean handleControl(Char c) {
        if (c instanceof Control) {
            if (c.equals(Control.DELETE)) {
                if (after.length() > 0) {
                    after = after.substring(1);
                }
            } else if (c.equals(Control.LEFT)) {
                if (before.length() > 0) {
                    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.CTRL_LEFT)) {
                int cut = cutWordBefore();
                if (cut > 0) {
                    after = before.substring(cut) + after;
                    before = before.substring(0, cut);
                } else {
                    after = before + after;
                    before = "";
                }
            } else if (c.equals(Control.RIGHT)) {
                if (after.length() > 0) {
                    before = before + after.charAt(0);
                    after = after.substring(1);
                }
            } else if (c.equals(Control.END)) {
                before = before + after;
                after = "";
            } else if (c.equals(Control.CTRL_RIGHT)) {
                int cut = cutWordAfter();
                if (cut > 0) {
                    before = before + after.substring(0, cut);
                    after = after.substring(cut);
                } else {
                    before = before + after;
                    after = "";
                }
            } else if (c.equals(Control.ALT_W)) {
                // delete word before the cursor.
                int cut = cutWordBefore();
                if (cut > 0) {
                    before = before.substring(0, cut);
                } else {
                    before = "";
                }
            } else if (c.equals(Control.ALT_D)) {
                // delete word after the cursor.
                int cut = cutWordAfter();
                if (cut > 0) {
                    after = after.substring(cut);
                } else {
                    after = "";
                }
            } else if (c.equals(Control.ALT_K)) {
                // delete everything after the cursor.
                after = "";
            } else if (c.equals(Control.ALT_U)) {
                // delete everything before the cursor.
                before = "";
            } else {
                printAbove("Invalid control: " + c.asString());
                return true;
            }
            printInputLine();
            return true;
        }
        return false;
    }

    /**
     * Find the position of the first character of the last word before
     * the cursor that is preceded by a delimiter.
     *
     * @return The word position or -1.
     */
    private int cutWordBefore() {
        if (before.length() > 0) {
            int cut = before.length() - 1;
            while (cut >= 0 && isDelimiter(before.charAt(cut))) {
                --cut;
            }
            // We know the 'cut' position character is not a
            // delimiter. Also cut all characters that is not
            // preceded by a delimiter.
            while (cut > 0) {
                if (isDelimiter(before.charAt(cut - 1))) {
                    return cut;
                }
                --cut;
            }
        }
        return -1;
    }

    /**
     * Find the position of the first delimiter character after the first word
     * after the cursor.
     *
     * @return The delimiter position or -1.
     */
    private int cutWordAfter() {
        if (after.length() > 0) {
            int cut = 0;
            while (cut < after.length() && isDelimiter(after.charAt(cut))) {
                ++cut;
            }
            final int last = after.length() - 1;
            while (cut <= last) {
                if (isDelimiter(after.charAt(cut))) {
                    return cut;
                }
                ++cut;
            }
        }
        return -1;
    }

    private void printAbove(String error) {
        if (printedError != null) {
            terminal.format("\r%s%s",
                            Control.UP,
                            Control.CURSOR_ERASE);
        } else {
            terminal.format("\r%s", Control.CURSOR_ERASE);
        }
        printedError = error;
        terminal.print(error);
        terminal.println();
        printInputLine();
    }

    private void printInputLine() {
        terminal.format("\r%s%s: %s",
                        Control.CURSOR_ERASE,
                        message,
                        before);
        if (after.length() > 0) {
            terminal.format("%s%s", after, Control.cursorLeft(after.length()));
        }
    }

    private boolean isDelimiter(char c) {
        return delimiterPattern.matcher(String.valueOf(c)).matches();
    }

    private final Terminal      terminal;
    private final String        message;
    private final CharValidator charValidator;
    private final LineValidator lineValidator;
    private final TabCompletion tabCompletion;
    private final Pattern       delimiterPattern;

    private String before;
    private String after;
    private String printedError;
}