From f9209a6a9a9cc74df0909cea8e8faebc09db810c Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Thu, 27 Nov 2025 10:40:59 +0000 Subject: [PATCH 1/2] Add scrolling support for menus with many options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a menu has more options than can fit on the screen, it now automatically scrolls as the user navigates. This prevents the UI from breaking when the terminal is too small. Changes: - Add scroll state tracking to Menu class (scroll_offset, max_visible) - Add scroll indicators ("↑ more" / "↓ more") to show when more options exist - Reserve consistent space for indicators to prevent UI shifting - Update both BaseStyle and BorderedStyle render_menu methods - Reset scroll position when filter text changes - Add scrolling-menu.py example demonstrating the feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- examples/scrolling-menu.py | 318 ++++++++++++++++++++++++++++++ src/rich_toolkit/menu.py | 105 +++++++++- src/rich_toolkit/styles/base.py | 33 +++- src/rich_toolkit/styles/border.py | 29 ++- 4 files changed, 477 insertions(+), 8 deletions(-) create mode 100644 examples/scrolling-menu.py 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")) From 23bab49ea4d4d048ee3ccfe03a570d7e71ef8496 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Thu, 27 Nov 2025 11:06:09 +0000 Subject: [PATCH 2/2] Add RELEASE.md for automated release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- RELEASE.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 RELEASE.md 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() +```