Add scrolling support to `Menu` class: Enable handling of long option lists by implem... master
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Wed, 28 Jan 2026 23:29:07 +0000 (01:29 +0200)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Wed, 28 Jan 2026 23:29:07 +0000 (01:29 +0200)
src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java

index 930cf1e..84d3d27 100644 (file)
@@ -11,7 +11,7 @@ import static java.lang.Math.max;
 import static java.lang.Math.min;
 
 /**
- * Prompts the user to select an option from a list using fuzzy matching and cursor keys.
+ * Prompts the user to select an option from a list using fuzzy matching and cursor keys with scrolling support.
  * <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.
@@ -20,12 +20,12 @@ import static java.lang.Math.min;
  * <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.
+ * or Escape to cancel the selection. For long lists, the menu automatically scrolls to keep the
+ * selection visible within the terminal's vertical limits.
  * </p>
  */
 public class Menu {
 
-
     /**
      * Displays an interactive menu for the user to select an option from a list using fuzzy matching and cursor navigation.
      * <p>
@@ -33,9 +33,10 @@ public class Menu {
      * sets the terminal to raw mode. This functionality may not work correctly in standard line-buffered terminals or IDE consoles.
      * </p>
      * <p>
-     * The user can navigate the list using up/down arrow keys, type to filter options via fuzzy matching (e.g., "abc" matches
-     * "aXbYc" but not "acb"), press Enter to select the current option, or Escape to cancel.
+     * The menu automatically scrolls when navigating beyond the visible area. The visible window size is determined
+     * by the terminal height (minus one line for the prompt).
      * </p>
+     *
      * @param prompt The prompt message to display above the menu.
      * @param options The list of options to present to the user.
      * @return The selected option string, or {@code null} if the user canceled with Escape.
@@ -46,110 +47,202 @@ public class Menu {
             throw new IllegalArgumentException("Options list cannot be empty");
         }
 
+        // Get terminal dimensions before entering raw mode
+        int terminalHeight = getTerminalHeight();
+        int visibleLines = max(1, terminalHeight - 1); // Reserve one line for prompt
+
         String originalSettings = getTerminalSettings();
         try {
             setTerminalToRaw();
 
             int selectedIndex = 0;
+            int scrollOffset = 0;
             String filterString = "";
             int lastTotalLines = 0;
 
             while (true) {
-
                 List<String> filteredOptions = filterOptions(options, filterString);
 
-                // Clamp selected to valid range
-                selectedIndex = max(0, min(selectedIndex, filteredOptions.size() - 1));
+                // Adjust selection index to valid range
+                if (!filteredOptions.isEmpty()) {
+                    selectedIndex = max(0, min(selectedIndex, filteredOptions.size() - 1));
+                } else {
+                    selectedIndex = 0;
+                }
 
-                lastTotalLines = updateDisplay(prompt, filteredOptions, selectedIndex, filterString, lastTotalLines);
+                // Adjust scroll offset to keep selection visible
+                if (!filteredOptions.isEmpty()) {
+                    // Reset scroll if list fits entirely on screen
+                    if (filteredOptions.size() <= visibleLines) {
+                        scrollOffset = 0;
+                    } else {
+                        // Ensure selection stays within visible window
+                        if (selectedIndex < scrollOffset) {
+                            scrollOffset = selectedIndex;
+                        } else if (selectedIndex >= scrollOffset + visibleLines) {
+                            scrollOffset = selectedIndex - visibleLines + 1;
+                        }
+                        // Clamp scroll offset to valid range
+                        scrollOffset = max(0, min(scrollOffset, filteredOptions.size() - visibleLines));
+                    }
+                } else {
+                    scrollOffset = 0;
+                }
 
-                int c = System.in.read(); // read a single character from user input
+                // Prepare visible portion of the list
+                List<String> displayOptions;
+                int displaySelectedIndex;
+                if (filteredOptions.isEmpty()) {
+                    displayOptions = Collections.singletonList("No matches found");
+                    displaySelectedIndex = 0;
+                } else {
+                    int start = scrollOffset;
+                    int end = min(scrollOffset + visibleLines, filteredOptions.size());
+                    displayOptions = filteredOptions.subList(start, end);
+                    displaySelectedIndex = selectedIndex - scrollOffset;
+                }
 
-                // Handle Escape key or arrow keys
-                if (c == 27) { // ESC
+                lastTotalLines = updateDisplay(
+                        prompt,
+                        displayOptions,
+                        displaySelectedIndex,
+                        filterString,
+                        lastTotalLines,
+                        scrollOffset,
+                        filteredOptions.size(),
+                        visibleLines
+                );
+
+                int c = System.in.read();
+
+                // Handle Escape sequences (arrow keys)
+                if (c == 27) {
                     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)
-                                selectedIndex = selectedIndex - 1;
-                            } else if (c2 == 66) { // Down arrow (ESC [ B)
-                                selectedIndex = selectedIndex + 1;
+                            if (c2 == 65) { // Up arrow
+                                if (selectedIndex > 0) selectedIndex--;
+                            } else if (c2 == 66) { // Down arrow
+                                if (selectedIndex < filteredOptions.size() - 1) selectedIndex++;
                             }
                         }
                     } else {
-                        // Esc key alone
+                        // ESC key alone - cancel selection
                         return null;
                     }
                 }
-
                 // Handle Enter key
                 else if (c == 13 || c == 10) {
-                    if (filteredOptions.isEmpty()) {
-                        return null;
+                    if (!filteredOptions.isEmpty()) {
+                        return filteredOptions.get(selectedIndex);
                     }
-                    return filteredOptions.get(selectedIndex);
+                    return null; // No matches available
                 }
-
-                // Handle Backspace (8 or 127)
+                // Handle Backspace
                 else if (c == 8 || c == 127) {
-                    if (filterString.length() > 0) {
+                    if (!filterString.isEmpty()) {
                         filterString = filterString.substring(0, filterString.length() - 1);
                         selectedIndex = 0;
+                        scrollOffset = 0;
                     }
                 }
-
                 // Handle printable ASCII characters
                 else if (c >= 32 && c <= 126) {
-                    filterString = filterString + (char) c;
+                    filterString += (char) c;
                     selectedIndex = 0;
+                    scrollOffset = 0;
                 }
             }
         } finally {
             restoreTerminalSettings(originalSettings);
+
+            // Make sure we transition to new the empty line when exiting the menu
+            System.out.println("");
+        }
+    }
+
+    /**
+     * Retrieves the current terminal height in lines using `stty size`.
+     *
+     * @return Terminal height in lines (minimum 10 if detection fails)
+     * @throws IOException If terminal query fails unexpectedly
+     */
+    private static int getTerminalHeight() throws IOException {
+        try {
+            Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty size < /dev/tty 2>/dev/null | awk '{print $1}'"});
+            if (!p.waitFor(1000, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+                p.destroy();
+                throw new IOException("Terminal size query timed out");
+            }
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
+                String line = reader.readLine();
+                if (line != null && !line.trim().isEmpty()) {
+                    return Integer.parseInt(line.trim());
+                }
+            }
+        } catch (Exception e) {
+            // Fall through to default
         }
+        return 24; // Reasonable default for most terminals
     }
 
     /**
      * Retrieves the current terminal settings using the {@code stty -g} command.
+     *
      * @return A string representing the current terminal settings.
      * @throws IOException If the command fails to execute or read the output.
      */
     private static String getTerminalSettings() throws IOException {
-        Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty -g < /dev/tty"});
+        Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty -g < /dev/tty 2>/dev/null"});
         try {
-            p.waitFor();
+            if (!p.waitFor(1000, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+                p.destroy();
+                throw new IOException("stty -g command timed out");
+            }
         } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
             throw new IOException(e);
         }
-        BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
-        return reader.readLine();
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
+            return reader.readLine();
+        }
     }
 
     /**
-    * Sets the terminal to raw mode with no echo.
-    * @throws IOException If the terminal cannot be set to raw mode.
-    */
+     * Sets the terminal to raw mode with no echo.
+     *
+     * @throws IOException If the terminal cannot be set to raw mode.
+     */
     private static void setTerminalToRaw() throws IOException {
-        Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty raw -echo < /dev/tty"});
+        Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty raw -echo < /dev/tty 2>/dev/null"});
         try {
-            p.waitFor();
+            if (!p.waitFor(1000, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+                p.destroy();
+                throw new IOException("stty raw command timed out");
+            }
         } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
             throw new IOException(e);
         }
     }
 
     /**
      * Restores the terminal to the specified settings.
+     *
      * @param settings The terminal settings string to restore.
      * @throws IOException If the terminal settings cannot be restored.
      */
     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"});
