--- /dev/null
+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
+ }
+
+}