InputLine.java

  1. /*
  2.  * Copyright (c) 2016, Stein Eldar Johnsen
  3.  *
  4.  * Licensed to the Apache Software Foundation (ASF) under one
  5.  * or more contributor license agreements. See the NOTICE file
  6.  * distributed with this work for additional information
  7.  * regarding copyright ownership. The ASF licenses this file
  8.  * to you under the Apache License, Version 2.0 (the
  9.  * "License"); you may not use this file except in compliance
  10.  * with the License. You may obtain a copy of the License at
  11.  *
  12.  *   http://www.apache.org/licenses/LICENSE-2.0
  13.  *
  14.  * Unless required by applicable law or agreed to in writing,
  15.  * software distributed under the License is distributed on an
  16.  * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  17.  * KIND, either express or implied. See the License for the
  18.  * specific language governing permissions and limitations
  19.  * under the License.
  20.  */
  21. package net.morimekta.terminal.input;

  22. import net.morimekta.strings.ConsoleUtil;
  23. import net.morimekta.strings.chr.Char;
  24. import net.morimekta.strings.chr.Control;
  25. import net.morimekta.terminal.LinePrinter;
  26. import net.morimekta.terminal.Terminal;

  27. import java.io.IOException;
  28. import java.io.UncheckedIOException;
  29. import java.util.regex.Pattern;

  30. /**
  31.  * Class that handled reading a line from terminal input with
  32.  * character and line validators, and optional tab completion.
  33.  * <p>
  34.  * When writing input into an app it is problematic if the app
  35.  * crashes or exits every time you makes invalid input. This can be
  36.  * solved with the {@link CharValidator} and {@link LineValidator}
  37.  * interfaces. The <code>CharValidator</code> validates that any single
  38.  * char input is valid, and will block if not. The <code>LineValidator</code>
  39.  * is triggered when the user finishes the input and checks if the
  40.  * line is valid as a whole, and blocks completion if not.
  41.  * <p>
  42.  * In addition to a {@link TabCompletion} interface may be provided that
  43.  * can complete input based on the current content <i>before</i> the
  44.  * cursor. If the <code>complete()</code> method returns a string, it
  45.  * will <b>replace</b> what was before.
  46.  */
  47. public class InputLine {
  48.     /**
  49.      * Line validator interface.
  50.      */
  51.     @FunctionalInterface
  52.     public interface LineValidator {
  53.         /**
  54.          * Validate the full line.
  55.          *
  56.          * @param line         The line to validate.
  57.          * @param errorPrinter Printer to print out error messages.
  58.          * @return True if valid, false otherwise.
  59.          */
  60.         boolean validate(String line, LinePrinter errorPrinter);
  61.     }

  62.     /**
  63.      * Character validator interface.
  64.      */
  65.     @FunctionalInterface
  66.     public interface CharValidator {
  67.         /**
  68.          * Validate the given char.
  69.          *
  70.          * @param ch           The char to validate.
  71.          * @param errorPrinter Printer to print out error messages.
  72.          * @return True if valid, false otherwise.
  73.          */
  74.         boolean validate(Char ch, LinePrinter errorPrinter);
  75.     }

  76.     /**
  77.      * Tab completion interface.
  78.      */
  79.     @FunctionalInterface
  80.     public interface TabCompletion {
  81.         /**
  82.          * Try to complete the given string.
  83.          * <p>
  84.          * The function should <b>only</b> print to the line printer if there
  85.          * is <b>no</b> selected completion.
  86.          *
  87.          * @param before       The string to be completed. What is before the carriage.
  88.          * @param errorPrinter Print errors or alternatives to this line printer.
  89.          * @return the completed string, or null if no completion.
  90.          */
  91.         String complete(String before, LinePrinter errorPrinter);
  92.     }

  93.     /**
  94.      * Constructor for simple line-input.
  95.      *
  96.      * @param terminal Terminal to use.
  97.      * @param message  Message to print.
  98.      */
  99.     public InputLine(Terminal terminal,
  100.                      String message) {
  101.         this(terminal, message, null, null, null);
  102.     }

  103.     /**
  104.      * Constructor for complete line-input.
  105.      *
  106.      * @param terminal      Terminal to use.
  107.      * @param message       Message to print.
  108.      * @param charValidator The character validator or null.
  109.      * @param lineValidator The line validator or null.
  110.      * @param tabCompletion The tab expander or null.
  111.      */
  112.     public InputLine(Terminal terminal,
  113.                      String message,
  114.                      CharValidator charValidator,
  115.                      LineValidator lineValidator,
  116.                      TabCompletion tabCompletion) {
  117.         this(terminal, message, charValidator, lineValidator, tabCompletion, Pattern.compile("[-/.\\s\\\\]"));
  118.     }

  119.     /**
  120.      * Constructor for complete line-input.
  121.      *
  122.      * @param terminal         Terminal to use.
  123.      * @param message          Message to print.
  124.      * @param charValidator    The character validator or null.
  125.      * @param lineValidator    The line validator or null.
  126.      * @param tabCompletion    The tab expander or null.
  127.      * @param delimiterPattern Pattern matching a character that delimit 'words' that
  128.      *                         are skipped or deleted with [ctrl-left], [ctrl-right],
  129.      *                         [alt-w] and [alt-d].
  130.      */
  131.     public InputLine(Terminal terminal,
  132.                      String message,
  133.                      CharValidator charValidator,
  134.                      LineValidator lineValidator,
  135.                      TabCompletion tabCompletion,
  136.                      Pattern delimiterPattern) {
  137.         if (charValidator == null) {
  138.             charValidator = (c, o) -> {
  139.                 if (c.codepoint() < 0x20 ||
  140.                     !ConsoleUtil.isConsolePrintable(c.codepoint())) {
  141.                     o.println("Invalid character: '" + c.asString() + "'");
  142.                     return false;
  143.                 }
  144.                 return true;
  145.             };
  146.         }
  147.         if (lineValidator == null) {
  148.             lineValidator = (l, o) -> {
  149.                 // Accept all.
  150.                 return true;
  151.             };
  152.         }

  153.         this.terminal = terminal;
  154.         this.message = message;
  155.         this.delimiterPattern = delimiterPattern;
  156.         this.charValidator = charValidator;
  157.         this.lineValidator = lineValidator;
  158.         this.tabCompletion = tabCompletion;
  159.     }

  160.     /**
  161.      * Read line from terminal.
  162.      *
  163.      * @return The resulting line.
  164.      */
  165.     public String readLine() {
  166.         return readLine(null);
  167.     }

  168.     /**
  169.      * Read line from terminal.
  170.      *
  171.      * @param initial The initial (default) value.
  172.      * @return The resulting line.
  173.      */
  174.     public String readLine(String initial) {
  175.         this.before = initial == null ? "" : initial;
  176.         this.after = "";
  177.         this.printedError = null;

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

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

  184.         try {
  185.             for (; ; ) {
  186.                 Char c = terminal.read();
  187.                 if (c == null) {
  188.                     throw new IOException("End of stream.");
  189.                 }

  190.                 int ch = c.codepoint();

  191.                 if (ch == Char.CR || ch == Char.LF) {
  192.                     String line = before + after;
  193.                     if (lineValidator.validate(line, this::printAbove)) {
  194.                         return line;
  195.                     }
  196.                     continue;
  197.                 }

  198.                 handleInterrupt(ch, c);

  199.                 if (handleTab(ch) ||
  200.                     handleBackSpace(ch) ||
  201.                     handleControl(c)) {
  202.                     continue;
  203.                 }

  204.                 if (charValidator.validate(c, this::printAbove)) {
  205.                     before = before + c;
  206.                     printInputLine();
  207.                 }
  208.             }
  209.         } catch (IOException e) {
  210.             throw new UncheckedIOException(e);
  211.         }
  212.     }

  213.     /**
  214.      * Handle tab and tab completion.
  215.      *
  216.      * @param ch The character code point.
  217.      * @return True if handled.
  218.      */
  219.     private boolean handleTab(int ch) {
  220.         if (ch == Char.TAB && tabCompletion != null) {
  221.             String completed = tabCompletion.complete(before, this::printAbove);
  222.             if (completed != null) {
  223.                 before = completed;
  224.                 printInputLine();
  225.             }
  226.             return true;
  227.         }

  228.         return false;
  229.     }

  230.     /**
  231.      * Handle backspace. These are not control sequences, so must be handled separately
  232.      * from those.
  233.      *
  234.      * @param ch The character code point.
  235.      * @return True if handled.
  236.      */
  237.     private boolean handleBackSpace(int ch) {
  238.         if (ch == Char.DEL || ch == Char.BS) {
  239.             // backspace...
  240.             if (before.length() > 0) {
  241.                 before = before.substring(0, before.length() - 1);
  242.                 printInputLine();
  243.             }
  244.             return true;
  245.         }

  246.         return false;
  247.     }

  248.     /**
  249.      * Handle user interrupts.
  250.      *
  251.      * @param ch The character code point.
  252.      * @param c  The char instance.
  253.      * @throws IOException If interrupted.
  254.      */
  255.     private void handleInterrupt(int ch, Char c) throws IOException {
  256.         if (ch == Char.ESC || ch == Char.ABR || ch == Char.EOF) {
  257.             throw new IOException("User interrupted: " + c.asString());
  258.         }
  259.     }

  260.     /**
  261.      * Handle control sequence chars.
  262.      *
  263.      * @param c The control char.
  264.      * @return If the char was handled.
  265.      */
  266.     private boolean handleControl(Char c) {
  267.         if (c instanceof Control) {
  268.             if (c.equals(Control.DELETE)) {
  269.                 if (after.length() > 0) {
  270.                     after = after.substring(1);
  271.                 }
  272.             } else if (c.equals(Control.LEFT)) {
  273.                 if (before.length() > 0) {
  274.                     after = "" + before.charAt(before.length() - 1) + after;
  275.                     before = before.substring(0, before.length() - 1);
  276.                 }
  277.             } else if (c.equals(Control.HOME)) {
  278.                 after = before + after;
  279.                 before = "";
  280.             } else if (c.equals(Control.CTRL_LEFT)) {
  281.                 int cut = cutWordBefore();
  282.                 if (cut > 0) {
  283.                     after = before.substring(cut) + after;
  284.                     before = before.substring(0, cut);
  285.                 } else {
  286.                     after = before + after;
  287.                     before = "";
  288.                 }
  289.             } else if (c.equals(Control.RIGHT)) {
  290.                 if (after.length() > 0) {
  291.                     before = before + after.charAt(0);
  292.                     after = after.substring(1);
  293.                 }
  294.             } else if (c.equals(Control.END)) {
  295.                 before = before + after;
  296.                 after = "";
  297.             } else if (c.equals(Control.CTRL_RIGHT)) {
  298.                 int cut = cutWordAfter();
  299.                 if (cut > 0) {
  300.                     before = before + after.substring(0, cut);
  301.                     after = after.substring(cut);
  302.                 } else {
  303.                     before = before + after;
  304.                     after = "";
  305.                 }
  306.             } else if (c.equals(Control.ALT_W)) {
  307.                 // delete word before the cursor.
  308.                 int cut = cutWordBefore();
  309.                 if (cut > 0) {
  310.                     before = before.substring(0, cut);
  311.                 } else {
  312.                     before = "";
  313.                 }
  314.             } else if (c.equals(Control.ALT_D)) {
  315.                 // delete word after the cursor.
  316.                 int cut = cutWordAfter();
  317.                 if (cut > 0) {
  318.                     after = after.substring(cut);
  319.                 } else {
  320.                     after = "";
  321.                 }
  322.             } else if (c.equals(Control.ALT_K)) {
  323.                 // delete everything after the cursor.
  324.                 after = "";
  325.             } else if (c.equals(Control.ALT_U)) {
  326.                 // delete everything before the cursor.
  327.                 before = "";
  328.             } else {
  329.                 printAbove("Invalid control: " + c.asString());
  330.                 return true;
  331.             }
  332.             printInputLine();
  333.             return true;
  334.         }
  335.         return false;
  336.     }

  337.     /**
  338.      * Find the position of the first character of the last word before
  339.      * the cursor that is preceded by a delimiter.
  340.      *
  341.      * @return The word position or -1.
  342.      */
  343.     private int cutWordBefore() {
  344.         if (before.length() > 0) {
  345.             int cut = before.length() - 1;
  346.             while (cut >= 0 && isDelimiter(before.charAt(cut))) {
  347.                 --cut;
  348.             }
  349.             // We know the 'cut' position character is not a
  350.             // delimiter. Also cut all characters that is not
  351.             // preceded by a delimiter.
  352.             while (cut > 0) {
  353.                 if (isDelimiter(before.charAt(cut - 1))) {
  354.                     return cut;
  355.                 }
  356.                 --cut;
  357.             }
  358.         }
  359.         return -1;
  360.     }

  361.     /**
  362.      * Find the position of the first delimiter character after the first word
  363.      * after the cursor.
  364.      *
  365.      * @return The delimiter position or -1.
  366.      */
  367.     private int cutWordAfter() {
  368.         if (after.length() > 0) {
  369.             int cut = 0;
  370.             while (cut < after.length() && isDelimiter(after.charAt(cut))) {
  371.                 ++cut;
  372.             }
  373.             final int last = after.length() - 1;
  374.             while (cut <= last) {
  375.                 if (isDelimiter(after.charAt(cut))) {
  376.                     return cut;
  377.                 }
  378.                 ++cut;
  379.             }
  380.         }
  381.         return -1;
  382.     }

  383.     private void printAbove(String error) {
  384.         if (printedError != null) {
  385.             terminal.format("\r%s%s",
  386.                             Control.UP,
  387.                             Control.CURSOR_ERASE);
  388.         } else {
  389.             terminal.format("\r%s", Control.CURSOR_ERASE);
  390.         }
  391.         printedError = error;
  392.         terminal.print(error);
  393.         terminal.println();
  394.         printInputLine();
  395.     }

  396.     private void printInputLine() {
  397.         terminal.format("\r%s%s: %s",
  398.                         Control.CURSOR_ERASE,
  399.                         message,
  400.                         before);
  401.         if (after.length() > 0) {
  402.             terminal.format("%s%s", after, Control.cursorLeft(after.length()));
  403.         }
  404.     }

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

  408.     private final Terminal      terminal;
  409.     private final String        message;
  410.     private final CharValidator charValidator;
  411.     private final LineValidator lineValidator;
  412.     private final TabCompletion tabCompletion;
  413.     private final Pattern       delimiterPattern;

  414.     private String before;
  415.     private String after;
  416.     private String printedError;
  417. }