Add `Menu` class for interactive CLI menu with fuzzy search and terminal navigation
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 25 Jan 2026 11:53:07 +0000 (13:53 +0200)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 25 Jan 2026 11:53:07 +0000 (13:53 +0200)
doc/index.org
src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java [new file with mode: 0644]

index 85cda1a..1b4459e 100644 (file)
@@ -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:
     <dependency>
         <groupId>eu.svjatoslav</groupId>
         <artifactId>cli-helper</artifactId>
-        <version>1.2</version>
+        <version>1.3</version>
     </dependency>
     ...
 </dependencies>
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 (file)
index 0000000..a118695
--- /dev/null
@@ -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.
+ * <p>
+ * <strong>Important: This functionality requires the terminal to be in raw mode for proper operation.</strong>
+ * 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.
+ * </p>
+ * <p>
+ * 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.
+ * </p>
+ */
+public class Menu {
+
+    public static String askSelection(String prompt, List<String> 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<String> 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<String> filterOptions(List<String> options, String filter) {
+        if (filter == null || filter.isEmpty()) {
+            return new ArrayList<>(options);
+        }
+
+        List<String> 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<String> options, int selected, String filter, int lastTotalLines) {
+        List<String> 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<String> options, String filter) {
+        List<String> filtered = filterOptions(options, filter);
+        int menuLines = filtered.isEmpty() ? 1 : filtered.size();
+        return 1 + menuLines;  // 1 for prompt line + menu lines
+    }
+
+}