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..100715278 --- /dev/null +++ b/console-ui/src/test/java/org/jline/consoleui/examples/BasicDynamic.java @@ -0,0 +1,273 @@ +/* + * 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.io.InterruptedIOException; +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()) { + Thread executeThread = Thread.currentThread(); + terminal.handle(Terminal.Signal.INT, signal -> executeThread.interrupt()); + 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 (InterruptedIOException e) { + System.out.println("Exiting application."); + } 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; + } +}