second commit

This commit is contained in:
2024-12-27 22:31:23 +09:00
parent 2353324570
commit 10a0f110ca
8819 changed files with 1307198 additions and 28 deletions

View File

@ -0,0 +1,3 @@
from .toolkit import RichToolkit, RichToolkitTheme
__all__ = ["RichToolkit", "RichToolkitTheme"]

View File

@ -0,0 +1,192 @@
import string
from typing import Any, Optional, Tuple
from abc import ABC, abstractmethod
import click
from rich.console import Console, Group, RenderableType
from rich.control import Control
from rich.live_render import LiveRender
from rich_toolkit.styles.base import BaseStyle
class TextInputHandler:
DOWN_KEY = "\x1b[B"
UP_KEY = "\x1b[A"
LEFT_KEY = "\x1b[D"
RIGHT_KEY = "\x1b[C"
BACKSPACE_KEY = "\x7f"
DELETE_KEY = "\x1b[3~"
def __init__(self, cursor_offset: int = 0):
self.text = ""
self.cursor_position = 0
self._cursor_offset = cursor_offset
def _move_cursor_left(self) -> None:
self.cursor_position = max(0, self.cursor_position - 1)
def _move_cursor_right(self) -> None:
self.cursor_position = min(len(self.text), self.cursor_position + 1)
def _insert_char(self, char: str) -> None:
self.text = (
self.text[: self.cursor_position] + char + self.text[self.cursor_position :]
)
self._move_cursor_right()
def _delete_char(self) -> None:
if self.cursor_position == 0:
return
self.text = (
self.text[: self.cursor_position - 1] + self.text[self.cursor_position :]
)
self._move_cursor_left()
def _delete_forward(self) -> None:
if self.cursor_position == len(self.text):
return
self.text = (
self.text[: self.cursor_position] + self.text[self.cursor_position + 1 :]
)
def update_text(self, text: str) -> None:
if text == self.BACKSPACE_KEY:
self._delete_char()
elif text == self.DELETE_KEY:
self._delete_forward()
elif text == self.LEFT_KEY:
self._move_cursor_left()
elif text == self.RIGHT_KEY:
self.cursor_position = min(len(self.text), self.cursor_position + 1)
elif text in (self.UP_KEY, self.DOWN_KEY):
pass
else:
for char in text:
if char in string.printable:
self._insert_char(char)
def fix_cursor(self) -> Tuple[Control, ...]:
return (Control.move_to_column(self._cursor_offset + self.cursor_position),)
class LiveInput(ABC, TextInputHandler):
def __init__(
self,
console: Console,
style: Optional[BaseStyle] = None,
cursor_offset: int = 0,
**metadata: Any,
):
self.console = console
self._live_render = LiveRender("")
if style is None:
self._live_render = LiveRender("")
else:
self._live_render = style.decorate_class(LiveRender, **metadata)("")
self._padding_bottom = 1
super().__init__(cursor_offset=cursor_offset)
@abstractmethod
def render_result(self) -> RenderableType:
raise NotImplementedError
@abstractmethod
def render_input(self) -> RenderableType:
raise NotImplementedError
@property
def should_show_cursor(self) -> bool:
return True
def position_cursor(self) -> Tuple[Control, ...]:
return (self._live_render.position_cursor(),)
def _refresh(self, show_result: bool = False) -> None:
renderable = self.render_result() if show_result else self.render_input()
self._live_render.set_renderable(renderable)
self._render(show_result)
def _render(self, show_result: bool = False) -> None:
after = self.fix_cursor() if not show_result else ()
self.console.print(
Control.show_cursor(self.should_show_cursor),
*self.position_cursor(),
self._live_render,
*after,
)
class Input(LiveInput):
def __init__(
self,
console: Console,
title: str,
style: Optional[BaseStyle] = None,
default: str = "",
cursor_offset: int = 0,
password: bool = False,
**metadata: Any,
):
self.title = title
self.default = default
self.password = password
self.console = console
self.style = style
super().__init__(
console=console, style=style, cursor_offset=cursor_offset, **metadata
)
def render_result(self) -> RenderableType:
if self.password:
return self.title
return self.title + " [result]" + (self.text or self.default)
def render_input(self) -> Group:
text = self.text
if self.password:
text = "*" * len(self.text)
# if there's no default value, add a space to keep the cursor visible
# and, most importantly, in the right place
default = self.default or " "
text = f"[text]{text}[/]" if self.text else f"[placeholder]{default }[/]"
return Group(self.title, text)
def ask(self) -> str:
self._refresh()
while True:
try:
key = click.getchar()
if key == "\r":
break
self.update_text(key)
except KeyboardInterrupt:
exit()
self._refresh()
self._refresh(show_result=True)
for _ in range(self._padding_bottom):
self.console.print()
return self.text or self.default

