From 948c8c471eb23016f54783b655f6a3ca8301e8b3 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Wed, 14 Aug 2024 20:30:57 +0200 Subject: [PATCH] Added possibility of cancelling prompts Cancelling a prompt by hitting the ESC key makes it go back to the previous prompt. By default, if we're already at the fist prompt we'll repeat the question until the user either answers it or forcefully quits the application. If on the other hand the `cancellableFirstPrompt` option of `UiConfig` has been set to `true`, the `prompt()` method will return `null`. Fixes #1035 --- .../consoleui/prompt/AbstractPrompt.java | 52 +++- .../jline/consoleui/prompt/ConsolePrompt.java | 40 ++- .../consoleui/examples/BasicDynamic.java | 268 ++++++++++++++++++ 3 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 console-ui/src/test/java/org/jline/consoleui/examples/BasicDynamic.java diff --git a/console-ui/src/main/java/org/jline/consoleui/prompt/AbstractPrompt.java b/console-ui/src/main/java/org/jline/consoleui/prompt/AbstractPrompt.java index be0cfc4c0..21eb21311 100644 --- a/console-ui/src/main/java/org/jline/consoleui/prompt/AbstractPrompt.java +++ b/console-ui/src/main/java/org/jline/consoleui/prompt/AbstractPrompt.java @@ -48,6 +48,8 @@ public abstract class AbstractPrompt { private Display display; private ListRange range = null; + public static final long DEFAULT_TIMEOUT_WITH_ESC = 150L; + public AbstractPrompt( Terminal terminal, List header, AttributedString message, ConsolePrompt.UiConfig cfg) { this(terminal, header, message, new ArrayList<>(), 0, cfg); @@ -312,7 +314,8 @@ private List displayLines(int cursorRow, AttributedString buff protected static class ExpandableChoicePrompt extends AbstractPrompt { private enum Operation { INSERT, - EXIT + EXIT, + CANCEL } private final int startColumn; @@ -345,6 +348,8 @@ private void bindKeys(KeyMap map) { map.bind(Operation.INSERT, Character.toString(i)); } map.bind(Operation.EXIT, "\r"); + map.bind(Operation.CANCEL, KeyMap.esc()); + map.setAmbiguousTimeout(DEFAULT_TIMEOUT_WITH_ESC); } public ExpandableChoiceResult execute() { @@ -396,6 +401,8 @@ public ExpandableChoiceResult execute() { break; } return new ExpandableChoiceResult(selectedId); + case CANCEL: + return null; } } } @@ -408,7 +415,8 @@ protected static class ConfirmPrompt extends AbstractPrompt { private enum Operation { NO, YES, - EXIT + EXIT, + CANCEL } private final int startColumn; @@ -442,6 +450,8 @@ private void bindKeys(KeyMap map) { map.bind(Operation.YES, yes, yes.toUpperCase()); map.bind(Operation.NO, no, no.toUpperCase()); map.bind(Operation.EXIT, "\r"); + map.bind(Operation.CANCEL, KeyMap.esc()); + map.setAmbiguousTimeout(DEFAULT_TIMEOUT_WITH_ESC); } public ConfirmResult execute() { @@ -472,6 +482,8 @@ public ConfirmResult execute() { break; } return new ConfirmResult(confirm); + case CANCEL: + return null; } } } @@ -487,13 +499,15 @@ private enum Operation { BEGINNING_OF_LINE, END_OF_LINE, SELECT_CANDIDATE, - EXIT + EXIT, + CANCEL } private enum SelectOp { FORWARD_ONE_LINE, BACKWARD_ONE_LINE, - EXIT + EXIT, + CANCEL } private final int startColumn; @@ -543,12 +557,16 @@ private void bindKeys(KeyMap map) { map.bind(Operation.RIGHT, ctrl('F')); map.bind(Operation.LEFT, ctrl('B')); map.bind(Operation.SELECT_CANDIDATE, "\t"); + map.bind(Operation.CANCEL, KeyMap.esc()); + map.setAmbiguousTimeout(DEFAULT_TIMEOUT_WITH_ESC); } private void bindSelectKeys(KeyMap map) { map.bind(SelectOp.FORWARD_ONE_LINE, "\t", "e", ctrl('E'), key(terminal, InfoCmp.Capability.key_down)); map.bind(SelectOp.BACKWARD_ONE_LINE, "y", ctrl('Y'), key(terminal, InfoCmp.Capability.key_up)); map.bind(SelectOp.EXIT, "\r"); + map.bind(SelectOp.CANCEL, KeyMap.esc()); + map.setAmbiguousTimeout(DEFAULT_TIMEOUT_WITH_ESC); } public InputResult execute() { @@ -620,9 +638,11 @@ public InputResult execute() { String selected = selectCandidate(firstItemRow - 1, buffer.toString(), row + 1, startColumn, matches); resetHeader(); - buffer.delete(0, buffer.length()); - buffer.append(selected); - column = startColumn + buffer.length(); + if (selected != null) { + buffer.delete(0, buffer.length()); + buffer.append(selected); + column = startColumn + buffer.length(); + } } break; case EXIT: @@ -630,6 +650,8 @@ public InputResult execute() { buffer.append(defaultValue); } return new InputResult(buffer.toString()); + case CANCEL: + return null; } } } @@ -663,6 +685,8 @@ String selectCandidate(int buffRow, String buffer, int row, int column, List items; @@ -789,6 +814,8 @@ private void bindKeys(KeyMap map) { map.bind(Operation.FORWARD_ONE_LINE, "e", ctrl('E'), key(terminal, InfoCmp.Capability.key_down)); map.bind(Operation.BACKWARD_ONE_LINE, "y", ctrl('Y'), key(terminal, InfoCmp.Capability.key_up)); map.bind(Operation.EXIT, "\r"); + map.bind(Operation.CANCEL, KeyMap.esc()); + map.setAmbiguousTimeout(DEFAULT_TIMEOUT_WITH_ESC); } public ListResult execute() { @@ -823,6 +850,8 @@ public ListResult execute() { case EXIT: T listItem = items.get(selectRow - firstItemRow); return new ListResult(listItem.getName()); + case CANCEL: + return null; } } } @@ -833,7 +862,8 @@ private enum Operation { FORWARD_ONE_LINE, BACKWARD_ONE_LINE, TOGGLE, - EXIT + EXIT, + CANCEL } private final List items; @@ -864,6 +894,8 @@ private void bindKeys(KeyMap map) { map.bind(Operation.BACKWARD_ONE_LINE, "y", ctrl('Y'), key(terminal, InfoCmp.Capability.key_up)); map.bind(Operation.TOGGLE, " "); map.bind(Operation.EXIT, "\r"); + map.bind(Operation.CANCEL, KeyMap.esc()); + map.setAmbiguousTimeout(DEFAULT_TIMEOUT_WITH_ESC); } public CheckboxResult execute() { @@ -895,6 +927,8 @@ public CheckboxResult execute() { break; case EXIT: return new CheckboxResult(selected); + case CANCEL: + return null; } } } diff --git a/console-ui/src/main/java/org/jline/consoleui/prompt/ConsolePrompt.java b/console-ui/src/main/java/org/jline/consoleui/prompt/ConsolePrompt.java index 24aa3cff0..f94350d1f 100644 --- a/console-ui/src/main/java/org/jline/consoleui/prompt/ConsolePrompt.java +++ b/console-ui/src/main/java/org/jline/consoleui/prompt/ConsolePrompt.java @@ -92,6 +92,7 @@ public Map prompt(List promptab public Map prompt( List header, List promptableElementList) throws IOException { Attributes attributes = terminal.enterRawMode(); + boolean cancelled = false; try { terminal.puts(InfoCmp.Capability.enter_ca_mode); terminal.puts(InfoCmp.Capability.keypad_xmit); @@ -99,7 +100,8 @@ public Map prompt( Map resultMap = new HashMap<>(); - for (PromptableElementIF pe : promptableElementList) { + for (int i = 0; i < promptableElementList.size(); i++) { + PromptableElementIF pe = promptableElementList.get(i); AttributedStringBuilder message = new AttributedStringBuilder(); message.style(config.style(".pr")).append("? "); message.style(config.style(".me")).append(pe.getMessage()).append(" "); @@ -170,6 +172,25 @@ public Map prompt( } else { throw new IllegalArgumentException("wrong type of promptable element"); } + if (result == null) { + // Prompt was cancelled by the user + if (i > 0) { + // Remove last result + header.remove(header.size() - 1); + // Go back to previous prompt + i -= 2; + continue; + } else { + if (config.cancellableFirstPrompt()) { + cancelled = true; + return null; + } else { + // Repeat current prompt + i -= 1; + continue; + } + } + } String resp = result.getResult(); if (result instanceof ConfirmResult) { ConfirmResult cr = (ConfirmResult) result; @@ -189,10 +210,12 @@ public Map prompt( terminal.puts(InfoCmp.Capability.exit_ca_mode); terminal.puts(InfoCmp.Capability.keypad_local); terminal.writer().flush(); - for (AttributedString as : header) { - as.println(terminal); + if (!cancelled) { + for (AttributedString as : header) { + as.println(terminal); + } + terminal.writer().flush(); } - terminal.writer().flush(); } } @@ -224,6 +247,7 @@ public static class UiConfig { private final StyleResolver resolver; private final ResourceBundle resourceBundle; private Map readerOptions = new HashMap<>(); + private boolean cancellableFirstPrompt; public UiConfig() { this(null, null, null, null); @@ -271,6 +295,14 @@ public ResourceBundle resourceBundle() { return resourceBundle; } + public boolean cancellableFirstPrompt() { + return cancellableFirstPrompt; + } + + public void setCancellableFirstPrompt(boolean cancellableFirstPrompt) { + this.cancellableFirstPrompt = cancellableFirstPrompt; + } + protected void setReaderOptions(Map readerOptions) { this.readerOptions = readerOptions; } diff --git a/console-ui/src/test/java/org/jline/consoleui/examples/BasicDynamic.java b/console-ui/src/test/java/org/jline/consoleui/examples/BasicDynamic.java new file mode 100644 index 000000000..c4bda83f1 --- /dev/null +++ b/console-ui/src/test/java/org/jline/consoleui/examples/BasicDynamic.java @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2024, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ +package org.jline.consoleui.examples; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jline.consoleui.elements.ConfirmChoice; +import org.jline.consoleui.prompt.ConfirmResult; +import org.jline.consoleui.prompt.ConsolePrompt; +import org.jline.consoleui.prompt.PromptResultItemIF; +import org.jline.consoleui.prompt.builder.PromptBuilder; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.impl.completer.StringsCompleter; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; +import org.jline.utils.OSUtils; + +public class BasicDynamic { + + private static void addInHeader(List header, String text) { + addInHeader(header, AttributedStyle.DEFAULT, text); + } + + private static void addInHeader(List header, AttributedStyle style, String text) { + AttributedStringBuilder asb = new AttributedStringBuilder(); + asb.style(style).append(text); + header.add(asb.toAttributedString()); + } + + public static void main(String[] args) { + List header = new ArrayList<>(); + AttributedStyle style = new AttributedStyle(); + addInHeader(header, style.italic().foreground(2), "Hello Dynamic World!"); + addInHeader( + header, "This is a demonstration of ConsoleUI java library. It provides a simple console interface"); + addInHeader( + header, + "for querying information from the user. ConsoleUI is inspired by Inquirer.js which is written"); + addInHeader(header, "in JavaScript."); + try (Terminal terminal = TerminalBuilder.builder().build()) { + ConsolePrompt.UiConfig config; + if (terminal.getType().equals(Terminal.TYPE_DUMB) + || terminal.getType().equals(Terminal.TYPE_DUMB_COLOR)) { + System.out.println(terminal.getName() + ": " + terminal.getType()); + throw new IllegalStateException("Dumb terminal detected.\nConsoleUi requires real terminal to work!\n" + + "Note: On Windows Jansi or JNA library must be included in classpath."); + } else if (OSUtils.IS_WINDOWS) { + config = new ConsolePrompt.UiConfig(">", "( )", "(x)", "( )"); + } else { + config = new ConsolePrompt.UiConfig("\u276F", "\u25EF ", "\u25C9 ", "\u25EF "); + } + config.setCancellableFirstPrompt(true); + // + // LineReader is needed only if you are adding JLine Completers in your prompts. + // If you are not using Completers you do not need to create LineReader. + // + LineReader reader = LineReaderBuilder.builder().terminal(terminal).build(); + ConsolePrompt prompt = new ConsolePrompt(reader, terminal, config); + + Map result1 = null, result2 = null, result3 = null; + while (result2 == null) { + List header1 = new ArrayList<>(header); + result1 = prompt.prompt(header1, pizzaOrHamburgerPrompt(prompt).build()); + if (result1 == null) { + System.out.println("User cancelled order."); + return; + } + while (result3 == null) { + if ("Pizza".equals(result1.get("product").getResult())) { + result2 = prompt.prompt(pizzaPrompt(prompt).build()); + } else { + result2 = prompt.prompt(hamburgerPrompt(prompt).build()); + } + if (result2 == null) { + break; + } + result3 = prompt.prompt(finalPrompt(prompt).build()); + } + } + + Map result = new HashMap<>(); + result.putAll(result1); + result.putAll(result2); + result.putAll(result3); + System.out.println("result = " + result); + + ConfirmResult delivery = (ConfirmResult) result.get("delivery"); + if (delivery.getConfirmed() == ConfirmChoice.ConfirmationValue.YES) { + System.out.println("We will deliver the order in 5 minutes"); + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + + static PromptBuilder pizzaOrHamburgerPrompt(ConsolePrompt prompt) { + PromptBuilder promptBuilder = prompt.getPromptBuilder(); + promptBuilder + .createInputPrompt() + .name("name") + .message("Please enter your name") + .defaultValue("John Doe") + .addCompleter(new StringsCompleter("Jim", "Jack", "John", "Donald", "Dock")) + .addPrompt(); + promptBuilder + .createListPrompt() + .name("product") + .message("Which do you want to order?") + .newItem() + .text("Pizza") + .add() // without name (name defaults to text) + .newItem() + .text("Hamburger") + .add() + .addPrompt(); + return promptBuilder; + } + + static PromptBuilder pizzaPrompt(ConsolePrompt prompt) { + PromptBuilder promptBuilder = prompt.getPromptBuilder(); + promptBuilder + .createListPrompt() + .name("pizzatype") + .message("Which pizza do you want?") + .newItem() + .text("Margherita") + .add() // without name (name defaults to text) + .newItem("veneziana") + .text("Veneziana") + .add() + .newItem("hawai") + .text("Hawai") + .add() + .newItem("quattro") + .text("Quattro Stagioni") + .add() + .addPrompt(); + promptBuilder + .createCheckboxPrompt() + .name("topping") + .message("Please select additional toppings:") + .newSeparator("standard toppings") + .add() + .newItem() + .name("cheese") + .text("Cheese") + .add() + .newItem("bacon") + .text("Bacon") + .add() + .newItem("onions") + .text("Onions") + .disabledText("Sorry. Out of stock.") + .add() + .newSeparator() + .text("special toppings") + .add() + .newItem("salami") + .text("Very hot salami") + .check() + .add() + .newItem("salmon") + .text("Smoked Salmon") + .add() + .newSeparator("and our speciality...") + .add() + .newItem("special") + .text("Anchovies, and olives") + .checked(true) + .add() + .addPrompt(); + return promptBuilder; + } + + static PromptBuilder hamburgerPrompt(ConsolePrompt prompt) { + PromptBuilder promptBuilder = prompt.getPromptBuilder(); + promptBuilder + .createListPrompt() + .name("hamburgertype") + .message("Which hamburger do you want?") + .newItem() + .text("Cheeseburger") + .add() // without name (name defaults to text) + .newItem("chickenburger") + .text("Chickenburger") + .add() + .newItem("veggieburger") + .text("Veggieburger") + .add() + .addPrompt(); + promptBuilder + .createCheckboxPrompt() + .name("ingredients") + .message("Please select additional ingredients:") + .newSeparator("standard ingredients") + .add() + .newItem() + .name("tomato") + .text("Tomato") + .add() + .newItem("lettuce") + .text("Lettuce") + .add() + .newItem("onions") + .text("Onions") + .disabledText("Sorry. Out of stock.") + .add() + .newSeparator() + .text("special ingredients") + .add() + .newItem("crispybacon") + .text("Crispy Bacon") + .check() + .add() + .addPrompt(); + return promptBuilder; + } + + static PromptBuilder finalPrompt(ConsolePrompt prompt) { + PromptBuilder promptBuilder = prompt.getPromptBuilder(); + promptBuilder + .createChoicePrompt() + .name("payment") + .message("How do you want to pay?") + .newItem() + .name("cash") + .message("Cash") + .key('c') + .asDefault() + .add() + .newItem("visa") + .message("Visa Card") + .key('v') + .add() + .newItem("master") + .message("Master Card") + .key('m') + .add() + .newSeparator("online payment") + .add() + .newItem("paypal") + .message("Paypal") + .key('p') + .add() + .addPrompt(); + promptBuilder + .createConfirmPromp() + .name("delivery") + .message("Is this order for delivery?") + .defaultValue(ConfirmChoice.ConfirmationValue.YES) + .addPrompt(); + return promptBuilder; + } +}