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.
* <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>
* 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.
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);
}
}
/**
* 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