Selection.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.selection;

import net.morimekta.io.tty.TTYMode;
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.Closeable;
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.NoSuchElementException;
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;
import static java.util.Objects.requireNonNull;
import static net.morimekta.strings.StringUtil.printableWidth;
import static net.morimekta.strings.StringUtil.wrap;

/**
 * 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 Selection} 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 Selection<E> implements LinePrinter, Closeable {
    /**
     * Create a new selection builder selecting from a list. If the list is
     * modified in the background (i.e. by actions), future fetches will be
     * able to pick that up, given the
     * {@link SelectionReaction#UPDATE_KEEP_ITEM} or
     * {@link SelectionReaction#UPDATE_KEEP_POSITION} reactions are used.
     *
     * @param source The list source of items.
     * @param <E>    The item type.
     * @return The selection builder.
     */
    public static <E> SelectionBuilder<E> newBuilder(List<E> source) {
        return newBuilder(new EntryListSource<>(source));
    }

    /**
     * Create a new selection builder selecting from an entry source. If the
     * list is modified in the background (i.e. by actions), future fetches will
     * be able to pick that up, given the
     * {@link SelectionReaction#UPDATE_KEEP_ITEM} or
     * {@link SelectionReaction#UPDATE_KEEP_POSITION} reactions are used.
     *
     * @param source The entry source of items.
     * @param <E>    The item type.
     * @return The selection builder.
     */
    public static <E> SelectionBuilder<E> newBuilder(EntrySource<E> source) {
        return new SelectionBuilder<>(source);
    }

    /**
     * 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.
     * @param pageHint     True if pagination is always on.
     * @param initialIndex Initial index of first selection.
     */
    protected Selection(Terminal terminal,
                        String prompt,
                        EntrySource<E> entries,
                        List<EntryCommand<E>> commands,
                        EntryPrinter<E> printer,
                        Clock clock,
                        int pageSize,
                        int pageMargin,
                        int lineWidth,
                        boolean pageHint,
                        int initialIndex) {
        this.terminal = terminal.withMode(TTYMode.RAW);
        this.entries = entries;
        this.commands = commands;
        this.clock = clock;

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

        this.printer = printer;
        this.pageSize = pageSize;
        if (lineWidth == 0) {
            this.lineWidth = terminal.tty().getTerminalSize().cols;
        } else {
            this.lineWidth = lineWidth;
        }
        this.digits = "";
        this.currentSize = entries.size();
        if (pageHint || currentSize > (pageSize + pageMargin)) {
            this.paged = true;
            this.shownEntries = pageSize;
        } else {
            this.paged = false;
            this.shownEntries = currentSize;
        }
        if (initialIndex > 0) {
            if (paged) {
                currentOffset = shownEntries * (initialIndex / shownEntries);
                currentIndex = initialIndex - currentOffset;
            } else {
                currentOffset = 0;
                currentIndex = initialIndex;
            }
        }
        currentEntry = entries.get(currentIndex + currentOffset);

        var ps = this.terminal.rawOut();
        // Initialize lines:
        // - prompt line
        var cmds = commands.stream()
                           .filter(c -> !c.hidden)
                           .map(c -> {
                               if (c.key.codepoint() == ' ') {
                                   return format("<space>=%s", c.name);
                               } else {
                                   return format("%s=%s",
                                                 c.key.asString(),
                                                 // Use NBSP in case we trigger wrapping.
                                                 c.name.replace(' ', '\u00a0'));
                               }
                           })
                           .collect(Collectors.joining(", "));
        var promptLine = format("%s [%s]", prompt, cmds);
        if (printableWidth(promptLine) > lineWidth) {
            ps.print(prompt);
            if (printableWidth(cmds) > lineWidth - 6) {
                for (var line : wrap("    [", "     ", cmds + "]", lineWidth).split("\n")) {
                    ps.print(Char.CR);
                    ps.print(Char.LF);
                    ps.print(line);
                }
            } else {
                ps.print(Char.CR);
                ps.print(Char.LF);
                ps.printf("    [%s]", cmds);
            }
        } else {
            ps.print(promptLine);
        }
        ps.print(Char.CR);
        ps.print(Char.LF);

        this.lineBuffer = new LineBuffer(ps);
        // - if (paged): hidden entries line
        if (paged) {
            lineBuffer.add(makeMoreEntriesLine());
        }
        // - shownEntries * (entry)
        addShownEntryLines(lineBuffer);
        // - if (paged): hidden entries line
        if (paged) {
            lineBuffer.add(makeMoreEntriesLine());
        }
        // - selection line
        lineBuffer.add(makeSelectionLine());
    }

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

    @Override
    public void println(String message) {
        addExtraLine(message);
    }

    // ----------------------------------

    /**
     * Run interactive selection, including action handling, and return the
     * selected item. See {@link SelectionReaction}.
     *
     * @return The selected result or null if exited without selection.
     * @throws UncheckedIOException If end of input or user interrupt.
     */
    public E runSelection() {
        try {
            for (; ; ) {
                Char c = terminal.charReader().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;
                }

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

                    int absoluteIndex = currentIndex + currentOffset;
                    SelectionReaction reaction = cmd.action.call(absoluteIndex, currentEntry, this);
                    if (reaction != null) {
                        switch (reaction) {
                            case SELECT:
                                return currentEntry;
                            case EXIT:
                                return null;
                            case STAY: {
                                // Updates the selected line only.
                                int off = paged ? 1 : 0;
                                lineBuffer.set(off + currentIndex, makeEntryLine(currentIndex, currentEntry));
                                break;
                            }
                            case UPDATE_KEEP_ITEM: {
                                refreshKeepEntry();
                                break;
                            }
                            case UPDATE_KEEP_POSITION: {
                                refreshKeepPosition();
                                break;
                            }
                        }
                    }
                } else {
                    replaceExtraLine(" --- Not found: " + c.asString());
                }
            }

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

    /**
     * Move selection to index. If selection is not on the current page, will
     * change page to the one with the selected index.
     *
     * @param index The index to move the selection to.
     * @throws IndexOutOfBoundsException If index outside bounds.
     */
    public void select(int index) {
        updateSelection(index);
    }

    /**
     * Move selection to specified entry. If selection is not on the current
     * page, will change page to the one with the selected entry.
     *
     * @param entry Entry to move selection to.
     * @throws NoSuchElementException If no such entry found.
     */
    public void select(E entry) {
        updateSelectionIndex(requireNonNull(entry, "entry == null"));
    }

    /**
     * refresh the items in the view, and stay on the same position (index
     * wise). If the number of available items has decreased to be lower than
     * for the current index, will select the last entry.
     */
    public void refreshKeepPosition() {
        var newSize = entries.size();
        var updates = new ArrayList<String>();
        if (!paged && newSize != currentSize) {
            // Only on non-paged can the total size of the view
            // change. Offset is always 0.
            shownEntries = newSize;
            currentSize = newSize;
            currentIndex = Math.min(currentIndex, newSize - 1);

            addShownEntryLines(updates);
            updates.add(makeSelectionLine());
            addExtraLines(updates);
            lineBuffer.clear();
            lineBuffer.addAll(updates);
        } else {
            currentSize = newSize;
            var newAbsolute = Math.min(currentIndex + currentOffset, newSize - 1);
            currentOffset = paged ? pageSize * (newAbsolute / pageSize) : 0;
            if (paged) {
                updates.add(makeMoreEntriesLine());
            }
            addShownEntryLines(updates);
            if (paged) {
                updates.add(makeMoreEntriesLine());
            }
            lineBuffer.setAll(0, updates);
        }
        currentEntry = entries.get(currentIndex + currentOffset);
    }

    /**
     * Refresh the items in the view, and stay on the same entry (so will look
     * for the currently selected entry in the entry source, and use that index
     * as the new selected / current position.). If that entry is not found,
     * will keep the current position.
     */
    public void refreshKeepEntry() {
        var newSize = entries.size();
        var updates = new ArrayList<String>();
        if (!paged && newSize != currentSize) {
            // Only on non-paged can the total size of the view
            // change. Offset is always 0.
            shownEntries = newSize;
            currentSize = newSize;
            updateSelectionIndex(currentEntry);

            addShownEntryLines(updates);
            updates.add(makeSelectionLine());
            addExtraLines(updates);
            lineBuffer.clear();
            lineBuffer.addAll(updates);
        } else {
            currentSize = newSize;
            updateSelectionIndex(currentEntry);
            if (paged) {
                updates.add(makeMoreEntriesLine());
            }
            addShownEntryLines(updates);
            if (paged) {
                updates.add(makeMoreEntriesLine());
            }
            lineBuffer.setAll(0, updates);
        }
    }

    /**
     * Make a single confirmation with no default. Printed below the selection
     * table.
     *
     * @param confirmation The confirmation text.
     * @return True if confirmed.
     */
    public boolean confirm(String confirmation) {
        return confirmInternal(confirmation + " [y/n]", null);
    }

    /**
     * Make a single confirmation with specified default. Printed below the
     * selection table.
     *
     * @param confirmation The confirmation text.
     * @param defaultValue The default value.
     * @return True if confirmed.
     */
    public boolean confirm(String confirmation, boolean defaultValue) {
        return confirmInternal(confirmation + (defaultValue ? " [Y/n]" : " [y/N]"), defaultValue);
    }

    // ------ PRIVATE ------

    private boolean confirmInternal(String confirmation, Boolean defaultValue) {
        addExtraLine(confirmation);
        try {
            for (; ; ) {
                Char c = terminal.charReader().read();
                if (c == null) {
                    throw new IOException("End of stream.");
                }
                if (c.codepoint() == Char.ESC || c.codepoint() == Char.ABR || c.codepoint() == Char.EOF) {
                    replaceExtraLine(String.format("%s User interrupted.", confirmation));
                    throw new IOException("User interrupted: " + c.asString());
                } else if (c.codepoint() == 'y') {
                    replaceExtraLine(confirmation + " Yes.");
                    return true;
                } else if (c.codepoint() == 'n') {
                    replaceExtraLine(confirmation + " No.");
                    return false;
                } else if (c.codepoint() == ' ' || c.codepoint() == Char.LF) {
                    if (defaultValue != null) {
                        replaceExtraLine(confirmation + " Yes.");
                        return defaultValue;
                    } else {
                        // ignore this letter if no default value exists.
                        continue;
                    }
                }
                replaceExtraLine(String.format(
                        "%s '%s' is not valid input.",
                        confirmation,
                        c.asString()));
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private void addExtraLines(List<String> lines) {
        if (extraLines > 0) {
            lines.addAll(lineBuffer.subList(lineBuffer.size() - extraLines, lineBuffer.size()));
        }
    }

    private void addShownEntryLines(List<String> lines) {
        var content = entries.load(currentOffset, shownEntries);
        for (int i = 0; i < content.size(); ++i) {
            lines.add(makeEntryLine(i, content.get(i)));
        }
        for (int i = content.size(); i < shownEntries; ++i) {
            lines.add(makeEntryLine(i, null));
        }
    }

    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.isEmpty() && 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 ? 2 : 0) + shownEntries;
            lineBuffer.set(off, makeSelectionLine());

            return true;
        } else if (!digits.isEmpty()) {
            digits = "";
            digitsTimestamp = 0;

            int off = (paged ? 2 : 0) + shownEntries;
            lineBuffer.set(off, makeSelectionLine());
        }

        return false;
    }

    private boolean handleControl(Char c) {
        if (c instanceof Control) {
            int last = currentSize - 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 if (c.equals(Control.PAGE_UP)) {
                if (currentIndex == 0) {
                    updateSelection(max(0, currentOffset - 1));
                } else {
                    updateSelection(currentOffset);
                }
            } else if (c.equals(Control.PAGE_DOWN)) {
                if (currentIndex == shownEntries - 1) {
                    updateSelection(min(last, currentOffset + shownEntries));
                } else {
                    updateSelection(min(last, currentOffset + shownEntries - 1));
                }
            } else {
                return false;
            }
            return true;
        }
        return false;
    }

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

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

    private void replaceExtraLine(String line) {
        if (extraLines == 0) {
            addExtraLine(line);
        } else {
            lineBuffer.set(lineBuffer.size() - 1, line);
        }
    }

    private void updateSelection(int newAbsolute) {
        int oldAbsolute = currentIndex + currentOffset;
        if (newAbsolute != oldAbsolute) {
            // something changed.
            int newOffset = paged ? pageSize * (newAbsolute / pageSize) : 0;
            int newIndex = newAbsolute - newOffset;

            if (newOffset != currentOffset) {
                // change page.
                currentIndex = newIndex;
                currentOffset = newOffset;
                currentEntry = entries.get(newAbsolute);

                ArrayList<String> updates = new ArrayList<>();
                updates.add(makeMoreEntriesLine());
                addShownEntryLines(updates);
                updates.add(makeMoreEntriesLine());
                lineBuffer.setAll(0, updates);
            } else {
                var oldIndex = currentIndex;
                var oldEntry = currentEntry;
                currentIndex = newIndex;
                currentEntry = entries.get(newAbsolute);
                int off = paged ? 1 : 0;
                lineBuffer.set(off + oldIndex, makeEntryLine(oldIndex, oldEntry));
                lineBuffer.set(off + currentIndex, makeEntryLine(newIndex, currentEntry));
            }
        } else {
            currentEntry = entries.get(newAbsolute);
        }
    }

    private void updateSelectionIndex(E selection) {
        if (selection != null) {
            int newAbsolute = entries.indexOf(selection);
            if (newAbsolute >= 0) {
                if (paged) {
                    currentOffset = pageSize * (newAbsolute / pageSize);
                    currentIndex = newAbsolute - currentOffset;
                } else {
                    currentIndex = newAbsolute;
                }
                currentEntry = selection;
            } else {
                throw new NoSuchElementException();
            }
        }
    }

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

    private String makeMoreEntriesLine() {
        int before = currentOffset;
        int after = max(0, currentSize - currentOffset - pageSize);
        int pagesBefore = currentOffset / pageSize;
        int pagesAfter = (int) ceil((double) after / pageSize);
        var width = (lineWidth - 4) / 2;

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

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

    private String makeEntryLine(int viewIndex, E entry) {
        int absoluteIndex = viewIndex + currentOffset;
        if (absoluteIndex >= currentSize) {
            return "";
        }

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

        int idxSize = 2 + (int) log10(currentSize);

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

        builder.append(selected ? printer.print(entry, bg) : printer.print(entry));

        String line = builder.toString();

        int pw = 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 final Terminal                       terminal;
    private final LineBuffer                     lineBuffer;
    private final EntrySource<E>                 entries;
    private final List<EntryCommand<E>>          commands;
    private final HashMap<Char, EntryCommand<E>> commandMap;
    private final EntryPrinter<E>                printer;
    private final Clock                          clock;
    private final int                            pageSize;
    private final int                            lineWidth;

    private final boolean paged;
    private       int     shownEntries;

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

    // The number of items seen last time size was checked.
    private int currentSize;
    // The item index relative to the current page.
    private int currentIndex;
    // The index offset to the real start.
    private int currentOffset;
    // The last item that was navigated to.
    private E   currentEntry;
}