diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..61ef452 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,41 @@ +--- +release type: minor +--- + +Add scrolling support for menus with many options. + +When a menu has more options than can fit on the terminal screen, it now +automatically scrolls as the user navigates with arrow keys. This prevents +the UI from breaking when the terminal is too small to display all options. + +Features: +- Automatic scrolling based on terminal height +- Scroll indicators (`↑ more` / `↓ more`) show when more options exist +- Works with both `TaggedStyle` and `BorderedStyle` +- Works with filterable menus (scroll resets when filter changes) +- Optional `max_visible` parameter for explicit control + +Example usage: + +```python +from rich_toolkit import RichToolkit +from rich_toolkit.styles.tagged import TaggedStyle + +# Auto-scrolling based on terminal height +with RichToolkit(style=TaggedStyle()) as app: + result = app.ask( + "Select a country:", + options=[{"name": country, "value": country} for country in countries], + allow_filtering=True, + ) + +# Or with explicit max_visible limit +from rich_toolkit.menu import Menu + +menu = Menu( + label="Pick an option:", + options=[{"name": f"Option {i}", "value": i} for i in range(50)], + max_visible=10, # Only show 10 options at a time +) +result = menu.ask() +``` diff --git a/examples/scrolling-menu.py b/examples/scrolling-menu.py new file mode 100644 index 0000000..72a309f --- /dev/null +++ b/examples/scrolling-menu.py @@ -0,0 +1,318 @@ +"""Example demonstrating scrollable menus with many options. + +This example shows how menus automatically scroll when there are more +options than can fit on the screen. Try resizing your terminal to see +the scrolling behavior adapt. +""" + +from typing import List + +from rich_toolkit import RichToolkit +from rich_toolkit.menu import Option +from rich_toolkit.styles.border import BorderedStyle +from rich_toolkit.styles.tagged import TaggedStyle + + +def get_country_options() -> List[Option[str]]: + """Generate a large list of country options to demonstrate scrolling.""" + countries = [ + "Afghanistan", + "Albania", + "Algeria", + "Andorra", + "Angola", + "Argentina", + "Armenia", + "Australia", + "Austria", + "Azerbaijan", + "Bahamas", + "Bahrain", + "Bangladesh", + "Barbados", + "Belarus", + "Belgium", + "Belize", + "Benin", + "Bhutan", + "Bolivia", + "Bosnia and Herzegovina", + "Botswana", + "Brazil", + "Brunei", + "Bulgaria", + "Burkina Faso", + "Burundi", + "Cambodia", + "Cameroon", + "Canada", + "Cape Verde", + "Central African Republic", + "Chad", + "Chile", + "China", + "Colombia", + "Comoros", + "Congo", + "Costa Rica", + "Croatia", + "Cuba", + "Cyprus", + "Czech Republic", + "Denmark", + "Djibouti", + "Dominica", + "Dominican Republic", + "Ecuador", + "Egypt", + "El Salvador", + "Equatorial Guinea", + "Eritrea", + "Estonia", + "Ethiopia", + "Fiji", + "Finland", + "France", + "Gabon", + "Gambia", + "Georgia", + "Germany", + "Ghana", + "Greece", + "Grenada", + "Guatemala", + "Guinea", + "Guinea-Bissau", + "Guyana", + "Haiti", + "Honduras", + "Hungary", + "Iceland", + "India", + "Indonesia", + "Iran", + "Iraq", + "Ireland", + "Israel", + "Italy", + "Jamaica", + "Japan", + "Jordan", + "Kazakhstan", + "Kenya", + "Kiribati", + "Kuwait", + "Kyrgyzstan", + "Laos", + "Latvia", + "Lebanon", + "Lesotho", + "Liberia", + "Libya", + "Liechtenstein", + "Lithuania", + "Luxembourg", + "Madagascar", + "Malawi", + "Malaysia", + "Maldives", + "Mali", + "Malta", + "Marshall Islands", + "Mauritania", + "Mauritius", + "Mexico", + "Micronesia", + "Moldova", + "Monaco", + "Mongolia", + "Montenegro", + "Morocco", + "Mozambique", + "Myanmar", + "Namibia", + "Nauru", + "Nepal", + "Netherlands", + "New Zealand", + "Nicaragua", + "Niger", + "Nigeria", + "North Korea", + "North Macedonia", + "Norway", + "Oman", + "Pakistan", + "Palau", + "Palestine", + "Panama", + "Papua New Guinea", + "Paraguay", + "Peru", + "Philippines", + "Poland", + "Portugal", + "Qatar", + "Romania", + "Russia", + "Rwanda", + "Saint Kitts and Nevis", + "Saint Lucia", + "Saint Vincent and the Grenadines", + "Samoa", + "San Marino", + "Sao Tome and Principe", + "Saudi Arabia", + "Senegal", + "Serbia", + "Seychelles", + "Sierra Leone", + "Singapore", + "Slovakia", + "Slovenia", + "Solomon Islands", + "Somalia", + "South Africa", + "South Korea", + "South Sudan", + "Spain", + "Sri Lanka", + "Sudan", + "Suriname", + "Sweden", + "Switzerland", + "Syria", + "Taiwan", + "Tajikistan", + "Tanzania", + "Thailand", + "Timor-Leste", + "Togo", + "Tonga", + "Trinidad and Tobago", + "Tunisia", + "Turkey", + "Turkmenistan", + "Tuvalu", + "Uganda", + "Ukraine", + "United Arab Emirates", + "United Kingdom", + "United States", + "Uruguay", + "Uzbekistan", + "Vanuatu", + "Vatican City", + "Venezuela", + "Vietnam", + "Yemen", + "Zambia", + "Zimbabwe", + ] + return [{"name": country, "value": country} for country in countries] + + +def get_numbered_options(count: int = 50) -> List[Option[int]]: + """Generate numbered options for simple demonstration.""" + return [{"name": f"Option {i + 1}", "value": i + 1} for i in range(count)] + + +theme = { + "tag.title": "black on #A7E3A2", + "tag": "white on #893AE3", + "placeholder": "grey85", + "text": "white", + "selected": "green", + "result": "grey85", + "progress": "on #893AE3", +} + + +def main(): + print("=" * 60) + print("Scrollable Menu Example") + print("=" * 60) + print() + print("This example demonstrates menus that automatically scroll") + print("when there are more options than can fit on the screen.") + print() + print("Use arrow keys (or j/k) to navigate, Enter to select.") + print("The menu will scroll as you move past visible options.") + print() + + # Example 1: Simple scrollable menu with TaggedStyle + print("-" * 60) + print("Example 1: Numbered options with TaggedStyle") + print("-" * 60) + + with RichToolkit(style=TaggedStyle(tag_width=12, theme=theme)) as app: + result = app.ask( + "Select a number (50 options):", + tag="number", + options=get_numbered_options(50), + ) + app.print_line() + app.print(f"You selected: {result}", tag="result") + + print() + + # Example 2: Country picker with filtering and BorderedStyle + print("-" * 60) + print("Example 2: Country picker with filtering (BorderedStyle)") + print("-" * 60) + print("Tip: Type to filter the list!") + print() + + with RichToolkit(style=BorderedStyle(theme=theme)) as app: + country = app.ask( + "Select your country:", + tag="country", + options=get_country_options(), + allow_filtering=True, + ) + app.print_line() + app.print(f"You selected: {country}", tag="result") + + print() + + # Example 3: Explicitly limited max_visible + print("-" * 60) + print("Example 3: Explicitly limited to 5 visible options") + print("-" * 60) + + from rich_toolkit.menu import Menu + + with RichToolkit(style=TaggedStyle(tag_width=12, theme=theme)) as app: + # Create menu with explicit max_visible limit + menu = Menu( + label="Pick a programming language:", + options=[ + {"name": "Python", "value": "python"}, + {"name": "JavaScript", "value": "js"}, + {"name": "TypeScript", "value": "ts"}, + {"name": "Rust", "value": "rust"}, + {"name": "Go", "value": "go"}, + {"name": "Java", "value": "java"}, + {"name": "C++", "value": "cpp"}, + {"name": "C#", "value": "csharp"}, + {"name": "Ruby", "value": "ruby"}, + {"name": "PHP", "value": "php"}, + {"name": "Swift", "value": "swift"}, + {"name": "Kotlin", "value": "kotlin"}, + ], + style=app.style, + max_visible=5, # Only show 5 options at a time + ) + + result = menu.ask() + app.print_line() + app.print(f"You selected: {result}", tag="result") + + print() + print("=" * 60) + print("Done!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/src/rich_toolkit/menu.py b/src/rich_toolkit/menu.py index 812e413..a8f42ed 100644 --- a/src/rich_toolkit/menu.py +++ b/src/rich_toolkit/menu.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Generic, List, Optional, TypeVar +from typing import TYPE_CHECKING, Generic, List, Optional, Tuple, TypeVar import click -from rich.console import RenderableType +from rich.console import Console, RenderableType from rich.text import Text from typing_extensions import Any, Literal, TypedDict @@ -32,6 +32,10 @@ class Menu(Generic[ReturnValue], TextInputHandler, Element): selection_char = "○" filter_prompt = "Filter: " + # Scroll indicators + MORE_ABOVE_INDICATOR = " ↑ more" + MORE_BELOW_INDICATOR = " ↓ more" + def __init__( self, label: str, @@ -41,6 +45,7 @@ def __init__( *, style: Optional[BaseStyle] = None, cursor_offset: int = 0, + max_visible: Optional[int] = None, **metadata: Any, ): self.label = Text.from_markup(label) @@ -56,6 +61,10 @@ def __init__( self._padding_bottom = 1 self.valid = None + # Scrolling state + self._scroll_offset: int = 0 + self._max_visible: Optional[int] = max_visible + cursor_offset = cursor_offset + len(self.filter_prompt) Element.__init__(self, style=style, metadata=metadata) @@ -99,6 +108,79 @@ def options(self) -> List[Option[ReturnValue]]: return self._options + def get_max_visible(self, console: Optional[Console] = None) -> Optional[int]: + """Calculate the maximum number of visible options based on terminal height. + + Args: + console: Console to get terminal height from. If None, uses default. + + Returns: + Maximum number of visible options, or None if no limit needed. + """ + if self._max_visible is not None: + return self._max_visible + + if self.inline: + # Inline menus don't need scrolling + return None + + if console is None: + console = Console() + + # Reserve space for: label (1), filter line if enabled (1), + # scroll indicators (2), validation message (1), margins (2) + reserved_lines = 6 + if self.allow_filtering: + reserved_lines += 1 + + available_height = console.height - reserved_lines + # At least show 3 options + return max(3, available_height) + + @property + def visible_options_range(self) -> Tuple[int, int]: + """Returns (start, end) indices for visible options.""" + max_visible = self.get_max_visible() + total_options = len(self.options) + + if max_visible is None or total_options <= max_visible: + return (0, total_options) + + start = self._scroll_offset + end = min(start + max_visible, total_options) + return (start, end) + + @property + def has_more_above(self) -> bool: + """Check if there are more options above the visible window.""" + return self._scroll_offset > 0 + + @property + def has_more_below(self) -> bool: + """Check if there are more options below the visible window.""" + max_visible = self.get_max_visible() + if max_visible is None: + return False + return self._scroll_offset + max_visible < len(self.options) + + def _ensure_selection_visible(self) -> None: + """Adjust scroll offset to ensure the selected item is visible.""" + max_visible = self.get_max_visible() + if max_visible is None: + return + + # If selection is above visible window, scroll up + if self.selected < self._scroll_offset: + self._scroll_offset = self.selected + + # If selection is below visible window, scroll down + elif self.selected >= self._scroll_offset + max_visible: + self._scroll_offset = self.selected - max_visible + 1 + + def _reset_scroll(self) -> None: + """Reset scroll offset (used when filter changes).""" + self._scroll_offset = 0 + def _update_selection(self, key: Literal["next", "prev"]) -> None: if key == "next": self.selected += 1 @@ -111,6 +193,9 @@ def _update_selection(self, key: Literal["next", "prev"]) -> None: if self.selected >= len(self.options): self.selected = 0 + # Ensure the selected item is visible after navigation + self._ensure_selection_visible() + def render_result(self) -> RenderableType: result_text = Text() @@ -141,6 +226,7 @@ def is_prev_key(self, key: str) -> bool: def handle_key(self, key: str) -> None: current_selection: Optional[str] = None + previous_filter_text = self.text if self.is_next_key(key): self._update_selection("next") @@ -164,6 +250,11 @@ def handle_key(self, key: str) -> None: self.selected = matching_index + # Reset scroll when filter text changes + if self.allow_filtering and self.text != previous_filter_text: + self._reset_scroll() + self._ensure_selection_visible() + def _handle_enter(self) -> bool: if self.allow_filtering and self.text and len(self.options) == 0: return False @@ -200,8 +291,18 @@ def ask(self) -> ReturnValue: @property def cursor_offset(self) -> CursorOffset: + # For non-inline menus with filtering, cursor is on the filter line + # top = 2 accounts for: label (1) + filter line position (1 from start) + # The filter line comes BEFORE scroll indicators, so no adjustment needed top = 2 left_offset = len(self.filter_prompt) + self.cursor_left return CursorOffset(top=top, left=left_offset) + + def _needs_scrolling(self) -> bool: + """Check if scrolling is needed (more options than can be displayed).""" + max_visible = self.get_max_visible() + if max_visible is None: + return False + return len(self.options) > max_visible diff --git a/src/rich_toolkit/styles/base.py b/src/rich_toolkit/styles/base.py index e2adab1..8670016 100644 --- a/src/rich_toolkit/styles/base.py +++ b/src/rich_toolkit/styles/base.py @@ -316,15 +316,34 @@ def render_menu( return result_content - for id_, option in enumerate(element.options): - if id_ == element.selected: + # Get visible range for scrolling + all_options = element.options + start, end = element.visible_options_range + visible_options = all_options[start:end] + + # Check if scrolling is needed (to reserve consistent space for indicators) + needs_scrolling = element._needs_scrolling() + + # Always reserve space for "more above" indicator when scrolling is enabled + # This prevents the menu from shifting when scrolling starts + if needs_scrolling: + if element.has_more_above: + menu.append(Text(element.MORE_ABOVE_INDICATOR + "\n", style="dim")) + else: + # Empty line to reserve space (same length as indicator for consistency) + menu.append(Text(" " * len(element.MORE_ABOVE_INDICATOR) + "\n")) + + for idx, option in enumerate(visible_options): + # Calculate actual index in full options list + actual_idx = start + idx + if actual_idx == element.selected: prefix = selected_prefix style = self.console.get_style("selected") else: prefix = not_selected_prefix style = self.console.get_style("text") - is_last = id_ == len(element.options) - 1 + is_last = idx == len(visible_options) - 1 menu.append( Text.assemble( @@ -335,6 +354,14 @@ def render_menu( ) ) + # Always reserve space for "more below" indicator when scrolling is enabled + if needs_scrolling: + if element.has_more_below: + menu.append(Text("\n" + element.MORE_BELOW_INDICATOR, style="dim")) + else: + # Empty line to reserve space (same length as indicator for consistency) + menu.append(Text("\n" + " " * len(element.MORE_BELOW_INDICATOR))) + if not element.options: menu = Text("No results found", style=self.console.get_style("text")) diff --git a/src/rich_toolkit/styles/border.py b/src/rich_toolkit/styles/border.py index 2dc7dcb..25f6d86 100644 --- a/src/rich_toolkit/styles/border.py +++ b/src/rich_toolkit/styles/border.py @@ -128,15 +128,31 @@ def render_menu( ) else: - for id_, option in enumerate(element.options): - if id_ == element.selected: + # Get visible range for scrolling + all_options = element.options + start, end = element.visible_options_range + visible_options = all_options[start:end] + + # Check if scrolling is needed (to reserve consistent space for indicators) + needs_scrolling = element._needs_scrolling() + + # Always reserve space for "more above" indicator when scrolling is enabled + if needs_scrolling: + if element.has_more_above: + menu.append(Text(element.MORE_ABOVE_INDICATOR + "\n", style="dim")) + else: + menu.append(Text(" " * len(element.MORE_ABOVE_INDICATOR) + "\n")) + + for idx, option in enumerate(visible_options): + actual_idx = start + idx + if actual_idx == element.selected: prefix = selected_prefix style = self.console.get_style("selected") else: prefix = not_selected_prefix style = self.console.get_style("text") - is_last = id_ == len(element.options) - 1 + is_last = idx == len(visible_options) - 1 menu.append( Text.assemble( @@ -147,6 +163,13 @@ def render_menu( ) ) + # Always reserve space for "more below" indicator when scrolling is enabled + if needs_scrolling: + if element.has_more_below: + menu.append(Text("\n" + element.MORE_BELOW_INDICATOR, style="dim")) + else: + menu.append(Text("\n" + " " * len(element.MORE_BELOW_INDICATOR))) + if not element.options: menu = Text("No results found", style=self.console.get_style("text"))