View File

@ -0,0 +1,302 @@
from typing import Generic, List, Optional, Tuple, TypeVar, cast
import click
from rich import get_console
from rich.console import Console, Group, RenderableType
from rich.control import Control
from rich.segment import ControlType
from rich.text import Text
from rich.live_render import LiveRender
from typing_extensions import Any, Literal, TypedDict
from .styles.base import BaseStyle
from .input import TextInputHandler, LiveInput
ReturnValue = TypeVar("ReturnValue")
class Option(TypedDict, Generic[ReturnValue]):
name: str
value: ReturnValue
class Menu(Generic[ReturnValue], LiveInput):
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,
title: str,
options: List[Option[ReturnValue]],
inline: bool = False,
allow_filtering: bool = False,
*,
style: Optional[BaseStyle] = None,
console: Optional[Console] = None,
cursor_offset: int = 0,
**metadata: Any,
):
self.console = console or get_console()
self.title = Text.from_markup(title)
self.inline = inline
self.allow_filtering = allow_filtering
self.selected = 0
self.metadata = metadata
self.style = style
self._options = options
self._padding_bottom = 1
cursor_offset = cursor_offset + len(self.filter_prompt)
super().__init__(
console=self.console,
style=self.style,
cursor_offset=cursor_offset,
**metadata,
)
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_input(self) -> RenderableType:
menu = Text(justify="left")
selected_prefix = Text(self.current_selection_char + " ")
not_selected_prefix = Text(self.selection_char + " ")
separator = Text("\t" if self.inline else "\n")
for id_, option in enumerate(self.options):
if id_ == self.selected:
prefix = selected_prefix
style = self.console.get_style("selected")
else:
prefix = not_selected_prefix
style = self.console.get_style("text")
menu.append(Text.assemble(prefix, option["name"], separator, style=style))
# TODO: inline is not wrapped (maybe that's good?)
if not self.options:
# menu.append("No results found", style=self.console.get_style("text"))
menu = Text("No results found\n\n", style=self.console.get_style("text"))
h = 0
if self._live_render._shape is not None:
_, h = self._live_render._shape
filter = (
[
Text.assemble(
(self.filter_prompt, self.console.get_style("text")),
(self.text, self.console.get_style("text")),
"\n",
)
]
if self.allow_filtering
else []
)
return Group(self.title, *filter, *menu)
def render_result(self) -> RenderableType:
result_text = Text()
result_text.append(self.title)
result_text.append(" ")
result_text.append(
self.options[self.selected]["name"],
style=self.console.get_style("result"),
)
return result_text
def update_text(self, text: str) -> None:
current_selection: Optional[str] = None
if self.options:
current_selection = self.options[self.selected]["name"]
super().update_text(text)
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
def reposition_cursor(self) -> Control:
if self.allow_filtering:
if self._live_render._shape is None:
return Control()
_, height = self._live_render._shape
move_down = height - 2
return Control(
(ControlType.CURSOR_DOWN, move_down),
ControlType.CARRIAGE_RETURN,
(ControlType.ERASE_IN_LINE, 2),
*(
(
(ControlType.CURSOR_UP, 1),
(ControlType.ERASE_IN_LINE, 2),
)
* (height - 1)
),
)
return self._live_render.position_cursor()
def position_cursor(self) -> Tuple[Control, ...]:
"""Positions the cursor at the end of the menu.
It moves the cursor up based on the size of the menu when filtering
is enabled. It does by taking into account the size of the menu
and the fact that we moved the cursor up in the previous render.
We use the shape of the menu to calculate the number of times we
need to move the cursor up, we do this because the options are
dynamic and we need the current size* of the menu to calculate
the correct position of the cursor.
* Current size means the previous size of the menu, but I say
current because we haven't rendered the updated menu yet :)
"""
original = super().position_cursor()
if self._live_render._shape is None or not self.allow_filtering:
return original
_, height = self._live_render._shape
move_down = height - 2
return (
Control(
(ControlType.CURSOR_DOWN, move_down),
),
*original,
)
def fix_cursor(self) -> Tuple[Control, ...]:
"""Fixes the position of cursor after rendering the menu.
It moves the cursor up based on the size of the menu, but
only if allow_filtering is enabled. (We don't show the cursor
when filtering is disabled.)
"""
move_cursor = ()
if self.allow_filtering:
height = len(self.options) + 1 if self.options else 2
move_cursor = (Control((ControlType.CURSOR_UP, height)),)
return (*super().fix_cursor(), *move_cursor)
@property
def should_show_cursor(self) -> bool:
return self.allow_filtering
def ask(self) -> ReturnValue:
self._refresh()
while True:
try:
key = self.get_key()
if key == "enter":
if self._handle_enter():
break
elif key is not None:
if key in ["next", "prev"]:
key = cast(Literal["next", "prev"], key)
self._update_selection(key)
else:
self.update_text(key)
except KeyboardInterrupt:
exit()
self._refresh()
self._refresh(show_result=True)
for _ in range(self._padding_bottom):
self.console.print()
return self.options[self.selected]["value"]

