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