InputSelection.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.StringUtil;
import net.morimekta.strings.chr.Char;
import net.morimekta.strings.chr.Color;
import net.morimekta.strings.chr.Control;
import net.morimekta.strings.chr.Unicode;
import net.morimekta.terminal.LineBuffer;
import net.morimekta.terminal.LinePrinter;
import net.morimekta.terminal.Terminal;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;

import static java.lang.Math.ceil;
import static java.lang.Math.log10;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.String.format;

/**
 * Tabular selection with simple navigation. It displays a list of items in a
 * table that can be paginated and navigated between using the arrow keys,
 * numbers, pg-up, pg-down etc.
 *
 * <pre>{@code
 * {message}: [c=command, o=overview]
 *  1 {line 1}
 *  2 {line 2}
 *  3 {line 3}
 * Your choice (1..N or c,o):
 * }</pre>
 * <p>
 * When doing actions, it is possible to add extra output to the user, this will
 * be printed <i>below</i> the `Your choice` line with a {@link LinePrinter}
 * connected back to the {@link InputSelection} instance.
 * <p>
 * By default, there are <b>no actions</b> on the items, even <code>exit</code>
 * is not provided. Actions are specified using a list of "commands", that each
 * have an action, where each action has a <code>Reaction</code>. Note that any
 * runtime exception thrown while handling an action exits the selection.
 */
public class InputSelection<E> {
    /**
     * Interface for the entry printer.
     *
     * @param <E> The entry type.
     */
    @FunctionalInterface
    public interface EntryPrinter<E> {
        /**
         * Print the entry line.
         *
         * @param entry   The entry to be printed.
         * @param bgColor The background color.
         * @return The entry line.
         */
        String print(E entry, Color bgColor);

        /**
         * Print the entry line with default background.
         *
         * @param entry The entry to be printed.
         * @return The entry line.
         */
        default String print(E entry) {
            return print(entry, Color.BG_DEFAULT);
        }
    }

    /**
     * Command reaction enum.
     */
    public enum Reaction {
        /**
         * Select the entry.
         */
        SELECT,

        /**
         * Exit selection with no value (null).
         */
        EXIT,

        /**
         * Stay in the selection.
         */
        STAY,

        /**
         * Stay in the selection and update entries (clear draw cache and redraw
         * all visible entries). Keeps the same selected item regardless of
         * position.
         */
        UPDATE_KEEP_ITEM,

        /**
         * Stay in the selection and update entries (clear draw cache and redraw
         * all visible entries). Keeps the same selected position regardless of
         * the underlying item.
         */
        UPDATE_KEEP_POSITION,
    }

    /**
     * The command action interface.
     *
     * @param <E> The entry value type.
     */
    @FunctionalInterface
    public interface Action<E> {
        /**
         * Call the command with the given entry.
         *
         * @param entry   The entry to work on.
         * @param printer Line printer to show extra messages.
         * @return The reaction.
         */
        Reaction call(E entry, LinePrinter printer);
    }

    /**
     * Command. The command works on an entry.
     *
     * @param <E> Value type.
     */
    public static class Command<E> {
        public Command(char key, String name, Action<E> action) {
            this(key, name, action, false);
        }

        public Command(char key, String name, Action<E> action, boolean hidden) {
            this(new Unicode(key), name, action, hidden);
        }

        public Command(Char key, String name, Action<E> action) {
            this(key, name, action, false);
        }

        public Command(Char key, String name, Action<E> action, boolean hidden) {
            // Since '\r' and 'n' are basically interchangeable.
            this.key = key.codepoint() == '\r' ? new Unicode('\n') : key;
            this.name = name;
            this.action = action;
            this.hidden = hidden;
        }

        private final Char      key;
        private final String    name;
        private final Action<E> action;
        private final boolean   hidden;
    }