View File

@ -0,0 +1,56 @@
from typing import Optional
from rich.console import Console
from rich.live import Live
from typing_extensions import Any, Literal
from .styles.base import BaseStyle
class Progress(Live):
def __init__(
self,
title: str,
style: Optional[BaseStyle] = None,
console: Optional[Console] = None,
transient: bool = False,
transient_on_error: bool = False,
) -> None:
self.current_message = title
self.style = style
self.is_error = False
self._transient_on_error = transient_on_error
super().__init__(console=console, refresh_per_second=8, transient=transient)
# TODO: remove this once rich uses "Self"
def __enter__(self) -> "Progress":
self.start(refresh=self._renderable is not None)
return self
def get_renderable(self) -> Any:
current_message = self.current_message
if not self.style:
return current_message
animation_status: Literal["started", "stopped", "error"] = (
"started" if self._started else "stopped"
)
if self.is_error:
animation_status = "error"
return self.style.with_decoration(
current_message,
animation_status=animation_status,
)
def log(self, text: str) -> None:
self.current_message = text
def set_error(self, text: str) -> None:
self.current_message = text
self.is_error = True
self.transient = self._transient_on_error

View File

View File

@ -0,0 +1,9 @@
from .fancy import FancyStyle
from .tagged import TaggedStyle
from .minimal import MinimalStyle
__all__ = [
"FancyStyle",
"TaggedStyle",
"MinimalStyle",
]

View File

@ -0,0 +1,125 @@
from abc import ABC, abstractmethod
from typing import Any, Generator, Iterable, List, Type, TypeVar
from typing_extensions import Literal
from rich.color import Color
from rich.console import (
Console,
ConsoleOptions,
ConsoleRenderable,
RenderableType,
RenderResult,
)
from rich.segment import Segment
from rich.text import Text
from rich_toolkit.utils.colors import lighten
ConsoleRenderableClass = TypeVar(
"ConsoleRenderableClass", bound=Type[ConsoleRenderable]
)
ANIMATION_STATUS = Literal["started", "stopped", "error", "no_animation"]
class BaseStyle(ABC):
result_color: Color
decoration_size: int
def __init__(self) -> None:
self.padding = 2
self.cursor_offset = 0
self._animation_counter = 0
self.decoration_size = 2
def empty_line(self) -> Text:
return Text(" ")
def with_decoration(
self,
*renderables: RenderableType,
animation_status: ANIMATION_STATUS = "no_animation",
**metadata: Any,
) -> ConsoleRenderable:
class WithDecoration:
@staticmethod
def __rich_console__(
console: Console, options: ConsoleOptions
) -> RenderResult:
for content in renderables:
# our styles are potentially adding some paddings on the left
# and right sides, so we need to adjust the max_width to
# make sure that rich takes that into account
options = options.update(
max_width=console.width - self.decoration_size
)
lines = console.render_lines(content, options, pad=False)
for line in Segment.split_lines(
self.decorate(
lines=lines,
console=console,
animation_status=animation_status,
**metadata,
)
):
yield from line
yield Segment.line()
return WithDecoration()
def decorate_class(
self, klass: ConsoleRenderableClass, **metadata: Any
) -> ConsoleRenderableClass:
style = self
class Decorated(klass): # type: ignore[valid-type,misc]
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
lines = Segment.split_lines(super().__rich_console__(console, options)) # type: ignore
yield from style.decorate(lines=lines, console=console, **metadata)
return Decorated # type: ignore
@abstractmethod
def decorate(
self,
console: Console,
lines: Iterable[List[Segment]],
animation_status: ANIMATION_STATUS = "no_animation",
**kwargs: Any,
) -> Generator[Segment, None, None]:
raise NotImplementedError()
def _get_animation_colors(
self,
console: Console,
steps: int = 5,
animation_status: ANIMATION_STATUS = "started",
**metadata: Any,
) -> List[Color]:
animated = animation_status == "started"
if animation_status == "error":
base_color = console.get_style("error").color
if base_color is None:
base_color = Color.parse("red")
else:
base_color = console.get_style("progress").bgcolor
if not base_color:
base_color = Color.parse("white")
if animated and base_color.triplet is not None:
return [lighten(base_color, 0.1 * i) for i in range(0, steps)]
return [base_color] * steps

