from __future__ import annotations from typing import TYPE_CHECKING, Generic, List, Optional, Tuple, TypeVar import click from rich.console import Console, RenderableType from rich.text import Text from typing_extensions import Any, Literal, TypedDict from ._input_handler import TextInputHandler from .element import CursorOffset, Element if TYPE_CHECKING: from .styles.base import BaseStyle ReturnValue = TypeVar("ReturnValue") class Option(TypedDict, Generic[ReturnValue]): name: str value: ReturnValue class Menu(Generic[ReturnValue], TextInputHandler, Element): DOWN_KEYS = [TextInputHandler.DOWN_KEY, "j"] UP_KEYS = [TextInputHandler.UP_KEY, "k"] LEFT_KEYS = [TextInputHandler.LEFT_KEY, "h"] RIGHT_KEYS = [TextInputHandler.RIGHT_KEY, "l"] current_selection_char = "●" selection_char = "○" filter_prompt = "Filter: " # Scroll indicators MORE_ABOVE_INDICATOR = " ↑ more" MORE_BELOW_INDICATOR = " ↓ more" def __init__( self, label: str, options: List[Option[ReturnValue]], inline: bool = False, allow_filtering: bool = False, *, style: Optional[BaseStyle] = None, cursor_offset: int = 0, max_visible: Optional[int] = None, **metadata: Any, ): self.label = Text.from_markup(label) self.inline = inline self.allow_filtering = allow_filtering self.selected = 0 self.metadata = metadata self._options = options 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) super().__init__() def get_key(self) -> Optional[str]: char = click.getchar() if char == "\r": return "enter" if self.allow_filtering: left_keys, right_keys = [[self.LEFT_KEY], [self.RIGHT_KEY]] down_keys, up_keys = [[self.DOWN_KEY], [self.UP_KEY]] else: left_keys, right_keys = self.LEFT_KEYS, self.RIGHT_KEYS down_keys, up_keys = self.DOWN_KEYS, self.UP_KEYS next_keys, prev_keys = ( (right_keys, left_keys) if self.inline else (down_keys, up_keys) ) if char in next_keys: return "next" if char in prev_keys: return "prev" if self.allow_filtering: return char return None @property def options(self) -> List[Option[ReturnValue]]: if self.allow_filtering: return [ option for option in self._options if self.text.lower() in option["name"].lower() ] 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 elif key == "prev": self.selected -= 1 if self.selected < 0: self.selected = len(self.options) - 1 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() result_text.append(self.label) result_text.append(" ") result_text.append( self.options[self.selected]["name"], style=self.console.get_style("result"), ) return result_text def is_next_key(self, key: str) -> bool: keys = self.RIGHT_KEYS if self.inline else self.DOWN_KEYS if self.allow_filtering: keys = [keys[0]] return key in keys def is_prev_key(self, key: str) -> bool: keys = self.LEFT_KEYS if self.inline else self.UP_KEYS if self.allow_filtering: keys = [keys[0]] return key in keys 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") elif self.is_prev_key(key): self._update_selection("prev") else: if self.options: current_selection = self.options[self.selected]["name"] super().handle_key(key) if current_selection: matching_index = next( ( index for index, option in enumerate(self.options) if option["name"] == current_selection ), 0, ) 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 return True @property def validation_message(self) -> Optional[str]: if self.valid is False: return "This field is required" return None def on_blur(self): self.on_validate() def on_validate(self): self.valid = len(self.options) > 0 @property def should_show_cursor(self) -> bool: return self.allow_filtering def ask(self) -> ReturnValue: from .container import Container container = Container(style=self.style, metadata=self.metadata) container.elements = [self] container.run() return self.options[self.selected]["value"] @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