From: Svjatoslav Agejenko Date: Sun, 25 Jan 2026 11:53:07 +0000 (+0200) Subject: Add `Menu` class for interactive CLI menu with fuzzy search and terminal navigation X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=d7c56422c233817be1808745b4d901f2dad5e177;p=cli-helper.git Add `Menu` class for interactive CLI menu with fuzzy search and terminal navigation --- diff --git a/doc/index.org b/doc/index.org index 85cda1a..1b4459e 100644 --- a/doc/index.org +++ b/doc/index.org @@ -26,7 +26,6 @@ Library provides following general functionalities: - [[id:4fca35e4-fdf1-4675-a36f-6206d6fb72cb][Asking for user input]] - [[id:eb7d5632-6152-4d37-8e55-1cf4da21c204][Commandline arguments processing]] - * User input helper :PROPERTIES: :ID: 4fca35e4-fdf1-4675-a36f-6206d6fb72cb @@ -135,7 +134,7 @@ Add following snippets to your project *pom.xml* file: eu.svjatoslav cli-helper - 1.2 + 1.3 ... diff --git a/src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java b/src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java new file mode 100644 index 0000000..a118695 --- /dev/null +++ b/src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java @@ -0,0 +1,226 @@ +package eu.svjatoslav.commons.cli_helper; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Prompts the user to select an option from a list using fuzzy matching and cursor keys. + *

+ * Important: This functionality requires the terminal to be in raw mode for proper operation. + * On Unix-like systems, this is automatically handled by setting terminal modes. + * This method will not work correctly in standard line-buffered terminal mode or IDE consoles. + *

+ *

+ * The user can navigate the list using up/down arrow keys, type to filter options using fuzzy matching + * (e.g., typing "abc" matches "aXbYc" but not "acb"), press Enter to select the current option, + * or Escape to cancel the selection. + *

+ */ +public class Menu { + + public static String askSelection(String prompt, List options) throws IOException { + if (options == null || options.isEmpty()) { + throw new IllegalArgumentException("Options list cannot be empty"); + } + + String originalSettings = getTerminalSettings(); + try { + setTerminalToRaw(); + + int selected = 0; + String filter = ""; + int lastTotalLines = 0; + + selected = updateDisplay(prompt, options, selected, filter, lastTotalLines); + lastTotalLines = getCurrentTotalLines(options, filter); + + while (true) { + int c = System.in.read(); + + // Handle Escape key or arrow keys + if (c == 27) { // ESC + if (System.in.available() >= 2) { + int c1 = System.in.read(); + int c2 = System.in.read(); + if (c1 == 91) { // [ + if (c2 == 65) { // Up arrow (ESC [ A) + selected = selected - 1; + } else if (c2 == 66) { // Down arrow (ESC [ B) + selected = selected + 1; + } + selected = updateDisplay(prompt, options, selected, filter, lastTotalLines); + lastTotalLines = getCurrentTotalLines(options, filter); + } + } else { + // Esc key alone + return null; + } + } + // Handle Enter key + else if (c == 13 || c == 10) { + List filtered = filterOptions(options, filter.toString()); + if (filtered.isEmpty()) { + return null; + } + return filtered.get(selected); + } + // Handle Backspace (8 or 127) + else if (c == 8 || c == 127) { + if (filter.length() > 0) { + filter = filter.substring(0, filter.length() - 1); + selected = 0; + selected = updateDisplay(prompt, options, selected, filter, lastTotalLines); + lastTotalLines = getCurrentTotalLines(options, filter); + } + } + // Handle printable ASCII characters + else if (c >= 32 && c <= 126) { + filter = filter + Character.toString((char)c); + selected = 0; + selected = updateDisplay(prompt, options, selected, filter, lastTotalLines); + lastTotalLines = getCurrentTotalLines(options, filter); + } + } + } finally { + restoreTerminalSettings(originalSettings); + } + } + + /** + * Gets the current terminal settings as a string. + */ + private static String getTerminalSettings() throws IOException { + Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty -g < /dev/tty"}); + try { + p.waitFor(); + } catch (InterruptedException e) { + throw new IOException(e); + } + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + return reader.readLine(); + } + + /** + * Sets the terminal to raw mode with no echo. + */ + private static void setTerminalToRaw() throws IOException { + Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty raw -echo < /dev/tty"}); + try { + p.waitFor(); + } catch (InterruptedException e) { + throw new IOException(e); + } + } + + /** + * Restores the terminal settings. + */ + private static void restoreTerminalSettings(String settings) throws IOException { + if (settings != null) { + Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty " + settings + " < /dev/tty"}); + try { + p.waitFor(); + } catch (InterruptedException e) { + throw new IOException(e); + } + } + } + + /** + * Filters options based on fuzzy matching. + * + * @param options the list of options to filter + * @param filter the filter string + * @return filtered list of options that match the fuzzy pattern + */ + private static List filterOptions(List options, String filter) { + if (filter == null || filter.isEmpty()) { + return new ArrayList<>(options); + } + + List filtered = new ArrayList<>(); + for (String option : options) { + if (matchesFuzzy(filter, option)) { + filtered.add(option); + } + } + return filtered; + } + + /** + * Checks if a string matches a filter using fuzzy matching (case-insensitive). + * Characters in the filter must appear in order in the string (not necessarily consecutively). + * + * @param filter the filter string + * @param option the option string to check + * @return true if the option matches the filter + */ + private static boolean matchesFuzzy(String filter, String option) { + filter = filter.toLowerCase(); + option = option.toLowerCase(); + int filterIndex = 0; + for (char c : option.toCharArray()) { + if (filterIndex < filter.length() && c == filter.charAt(filterIndex)) { + filterIndex++; + } + } + return filterIndex == filter.length(); + } + + + /** + * Updates the display based on the current state and returns the adjusted selected index. + */ + private static int updateDisplay(String prompt, List options, int selected, String filter, int lastTotalLines) { + List filtered = filterOptions(options, filter); + + if (filtered.isEmpty()) { + filtered = Collections.singletonList("No matches found"); + } + + // Clamp selected to valid range + selected = Math.max(0, Math.min(selected, filtered.size() - 1)); + + // ──────────────────────────────────────────────── + // 1. Position cursor at the START of our display area + // ──────────────────────────────────────────────── + if (lastTotalLines > 0) { + System.out.print("\033[" + lastTotalLines + "A"); // move cursor up to prompt line + } + System.out.print("\r"); // go to column 1 (just in case) + + // ──────────────────────────────────────────────── + // 2. Clear from here downward (removes old leftover lines perfectly) + // ──────────────────────────────────────────────── + System.out.print("\033[J"); // ESC [ J = clear from cursor to end of screen + + // ──────────────────────────────────────────────── + // 3. Print fresh content + // ──────────────────────────────────────────────── + // Prompt + current filter (on one line) + System.out.println(prompt + ": " + filter); + + // Menu items + for (int i = 0; i < filtered.size(); i++) { + String line = (i == selected ? "> " : " ") + filtered.get(i); + System.out.print("\r"); + System.out.println(line); + } + System.out.print("\r"); + + System.out.flush(); // ensure immediate display + + return selected; + } + + private static int getCurrentTotalLines(List options, String filter) { + List filtered = filterOptions(options, filter); + int menuLines = filtered.isEmpty() ? 1 : filtered.size(); + return 1 + menuLines; // 1 for prompt line + menu lines + } + +}