View File

@ -0,0 +1,63 @@
from typing import Any, Generator, Iterable, List
from rich._loop import loop_first_last
from rich.console import (
Console,
)
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from .base import ANIMATION_STATUS, BaseStyle
class FancyStyle(BaseStyle):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.cursor_offset = 2
self.decoration_size = 2
def decorate(
self,
console: Console,
lines: Iterable[List[Segment]],
animation_status: ANIMATION_STATUS = "no_animation",
**metadata: Any,
) -> Generator[Segment, None, None]:
if animation_status != "no_animation":
colors = self._get_animation_colors(
console, animation_status=animation_status, **metadata
)
self._animation_counter += 1
color_index = self._animation_counter % len(colors)
for first, last, line in loop_first_last(lines):
if first:
yield Segment("", style=Style(color=colors[color_index]))
else:
yield Segment("")
yield from line
yield Segment.line()
return
for first, last, line in loop_first_last(lines):
if first:
decoration = "" if metadata.get("title", False) else ""
elif last:
decoration = ""
else:
decoration = ""
yield Segment(decoration)
yield from line
if not last:
yield Segment.line()
def empty_line(self) -> Text:
return Text("")

View File

@ -0,0 +1,59 @@
from typing import Any, Generator, Iterable, List
from rich._loop import loop_first_last
from rich.console import (
Console,
)
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from .base import ANIMATION_STATUS, BaseStyle
class MinimalStyle(BaseStyle):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.cursor_offset = 2
self.decoration_size = 2
def decorate(
self,
console: Console,
lines: Iterable[List[Segment]],
animation_status: ANIMATION_STATUS = "no_animation",
**metadata: Any,
) -> Generator[Segment, None, None]:
if animation_status != "no_animation":
block = ""
colors = self._get_animation_colors(
console, animation_status=animation_status, **metadata
)
self._animation_counter += 1
color_index = self._animation_counter % len(colors)
for first, last, line in loop_first_last(lines):
if first:
yield Segment(f"{block} ", style=Style(color=colors[color_index]))
else:
yield Segment(" " * 2)
yield from line
yield Segment.line()
return
for first, last, line in loop_first_last(lines):
decoration = " " * 2
yield Segment(decoration)
yield from line
if not last:
yield Segment.line()
def empty_line(self) -> Text:
return Text("")

View File