    /**
     * Create a selection instance.
     *
     * @param terminal The terminal to print to.
     * @param prompt   The prompt to introduce the selection with.
     * @param entries  The list of entries.
     * @param commands The list of commands.
     * @param printer  The entry printer.
     */
    public InputSelection(Terminal terminal,
                          String prompt,
                          List<E> entries,
                          List<Command<E>> commands,
                          EntryPrinter<E> printer) {
        this(terminal, prompt, entries, commands, printer, defaultPageSize(terminal), 5, 0);
    }

    /**
     * Create a selection instance.
     *
     * @param terminal   The terminal to print to.
     * @param prompt     The prompt to introduce the selection with.
     * @param entries    The list of entries.
     * @param commands   The list of commands.
     * @param printer    The entry printer.
     * @param pageSize   The max number of entries per page.
     * @param pageMargin The number of entries above page size needed to trigger
     *                   paging.
     * @param lineWidth  The number of columns to print on.
     */
    public InputSelection(Terminal terminal,
                          String prompt,
                          List<E> entries,
                          List<Command<E>> commands,
                          EntryPrinter<E> printer,
                          int pageSize,
                          int pageMargin,
                          int lineWidth) {
        this(terminal, prompt, entries, commands, printer, Clock.systemUTC(), pageSize, pageMargin, lineWidth);
    }

    /**
     * Create a selection instance.
     *
     * @param terminal   The terminal to print to.
     * @param prompt     The prompt to introduce the selection with.
     * @param entries    The list of entries.
     * @param commands   The list of commands.
     * @param printer    The entry printer.
     * @param clock      The system clock.
     * @param pageSize   The max number of entries per page.
     * @param pageMargin The number of entries above page size needed to trigger
     *                   paging.
     * @param lineWidth  The number of columns to print on.
     */
    public InputSelection(Terminal terminal,
                          String prompt,
                          List<E> entries,
                          List<Command<E>> commands,
                          EntryPrinter<E> printer,
                          Clock clock,
                          int pageSize,
                          int pageMargin,
                          int lineWidth) {
        this.terminal = terminal;
        this.lineBuffer = new net.morimekta.terminal.LineBuffer(terminal);
        this.prompt = prompt;
        this.entries = entries;
        this.commands = commands;
        this.clock = clock;

        this.commandMap = new HashMap<>();
        for (Command<E> cmd : commands) {
            this.commandMap.put(cmd.key, cmd);
        }

        this.printer = printer;
        this.pageSize = pageSize;
        if (lineWidth == 0) {
            this.lineWidth = terminal.getTTY().getTerminalSize().cols;
        } else {
            this.lineWidth = lineWidth;
        }
        this.digits = "";

        if (entries.size() > (pageSize + pageMargin)) {
            this.paged = true;
            this.shownEntries = pageSize;
        } else {
            this.paged = false;
            this.shownEntries = entries.size();
        }
    }

    public E select() {
        return select(null);
    }

