207 lines
5.3 KiB
Python
207 lines
5.3 KiB
Python
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)
|