+        if (settings != null && !settings.trim().isEmpty()) {
+            Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", "stty " + settings + " < /dev/tty 2>/dev/null"});
             try {
-                p.waitFor();
+                if (!p.waitFor(1000, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+                    p.destroy();
+                    throw new IOException("stty restore command timed out");
+                }
             } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
                 throw new IOException(e);
             }
         }
@@ -158,80 +251,99 @@ public class Menu {
     /**
      * Filters the given list of options using a fuzzy matching algorithm.
      * Characters in the filter must appear in order in the option string (not necessarily consecutively), case-insensitive.
+     *
      * @param options The list of options to filter.
-     * @param filter The filter string (case-insensitive).
+     * @param filter  The filter string (case-insensitive).
      * @return A new list containing only the 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> result = new ArrayList<>();
+        String lowerFilter = filter.toLowerCase();
 
-        for (String option : options)
-            if (matchesFuzzy(filter, option))
+        for (String option : options) {
+            if (matchesFuzzy(lowerFilter, option.toLowerCase())) {
                 result.add(option);
+            }
+        }
 
         return result;
     }
 
     /**
-     * Checks if a string matches a filter using fuzzy matching (case-insensitive).
+     * Checks if a string matches a filter using fuzzy matching.
      * 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
+     * @param filter the filter string (already lowercased)
+     * @param option the option string to check (already lowercased)
      * @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))
+        for (char c : option.toCharArray()) {
+            if (filterIndex < filter.length() && c == filter.charAt(filterIndex)) {
                 filterIndex++;
-
+            }
+        }
         return filterIndex == filter.length();
     }
 
     /**
-     * Updates the terminal display to show the current menu state, including the prompt, filter string, and filtered options.
-     * Handles cursor positioning and clearing previous output to avoid flicker.
-     * @param prompt The prompt message to display at the top.
-     * @param filteredOptions The list of options currently matching the filter.
-     * @param selectedIndex The index of the currently selected option.
-     * @param filterString The current filter string typed by the user.
-     * @param lastTotalLines The total number of lines in the previous display, used for cursor positioning.
-     * @return The total number of lines in the current display (prompt line + menu lines).
+     * Updates the terminal display to show the current menu state with scroll indicators.
+     *
+     * @param prompt                The prompt message to display at the top
+     * @param displayOptions        Visible portion of options to render
+     * @param selectedIndex         Selected index within the visible portion
+     * @param filterString          Current filter text
+     * @param lastTotalLines        Previous display height for cursor positioning
+     * @param scrollOffset          Current scroll position (index of first visible item)
+     * @param totalItems            Total number of filtered items
+     * @param visibleLines          Maximum visible lines available for menu items
+     * @return Total lines rendered in current display
      */
-    private static int updateDisplay(String prompt, List<String> filteredOptions, int selectedIndex, String filterString, int lastTotalLines) {
-
-        if (filteredOptions.isEmpty()) filteredOptions = Collections.singletonList("No matches found");
-
-        // Position cursor at the START of the 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)
-
-        // Clear from here downward (removes old leftover lines perfectly)
-        System.out.print("\033[J");                             // ESC [ J = clear from cursor to end of screen
+    private static int updateDisplay(
+            String prompt,
+            List<String> displayOptions,
+            int selectedIndex,
+            String filterString,
+            int lastTotalLines,
+            int scrollOffset,
+            int totalItems,
+            int visibleLines
+    ) {
+        // Calculate total lines to render (prompt + menu items)
+        int totalLines = 1 + displayOptions.size();
+
+        // Reposition cursor to start of previous display
+        if (lastTotalLines > 0) {
+            System.out.print("\033[" + lastTotalLines + "A");
+        }
+        //System.out.print("\r");
+        System.out.print("\033[J"); // Clear to end of screen
 
-        // Print fresh content:
-        // Prompt + current filter (on one line)
-        System.out.println(prompt + ": " + filterString);
+        // Render prompt line with optional scroll indicators
+        StringBuilder promptLine = new StringBuilder(prompt).append(": ").append(filterString);
 
-        // Menu items
-        for (int i = 0; i < filteredOptions.size(); i++) {
-            String line = (i == selectedIndex ? "> " : "  ") + filteredOptions.get(i);
-            System.out.print("\r");
-            System.out.println(line);
+        // Add scroll indicators if content is truncated
+        if (totalItems > visibleLines) {
+            if (scrollOffset > 0) {
+                promptLine.append(" \u2191"); // Up arrow indicator
+            }
+            if (scrollOffset + visibleLines < totalItems) {
+                promptLine.append(" \u2193"); // Down arrow indicator
+            }
         }
-        System.out.print("\r");
+        System.out.print( "\r" + promptLine);
 
-        System.out.flush();  // ensure immediate display
+        // Render visible menu items
+        for (int i = 0; i < displayOptions.size(); i++) {
+            String prefix = (i == selectedIndex) ? "> " : "  ";
+            System.out.print("\n\r");
+            System.out.print(prefix + displayOptions.get(i));
+        }
+        System.out.flush();
 
-        return filteredOptions.size() + 1;  // 1 for prompt line + menu lines
+        return totalLines;
     }
-
-}
+}
\ No newline at end of file