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