    public E select(E initial) {
        updateSelectionIndex(initial);

        // Initialize lines:
        // - prompt line
        lineBuffer.add(makePromptLine());
        // - if (paged): hidden entries line
        if (paged) {
            lineBuffer.add(makeMoreEntriesLine());
        }
        // - shownEntries * (entry)
        for (int i = 0; i < shownEntries; ++i) {
            lineBuffer.add(makeEntryLine(i));
        }

        // - if (paged): hidden entries line
        if (paged) {
            lineBuffer.add(makeMoreEntriesLine());
        }
        // - selection line
        lineBuffer.add(makeSelectionLine());

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

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

                if (handleDigit(ch) ||
                    handleControl(c)) {
                    continue;
                }

                Command<E> cmd = commandMap.get(c);
                if (cmd != null) {
                    clearExtraLines();

                    int index = currentIndex + currentOffset;

                    E current = entries.get(index);
                    Reaction reaction = cmd.action.call(current, this::printExtraLine);
                    switch (reaction) {
                        case SELECT:
                            return current;
                        case EXIT:
                            return null;
                        case STAY: {
                            // Updates the selected line only.
                            int off = paged ? 2 : 1;
                            lineBuffer.update(off + currentOffset, makeEntryLine(index));
                            break;
                        }
                        case UPDATE_KEEP_ITEM: {
                            // E.g. updated sorting. Number of entries must
                            // remain the same.
                            updateSelectionIndex(current);

                            ArrayList<String> updates = new ArrayList<>();

                            if (paged) {
                                updates.add(makeMoreEntriesLine());
                            }
                            for (int i = 0; i < shownEntries; ++i) {
                                updates.add(makeEntryLine(i));
                            }
                            if (paged) {
                                updates.add(makeMoreEntriesLine());
                            }
                            lineBuffer.update(1, updates);
                            break;
                        }
                        case UPDATE_KEEP_POSITION: {
                            int off = paged ? 2 : 1;
                            ArrayList<String> updates = new ArrayList<>();
                            for (int i = 0; i < shownEntries; ++i) {
                                updates.add(makeEntryLine(i));
                            }
                            lineBuffer.update(off, updates);
                            break;
                        }
                    }
                } else {
                    printExtraLine(" --- Not found: " + c.asString());
                }
            }

        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private boolean handleDigit(int ch) {
        if ('0' <= ch && ch <= '9') {
            long ts = clock.millis();
            // Forget typed digits after 2 seconds.
            if (ts > digitsTimestamp + 2000) {
                digits = "";
            }
            digitsTimestamp = ts;

            int pos = Integer.parseInt(digits + (char) ch);
            // if this is not the first digit, AND the position has run past
            // the last item, go back and make it the first digit.
            if (digits.length() > 0 && pos >= entries.size()) {
                digits = String.valueOf((char) ch);
                pos = ch - '0';
            } else {
                digits = String.valueOf(pos);
            }

            if (pos <= entries.size()) {
                updateSelection(pos - 1);
            } else {
                updateSelection(entries.size() - 1);
            }
            int off = (paged ? 3 : 1) + shownEntries;
            lineBuffer.update(off, makeSelectionLine());

            return true;
        } else {
            digits = "";
            digitsTimestamp = 0;

            int off = (paged ? 3 : 1) + shownEntries;
            lineBuffer.update(off, makeSelectionLine());
        }

        return false;
    }

    private boolean handleControl(Char c) {
        if (c instanceof Control) {
            int last = entries.size() - 1;
            if (c.equals(Control.HOME)) {
                updateSelection(0);
            } else if (c.equals(Control.END)) {
                updateSelection(last);
            } else if (c.equals(Control.UP)) {
                updateSelection(max(0, currentIndex + currentOffset - 1));
            } else if (c.equals(Control.DOWN)) {
                updateSelection(min(last, currentIndex + currentOffset + 1));
            } else if (c.equals(Control.LEFT)) {
                updateSelection(max(0, currentIndex + currentOffset - shownEntries));
            } else if (c.equals(Control.RIGHT)) {
                updateSelection(min(last, currentIndex + currentOffset + shownEntries));
            } else {
                return false;
            }
            return true;
        }
        return false;
    }

    private void clearExtraLines() {
        if (extraLines > 0) {
            lineBuffer.clearLast(extraLines);
        }
        extraLines = 0;
    }

    private void printExtraLine(String line) {
        lineBuffer.add(line);
        ++extraLines;
    }

    private void updateSelection(int newAbsolute) {
        int currentAbsolute = currentIndex + currentOffset;
        if (newAbsolute != currentAbsolute) {
            // something changed.
            int offset = paged ? pageSize * (newAbsolute / pageSize) : 0;
            int index = newAbsolute - offset;
            int off = paged ? 2 : 1;

            if (offset != currentOffset) {
                // change page.
                currentIndex = index;
                currentOffset = offset;

                ArrayList<String> updates = new ArrayList<>();
                updates.add(makeMoreEntriesLine());
                for (int i = 0; i < shownEntries; ++i) {
                    updates.add(makeEntryLine(i));
                }
                updates.add(makeMoreEntriesLine());
                lineBuffer.update(1, updates);
            } else {
                int oldIndex = currentIndex;
                currentIndex = index;
                lineBuffer.update(off + oldIndex, makeEntryLine(oldIndex));
                lineBuffer.update(off + currentIndex, makeEntryLine(currentIndex));
            }
        }
    }