@ -0,0 +1,100 @@
from typing import Any, Generator, Iterable, List
from rich._loop import loop_first_last
from rich.console import Console
from rich.segment import Segment
from rich.style import Style
from .base import ANIMATION_STATUS, BaseStyle
class TaggedStyle(BaseStyle):
def __init__(self, *args, **kwargs) -> None:
self.tag_width = kwargs.pop("tag_width", 14)
super().__init__(*args, **kwargs)
self.padding = 2
self.cursor_offset = self.tag_width + self.padding
self.decoration_size = self.tag_width + self.padding
def _render_tag(
self,
text: str,
console: Console,
**metadata: Any,
) -> Generator[Segment, None, None]:
style_name = "tag.title" if metadata.get("title", False) else "tag"
style = console.get_style(style_name)
if text:
text = f" {text} "
left_padding = self.tag_width - len(text)
left_padding = max(0, left_padding)
yield Segment(" " * left_padding)
yield Segment(text, style=style)
yield Segment(" " * self.padding)
def decorate(
self,
console: Console,
lines: Iterable[List[Segment]],
animation_status: ANIMATION_STATUS = "no_animation",
**metadata: Any,
) -> Generator[Segment, None, None]:
if animation_status != "no_animation":
yield from self.decorate_with_animation(
lines=lines,
console=console,
animation_status=animation_status,
**metadata,
)
return
tag = metadata.get("tag", "")
for first, last, line in loop_first_last(lines):
text = tag if first else ""
yield from self._render_tag(text, console=console, **metadata)
yield from line
if not last:
yield Segment.line()
def decorate_with_animation(
self,
console: Console,
lines: Iterable[List[Segment]],
animation_status: ANIMATION_STATUS = "no_animation",
**metadata: Any,
) -> Generator[Segment, None, None]:
block = ""
block_length = 5
colors = self._get_animation_colors(
console, steps=block_length, animation_status=animation_status, **metadata
)
left_padding = self.tag_width - block_length
left_padding = max(0, left_padding)
self._animation_counter += 1
for first, _, line in loop_first_last(lines):
if first:
yield Segment(" " * left_padding)
for j in range(block_length):
color_index = (j + self._animation_counter) % len(colors)
yield Segment(block, style=Style(color=colors[color_index]))
else:
yield Segment(" " * self.tag_width)
yield Segment(" " * self.padding)
yield from line
yield Segment.line()

View File

@ -0,0 +1,111 @@
from typing import Any, Dict, List, Union
from rich.console import Console, RenderableType
from rich.theme import Theme
from .styles.base import BaseStyle
from .input import Input
from .menu import Menu, Option, ReturnValue
from .progress import Progress
class RichToolkitTheme:
def __init__(self, style: BaseStyle, theme: Dict[str, str]) -> None:
self.style = style
self.rich_theme = Theme(theme)
class RichToolkit:
def __init__(
self,
theme: RichToolkitTheme,
handle_keyboard_interrupts: bool = True,
) -> None:
self.console = Console(theme=theme.rich_theme)
self.theme = theme
self.handle_keyboard_interrupts = handle_keyboard_interrupts
def __enter__(self):
self.console.print()
return self
def __exit__(
self, exc_type: Any, exc_value: Any, traceback: Any
) -> Union[bool, None]:
if self.handle_keyboard_interrupts and exc_type is KeyboardInterrupt:
# we want to handle keyboard interrupts gracefully, instead of showing a traceback
# or any other error message
return True
self.console.print()
def print_title(self, title: str, **metadata: Any) -> None:
self.console.print(
self.theme.style.with_decoration(title, title=True, **metadata)
)
def print(self, *renderables: RenderableType, **metadata: Any) -> None:
self.console.print(self.theme.style.with_decoration(*renderables, **metadata))
def print_as_string(self, *renderables: RenderableType, **metadata: Any) -> str:
with self.console.capture() as capture:
self.print(*renderables, **metadata)
return capture.get().rstrip()
def print_line(self) -> None:
self.console.print(self.theme.style.empty_line())
def confirm(self, title: str, **metadata: Any) -> bool:
return self.ask(
title=title,
options=[
Option({"value": True, "name": "Yes"}),
Option({"value": False, "name": "No"}),
],
inline=True,
**metadata,
)
def ask(
self,
title: str,
options: List[Option[ReturnValue]],
inline: bool = False,
allow_filtering: bool = False,
**metadata: Any,
) -> ReturnValue:
return Menu(
title=title,
options=options,
console=self.console,
style=self.theme.style,
inline=inline,
allow_filtering=allow_filtering,
cursor_offset=self.theme.style.cursor_offset,
**metadata,
).ask()
def input(
self, title: str, default: str = "", password: bool = False, **metadata: Any
) -> str:
return Input(
console=self.console,
style=self.theme.style,
title=title,
default=default,
cursor_offset=self.theme.style.cursor_offset,
password=password,
**metadata,
).ask()
def progress(
self, title: str, transient: bool = False, transient_on_error: bool = False
) -> Progress:
return Progress(
title=title,
console=self.console,
style=self.theme.style,
transient=transient,
transient_on_error=transient_on_error,
)

View File

@ -0,0 +1,14 @@
from rich.color import Color
from rich.color_triplet import ColorTriplet
def lighten(color: Color, amount: float) -> Color:
assert color.triplet
r, g, b = color.triplet
r = int(r + (255 - r) * amount)
g = int(g + (255 - g) * amount)
b = int(b + (255 - b) * amount)
return Color.from_triplet(ColorTriplet(r, g, b))