Changed code to support older Python versions

This commit is contained in:
Malasaur 2025-12-01 23:27:09 +01:00
parent eb92d2d36f
commit 582458cdd0
5027 changed files with 794942 additions and 4 deletions

View file

@ -0,0 +1,308 @@
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