from __future__ import annotations from typing import TYPE_CHECKING, Generic, List, Optional, TypeVar import click from rich.console import 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: " def __init__( self, label: str, options: List[Option[ReturnValue]], inline: bool = False, allow_filtering: bool = False, *, style: Optional[BaseStyle] = None, cursor_offset: int = 0, **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 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 _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 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 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 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: top = 2 left_offset = len(self.filter_prompt) + self.cursor_left return CursorOffset(top=top, left=left_offset)