jline / jline3

JLine is a Java library for handling console input.
Other
1.49k stars 217 forks source link

Dynamic console-ui prompts #1051

Open quintesse opened 2 months ago

quintesse commented 2 months ago

Right now the type and contents of each item in a prompt has to be defined before executing the prompt. This makes the prompt a very static thing that can't react to a user's input. It would be nice if a prompt could be more dynamic and change depending on external factors (most likely user input).

A possible example could be a question that asks to select a country and then follows up with a question to select a city. The answer to the first question obviously affects the possible option in the second one.

But one could also imagine prompts where the number and type of questions changes completely.

For example when asking for a payment method: a) credit card b) bank account c) paypal and then depending on the answer the follow up questions would either be [credit card number, expiration date, cvv], [iban] or [email].

quintesse commented 2 months ago

In itself the idea wouldn't be too hard to implement but the current console-ui wasn't designed with dynamic prompts in mind so it's not immediately a great fit for this idea.

~An ideal pseudo-code solution that I could imagine would be something like:~

ConsolePrompt prompt = new ConsolePrompt(terminal);
prompt.start(); // Sets up prompt
var countryListPrompt = promptBuilder.createListPrompt()
    .name("country")
    .newItem("germany").add()
    .newItem("italy").add()
    .newItem("spain").add()
    .build();
String country= prompt.prompt(countryListPrompt).getResult();
var cityListPrompt = getCitiesListPrompt(promptBuilder, country);
String city= prompt.prompt(cityListPrompt).getResult();
prompt.end(); // resets terminal back to normal etc

~The start() / end() is a bit cumbersome, but it's just an example. One could imagine using a Closeable and use a try-resources block. Imagine something like:~

try (ConsolePrompt prompt = ConsolePrompt.execute(terminal)) {
    var countryListPrompt = ... ;
    String country= prompt.prompt(countryListPrompt).getResult();
    var cityListPrompt = ... ;
    String city= prompt.prompt(cityListPrompt).getResult();
}

Edit: After thinking about it some more I've realized that the above idea might be nice but isn't compatible with the new feature where you can cancel out of questions to go back to the previous question. At least not without making the code a whole lot uglier. Need to think about this some more.

quintesse commented 2 months ago

NB: One issue that affects any kind of solution to this is that the various builder classes aren't clean builders, they not only build but also add the result to the PromptBuilder, which makes reusing them to build single items harder. So a first step would be to add a build() method (addPrompt() would then simply call that and add its result to the prompt builder).

quintesse commented 2 months ago

One option would be that instead of passing a list of prompt elements to prompt() we'd pass a "provider", a function that returns the next prompt element to display. And to be able to make decisions depending on previous user input we'd pass the current state of the result map as an argument to that function. Imagine this new prompt() like this:

public Map<String, PromptResultItemIF> prompt(
        List<AttributedString> header,
        Function<Map<String, PromptResultItemIF>, PromptableElementIF> promptableElementProvider
) throws IOException {
    . . .
}

Usage would not be as nice as the first example I gave, but would look more like this:

void myShowPrompt(Terminal terminal) {
    ConsolePrompt prompt = new ConsolePrompt(terminal);
    var result = prompt.prompt(this::nextQuestion);
    // Do something with result here
}

PromptableElementIF nextQuestion(Map<String, PromptResultItemIF> results) {
    if ((!results.containsKey("country")) {
        // First question
        return ListPromptBuilder.create()
            .name("country")
            .newItem("germany").add()
            .newItem("italy").add()
            .newItem("spain").add()
            .build();
    } else if (!results.containsKey("city")) {
        // Second question
        String country = results.get("country").getResult();
        return getCitiesListPrompt(promptBuilder, country);
    } else if ( ... ) {
        // Any other questions
    }
    return null; // No further questions
}