    private void updateSelectionIndex(E selection) {
        if (selection != null) {
            int currentAbsolute = entries.indexOf(selection);
            if (paged) {
                currentOffset = pageSize * (currentAbsolute / pageSize);
                currentIndex = currentAbsolute - currentOffset;
            } else {
                currentIndex = currentAbsolute;
            }
        }
    }

    private String makePromptLine() {
        return format("%s [%s]",
                      prompt,
                      commands.stream()
                              .filter(c -> !c.hidden)
                              .map(c -> format("%s=%s", c.key, c.name))
                              .collect(Collectors.joining(", ")));
    }

    private String makeSelectionLine() {
        return format("Your choice (%d..%d or %s): %s",
                      1, entries.size(),
                      commands.stream()
                              .filter(c -> !c.hidden)
                              .map(c -> format("%s", c.key))
                              .collect(Collectors.joining(",")),
                      digits);
    }

    private String makeMoreEntriesLine() {
        int before = currentOffset;
        int after = max(0, entries.size() - currentOffset - pageSize);
        int pagesBefore = currentOffset / pageSize;
        int pagesAfter = (int) ceil((double) after / pageSize);

        String seeBefore = StringUtil.rightPad(
                before == 0 ? "" : format("<-- (pages: %d, items: %d)", pagesBefore, before), 38);
        String seeAfter = StringUtil.leftPad(
                after == 0 ? "" : format("(pages: %d, items: %d) -->", pagesAfter, after), 38);

        return format("  %s%s  ", seeBefore, seeAfter);
    }

    private String makeEntryLine(int index) {
        int absoluteIndex = index + currentOffset;
        if (absoluteIndex >= entries.size()) {
            return "";
        }

        StringBuilder builder = new StringBuilder();
        boolean selected = index == currentIndex;
        Color bg = selected ? Color.BG_BLUE : null;
        if (selected) {
            builder.append(bg);
        }

        int idxSize = 2 + (int) log10(entries.size());

        builder.append(StringUtil.leftPad(String.valueOf(absoluteIndex + 1), idxSize))
               .append(' ');

        E entry = entries.get(absoluteIndex);
        builder.append(selected ? printer.print(entry, bg) : printer.print(entry));

        String line = builder.toString();

        int pw = StringUtil.printableWidth(line);
        if (pw > lineWidth) {
            line = StringUtil.clipWidth(line, lineWidth);
        } else if (pw < lineWidth && bg != null) {
            builder.append(Color.CLEAR)
                   .append(bg)
                   .append(" ".repeat(lineWidth - pw));
            line = builder.toString();
        }
        return line + Color.CLEAR;
    }

    private static int defaultPageSize(Terminal terminal) {
        if (terminal.getTTY().isInteractive()) {
            // 2 rows above, 2 rows below, 5 margin and 3 extra.
            // 22 rows -> 10 entries + 5 margin
            // 44 rows -> 22 entries + 5 margin
            // 80 rows -> 35 entries + 5 margin (max)
            return Math.max(35, terminal.getTTY().getTerminalSize().rows - 12);
        }
        return 20;
    }

    private final Terminal                  terminal;
    private final LineBuffer                lineBuffer;
    private final String                    prompt;
    private final List<E>                   entries;
    private final List<Command<E>>          commands;
    private final HashMap<Char, Command<E>> commandMap;
    private final EntryPrinter<E>           printer;
    private final Clock                     clock;
    private final int                       pageSize;
    private final int                       lineWidth;

    private final boolean paged;
    private final int     shownEntries;

    private String digits;
    private long   digitsTimestamp;
    private int    extraLines;

    // The item index relative to the current page.
    private int currentIndex;
    // The index offset to the real start.
    private int currentOffset;
}