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