SelectionBuilder.java

/*
 * Copyright 2024 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.TTY;
import net.morimekta.strings.chr.Char;
import net.morimekta.terminal.LinePrinter;
import net.morimekta.terminal.Terminal;

import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.function.Function;

import static java.util.Objects.requireNonNull;

/**
 * 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 SelectionBuilder} 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 SelectionBuilder<E> {
    /**
     * Create a selection builder instance.
     *
     * @param source Entry source for getting entries, and specifying entry
     *               type.
     */
    public SelectionBuilder(EntrySource<E> source) {
        this.entries = requireNonNull(source, "source == null");
        this.commands = new ArrayList<>();
    }

    /**
     * Set the initially selected item.
     *
     * @param initial Initial entry. If entry is not found in the entry source,
     *                this method will throw exception.
     * @return The builder.
     */
    public SelectionBuilder<E> initial(E initial) {
        if (initialIndex != null) {
            throw new IllegalStateException("Initial entry override");
        }
        var idx = entries.indexOf(initial);
        if (idx < 0) {
            throw new NoSuchElementException("Invalid initial: " + initial);
        }
        this.initialIndex = idx;
        return this;
    }

    /**
     * Set the initially selected item index.
     *
     * @param index The index to set.
     * @return The builder.
     */
    public SelectionBuilder<E> initial(int index) {
        if (index < 0) {
            throw new IllegalArgumentException("Invalid initial: index=" + index);
        }
        if (index >= entries.size()) {
            throw new IndexOutOfBoundsException("Invalid initial: index=" + index + ", size=" + entries.size());
        }
        if (initialIndex != null) {
            throw new IllegalStateException("Initial entry override");
        }
        this.initialIndex = index;
        return this;
    }

    /**
     * Add a reaction to key-press.
     *
     * @param c        The character to react to.
     * @param name     The command name.
     * @param reaction The reaction.
     * @return The builder.
     */
    public SelectionBuilder<E> on(char c, String name, SelectionReaction reaction) {
        return on(c, name, (idx, entry, sel) -> reaction);
    }

    /**
     * Add an action to key-press.
     *
     * @param c      The character to react to.
     * @param name   The command name.
     * @param action The action.
     * @return The builder.
     */
    public SelectionBuilder<E> on(char c, String name, Function<E, SelectionReaction> action) {
        return on(c, name, (idx, entry, sel) -> action.apply(entry));
    }

    /**
     * Add an action to key-press.
     *
     * @param c      The character to react to.
     * @param name   The command name.
     * @param action The action.
     * @return The builder.
     */
    public SelectionBuilder<E> on(char c, String name, EntryAction<E> action) {
        commands.add(new EntryCommand<>(c, name, action));
        return this;
    }

    /**
     * Add a reaction to key-press.
     *
     * @param c        The character to react to.
     * @param name     The command name.
     * @param reaction The reaction.
     * @return The builder.
     */
    public SelectionBuilder<E> on(Char c, String name, SelectionReaction reaction) {
        return on(c, name, (idx, entry, sel) -> reaction);
    }

    /**
     * Add an action to key-press.
     *
     * @param c      The character to react to.
     * @param name   The command name.
     * @param action The action.
     * @return The builder.
     */
    public SelectionBuilder<E> on(Char c, String name, Function<E, SelectionReaction> action) {
        return on(c, name, (idx, entry, sel) -> action.apply(entry));
    }

    /**
     * Add an action to key-press.
     *
     * @param c      The character to react to.
     * @param name   The command name.
     * @param action The action.
     * @return The builder.
     */
    public SelectionBuilder<E> on(Char c, String name, EntryAction<E> action) {
        commands.add(new EntryCommand<>(c, name, action));
        return this;
    }

    /**
     * Add a hidden reaction to key-press. This will not be shown in the prompt
     * overview of actions.
     *
     * @param c        The character to react to.
     * @param reaction The reaction.
     * @return The builder.
     */
    public SelectionBuilder<E> hiddenOn(char c, SelectionReaction reaction) {
        return hiddenOn(c, (idx, entry, sel) -> reaction);
    }

    /**
     * Add a hidden reaction to key-press. This will not be shown in the prompt
     * overview of actions.
     *
     * @param c      The character to react to.
     * @param action The action.
     * @return The builder.
     */
    public SelectionBuilder<E> hiddenOn(char c, Function<E, SelectionReaction> action) {
        return hiddenOn(c, (idx, entry, sel) -> action.apply(entry));
    }

    /**
     * Add a hidden reaction to key-press. This will not be shown in the prompt
     * overview of actions.
     *
     * @param c      The character to react to.
     * @param action The action.
     * @return The builder.
     */
    public SelectionBuilder<E> hiddenOn(char c, EntryAction<E> action) {
        commands.add(new EntryCommand<>(c, action));
        return this;
    }

    /**
     * Add a hidden reaction to key-press. This will not be shown in the prompt
     * overview of actions.
     *
     * @param c        The character to react to.
     * @param reaction The reaction.
     * @return The builder.
     */
    public SelectionBuilder<E> hiddenOn(Char c, SelectionReaction reaction) {
        return hiddenOn(c, (idx, entry, sel) -> reaction);
    }

    /**
     * Add a hidden reaction to key-press. This will not be shown in the prompt
     * overview of actions.
     *
     * @param c      The character to react to.
     * @param action The action.
     * @return The builder.
     */
    public SelectionBuilder<E> hiddenOn(Char c, Function<E, SelectionReaction> action) {
        return hiddenOn(c, (idx, entry, sel) -> action.apply(entry));
    }

    /**
     * Add a hidden reaction to key-press. This will not be shown in the prompt
     * overview of actions.
     *
     * @param c      The character to react to.
     * @param action The action.
     * @return The builder.
     */
    public SelectionBuilder<E> hiddenOn(Char c, EntryAction<E> action) {
        commands.add(new EntryCommand<>(c, action));
        return this;
    }

    /**
     * @param prompt Prompt to show before the selection.
     * @return The builder.
     */
    public SelectionBuilder<E> prompt(String prompt) {
        this.prompt = requireNonNull(prompt, "prompt == null");
        return this;
    }

    /**
     * @param printer Printer for making selection line from items.
     * @return The builder.
     */
    public SelectionBuilder<E> printer(EntryPrinter<E> printer) {
        this.printer = printer;
        return this;
    }

    /**
     * @param tty TTY to control terminal IO.
     * @return The builder.
     */
    public SelectionBuilder<E> tty(TTY tty) {
        if (terminal != null) {
            throw new IllegalArgumentException("terminal is already set");
        }
        this.tty = requireNonNull(tty, "tty == null");
        return this;
    }

    /**
     * @param terminal Terminal for handling IO.
     * @return The builder.
     */
    public SelectionBuilder<E> terminal(Terminal terminal) {
        if (tty != null) {
            throw new IllegalArgumentException("tty is already set");
        }
        this.terminal = requireNonNull(terminal, "terminal == null");
        return this;
    }

    /**
     * @param clock Clock for timing actions. Used to decide whether a digit
     *              adds digit to positional number or is a new position.
     * @return The builder.
     */
    public SelectionBuilder<E> clock(Clock clock) {
        this.clock = requireNonNull(clock, "clock == null");
        return this;
    }

    /**
     * @param pageSize A fixed page size, or number of visible items.
     * @return The builder.
     */
    public SelectionBuilder<E> pageSize(int pageSize) {
        if (pageSize <= 0) {
            throw new IllegalArgumentException("pageSize " + pageSize + " is negative");
        }
        this.pageSize = pageSize;
        return this;
    }

    /**
     * Force the selection to be paged. This is handy if it is possible to
     * increase number of selection items while it is running.
     *
     * @return The builder.
     */
    public SelectionBuilder<E> paged() {
        this.paged = true;
        return this;
    }

    /**
     * @param pageMargin Page margin, number of items above the page-size needed
     *                   to trigger pagination on the selection itself. This is
     *                   to avoid adding a single page #2 with only very few
     *                   items in it.
     * @return The builder.
     */
    public SelectionBuilder<E> pageMargin(int pageMargin) {
        if (pageMargin < 0) {
            throw new IllegalArgumentException("pageMargin " + pageMargin + " is negative");
        }
        this.pageMargin = pageMargin;
        return this;
    }

    /**
     * @param lineWidth Line width for formatting the selection lines itself.
     *                  If not set will automatically detect line width from
     *                  TTY.
     * @return The builder.
     */
    public SelectionBuilder<E> lineWidth(int lineWidth) {
        if (lineWidth < 0) {
            throw new IllegalArgumentException("lineWidth " + lineWidth + " is negative");
        }
        this.lineWidth = lineWidth;
        return this;
    }

    /**
     * Build and start the selection. The returned selection instance must be
     * closed by the caller.
     *
     * @return The selection.
     */
    public Selection<E> build() {
        var tty = terminal != null
                  ? terminal.tty()
                  : (this.tty != null
                     ? this.tty
                     : new TTY());
        var term = terminal != null ? terminal : new Terminal(tty);
        if (lineWidth == 0) {
            if (tty.isInteractive()) {
                lineWidth = tty.getTerminalSize().cols;
            } else {
                lineWidth = 80;
            }
        }
        if (pageSize == 0) {
            if (tty.isInteractive()) {
                // 2 rows above, 2 rows below, 5 margin and 3 extra.
                // 22 rows -> 10 entries + 5 margin + 7 lines for header and footer.
                // 44 rows -> 22 entries + 5 margin +        ----  ||  ----
                // 80 rows -> 35 entries + 5 margin (max) +  ----  ||  ----
                pageSize = Math.min(35, tty.getTerminalSize().rows - (pageMargin + 7));
            } else {
                pageSize = 10;
            }
        }
        return new Selection<>(
                term,
                prompt,
                entries,
                commands,
                printer,
                clock,
                pageSize,
                pageMargin,
                lineWidth,
                paged,
                initialIndex == null ? 0 : initialIndex);
    }

    private final EntrySource<E>        entries;
    private final List<EntryCommand<E>> commands;

    private EntryPrinter<E> printer      = (e, bg) -> e.toString();
    private Clock           clock        = Clock.systemUTC();
    private String          prompt       = "Select";
    private Terminal        terminal     = null;
    private TTY             tty          = null;
    private int             pageSize     = 0;
    private int             pageMargin   = 5;
    private int             lineWidth    = 0;
    private boolean         paged        = false;
    private Integer         initialIndex = null;
}