From: Svjatoslav Agejenko Date: Wed, 28 Jan 2026 23:29:07 +0000 (+0200) Subject: Add scrolling support to `Menu` class: Enable handling of long option lists by implem... X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=HEAD;p=cli-helper.git Add scrolling support to `Menu` class: Enable handling of long option lists by implementing a scrolling mechanism that keeps the selection visible within terminal constraints. --- diff --git a/src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java b/src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java index 930cf1e..84d3d27 100644 --- a/src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java +++ b/src/main/java/eu/svjatoslav/commons/cli_helper/Menu.java @@ -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. *

* 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. @@ -20,12 +20,12 @@ import static java.lang.Math.min; *

* 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. *

*/ public class Menu { - /** * Displays an interactive menu for the user to select an option from a list using fuzzy matching and cursor navigation. *

@@ -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. *

*

- * 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). *

+ * * @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 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 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 filterOptions(List options, String filter) { if (filter == null || filter.isEmpty()) return new ArrayList<>(options); List 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 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 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