fix: flatten React Router structure — no nested Routes, each page is a top-level route
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from .input import Validator
|
||||
from .toolkit import RichToolkit, RichToolkitTheme
|
||||
|
||||
__all__ = ["RichToolkit", "RichToolkitTheme", "Validator"]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Unified getchar implementation for all platforms.
|
||||
|
||||
Combines approaches from:
|
||||
- Textual (Unix/Linux): Copyright (c) 2023 Textualize Inc., MIT License
|
||||
- Click (Windows fallback): Copyright 2014 Pallets, BSD-3-Clause License
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from codecs import getincrementaldecoder
|
||||
from typing import Optional, TextIO
|
||||
|
||||
|
||||
def getchar() -> str:
|
||||
"""
|
||||
Read input from stdin with support for longer pasted text.
|
||||
|
||||
On Windows:
|
||||
- Uses msvcrt for native Windows console input
|
||||
- Handles special keys that send two-byte sequences
|
||||
- Reads up to 4096 characters for paste support
|
||||
|
||||
On Unix/Linux:
|
||||
- Uses Textual's approach with manual termios configuration
|
||||
- Reads up to 4096 bytes with proper UTF-8 decoding
|
||||
- Provides fine-grained terminal control
|
||||
|
||||
Returns:
|
||||
str: The input character(s) read from stdin
|
||||
|
||||
Raises:
|
||||
KeyboardInterrupt: When CTRL+C is pressed
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
# Windows implementation
|
||||
try:
|
||||
import msvcrt
|
||||
except ImportError:
|
||||
# Fallback if msvcrt is not available
|
||||
return sys.stdin.read(1)
|
||||
|
||||
# Use getwch for Unicode support
|
||||
func = msvcrt.getwch # type: ignore
|
||||
|
||||
# Read first character
|
||||
rv = func()
|
||||
|
||||
# Check for special keys (they send two characters)
|
||||
if rv in ("\x00", "\xe0"):
|
||||
# Special key, read the second character
|
||||
rv += func()
|
||||
return rv
|
||||
|
||||
# Check if more input is available (for paste support)
|
||||
chars = [rv]
|
||||
max_chars = 4096
|
||||
|
||||
# Keep reading while characters are available
|
||||
while len(chars) < max_chars and msvcrt.kbhit(): # type: ignore
|
||||
next_char = func()
|
||||
|
||||
# Handle special keys during paste
|
||||
if next_char in ("\x00", "\xe0"):
|
||||
# Stop here, let this be handled in next call
|
||||
break
|
||||
|
||||
chars.append(next_char)
|
||||
|
||||
# Check for CTRL+C
|
||||
if next_char == "\x03":
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
result = "".join(chars)
|
||||
|
||||
# Check for CTRL+C in the full result
|
||||
if "\x03" in result:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
return result
|
||||
|
||||
else:
|
||||
# Unix/Linux implementation (Textual approach)
|
||||
import termios
|
||||
import tty
|
||||
|
||||
f: Optional[TextIO] = None
|
||||
fd: int
|
||||
|
||||
# Get the file descriptor
|
||||
if not sys.stdin.isatty():
|
||||
f = open("/dev/tty")
|
||||
fd = f.fileno()
|
||||
else:
|
||||
fd = sys.stdin.fileno()
|
||||
|
||||
try:
|
||||
# Save current terminal settings
|
||||
attrs_before = termios.tcgetattr(fd)
|
||||
|
||||
try:
|
||||
# Configure terminal settings (Textual-style)
|
||||
newattr = termios.tcgetattr(fd)
|
||||
|
||||
# Patch LFLAG (local flags)
|
||||
# Disable:
|
||||
# - ECHO: Don't echo input characters
|
||||
# - ICANON: Disable canonical mode (line-by-line input)
|
||||
# - IEXTEN: Disable extended processing
|
||||
# - ISIG: Disable signal generation
|
||||
newattr[tty.LFLAG] &= ~(
|
||||
termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG
|
||||
)
|
||||
|
||||
# Patch IFLAG (input flags)
|
||||
# Disable:
|
||||
# - IXON/IXOFF: XON/XOFF flow control
|
||||
# - ICRNL/INLCR/IGNCR: Various newline translations
|
||||
newattr[tty.IFLAG] &= ~(
|
||||
termios.IXON
|
||||
| termios.IXOFF
|
||||
| termios.ICRNL
|
||||
| termios.INLCR
|
||||
| termios.IGNCR
|
||||
)
|
||||
|
||||
# Set VMIN to 1 (minimum number of characters to read)
|
||||
# This ensures we get at least 1 character
|
||||
newattr[tty.CC][termios.VMIN] = 1
|
||||
|
||||
# Apply the new terminal settings
|
||||
termios.tcsetattr(fd, termios.TCSANOW, newattr)
|
||||
|
||||
# Read up to 4096 bytes (same as Textual)
|
||||
raw_data = os.read(fd, 1024 * 4)
|
||||
|
||||
# Use incremental UTF-8 decoder for proper Unicode handling
|
||||
decoder = getincrementaldecoder("utf-8")()
|
||||
result = decoder.decode(raw_data, final=True)
|
||||
|
||||
# Check for CTRL+C (ASCII 3)
|
||||
if "\x03" in result:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
# Restore original terminal settings
|
||||
termios.tcsetattr(fd, termios.TCSANOW, attrs_before)
|
||||
sys.stdout.flush()
|
||||
|
||||
if f is not None:
|
||||
f.close()
|
||||
|
||||
except termios.error:
|
||||
# If we can't control the terminal, fall back to simple read
|
||||
return sys.stdin.read(1)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Unified input handler for all platforms."""
|
||||
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
|
||||
class TextInputHandler:
|
||||
"""Input handler with platform-specific key code support."""
|
||||
|
||||
# Platform-specific key codes
|
||||
if sys.platform == "win32":
|
||||
# Windows uses \xe0 prefix for special keys when using msvcrt.getwch
|
||||
DOWN_KEY = "\xe0P" # Down arrow
|
||||
UP_KEY = "\xe0H" # Up arrow
|
||||
LEFT_KEY = "\xe0K" # Left arrow
|
||||
RIGHT_KEY = "\xe0M" # Right arrow
|
||||
DELETE_KEY = "\xe0S" # Delete key
|
||||
BACKSPACE_KEY = "\x08" # Backspace
|
||||
TAB_KEY = "\t"
|
||||
SHIFT_TAB_KEY = "\x00\x0f" # Shift+Tab
|
||||
ENTER_KEY = "\r"
|
||||
|
||||
# Alternative codes that might be sent
|
||||
ALT_BACKSPACE = "\x7f"
|
||||
ALT_DELETE = "\x00S"
|
||||
else:
|
||||
# Unix/Linux key codes (ANSI escape sequences)
|
||||
DOWN_KEY = "\x1b[B"
|
||||
UP_KEY = "\x1b[A"
|
||||
LEFT_KEY = "\x1b[D"
|
||||
RIGHT_KEY = "\x1b[C"
|
||||
BACKSPACE_KEY = "\x7f"
|
||||
DELETE_KEY = "\x1b[3~"
|
||||
TAB_KEY = "\t"
|
||||
SHIFT_TAB_KEY = "\x1b[Z"
|
||||
ENTER_KEY = "\r"
|
||||
|
||||
# Alternative codes
|
||||
ALT_BACKSPACE = "\x08"
|
||||
ALT_DELETE = None
|
||||
|
||||
def __init__(self):
|
||||
self.text = ""
|
||||
self._cursor_index = 0 # Character index in the text string
|
||||
|
||||
@property
|
||||
def cursor_left(self) -> int:
|
||||
"""Visual cursor position in display columns."""
|
||||
return self._get_text_width(self.text[: self._cursor_index])
|
||||
|
||||
@staticmethod
|
||||
def _get_char_width(char: str) -> int:
|
||||
"""Get the display width of a character (1 for normal, 2 for CJK/fullwidth)."""
|
||||
if not char:
|
||||
return 0
|
||||
|
||||
# Check East Asian Width property
|
||||
east_asian_width = unicodedata.east_asian_width(char)
|
||||
# F (Fullwidth) and W (Wide) characters take 2 columns
|
||||
if east_asian_width in ("F", "W"):
|
||||
return 2
|
||||
# A (Ambiguous) characters are typically 2 columns in CJK contexts
|
||||
# but for simplicity we'll treat them as 1 (can be made configurable)
|
||||
return 1
|
||||
|
||||
def _get_text_width(self, text: str) -> int:
|
||||
"""Get the total display width of a text string."""
|
||||
return sum(self._get_char_width(char) for char in text)
|
||||
|
||||
def _move_cursor_left(self) -> None:
|
||||
self._cursor_index = max(0, self._cursor_index - 1)
|
||||
|
||||
def _move_cursor_right(self) -> None:
|
||||
self._cursor_index = min(len(self.text), self._cursor_index + 1)
|
||||
|
||||
def _insert_char(self, char: str) -> None:
|
||||
self.text = (
|
||||
self.text[: self._cursor_index] + char + self.text[self._cursor_index :]
|
||||
)
|
||||
self._cursor_index += 1
|
||||
|
||||
def _delete_char(self) -> None:
|
||||
"""Delete character before cursor (backspace)."""
|
||||
if self._cursor_index == 0:
|
||||
return
|
||||
|
||||
self.text = (
|
||||
self.text[: self._cursor_index - 1] + self.text[self._cursor_index :]
|
||||
)
|
||||
self._cursor_index -= 1
|
||||
|
||||
def _delete_forward(self) -> None:
|
||||
"""Delete character at cursor (delete key)."""
|
||||
if self._cursor_index >= len(self.text):
|
||||
return
|
||||
|
||||
self.text = (
|
||||
self.text[: self._cursor_index] + self.text[self._cursor_index + 1 :]
|
||||
)
|
||||
|
||||
def handle_key(self, key: str) -> None:
|
||||
# Handle backspace (both possible codes)
|
||||
if key == self.BACKSPACE_KEY or (
|
||||
self.ALT_BACKSPACE and key == self.ALT_BACKSPACE
|
||||
):
|
||||
self._delete_char()
|
||||
# Handle delete key
|
||||
elif key == self.DELETE_KEY or (self.ALT_DELETE and key == self.ALT_DELETE):
|
||||
self._delete_forward()
|
||||
elif key == self.LEFT_KEY:
|
||||
self._move_cursor_left()
|
||||
elif key == self.RIGHT_KEY:
|
||||
self._move_cursor_right()
|
||||
elif key in (
|
||||
self.UP_KEY,
|
||||
self.DOWN_KEY,
|
||||
self.ENTER_KEY,
|
||||
self.SHIFT_TAB_KEY,
|
||||
self.TAB_KEY,
|
||||
):
|
||||
pass
|
||||
else:
|
||||
# Handle regular text input
|
||||
# Special keys on Windows start with \x00 or \xe0
|
||||
if sys.platform == "win32" and key and key[0] in ("\x00", "\xe0"):
|
||||
# Skip special key sequences
|
||||
return
|
||||
|
||||
# Even if we call this handle_key, in some cases we might receive
|
||||
# multiple keys at once (e.g., during paste operations)
|
||||
for char in key:
|
||||
self._insert_char(char)
|
||||
@@ -0,0 +1,150 @@
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console, ConsoleOptions, RenderResult, Style
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel as RichPanel
|
||||
from rich.segment import Segment
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
# this is a custom version of Rich's panel, where we override
|
||||
# the __rich_console__ magic method to just render a basic panel
|
||||
class Panel(RichPanel):
|
||||
def __rich_console__(
|
||||
self, console: "Console", options: "ConsoleOptions"
|
||||
) -> "RenderResult":
|
||||
# copied from Panel.__rich_console__
|
||||
_padding = Padding.unpack(self.padding)
|
||||
renderable = (
|
||||
Padding(self.renderable, _padding) if any(_padding) else self.renderable
|
||||
)
|
||||
style = console.get_style(self.style)
|
||||
partial_border_style = console.get_style(self.border_style)
|
||||
border_style = style + partial_border_style
|
||||
width = (
|
||||
options.max_width
|
||||
if self.width is None
|
||||
else min(options.max_width, self.width)
|
||||
)
|
||||
|
||||
safe_box: bool = console.safe_box if self.safe_box is None else self.safe_box
|
||||
box = self.box.substitute(options, safe=safe_box)
|
||||
|
||||
def align_text(
|
||||
text: Text, width: int, align: str, character: str, style: Style
|
||||
) -> Text:
|
||||
"""Gets new aligned text.
|
||||
|
||||
Args:
|
||||
text (Text): Title or subtitle text.
|
||||
width (int): Desired width.
|
||||
align (str): Alignment.
|
||||
character (str): Character for alignment.
|
||||
style (Style): Border style
|
||||
|
||||
Returns:
|
||||
Text: New text instance
|
||||
"""
|
||||
text = text.copy()
|
||||
text.truncate(width)
|
||||
excess_space = width - cell_len(text.plain)
|
||||
if text.style:
|
||||
text.stylize(console.get_style(text.style))
|
||||
|
||||
if excess_space:
|
||||
if align == "left":
|
||||
return Text.assemble(
|
||||
text,
|
||||
(character * excess_space, style),
|
||||
no_wrap=True,
|
||||
end="",
|
||||
)
|
||||
elif align == "center":
|
||||
left = excess_space // 2
|
||||
return Text.assemble(
|
||||
(character * left, style),
|
||||
text,
|
||||
(character * (excess_space - left), style),
|
||||
no_wrap=True,
|
||||
end="",
|
||||
)
|
||||
else:
|
||||
return Text.assemble(
|
||||
(character * excess_space, style),
|
||||
text,
|
||||
no_wrap=True,
|
||||
end="",
|
||||
)
|
||||
return text
|
||||
|
||||
title_text = self._title
|
||||
if title_text is not None:
|
||||
title_text.stylize_before(partial_border_style)
|
||||
|
||||
child_width = (
|
||||
width - 2
|
||||
if self.expand
|
||||
else console.measure(
|
||||
renderable, options=options.update_width(width - 2)
|
||||
).maximum
|
||||
)
|
||||
child_height = self.height or options.height or None
|
||||
if child_height:
|
||||
child_height -= 2
|
||||
if title_text is not None:
|
||||
child_width = min(
|
||||
options.max_width - 2, max(child_width, title_text.cell_len + 2)
|
||||
)
|
||||
|
||||
width = child_width + 2
|
||||
child_options = options.update(
|
||||
width=child_width, height=child_height, highlight=self.highlight
|
||||
)
|
||||
lines = console.render_lines(renderable, child_options, style=style)
|
||||
|
||||
line_start = Segment(box.mid_left, border_style)
|
||||
line_end = Segment(f"{box.mid_right}", border_style)
|
||||
new_line = Segment.line()
|
||||
if title_text is None or width <= 4:
|
||||
yield Segment(box.get_top([width - 2]), border_style)
|
||||
else:
|
||||
title_text = align_text(
|
||||
title_text,
|
||||
width - 4,
|
||||
self.title_align,
|
||||
box.top,
|
||||
border_style,
|
||||
)
|
||||
# changed from `box.top_left + box.top` to just `box.top_left``
|
||||
yield Segment(box.top_left, border_style)
|
||||
yield from console.render(title_text, child_options.update_width(width - 4))
|
||||
# changed from `box.top + box.top_right` to `box.top * 2 + box.top_right``
|
||||
yield Segment(box.top * 2 + box.top_right, border_style)
|
||||
|
||||
yield new_line
|
||||
for line in lines:
|
||||
yield line_start
|
||||
yield from line
|
||||
yield line_end
|
||||
yield new_line
|
||||
|
||||
subtitle_text = self._subtitle
|
||||
if subtitle_text is not None:
|
||||
subtitle_text.stylize_before(partial_border_style)
|
||||
|
||||
if subtitle_text is None or width <= 4:
|
||||
yield Segment(box.get_bottom([width - 2]), border_style)
|
||||
else:
|
||||
subtitle_text = align_text(
|
||||
subtitle_text,
|
||||
width - 4,
|
||||
self.subtitle_align,
|
||||
box.bottom,
|
||||
border_style,
|
||||
)
|
||||
yield Segment(box.bottom_left + box.bottom, border_style)
|
||||
yield from console.render(
|
||||
subtitle_text, child_options.update_width(width - 4)
|
||||
)
|
||||
yield Segment(box.bottom + box.bottom_right, border_style)
|
||||
|
||||
yield new_line
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
from .element import Element
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .styles.base import BaseStyle
|
||||
|
||||
|
||||
class Button(Element):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
label: str,
|
||||
callback: Optional[Callable] = None,
|
||||
style: Optional[BaseStyle] = None,
|
||||
**metadata: Any,
|
||||
):
|
||||
self.name = name
|
||||
self.label = label
|
||||
self.callback = callback
|
||||
|
||||
super().__init__(style=style, metadata=metadata)
|
||||
|
||||
def activate(self) -> Any:
|
||||
if self.callback:
|
||||
return self.callback()
|
||||
return True
|
||||
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
from rich.control import Control, ControlType
|
||||
from rich.live_render import LiveRender
|
||||
from rich.segment import Segment
|
||||
|
||||
from ._getchar import getchar
|
||||
from ._input_handler import TextInputHandler
|
||||
|
||||
from .element import Element
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .styles import BaseStyle
|
||||
|
||||
|
||||
class Container(Element):
|
||||
def __init__(
|
||||
self,
|
||||
style: Optional[BaseStyle] = None,
|
||||
metadata: Optional[Dict[Any, Any]] = None,
|
||||
):
|
||||
self.elements: List[Element] = []
|
||||
self.active_element_index = 0
|
||||
self.previous_element_index = 0
|
||||
self._live_render = LiveRender("")
|
||||
|
||||
super().__init__(style=style, metadata=metadata)
|
||||
|
||||
self.console = self.style.console
|
||||
|
||||
def _refresh(self, done: bool = False):
|
||||
content = self.style.render_element(self, done=done)
|
||||
self._live_render.set_renderable(content)
|
||||
|
||||
active_element = self.elements[self.active_element_index]
|
||||
|
||||
should_show_cursor = (
|
||||
active_element.should_show_cursor
|
||||
if hasattr(active_element, "should_show_cursor")
|
||||
else False
|
||||
)
|
||||
|
||||
# Always show cursor when done to restore terminal state
|
||||
if done:
|
||||
should_show_cursor = True
|
||||
|
||||
self.console.print(
|
||||
Control.show_cursor(should_show_cursor),
|
||||
*self.move_cursor_at_beginning(),
|
||||
self._live_render,
|
||||
)
|
||||
|
||||
if not done:
|
||||
self.console.print(
|
||||
*self.move_cursor_to_active_element(),
|
||||
)
|
||||
|
||||
@property
|
||||
def _active_element(self) -> Element:
|
||||
return self.elements[self.active_element_index]
|
||||
|
||||
def _get_size(self, element: Element) -> Tuple[int, int]:
|
||||
renderable = self.style.render_element(element, done=False, parent=self)
|
||||
|
||||
lines = self.console.render_lines(renderable, self.console.options, pad=False)
|
||||
|
||||
return Segment.get_shape(lines)
|
||||
|
||||
def _get_element_position(self, element_index: int) -> int:
|
||||
position = 0
|
||||
|
||||
for i in range(element_index + 1):
|
||||
current_element = self.elements[i]
|
||||
|
||||
if i == element_index:
|
||||
position += self.style.get_cursor_offset_for_element(
|
||||
current_element, parent=self
|
||||
).top
|
||||
else:
|
||||
size = self._get_size(current_element)
|
||||
position += size[1]
|
||||
|
||||
return position
|
||||
|
||||
@property
|
||||
def _active_element_position(self) -> int:
|
||||
return self._get_element_position(self.active_element_index)
|
||||
|
||||
def get_offset_for_element(self, element_index: int) -> int:
|
||||
if self._live_render._shape is None:
|
||||
return 0
|
||||
|
||||
position = self._get_element_position(element_index)
|
||||
|
||||
_, height = self._live_render._shape
|
||||
|
||||
return height - position
|
||||
|
||||
def get_offset_for_active_element(self) -> int:
|
||||
return self.get_offset_for_element(self.active_element_index)
|
||||
|
||||
def move_cursor_to_active_element(self) -> Tuple[Control, ...]:
|
||||
move_up = self.get_offset_for_active_element()
|
||||
|
||||
move_cursor = (
|
||||
(Control((ControlType.CURSOR_UP, move_up)),) if move_up > 0 else ()
|
||||
)
|
||||
|
||||
cursor_left = self.style.get_cursor_offset_for_element(
|
||||
self._active_element, parent=self
|
||||
).left
|
||||
|
||||
return (Control.move_to_column(cursor_left), *move_cursor)
|
||||
|
||||
def move_cursor_at_beginning(self) -> Tuple[Control, ...]:
|
||||
if self._live_render._shape is None:
|
||||
return (Control(),)
|
||||
|
||||
original = (self._live_render.position_cursor(),)
|
||||
|
||||
# Use the previous element type and index for cursor positioning
|
||||
move_down = self.get_offset_for_element(self.previous_element_index)
|
||||
|
||||
if move_down == 0:
|
||||
return original
|
||||
|
||||
return (
|
||||
Control(
|
||||
(ControlType.CURSOR_DOWN, move_down),
|
||||
),
|
||||
*original,
|
||||
)
|
||||
|
||||
def handle_enter_key(self) -> bool:
|
||||
from .input import Input
|
||||
from .menu import Menu
|
||||
|
||||
active_element = self.elements[self.active_element_index]
|
||||
|
||||
if isinstance(active_element, (Input, Menu)):
|
||||
active_element.on_validate()
|
||||
|
||||
if active_element.valid is False:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _focus_next(self) -> None:
|
||||
self.active_element_index += 1
|
||||
|
||||
if self.active_element_index >= len(self.elements):
|
||||
self.active_element_index = 0
|
||||
|
||||
if self._active_element.focusable is False:
|
||||
self._focus_next()
|
||||
|
||||
def _focus_previous(self) -> None:
|
||||
self.active_element_index -= 1
|
||||
|
||||
if self.active_element_index < 0:
|
||||
self.active_element_index = len(self.elements) - 1
|
||||
|
||||
if self._active_element.focusable is False:
|
||||
self._focus_previous()
|
||||
|
||||
def run(self):
|
||||
self._refresh()
|
||||
|
||||
while True:
|
||||
try:
|
||||
key = getchar()
|
||||
|
||||
self.previous_element_index = self.active_element_index
|
||||
|
||||
if key in (TextInputHandler.SHIFT_TAB_KEY, TextInputHandler.TAB_KEY):
|
||||
if hasattr(self._active_element, "on_blur"):
|
||||
self._active_element.on_blur()
|
||||
|
||||
if key == TextInputHandler.SHIFT_TAB_KEY:
|
||||
self._focus_previous()
|
||||
else:
|
||||
self._focus_next()
|
||||
|
||||
active_element = self.elements[self.active_element_index]
|
||||
active_element.handle_key(key)
|
||||
|
||||
if key == TextInputHandler.ENTER_KEY:
|
||||
if self.handle_enter_key():
|
||||
break
|
||||
|
||||
self._refresh()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
for element in self.elements:
|
||||
element.on_cancel()
|
||||
|
||||
self._refresh(done=True)
|
||||
exit()
|
||||
|
||||
self._refresh(done=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, NamedTuple, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .styles import BaseStyle
|
||||
|
||||
|
||||
class CursorOffset(NamedTuple):
|
||||
top: int
|
||||
left: int
|
||||
|
||||
|
||||
class Element:
|
||||
metadata: Dict[Any, Any] = {}
|
||||
style: BaseStyle
|
||||
|
||||
focusable: bool = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
style: Optional[BaseStyle] = None,
|
||||
metadata: Optional[Dict[Any, Any]] = None,
|
||||
):
|
||||
from .styles import MinimalStyle
|
||||
|
||||
self._cancelled = False
|
||||
self.metadata = metadata or {}
|
||||
self.style = style or MinimalStyle()
|
||||
|
||||
@property
|
||||
def cursor_offset(self) -> CursorOffset:
|
||||
return CursorOffset(top=0, left=0)
|
||||
|
||||
@property
|
||||
def should_show_cursor(self) -> bool:
|
||||
return False
|
||||
|
||||
def handle_key(self, key: str) -> None: # noqa: B027
|
||||
pass
|
||||
|
||||
def on_cancel(self) -> None: # noqa: B027
|
||||
self._cancelled = True
|
||||
@@ -0,0 +1,78 @@
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from rich_toolkit.element import Element
|
||||
from rich_toolkit.spacer import Spacer
|
||||
from rich_toolkit.styles import BaseStyle
|
||||
|
||||
from .button import Button
|
||||
from .container import Container
|
||||
from .input import Input
|
||||
|
||||
|
||||
class Form(Container):
|
||||
def __init__(self, title: str, style: BaseStyle):
|
||||
super().__init__(style)
|
||||
|
||||
self.title = title
|
||||
|
||||
def _append_element(self, element: Element):
|
||||
if len(self.elements) > 0:
|
||||
self.elements.append(Spacer())
|
||||
|
||||
self.elements.append(element)
|
||||
|
||||
def add_input(
|
||||
self,
|
||||
name: str,
|
||||
label: str,
|
||||
placeholder: Optional[str] = None,
|
||||
password: bool = False,
|
||||
inline: bool = False,
|
||||
required: bool = False,
|
||||
**metadata: Any,
|
||||
):
|
||||
input = Input(
|
||||
label=label,
|
||||
placeholder=placeholder,
|
||||
name=name,
|
||||
password=password,
|
||||
inline=inline,
|
||||
required=required,
|
||||
**metadata,
|
||||
)
|
||||
|
||||
self._append_element(input)
|
||||
|
||||
def add_button(
|
||||
self,
|
||||
name: str,
|
||||
label: str,
|
||||
callback: Optional[Callable] = None,
|
||||
**metadata: Any,
|
||||
):
|
||||
button = Button(name=name, label=label, callback=callback, **metadata)
|
||||
self._append_element(button)
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
|
||||
return self._collect_data()
|
||||
|
||||
def handle_enter_key(self) -> bool:
|
||||
all_valid = True
|
||||
|
||||
for element in self.elements:
|
||||
if isinstance(element, Input):
|
||||
element.on_validate()
|
||||
|
||||
if element.valid is False:
|
||||
all_valid = False
|
||||
|
||||
return all_valid
|
||||
|
||||
def _collect_data(self) -> dict:
|
||||
return {
|
||||
input.name: input.text
|
||||
for input in self.elements
|
||||
if isinstance(input, Input)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional, Protocol
|
||||
|
||||
from ._input_handler import TextInputHandler
|
||||
|
||||
from .element import CursorOffset, Element
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .styles.base import BaseStyle
|
||||
|
||||
|
||||
class Validator(Protocol):
|
||||
"""Protocol for validators that can validate input values.
|
||||
|
||||
Any object with a validate_python method can be used as a validator.
|
||||
This includes Pydantic's TypeAdapter or custom validators.
|
||||
|
||||
Example with Pydantic TypeAdapter:
|
||||
>>> from pydantic import TypeAdapter
|
||||
>>> validator = TypeAdapter(int)
|
||||
>>> input_field = Input(validator=validator)
|
||||
|
||||
Example with custom validator:
|
||||
>>> class MyValidator:
|
||||
... def validate_python(self, value):
|
||||
... if not value.startswith("x"):
|
||||
... raise ValueError("Must start with x")
|
||||
... return value
|
||||
>>> input_field = Input(validator=MyValidator())
|
||||
"""
|
||||
|
||||
def validate_python(self, value: Any) -> Any:
|
||||
"""Validate a Python value and return the validated result.
|
||||
|
||||
Args:
|
||||
value: The value to validate
|
||||
|
||||
Returns:
|
||||
The validated value
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class Input(TextInputHandler, Element):
|
||||
label: Optional[str] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: Optional[str] = None,
|
||||
placeholder: Optional[str] = None,
|
||||
default: Optional[str] = None,
|
||||
default_as_placeholder: bool = True,
|
||||
required: bool = False,
|
||||
required_message: Optional[str] = None,
|
||||
password: bool = False,
|
||||
inline: bool = False,
|
||||
name: Optional[str] = None,
|
||||
style: Optional[BaseStyle] = None,
|
||||
validator: Optional[Validator] = None,
|
||||
value: Optional[str] = None,
|
||||
**metadata: Any,
|
||||
):
|
||||
self.name = name
|
||||
self.label = label
|
||||
self._placeholder = placeholder
|
||||
self.default = default
|
||||
self.default_as_placeholder = default_as_placeholder
|
||||
self.required = required
|
||||
self.password = password
|
||||
self.inline = inline
|
||||
self._height = 0
|
||||
|
||||
self.text = ""
|
||||
self.valid = None
|
||||
self.required_message = required_message
|
||||
self._validation_message: Optional[str] = None
|
||||
self._validator: Optional[Validator] = validator
|
||||
|
||||
Element.__init__(self, style=style, metadata=metadata)
|
||||
super().__init__()
|
||||
|
||||
if value:
|
||||
self.text = value
|
||||
self._cursor_index = len(value)
|
||||
|
||||
@property
|
||||
def placeholder(self) -> str:
|
||||
if self._placeholder:
|
||||
return self._placeholder
|
||||
|
||||
if self.default_as_placeholder and self.default:
|
||||
return self.default
|
||||
|
||||
return ""
|
||||
|
||||
@property
|
||||
def validation_message(self) -> Optional[str]:
|
||||
if self._validation_message:
|
||||
return self._validation_message
|
||||
|
||||
assert self.valid
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def cursor_offset(self) -> CursorOffset:
|
||||
top = 1 if self.inline else 2
|
||||
|
||||
left_offset = 0
|
||||
|
||||
if self.inline and self.label:
|
||||
left_offset = len(self.label) + 1
|
||||
|
||||
return CursorOffset(top=top, left=self.cursor_left + left_offset)
|
||||
|
||||
@property
|
||||
def should_show_cursor(self) -> bool:
|
||||
return True
|
||||
|
||||
def on_blur(self):
|
||||
self.on_validate()
|
||||
|
||||
def on_validate(self):
|
||||
value = self.value.strip()
|
||||
|
||||
if not value and self.required:
|
||||
self.valid = False
|
||||
self._validation_message = self.required_message or "This field is required"
|
||||
|
||||
return
|
||||
|
||||
if self._validator:
|
||||
from pydantic import ValidationError
|
||||
|
||||
try:
|
||||
self._validator.validate_python(value)
|
||||
except ValidationError as e:
|
||||
self.valid = False
|
||||
|
||||
# Extract error message from Pydantic ValidationError
|
||||
self._validation_message = e.errors()[0].get("msg", "Validation failed")
|
||||
|
||||
return
|
||||
|
||||
self._validation_message = None
|
||||
self.valid = True
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return self.text or self.default or ""
|
||||
|
||||
def ask(self) -> str:
|
||||
from .container import Container
|
||||
|
||||
container = Container(style=self.style)
|
||||
|
||||
container.elements = [self]
|
||||
|
||||
container.run()
|
||||
|
||||
return self.value
|
||||
@@ -0,0 +1,374 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Generic,
|
||||
List,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
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 = "○"
|
||||
checked_char = "■"
|
||||
unchecked_char = "□"
|
||||
filter_prompt = "Filter: "
|
||||
|
||||
@property
|
||||
def selection_count_hint(self) -> Optional[str]:
|
||||
"""Return a hint like '(3 selected)' when filtering hides checked items."""
|
||||
if not self.multiple or not self.allow_filtering or not self.checked:
|
||||
return None
|
||||
return f"({len(self.checked)} selected)"
|
||||
|
||||
@property
|
||||
def active_prefix(self) -> str:
|
||||
"""Prefix for the active/checked option."""
|
||||
return self.checked_char if self.multiple else self.current_selection_char
|
||||
|
||||
@property
|
||||
def inactive_prefix(self) -> str:
|
||||
"""Prefix for inactive/unchecked options."""
|
||||
return self.unchecked_char if self.multiple else self.selection_char
|
||||
|
||||
# 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,
|
||||
multiple: bool = False,
|
||||
*,
|
||||
style: Optional[BaseStyle] = None,
|
||||
cursor_offset: int = 0,
|
||||
max_visible: Optional[int] = None,
|
||||
**metadata: Any,
|
||||
):
|
||||
if multiple and inline:
|
||||
raise ValueError("multiple and inline cannot both be True")
|
||||
|
||||
self.label = Text.from_markup(label)
|
||||
self.inline = inline
|
||||
self.allow_filtering = allow_filtering
|
||||
self.multiple = multiple
|
||||
|
||||
self.selected = 0
|
||||
self.checked: Set[int] = set()
|
||||
|
||||
self._options = options
|
||||
self._option_index = {id(opt): idx for idx, opt in enumerate(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 _get_option_index(self, option: Option[ReturnValue]) -> int:
|
||||
"""Return the index of an option in _options using identity lookup."""
|
||||
return self._option_index[id(option)]
|
||||
|
||||
def _toggle_current(self) -> None:
|
||||
"""Toggle the checked state of the current cursor item."""
|
||||
if not self.options:
|
||||
return
|
||||
option_index = self._get_option_index(self.options[self.selected])
|
||||
self.checked ^= {option_index}
|
||||
|
||||
def is_option_checked(self, filtered_index: int) -> bool:
|
||||
"""Check if a filtered-list option is checked."""
|
||||
return self._get_option_index(self.options[filtered_index]) in self.checked
|
||||
|
||||
def is_option_checked_by_ref(self, option: Option[ReturnValue]) -> bool:
|
||||
"""Check if an option is checked using its object identity."""
|
||||
return self._get_option_index(option) in self.checked
|
||||
|
||||
@property
|
||||
def result_display_name(self) -> str:
|
||||
"""Return the display name for the result (used when the menu is done)."""
|
||||
if self.multiple:
|
||||
return ", ".join(self._options[i]["name"] for i in sorted(self.checked))
|
||||
return self.options[self.selected]["name"]
|
||||
|
||||
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.result_display_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.multiple and key == " ":
|
||||
self._toggle_current()
|
||||
return
|
||||
|
||||
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()
|
||||
|
||||
@property
|
||||
def validation_message(self) -> Optional[str]:
|
||||
if self.valid is False:
|
||||
if self.multiple:
|
||||
return "Please select at least one option"
|
||||
return "This field is required"
|
||||
|
||||
return None
|
||||
|
||||
def on_blur(self):
|
||||
self.on_validate()
|
||||
|
||||
def on_validate(self):
|
||||
if self.multiple:
|
||||
self.valid = len(self.checked) > 0
|
||||
else:
|
||||
self.valid = len(self.options) > 0
|
||||
|
||||
@property
|
||||
def should_show_cursor(self) -> bool:
|
||||
return self.allow_filtering
|
||||
|
||||
def ask(self) -> Union[ReturnValue, List[ReturnValue]]:
|
||||
from .container import Container
|
||||
|
||||
container = Container(style=self.style, metadata=self.metadata)
|
||||
|
||||
container.elements = [self]
|
||||
|
||||
container.run()
|
||||
|
||||
if self.multiple:
|
||||
return [self._options[i]["value"] for i in sorted(self.checked)]
|
||||
|
||||
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
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
|
||||
from .element import Element
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .styles.base import BaseStyle
|
||||
|
||||
|
||||
class ProgressLine(Element):
|
||||
def __init__(self, text: str | Text, parent: Progress):
|
||||
self.text = text
|
||||
self.parent = parent
|
||||
|
||||
|
||||
class Progress(Live, Element):
|
||||
current_message: str | Text
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
style: Optional[BaseStyle] = None,
|
||||
console: Optional[Console] = None,
|
||||
transient: bool = False,
|
||||
transient_on_error: bool = False,
|
||||
inline_logs: bool = False,
|
||||
lines_to_show: int = -1,
|
||||
preserve_logs: bool = False,
|
||||
quiet: bool = False,
|
||||
**metadata: Dict[Any, Any],
|
||||
) -> None:
|
||||
self._inline_logs = inline_logs
|
||||
self._preserve_logs = preserve_logs
|
||||
self._title = title
|
||||
self.current_message = title
|
||||
self.is_error = False
|
||||
self._transient_on_error = transient_on_error
|
||||
self.lines_to_show = lines_to_show
|
||||
self._quiet = quiet
|
||||
|
||||
self.logs: List[ProgressLine] = []
|
||||
self._log_line_open = False
|
||||
|
||||
self._cancelled = False
|
||||
|
||||
Element.__init__(self, style=style, metadata=metadata)
|
||||
super().__init__(console=console, refresh_per_second=8, transient=transient)
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self._title
|
||||
|
||||
@title.setter
|
||||
def title(self, title: str) -> None:
|
||||
if (
|
||||
self._preserve_logs
|
||||
and self._inline_logs
|
||||
and self.current_message == self._title
|
||||
):
|
||||
self.current_message = title
|
||||
|
||||
self._title = title
|
||||
|
||||
# TODO: remove this once rich uses "Self"
|
||||
def __enter__(self) -> "Progress":
|
||||
if self._quiet:
|
||||
return self
|
||||
|
||||
self.start(refresh=self._renderable is not None)
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: type | None, *args: object) -> None:
|
||||
if exc_type is KeyboardInterrupt:
|
||||
self._cancelled = True
|
||||
|
||||
if self._quiet:
|
||||
return None
|
||||
|
||||
super().__exit__(exc_type, *args)
|
||||
|
||||
def get_renderable(self) -> RenderableType:
|
||||
return self.style.render_element(self, done=not self._started)
|
||||
|
||||
def _append_text(self, target: str | Text, text: str | Text) -> str | Text:
|
||||
if isinstance(target, str) and isinstance(text, str):
|
||||
return target + text
|
||||
|
||||
return Text.assemble(target, text)
|
||||
|
||||
def _split_log_text(self, text: str | Text) -> list[tuple[str | Text, bool]]:
|
||||
if isinstance(text, str):
|
||||
lines = text.splitlines(keepends=True)
|
||||
if not lines:
|
||||
return [(text, False)]
|
||||
|
||||
return [
|
||||
(
|
||||
line[:-1] if line.endswith("\n") else line,
|
||||
line.endswith("\n"),
|
||||
)
|
||||
for line in lines
|
||||
]
|
||||
|
||||
lines = text.split("\n", include_separator=True)
|
||||
result: list[tuple[str | Text, bool]] = []
|
||||
|
||||
for line in lines:
|
||||
ends_with_newline = line.plain.endswith("\n")
|
||||
if ends_with_newline:
|
||||
line = line.copy()
|
||||
line.right_crop(1)
|
||||
|
||||
result.append((line, ends_with_newline))
|
||||
|
||||
return result
|
||||
|
||||
def log(self, text: str | Text, end: str = "\n") -> None:
|
||||
if self._preserve_logs and not self._quiet:
|
||||
self.console.print(text, end=end, soft_wrap=True)
|
||||
return
|
||||
|
||||
if end != "\n":
|
||||
text = self._append_text(text, end)
|
||||
|
||||
lines = self._split_log_text(text)
|
||||
lines[-1] = (lines[-1][0], lines[-1][1] or end.endswith("\n"))
|
||||
|
||||
should_append = self._log_line_open
|
||||
|
||||
if self._inline_logs:
|
||||
for line, is_closed in lines:
|
||||
if should_append and self.logs:
|
||||
self.logs[-1].text = self._append_text(self.logs[-1].text, line)
|
||||
else:
|
||||
self.logs.append(ProgressLine(line, self))
|
||||
|
||||
should_append = not is_closed
|
||||
else:
|
||||
if should_append:
|
||||
self.current_message = self._append_text(self.current_message, text)
|
||||
else:
|
||||
self.current_message = text
|
||||
|
||||
should_append = not lines[-1][1]
|
||||
|
||||
self._log_line_open = should_append
|
||||
|
||||
def set_error(self, text: str) -> None:
|
||||
self.current_message = text
|
||||
self.is_error = True
|
||||
self.transient = self._transient_on_error
|
||||
self._log_line_open = False
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .element import Element
|
||||
|
||||
|
||||
class Spacer(Element):
|
||||
focusable = False
|
||||
@@ -0,0 +1,7 @@
|
||||
from .base import BaseStyle
|
||||
from .border import BorderedStyle
|
||||
from .fancy import FancyStyle
|
||||
from .minimal import MinimalStyle
|
||||
from .tagged import TaggedStyle
|
||||
|
||||
__all__ = ["BaseStyle", "BorderedStyle", "TaggedStyle", "FancyStyle", "MinimalStyle"]
|
||||
@@ -0,0 +1,545 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Type, TypeVar, Union
|
||||
|
||||
from rich.color import Color
|
||||
from rich.console import Console, ConsoleRenderable, Group, RenderableType
|
||||
from rich.text import Text
|
||||
from rich.theme import Theme
|
||||
from typing_extensions import Literal
|
||||
|
||||
from rich_toolkit.button import Button
|
||||
from rich_toolkit.container import Container
|
||||
from rich_toolkit.element import CursorOffset, Element
|
||||
from rich_toolkit.input import Input
|
||||
from rich_toolkit.menu import Menu
|
||||
from rich_toolkit.progress import Progress, ProgressLine
|
||||
from rich_toolkit.spacer import Spacer
|
||||
from rich_toolkit.utils.colors import (
|
||||
fade_text,
|
||||
get_terminal_background_color,
|
||||
get_terminal_text_color,
|
||||
lighten,
|
||||
)
|
||||
|
||||
ConsoleRenderableClass = TypeVar(
|
||||
"ConsoleRenderableClass", bound=Type[ConsoleRenderable]
|
||||
)
|
||||
|
||||
|
||||
class BaseStyle:
|
||||
brightness_multiplier = 0.1
|
||||
|
||||
base_theme = {
|
||||
"tag.title": "bold",
|
||||
"tag": "bold",
|
||||
"text": "#ffffff",
|
||||
"selected": "green",
|
||||
"result": "white",
|
||||
"progress": "on #893AE3",
|
||||
"error": "red",
|
||||
"cancelled": "red italic",
|
||||
# is there a way to make nested styles?
|
||||
# like label.active uses active style if not set?
|
||||
"active": "green",
|
||||
"title.error": "white",
|
||||
"title.cancelled": "white",
|
||||
"placeholder": "grey62",
|
||||
"placeholder.cancelled": "indian_red strike",
|
||||
}
|
||||
|
||||
_should_show_progress_title = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
theme: Optional[Dict[str, str]] = None,
|
||||
background_color: str = "#000000",
|
||||
text_color: str = "#FFFFFF",
|
||||
):
|
||||
self.background_color = get_terminal_background_color(background_color)
|
||||
self.text_color = get_terminal_text_color(text_color)
|
||||
self.animation_counter = 0
|
||||
|
||||
base_theme = Theme(self.base_theme)
|
||||
self.theme = base_theme
|
||||
self.console = Console(theme=base_theme)
|
||||
|
||||
if theme:
|
||||
self.console.push_theme(Theme(theme))
|
||||
|
||||
def empty_line(self) -> RenderableType:
|
||||
return " "
|
||||
|
||||
def render_context_enter(self) -> Optional[RenderableType]:
|
||||
return ""
|
||||
|
||||
def render_context_exit(self) -> Optional[RenderableType]:
|
||||
return ""
|
||||
|
||||
def _get_animation_colors(
|
||||
self,
|
||||
steps: int = 5,
|
||||
breathe: bool = False,
|
||||
animation_status: Literal["started", "stopped", "error"] = "started",
|
||||
**metadata: Any,
|
||||
) -> list[Color]:
|
||||
animated = animation_status == "started"
|
||||
|
||||
if animation_status == "error":
|
||||
base_color = self.console.get_style("error").color
|
||||
|
||||
if base_color is None:
|
||||
base_color = Color.parse("red")
|
||||
|
||||
else:
|
||||
base_color = self.console.get_style("progress").bgcolor
|
||||
|
||||
if not base_color:
|
||||
base_color = Color.from_rgb(255, 255, 255)
|
||||
|
||||
if breathe:
|
||||
steps = steps // 2
|
||||
|
||||
if animated and base_color.triplet is not None:
|
||||
colors = [
|
||||
lighten(base_color, self.brightness_multiplier * i)
|
||||
for i in range(0, steps)
|
||||
]
|
||||
|
||||
else:
|
||||
colors = [base_color] * steps
|
||||
|
||||
if breathe:
|
||||
colors = colors + colors[::-1]
|
||||
|
||||
return colors
|
||||
|
||||
def _count_label_lines(self, label: str, decoration_width: int = 0) -> int:
|
||||
available_width = self.console.width - decoration_width
|
||||
if available_width <= 0:
|
||||
return 1
|
||||
renderable = Text.from_markup(label) if isinstance(label, str) else label
|
||||
lines = self.console.render_lines(
|
||||
renderable,
|
||||
self.console.options.update_width(available_width),
|
||||
pad=False,
|
||||
)
|
||||
return len(lines)
|
||||
|
||||
def get_cursor_offset_for_element(
|
||||
self, element: Element, parent: Optional[Element] = None
|
||||
) -> CursorOffset:
|
||||
offset = element.cursor_offset
|
||||
if isinstance(element, Input) and not element.inline and element.label:
|
||||
label_lines = self._count_label_lines(element.label)
|
||||
return CursorOffset(top=label_lines + 1, left=offset.left)
|
||||
return offset
|
||||
|
||||
def render_element(
|
||||
self,
|
||||
element: Any,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
**kwargs: Any,
|
||||
) -> RenderableType:
|
||||
if isinstance(element, str):
|
||||
return self.render_string(element, is_active, done, parent)
|
||||
elif isinstance(element, Button):
|
||||
return self.render_button(element, is_active, done, parent)
|
||||
elif isinstance(element, Container):
|
||||
return self.render_container(element, is_active, done, parent)
|
||||
elif isinstance(element, Input):
|
||||
return self.render_input(element, is_active, done, parent)
|
||||
elif isinstance(element, Menu):
|
||||
return self.render_menu(element, is_active, done, parent)
|
||||
elif isinstance(element, Progress):
|
||||
self.animation_counter += 1
|
||||
|
||||
return self.render_progress(element, is_active, done, parent)
|
||||
elif isinstance(element, ProgressLine):
|
||||
return self.render_progress_log_line(
|
||||
element.text,
|
||||
parent=parent,
|
||||
index=kwargs.get("index", 0),
|
||||
max_lines=kwargs.get("max_lines", -1),
|
||||
total_lines=kwargs.get("total_lines", -1),
|
||||
)
|
||||
elif isinstance(element, Spacer):
|
||||
return self.render_spacer()
|
||||
elif isinstance(element, ConsoleRenderable):
|
||||
return element
|
||||
|
||||
raise ValueError(f"Unknown element type: {type(element)}")
|
||||
|
||||
def render_string(
|
||||
self,
|
||||
string: str,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
return string
|
||||
|
||||
def render_button(
|
||||
self,
|
||||
element: Button,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
style = "black on blue" if is_active else "white on black"
|
||||
return Text(f" {element.label} ", style=style)
|
||||
|
||||
def render_spacer(self) -> RenderableType:
|
||||
return ""
|
||||
|
||||
def render_container(
|
||||
self,
|
||||
container: Container,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
content = []
|
||||
|
||||
for i, element in enumerate(container.elements):
|
||||
content.append(
|
||||
self.render_element(
|
||||
element,
|
||||
is_active=i == container.active_element_index,
|
||||
done=done,
|
||||
parent=container,
|
||||
)
|
||||
)
|
||||
|
||||
return Group(*content, "\n" if not done else "")
|
||||
|
||||
def render_input(
|
||||
self,
|
||||
element: Input,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
label = self.render_input_label(element, is_active=is_active, parent=parent)
|
||||
text = self.render_input_value(
|
||||
element, is_active=is_active, parent=parent, done=done
|
||||
)
|
||||
|
||||
contents = []
|
||||
|
||||
if element.inline or done:
|
||||
if done and element.password:
|
||||
text = "*" * len(element.text)
|
||||
if label:
|
||||
text = f"{label} {text}"
|
||||
|
||||
contents.append(text)
|
||||
else:
|
||||
if label:
|
||||
contents.append(label)
|
||||
|
||||
contents.append(text)
|
||||
|
||||
if validation_message := self.render_validation_message(element):
|
||||
contents.extend(validation_message)
|
||||
|
||||
# TODO: do we need this?
|
||||
element._height = len(contents)
|
||||
|
||||
return Group(*contents)
|
||||
|
||||
def render_validation_message(
|
||||
self, element: Union[Input, Menu]
|
||||
) -> Optional[list[RenderableType]]:
|
||||
if element._cancelled:
|
||||
return [Text(""), "[cancelled]Cancelled.[/]"]
|
||||
|
||||
if element.valid is False:
|
||||
return [Text(""), f"[error]{element.validation_message}[/]"]
|
||||
|
||||
return None
|
||||
|
||||
# TODO: maybe don't reuse this for menus
|
||||
def render_input_value(
|
||||
self,
|
||||
input: Union[Menu, Input],
|
||||
is_active: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
done: bool = False,
|
||||
) -> RenderableType:
|
||||
text = input.text
|
||||
|
||||
if isinstance(input, Input) and input.password and text:
|
||||
text = "*" * len(text)
|
||||
|
||||
if input._cancelled:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
return f"[placeholder.cancelled]{text}[/]"
|
||||
|
||||
if done:
|
||||
if (
|
||||
not text
|
||||
and isinstance(input, Input)
|
||||
and input.default_as_placeholder
|
||||
and input.default
|
||||
):
|
||||
text = input.default
|
||||
|
||||
return f"[result]{text}[/]"
|
||||
|
||||
if not text:
|
||||
placeholder = input.placeholder if isinstance(input, Input) else ""
|
||||
|
||||
# Use zero-width space when placeholder is empty to prevent
|
||||
# the line from being stripped as a trailing blank line
|
||||
placeholder = placeholder or "\u200b"
|
||||
return f"[placeholder]{placeholder}[/]"
|
||||
|
||||
return f"[text]{text}[/]"
|
||||
|
||||
def render_input_label(
|
||||
self,
|
||||
input: Union[Input, Menu],
|
||||
is_active: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> Union[str, Text, None]:
|
||||
from rich_toolkit.form import Form
|
||||
|
||||
label: Union[str, Text, None] = None
|
||||
|
||||
if input.label:
|
||||
label = input.label
|
||||
|
||||
if isinstance(parent, Form):
|
||||
if is_active:
|
||||
label = f"[active]{label}[/]"
|
||||
elif input.valid is False:
|
||||
label = f"[error]{label}[/]"
|
||||
|
||||
return label
|
||||
|
||||
def _build_menu_options(self, element: Menu, separator: Text) -> Text:
|
||||
"""Build the menu Text containing scroll indicators and option items."""
|
||||
menu = Text(justify="left")
|
||||
|
||||
checked_prefix = Text(element.active_prefix + " ")
|
||||
unchecked_prefix = Text(element.inactive_prefix + " ")
|
||||
|
||||
start, end = element.visible_options_range
|
||||
visible_options = element.options[start:end]
|
||||
needs_scrolling = element._needs_scrolling()
|
||||
|
||||
# Reserve space for scroll indicators to prevent layout shift
|
||||
if needs_scrolling:
|
||||
if element.has_more_above:
|
||||
menu.append(Text(element.MORE_ABOVE_INDICATOR + "\n", style="dim"))
|
||||
else:
|
||||
menu.append(Text(" " * len(element.MORE_ABOVE_INDICATOR) + "\n"))
|
||||
|
||||
for idx, option in enumerate(visible_options):
|
||||
actual_idx = start + idx
|
||||
is_at_cursor = actual_idx == element.selected
|
||||
|
||||
# Prefix reflects checked state (multi-select) or cursor (single-select)
|
||||
if element.multiple:
|
||||
is_marked = element.is_option_checked_by_ref(option)
|
||||
else:
|
||||
is_marked = is_at_cursor
|
||||
prefix = checked_prefix if is_marked else unchecked_prefix
|
||||
|
||||
# Style reflects cursor position regardless of checked state
|
||||
style = self.console.get_style("selected" if is_at_cursor else "text")
|
||||
|
||||
is_last = idx == len(visible_options) - 1
|
||||
|
||||
menu.append(
|
||||
Text.assemble(
|
||||
prefix,
|
||||
option["name"],
|
||||
separator if not is_last else "",
|
||||
style=style,
|
||||
)
|
||||
)
|
||||
|
||||
if needs_scrolling:
|
||||
if element.has_more_below:
|
||||
menu.append(Text("\n" + element.MORE_BELOW_INDICATOR, style="dim"))
|
||||
else:
|
||||
menu.append(Text("\n" + " " * len(element.MORE_BELOW_INDICATOR)))
|
||||
|
||||
if not element.options:
|
||||
menu = Text("No results found", style=self.console.get_style("text"))
|
||||
|
||||
return menu
|
||||
|
||||
def _build_filter_parts(self, element: Menu) -> list[RenderableType]:
|
||||
if not element.allow_filtering:
|
||||
return []
|
||||
|
||||
filter_parts: list[RenderableType] = []
|
||||
|
||||
filter_line = Text.assemble(
|
||||
(element.filter_prompt, self.console.get_style("text")),
|
||||
(element.text, self.console.get_style("text")),
|
||||
)
|
||||
|
||||
if hint := element.selection_count_hint:
|
||||
filter_line.append(f" {hint}", style="dim")
|
||||
|
||||
filter_line.append("\n")
|
||||
filter_parts.append(filter_line)
|
||||
|
||||
return filter_parts
|
||||
|
||||
def render_menu(
|
||||
self,
|
||||
element: Menu,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
label = self.render_input_label(element, is_active=is_active, parent=parent)
|
||||
|
||||
if done:
|
||||
result_content = Text()
|
||||
|
||||
if label:
|
||||
result_content.append(label)
|
||||
result_content.append(" ")
|
||||
|
||||
# For single-select menus, check if selection is valid
|
||||
# For multi-select menus, result_display_name only uses checked items
|
||||
should_show_cancelled = element._cancelled
|
||||
if not element.multiple:
|
||||
selection_is_valid = 0 <= element.selected < len(element.options)
|
||||
should_show_cancelled = should_show_cancelled or not selection_is_valid
|
||||
|
||||
if should_show_cancelled:
|
||||
result_content.append(
|
||||
"Cancelled.",
|
||||
style=self.console.get_style("cancelled"),
|
||||
)
|
||||
else:
|
||||
result_content.append(
|
||||
element.result_display_name,
|
||||
style=self.console.get_style("result"),
|
||||
)
|
||||
|
||||
return result_content
|
||||
|
||||
separator = Text(" " if element.inline else "\n")
|
||||
menu = self._build_menu_options(element, separator)
|
||||
filter_parts = self._build_filter_parts(element)
|
||||
|
||||
content: list[RenderableType] = []
|
||||
|
||||
if label:
|
||||
content.append(label)
|
||||
|
||||
content.extend(filter_parts)
|
||||
content.append(menu)
|
||||
|
||||
if message := self.render_validation_message(element):
|
||||
content.extend(message)
|
||||
|
||||
return Group(*content)
|
||||
|
||||
def _render_cancelled_progress_message(self) -> Text:
|
||||
return Text("Cancelled.", style=self.console.get_style("cancelled"))
|
||||
|
||||
def _render_progress_content(self, element: Progress) -> RenderableType:
|
||||
content: str | Group | Text = element.current_message
|
||||
|
||||
if element.logs and element._inline_logs:
|
||||
lines_to_show = (
|
||||
element.logs[-element.lines_to_show :]
|
||||
if element.lines_to_show > 0
|
||||
else element.logs
|
||||
)
|
||||
|
||||
start_content = [element.title, ""]
|
||||
|
||||
if not self._should_show_progress_title:
|
||||
start_content = []
|
||||
|
||||
content = Group(
|
||||
*start_content,
|
||||
*[
|
||||
self.render_element(
|
||||
line,
|
||||
index=index,
|
||||
max_lines=element.lines_to_show,
|
||||
total_lines=len(element.logs),
|
||||
parent=element,
|
||||
)
|
||||
for index, line in enumerate(lines_to_show)
|
||||
],
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
def _progress_has_content_beyond_title(self, element: Progress) -> bool:
|
||||
if element.logs and element._inline_logs:
|
||||
return True
|
||||
|
||||
if isinstance(element.current_message, Text):
|
||||
return element.current_message.plain != element.title
|
||||
|
||||
return element.current_message != element.title
|
||||
|
||||
def render_progress(
|
||||
self,
|
||||
element: Progress,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
content = self._render_progress_content(element)
|
||||
|
||||
if done and element._cancelled:
|
||||
return Group(content, self._render_cancelled_progress_message())
|
||||
|
||||
return content
|
||||
|
||||
def render_progress_log_line(
|
||||
self,
|
||||
line: str | Text,
|
||||
index: int,
|
||||
max_lines: int = -1,
|
||||
total_lines: int = -1,
|
||||
parent: Optional[Element] = None,
|
||||
) -> Text:
|
||||
line = Text.from_markup(line) if isinstance(line, str) else line
|
||||
if max_lines == -1:
|
||||
return line
|
||||
|
||||
shown_lines = min(total_lines, max_lines)
|
||||
|
||||
# this is the minimum brightness based on the max_lines
|
||||
min_brightness = 0.4
|
||||
# but we want to have a slightly higher brightness if there's less than max_lines
|
||||
# otherwise you could get the something like this:
|
||||
|
||||
# line 1 -> very dark
|
||||
# line 2 -> slightly darker
|
||||
# line 3 -> normal
|
||||
|
||||
# which is ok, but not great, so we we increase the brightness if there's less than max_lines
|
||||
# so that the last line is always the brightest
|
||||
current_min_brightness = min_brightness + abs(shown_lines - max_lines) * 0.1
|
||||
current_min_brightness = min(max(current_min_brightness, min_brightness), 1.0)
|
||||
|
||||
brightness_multiplier = ((index + 1) / shown_lines) * (
|
||||
1.0 - current_min_brightness
|
||||
) + current_min_brightness
|
||||
|
||||
return fade_text(
|
||||
line,
|
||||
text_color=Color.parse(self.text_color),
|
||||
background_color=self.background_color,
|
||||
brightness_multiplier=brightness_multiplier,
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
|
||||
from rich import box
|
||||
from rich.color import Color
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
|
||||
from rich_toolkit._rich_components import Panel
|
||||
from rich_toolkit.container import Container
|
||||
from rich_toolkit.element import CursorOffset, Element
|
||||
from rich_toolkit.form import Form
|
||||
from rich_toolkit.input import Input
|
||||
from rich_toolkit.menu import Menu
|
||||
from rich_toolkit.progress import Progress
|
||||
|
||||
from .base import BaseStyle
|
||||
|
||||
|
||||
class BorderedStyle(BaseStyle):
|
||||
box = box.SQUARE
|
||||
_should_show_progress_title = False
|
||||
|
||||
def empty_line(self) -> RenderableType:
|
||||
return ""
|
||||
|
||||
def _box(
|
||||
self,
|
||||
content: RenderableType,
|
||||
title: Union[str, Text, None],
|
||||
is_active: bool,
|
||||
border_color: Color,
|
||||
after: Tuple[RenderableType, ...] = (),
|
||||
) -> RenderableType:
|
||||
return Group(
|
||||
Panel(
|
||||
content,
|
||||
title=title,
|
||||
title_align="left",
|
||||
highlight=is_active,
|
||||
width=50,
|
||||
box=self.box,
|
||||
border_style=Style(color=border_color),
|
||||
),
|
||||
*after,
|
||||
)
|
||||
|
||||
def render_container(
|
||||
self,
|
||||
element: Container,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
content = super().render_container(element, is_active, done, parent)
|
||||
|
||||
if isinstance(element, Form):
|
||||
return self._box(content, element.title, is_active, Color.parse("white"))
|
||||
|
||||
return content
|
||||
|
||||
def render_input(
|
||||
self,
|
||||
element: Input,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
**metadata: Any,
|
||||
) -> RenderableType:
|
||||
validation_message: Tuple[RenderableType, ...] = ()
|
||||
|
||||
if isinstance(parent, Form):
|
||||
return super().render_input(element, is_active, done, parent, **metadata)
|
||||
|
||||
if messages := self.render_validation_message(element):
|
||||
validation_message = tuple(messages)
|
||||
|
||||
title = self.render_input_label(
|
||||
element,
|
||||
is_active=is_active,
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
# Determine border color based on validation state
|
||||
if element.valid is False:
|
||||
try:
|
||||
border_color = self.console.get_style("error").color or Color.parse(
|
||||
"red"
|
||||
)
|
||||
except Exception:
|
||||
# Fallback if error style is not defined
|
||||
border_color = Color.parse("red")
|
||||
else:
|
||||
border_color = Color.parse("white")
|
||||
|
||||
return self._box(
|
||||
self.render_input_value(element, is_active=is_active, parent=parent),
|
||||
title,
|
||||
is_active,
|
||||
border_color,
|
||||
after=validation_message,
|
||||
)
|
||||
|
||||
def render_menu(
|
||||
self,
|
||||
element: Menu,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
**metadata: Any,
|
||||
) -> RenderableType:
|
||||
validation_message: Tuple[RenderableType, ...] = ()
|
||||
|
||||
content: list[RenderableType] = []
|
||||
|
||||
if done:
|
||||
content.append(
|
||||
Text(
|
||||
element.result_display_name,
|
||||
style=self.console.get_style("result"),
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
separator = Text("\t" if element.inline else "\n")
|
||||
menu = self._build_menu_options(element, separator)
|
||||
filter_parts = self._build_filter_parts(element)
|
||||
|
||||
content.extend(filter_parts)
|
||||
content.append(menu)
|
||||
|
||||
if messages := self.render_validation_message(element):
|
||||
validation_message = tuple(messages)
|
||||
|
||||
result = Group(*content)
|
||||
|
||||
return self._box(
|
||||
result,
|
||||
self.render_input_label(element),
|
||||
is_active,
|
||||
Color.parse("white"),
|
||||
after=validation_message,
|
||||
)
|
||||
|
||||
def render_progress(
|
||||
self,
|
||||
element: Progress,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
if done and element._cancelled:
|
||||
if self._progress_has_content_beyond_title(element):
|
||||
content = Group(
|
||||
self._render_progress_content(element),
|
||||
self._render_cancelled_progress_message(),
|
||||
)
|
||||
else:
|
||||
content = self._render_cancelled_progress_message()
|
||||
|
||||
return self._box(
|
||||
content, element.title, is_active, border_color=Color.parse("white")
|
||||
)
|
||||
|
||||
content: str | Group | Text = element.current_message
|
||||
title: Union[str, Text, None] = None
|
||||
|
||||
title = element.title
|
||||
|
||||
if element.logs and element._inline_logs:
|
||||
lines_to_show = (
|
||||
element.logs[-element.lines_to_show :]
|
||||
if element.lines_to_show > 0
|
||||
else element.logs
|
||||
)
|
||||
|
||||
content = Group(
|
||||
*[
|
||||
self.render_element(
|
||||
line,
|
||||
index=index,
|
||||
max_lines=element.lines_to_show,
|
||||
total_lines=len(element.logs),
|
||||
)
|
||||
for index, line in enumerate(lines_to_show)
|
||||
]
|
||||
)
|
||||
|
||||
border_color = Color.parse("white")
|
||||
|
||||
if not done:
|
||||
colors = self._get_animation_colors(
|
||||
steps=10, animation_status="started", breathe=True
|
||||
)
|
||||
|
||||
border_color = colors[self.animation_counter % 10]
|
||||
|
||||
return self._box(content, title, is_active, border_color=border_color)
|
||||
|
||||
def get_cursor_offset_for_element(
|
||||
self, element: Element, parent: Optional[Element] = None
|
||||
) -> CursorOffset:
|
||||
top_offset = element.cursor_offset.top
|
||||
left_offset = element.cursor_offset.left + 2
|
||||
|
||||
if isinstance(element, Input) and element.inline:
|
||||
# we don't support inline inputs yet in border style
|
||||
top_offset += 1
|
||||
inline_left_offset = (len(element.label) - 1) if element.label else 0
|
||||
left_offset = element.cursor_offset.left - inline_left_offset
|
||||
|
||||
if isinstance(parent, Form):
|
||||
top_offset += 1
|
||||
|
||||
return CursorOffset(top=top_offset, left=left_offset)
|
||||
@@ -0,0 +1,202 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from rich._loop import loop_first_last
|
||||
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from typing_extensions import Literal
|
||||
|
||||
from rich_toolkit.container import Container
|
||||
from rich_toolkit.element import CursorOffset, Element
|
||||
from rich_toolkit.form import Form
|
||||
from rich_toolkit.progress import Progress
|
||||
from rich_toolkit.styles.base import BaseStyle
|
||||
|
||||
|
||||
class FancyPanel:
|
||||
def __init__(
|
||||
self,
|
||||
renderable: RenderableType,
|
||||
style: BaseStyle,
|
||||
title: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
is_animated: Optional[bool] = None,
|
||||
animation_counter: Optional[int] = None,
|
||||
done: bool = False,
|
||||
) -> None:
|
||||
self.renderable = renderable
|
||||
self._title = title
|
||||
self.metadata = metadata or {}
|
||||
self.width = None
|
||||
self.expand = True
|
||||
self.is_animated = is_animated
|
||||
self.counter = animation_counter or 0
|
||||
self.style = style
|
||||
self.done = done
|
||||
|
||||
def _get_decoration(self, suffix: str = "") -> Segment:
|
||||
char = "┌" if self.metadata.get("title") else "◆"
|
||||
|
||||
animated = not self.done and self.is_animated
|
||||
|
||||
animation_status: Literal["started", "stopped", "error"] = (
|
||||
"started" if animated else "stopped"
|
||||
)
|
||||
|
||||
color = self.style._get_animation_colors(
|
||||
steps=14, breathe=True, animation_status=animation_status
|
||||
)[self.counter % 14]
|
||||
|
||||
return Segment(char + suffix, style=Style.from_color(color))
|
||||
|
||||
def _strip_trailing_newlines(
|
||||
self, lines: List[List[Segment]]
|
||||
) -> List[List[Segment]]:
|
||||
# remove all empty lines from the end of the list
|
||||
|
||||
while lines and all(segment.text.strip() == "" for segment in lines[-1]):
|
||||
lines.pop()
|
||||
|
||||
return lines
|
||||
|
||||
def __rich_console__(
|
||||
self, console: "Console", options: "ConsoleOptions"
|
||||
) -> "RenderResult":
|
||||
renderable = self.renderable
|
||||
|
||||
lines = console.render_lines(
|
||||
renderable, options.update_width(options.max_width - 2)
|
||||
)
|
||||
lines = self._strip_trailing_newlines(lines)
|
||||
|
||||
line_start = self._get_decoration()
|
||||
|
||||
new_line = Segment.line()
|
||||
|
||||
if self._title is not None:
|
||||
yield line_start
|
||||
yield Segment(" ")
|
||||
yield Segment(self._title)
|
||||
if lines:
|
||||
yield new_line
|
||||
|
||||
for first, last, line in loop_first_last(lines):
|
||||
if first and not self._title:
|
||||
decoration = (
|
||||
Segment("┌ ")
|
||||
if self.metadata.get("title", False)
|
||||
else self._get_decoration(suffix=" ")
|
||||
)
|
||||
elif last and self.metadata.get("started", True):
|
||||
decoration = Segment("└ ")
|
||||
else:
|
||||
decoration = Segment("│ ")
|
||||
|
||||
yield decoration
|
||||
yield from line
|
||||
|
||||
if not last:
|
||||
yield new_line
|
||||
|
||||
|
||||
class FancyStyle(BaseStyle):
|
||||
_should_show_progress_title = False
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.cursor_offset = 2
|
||||
self.decoration_size = 2
|
||||
|
||||
def _should_decorate(self, element: Any, parent: Optional[Element] = None) -> bool:
|
||||
return not isinstance(parent, (Progress, Container))
|
||||
|
||||
def render_element(
|
||||
self,
|
||||
element: Any,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
**metadata: Any,
|
||||
) -> RenderableType:
|
||||
title: Optional[str] = None
|
||||
|
||||
is_animated = False
|
||||
|
||||
if isinstance(element, Progress):
|
||||
title = element.title
|
||||
is_animated = True
|
||||
|
||||
rendered = super().render_element(
|
||||
element=element, is_active=is_active, done=done, parent=parent, **metadata
|
||||
)
|
||||
|
||||
if self._should_decorate(element, parent):
|
||||
rendered = FancyPanel(
|
||||
rendered,
|
||||
title=title,
|
||||
metadata=metadata,
|
||||
is_animated=is_animated,
|
||||
done=done,
|
||||
animation_counter=self.animation_counter,
|
||||
style=self,
|
||||
)
|
||||
|
||||
return rendered
|
||||
|
||||
def render_progress(
|
||||
self,
|
||||
element: Progress,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
) -> RenderableType:
|
||||
if done and element._cancelled:
|
||||
if self._progress_has_content_beyond_title(element):
|
||||
return Group(
|
||||
self._render_progress_content(element),
|
||||
self._render_cancelled_progress_message(),
|
||||
)
|
||||
|
||||
return self._render_cancelled_progress_message()
|
||||
|
||||
return super().render_progress(element, is_active, done, parent)
|
||||
|
||||
def empty_line(self) -> Text:
|
||||
"""Return an empty line with decoration.
|
||||
|
||||
Returns:
|
||||
A text object representing an empty line
|
||||
"""
|
||||
return Text("│", style="fancy.normal")
|
||||
|
||||
def get_cursor_offset_for_element(
|
||||
self, element: Element, parent: Optional[Element] = None
|
||||
) -> CursorOffset:
|
||||
"""Get the cursor offset for an element.
|
||||
|
||||
Args:
|
||||
element: The element to get the cursor offset for
|
||||
|
||||
Returns:
|
||||
The cursor offset
|
||||
"""
|
||||
from rich_toolkit.input import Input
|
||||
|
||||
if isinstance(element, Form):
|
||||
return element.cursor_offset
|
||||
|
||||
offset = element.cursor_offset
|
||||
top = offset.top
|
||||
|
||||
if isinstance(element, Input) and not element.inline and element.label:
|
||||
label_lines = self._count_label_lines(
|
||||
element.label, decoration_width=self.decoration_size
|
||||
)
|
||||
top = label_lines + 1
|
||||
|
||||
return CursorOffset(
|
||||
top=top,
|
||||
left=self.decoration_size + offset.left,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import RenderableType
|
||||
|
||||
from .base import BaseStyle
|
||||
|
||||
|
||||
class MinimalStyle(BaseStyle):
|
||||
def render_context_enter(self) -> Optional[RenderableType]:
|
||||
return None
|
||||
|
||||
def render_context_exit(self) -> Optional[RenderableType]:
|
||||
return None
|
||||
@@ -0,0 +1,170 @@
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.table import Column, Table
|
||||
from typing_extensions import Literal
|
||||
|
||||
from rich_toolkit.container import Container
|
||||
from rich_toolkit.element import CursorOffset, Element
|
||||
from rich_toolkit.progress import Progress, ProgressLine
|
||||
|
||||
from .base import BaseStyle
|
||||
|
||||
|
||||
def has_emoji(tag: str) -> bool:
|
||||
return bool(re.search(r"[\U0001F300-\U0001F9FF]", tag))
|
||||
|
||||
|
||||
class TaggedStyle(BaseStyle):
|
||||
block = "█"
|
||||
block_length = 5
|
||||
|
||||
def __init__(self, tag_width: int = 12, theme: Optional[Dict[str, str]] = None):
|
||||
self.tag_width = tag_width
|
||||
|
||||
theme = theme or {
|
||||
"tag.title": "bold",
|
||||
"tag": "bold",
|
||||
}
|
||||
|
||||
super().__init__(theme=theme)
|
||||
|
||||
def _get_tag_segments(
|
||||
self,
|
||||
metadata: Dict[str, Any],
|
||||
is_animated: bool = False,
|
||||
done: bool = False,
|
||||
animation_status: Optional[Literal["started", "stopped", "error"]] = None,
|
||||
) -> Tuple[List[Segment], int]:
|
||||
if tag := metadata.get("tag", ""):
|
||||
tag = f" {tag} "
|
||||
|
||||
style_name = "tag.title" if metadata.get("title", False) else "tag"
|
||||
|
||||
style = self.console.get_style(style_name)
|
||||
|
||||
if is_animated:
|
||||
if animation_status is None:
|
||||
animation_status = "started" if not done else "stopped"
|
||||
|
||||
tag = " " * self.block_length
|
||||
colors = self._get_animation_colors(
|
||||
steps=self.block_length, animation_status=animation_status
|
||||
)
|
||||
|
||||
if done:
|
||||
colors = [colors[-1]]
|
||||
|
||||
tag_segments = [
|
||||
Segment(
|
||||
self.block,
|
||||
style=Style(
|
||||
color=colors[(self.animation_counter + i) % len(colors)]
|
||||
),
|
||||
)
|
||||
for i in range(self.block_length)
|
||||
]
|
||||
else:
|
||||
tag_segments = [Segment(tag, style=style)]
|
||||
|
||||
left_padding = self.tag_width - len(tag)
|
||||
left_padding = max(0, left_padding)
|
||||
|
||||
return tag_segments, left_padding
|
||||
|
||||
def _get_tag(
|
||||
self,
|
||||
metadata: Dict[str, Any],
|
||||
is_animated: bool = False,
|
||||
done: bool = False,
|
||||
animation_status: Optional[Literal["started", "stopped", "error"]] = None,
|
||||
) -> Group:
|
||||
tag_segments, left_padding = self._get_tag_segments(
|
||||
metadata, is_animated, done, animation_status=animation_status
|
||||
)
|
||||
|
||||
left = [Segment(" " * left_padding), *tag_segments]
|
||||
|
||||
return Group(*left)
|
||||
|
||||
def _tag_element(
|
||||
self,
|
||||
child: RenderableType,
|
||||
is_animated: bool = False,
|
||||
done: bool = False,
|
||||
animation_status: Optional[Literal["started", "stopped", "error"]] = None,
|
||||
**metadata: Dict[str, Any],
|
||||
) -> RenderableType:
|
||||
table = Table.grid(
|
||||
# TODO: why do we add 2? :D we probably did this in the previous version
|
||||
Column(width=self.tag_width + 2, no_wrap=True),
|
||||
Column(no_wrap=False, overflow="fold"),
|
||||
padding=(0, 0, 0, 0),
|
||||
collapse_padding=True,
|
||||
pad_edge=False,
|
||||
)
|
||||
|
||||
table.add_row(
|
||||
self._get_tag(
|
||||
metadata, is_animated, done, animation_status=animation_status
|
||||
),
|
||||
Group(child),
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
def render_element(
|
||||
self,
|
||||
element: Any,
|
||||
is_active: bool = False,
|
||||
done: bool = False,
|
||||
parent: Optional[Element] = None,
|
||||
**kwargs: Any,
|
||||
) -> RenderableType:
|
||||
is_animated = isinstance(element, Progress)
|
||||
should_tag = not isinstance(element, (ProgressLine, Container))
|
||||
|
||||
rendered = super().render_element(
|
||||
element=element, is_active=is_active, done=done, parent=parent, **kwargs
|
||||
)
|
||||
|
||||
metadata = kwargs
|
||||
if isinstance(element, Element) and element.metadata:
|
||||
metadata = {**element.metadata, **metadata}
|
||||
|
||||
if should_tag:
|
||||
animation_status = None
|
||||
if isinstance(element, Progress) and element._cancelled:
|
||||
animation_status = "error"
|
||||
|
||||
rendered = self._tag_element(
|
||||
rendered,
|
||||
is_animated=is_animated,
|
||||
done=done,
|
||||
animation_status=animation_status,
|
||||
**metadata,
|
||||
)
|
||||
|
||||
return rendered
|
||||
|
||||
def get_cursor_offset_for_element(
|
||||
self, element: Element, parent: Optional[Element] = None
|
||||
) -> CursorOffset:
|
||||
from rich_toolkit.input import Input
|
||||
|
||||
offset = element.cursor_offset
|
||||
top = offset.top
|
||||
|
||||
if isinstance(element, Input) and not element.inline and element.label:
|
||||
label_lines = self._count_label_lines(
|
||||
element.label, decoration_width=self.tag_width + 2
|
||||
)
|
||||
top = label_lines + 1
|
||||
|
||||
return CursorOffset(
|
||||
top=top,
|
||||
left=self.tag_width + offset.left + 2,
|
||||
)
|
||||
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from functools import wraps
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
|
||||
from rich.console import ConsoleRenderable, RenderableType
|
||||
from rich.pretty import Pretty
|
||||
from rich.text import Text
|
||||
from rich.theme import Theme
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from .input import Input
|
||||
from .menu import Menu, Option, ReturnValue
|
||||
from .progress import Progress
|
||||
from .styles.base import BaseStyle
|
||||
|
||||
OutputT = TypeVar("OutputT")
|
||||
ReturnT = TypeVar("ReturnT")
|
||||
P = ParamSpec("P")
|
||||
OutputRenderer = Union[
|
||||
Callable[[OutputT], Optional[RenderableType]],
|
||||
Callable[[OutputT, "RichToolkit"], Optional[RenderableType]],
|
||||
]
|
||||
|
||||
|
||||
def _unavailable_in_json_mode(
|
||||
method_name: str,
|
||||
) -> Callable[
|
||||
[Callable[Concatenate["RichToolkit", P], ReturnT]],
|
||||
Callable[Concatenate["RichToolkit", P], ReturnT],
|
||||
]:
|
||||
def decorator(
|
||||
method: Callable[Concatenate["RichToolkit", P], ReturnT],
|
||||
) -> Callable[Concatenate["RichToolkit", P], ReturnT]:
|
||||
@wraps(method)
|
||||
def wrapper(self: "RichToolkit", *args: P.args, **kwargs: P.kwargs) -> ReturnT:
|
||||
if self.mode == "json":
|
||||
raise RuntimeError(f"{method_name}() is not available in JSON mode")
|
||||
|
||||
return method(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _is_output_stream(data: Any) -> bool:
|
||||
return isinstance(data, Iterator)
|
||||
|
||||
|
||||
def _is_ci_enabled() -> bool:
|
||||
value = os.environ.get("CI")
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
return value.lower() not in {"", "0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _dump_output_data(data: Any) -> Any:
|
||||
model_dump = getattr(data, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
return _dump_output_data(model_dump(mode="json"))
|
||||
|
||||
if isinstance(data, dict):
|
||||
return {key: _dump_output_data(value) for key, value in data.items()}
|
||||
|
||||
if isinstance(data, (list, tuple)):
|
||||
return [_dump_output_data(item) for item in data]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _format_output_value(value: Any) -> str:
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False, allow_nan=False)
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def _record_lines(data: dict[Any, Any]) -> list[str]:
|
||||
return [f"{key}: {_format_output_value(value)}" for key, value in data.items()]
|
||||
|
||||
|
||||
def _default_output_renderable(data: Any) -> RenderableType:
|
||||
dumped = _dump_output_data(data)
|
||||
|
||||
if isinstance(dumped, dict):
|
||||
return Text("\n".join(_record_lines(dumped)))
|
||||
|
||||
if isinstance(dumped, list):
|
||||
lines: list[str] = []
|
||||
for index, item in enumerate(dumped):
|
||||
if not isinstance(item, dict):
|
||||
return Pretty(dumped)
|
||||
if index:
|
||||
lines.append("")
|
||||
lines.extend(_record_lines(item))
|
||||
return Text("\n".join(lines))
|
||||
|
||||
return Pretty(dumped)
|
||||
|
||||
|
||||
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,
|
||||
style: Optional[BaseStyle] = None,
|
||||
theme: Optional[RichToolkitTheme] = None,
|
||||
handle_keyboard_interrupts: bool = True,
|
||||
mode: Literal["human", "json"] = "human",
|
||||
preserve_progress_logs: Optional[bool] = None,
|
||||
) -> None:
|
||||
"""Create a toolkit.
|
||||
|
||||
Args:
|
||||
style: Style used to render toolkit elements.
|
||||
theme: Legacy theme configuration.
|
||||
handle_keyboard_interrupts: Suppress keyboard interrupts when enabled.
|
||||
mode: Render human-readable or JSON output.
|
||||
preserve_progress_logs: Print each progress log message immediately
|
||||
without inserting line breaks at the console width. When `None`,
|
||||
this is enabled in CI and for non-interactive consoles.
|
||||
"""
|
||||
if mode not in ("human", "json"):
|
||||
raise ValueError("mode must be 'human' or 'json'")
|
||||
|
||||
self.mode = mode
|
||||
self._json_output_written = False
|
||||
|
||||
self.theme = theme
|
||||
if theme is not None:
|
||||
# TODO: deprecate
|
||||
self.style = theme.style
|
||||
self.style.theme = theme.rich_theme
|
||||
self.style.console.push_theme(theme.rich_theme)
|
||||
else:
|
||||
if style is None:
|
||||
from .styles import MinimalStyle
|
||||
|
||||
style = MinimalStyle()
|
||||
|
||||
self.style = style
|
||||
|
||||
self.console = self.style.console
|
||||
if preserve_progress_logs is None:
|
||||
preserve_progress_logs = _is_ci_enabled() or not self.console.is_terminal
|
||||
self.preserve_progress_logs = preserve_progress_logs
|
||||
|
||||
self.handle_keyboard_interrupts = handle_keyboard_interrupts
|
||||
|
||||
def __enter__(self):
|
||||
if self.mode == "human":
|
||||
if (renderable := self.style.render_context_enter()) is not None:
|
||||
self.console.print(renderable)
|
||||
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
|
||||
|
||||
if self.mode == "human":
|
||||
if (renderable := self.style.render_context_exit()) is not None:
|
||||
self.console.print(renderable)
|
||||
|
||||
return None
|
||||
|
||||
def print_title(self, title: str, end: str = "\n", **metadata: Any) -> None:
|
||||
if self.mode == "json":
|
||||
return
|
||||
|
||||
self.console.print(
|
||||
self.style.render_element(title, title=True, **metadata), end=end
|
||||
)
|
||||
|
||||
def _print(
|
||||
self,
|
||||
*renderables: RenderableType,
|
||||
end: str = "\n",
|
||||
_force: bool = False,
|
||||
**metadata: Any,
|
||||
) -> None:
|
||||
if self.mode == "json" and not _force:
|
||||
return
|
||||
|
||||
self.console.print(
|
||||
*[
|
||||
self.style.render_element(renderable, **metadata)
|
||||
for renderable in renderables
|
||||
],
|
||||
end=end,
|
||||
)
|
||||
|
||||
def print(
|
||||
self, *renderables: RenderableType, end: str = "\n", **metadata: Any
|
||||
) -> None:
|
||||
self._print(*renderables, end=end, _force=False, **metadata)
|
||||
|
||||
def print_as_string(self, *renderables: RenderableType, **metadata: Any) -> str:
|
||||
with self.console.capture() as capture:
|
||||
self._print(*renderables, _force=True, **metadata)
|
||||
|
||||
return capture.get().rstrip()
|
||||
|
||||
def print_line(self) -> None:
|
||||
if self.mode == "json":
|
||||
return
|
||||
|
||||
self.console.print(self.style.empty_line())
|
||||
|
||||
def _write_json_line(self, data: Any) -> None:
|
||||
payload = json.dumps(
|
||||
_dump_output_data(data),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
sys.stdout.write(payload + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def _render_custom_output(
|
||||
self, render_output: OutputRenderer[Any], data: Any
|
||||
) -> None:
|
||||
signature = inspect.signature(render_output)
|
||||
|
||||
if len(signature.parameters) == 1:
|
||||
render_one_arg = cast(
|
||||
Callable[[Any], Optional[RenderableType]],
|
||||
render_output,
|
||||
)
|
||||
renderable = render_one_arg(data)
|
||||
else:
|
||||
render_two_args = cast(
|
||||
Callable[[Any, RichToolkit], Optional[RenderableType]],
|
||||
render_output,
|
||||
)
|
||||
renderable = render_two_args(data, self)
|
||||
|
||||
if renderable is not None:
|
||||
self.print(renderable)
|
||||
|
||||
def _write_json_output(self, data: Any) -> None:
|
||||
if _is_output_stream(data):
|
||||
for item in data:
|
||||
self._write_json_line(item)
|
||||
return
|
||||
|
||||
self._write_json_line(data)
|
||||
|
||||
def _render_human_output(
|
||||
self,
|
||||
data: Any,
|
||||
render_output: Optional[Union[RenderableType, OutputRenderer[Any]]] = None,
|
||||
) -> None:
|
||||
if render_output is not None:
|
||||
if callable(render_output):
|
||||
self._render_custom_output(
|
||||
cast(OutputRenderer[Any], render_output), data
|
||||
)
|
||||
else:
|
||||
self.print(render_output)
|
||||
|
||||
return
|
||||
|
||||
if isinstance(data, (str, ConsoleRenderable)):
|
||||
self.print(data)
|
||||
else:
|
||||
self.print(_default_output_renderable(data))
|
||||
|
||||
@overload
|
||||
def output(self, data: OutputT, render_output: None = None) -> None: ...
|
||||
|
||||
@overload
|
||||
def output(self, data: OutputT, render_output: OutputRenderer[OutputT]) -> None: ...
|
||||
|
||||
@overload
|
||||
def output(self, data: Any, render_output: RenderableType) -> None: ...
|
||||
|
||||
def output(
|
||||
self,
|
||||
data: Any,
|
||||
render_output: Optional[Union[RenderableType, OutputRenderer[Any]]] = None,
|
||||
) -> None:
|
||||
if self.mode == "json":
|
||||
if self._json_output_written:
|
||||
raise RuntimeError("output() was already called in JSON mode")
|
||||
|
||||
self._write_json_output(data)
|
||||
self._json_output_written = True
|
||||
return
|
||||
|
||||
if _is_output_stream(data):
|
||||
for item in data:
|
||||
self._render_human_output(item, render_output=render_output)
|
||||
return
|
||||
|
||||
self._render_human_output(data, render_output=render_output)
|
||||
|
||||
@_unavailable_in_json_mode("confirm")
|
||||
def confirm(self, label: str, **metadata: Any) -> bool:
|
||||
options: List[Option[bool]] = [
|
||||
Option({"value": True, "name": "Yes"}),
|
||||
Option({"value": False, "name": "No"}),
|
||||
]
|
||||
|
||||
return self.ask(
|
||||
label=label,
|
||||
options=options,
|
||||
inline=True,
|
||||
**metadata,
|
||||
)
|
||||
|
||||
@overload
|
||||
def ask(
|
||||
self,
|
||||
label: str,
|
||||
options: List[Option[ReturnValue]],
|
||||
inline: bool = False,
|
||||
allow_filtering: bool = False,
|
||||
multiple: Literal[False] = False,
|
||||
**metadata: Any,
|
||||
) -> ReturnValue: ...
|
||||
|
||||
@overload
|
||||
def ask(
|
||||
self,
|
||||
label: str,
|
||||
options: List[Option[ReturnValue]],
|
||||
inline: bool = False,
|
||||
allow_filtering: bool = False,
|
||||
*,
|
||||
multiple: Literal[True],
|
||||
**metadata: Any,
|
||||
) -> List[ReturnValue]: ...
|
||||
|
||||
def ask(
|
||||
self,
|
||||
label: str,
|
||||
options: List[Option[ReturnValue]],
|
||||
inline: bool = False,
|
||||
allow_filtering: bool = False,
|
||||
multiple: bool = False,
|
||||
**metadata: Any,
|
||||
) -> Union[ReturnValue, List[ReturnValue]]:
|
||||
if self.mode == "json":
|
||||
raise RuntimeError("ask() is not available in JSON mode")
|
||||
|
||||
return Menu(
|
||||
label=label,
|
||||
options=options,
|
||||
console=self.console,
|
||||
style=self.style,
|
||||
inline=inline,
|
||||
allow_filtering=allow_filtering,
|
||||
multiple=multiple,
|
||||
**metadata,
|
||||
).ask()
|
||||
|
||||
@_unavailable_in_json_mode("input")
|
||||
def input(
|
||||
self,
|
||||
title: str,
|
||||
default: str = "",
|
||||
placeholder: str = "",
|
||||
password: bool = False,
|
||||
required: bool = False,
|
||||
required_message: str = "",
|
||||
inline: bool = False,
|
||||
value: str = "",
|
||||
**metadata: Any,
|
||||
) -> str:
|
||||
return Input(
|
||||
name=title,
|
||||
label=title,
|
||||
default=default,
|
||||
placeholder=placeholder,
|
||||
password=password,
|
||||
required=required,
|
||||
required_message=required_message,
|
||||
inline=inline,
|
||||
style=self.style,
|
||||
value=value,
|
||||
**metadata,
|
||||
).ask()
|
||||
|
||||
def progress(
|
||||
self,
|
||||
title: str,
|
||||
transient: bool = False,
|
||||
transient_on_error: bool = False,
|
||||
inline_logs: bool = False,
|
||||
lines_to_show: int = -1,
|
||||
preserve_logs: Optional[bool] = None,
|
||||
**metadata: Any,
|
||||
) -> Progress:
|
||||
"""Create a progress display.
|
||||
|
||||
Args:
|
||||
title: Initial progress message.
|
||||
transient: Remove the progress display when it finishes.
|
||||
transient_on_error: Remove the progress display when it errors.
|
||||
inline_logs: Display logged messages as separate lines.
|
||||
lines_to_show: Maximum number of inline log lines to display. Negative
|
||||
values display all lines.
|
||||
preserve_logs: Override the toolkit's progress-log preservation setting.
|
||||
**metadata: Additional metadata passed to the style renderer.
|
||||
"""
|
||||
return Progress(
|
||||
title=title,
|
||||
console=self.console,
|
||||
style=self.style,
|
||||
transient=True if self.mode == "json" else transient,
|
||||
transient_on_error=transient_on_error,
|
||||
inline_logs=inline_logs,
|
||||
lines_to_show=lines_to_show,
|
||||
preserve_logs=(
|
||||
self.preserve_progress_logs if preserve_logs is None else preserve_logs
|
||||
),
|
||||
quiet=self.mode == "json",
|
||||
**metadata,
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
from rich.color import Color
|
||||
from rich.color_triplet import ColorTriplet
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from typing_extensions import Literal
|
||||
|
||||
|
||||
def lighten(color: Color, amount: float) -> Color:
|
||||
triplet = color.triplet
|
||||
|
||||
if not triplet:
|
||||
triplet = color.get_truecolor()
|
||||
|
||||
r, g, b = 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))
|
||||
|
||||
|
||||
def darken(color: Color, amount: float) -> Color:
|
||||
triplet = color.triplet
|
||||
|
||||
if not triplet:
|
||||
triplet = color.get_truecolor()
|
||||
|
||||
r, g, b = triplet
|
||||
|
||||
r = int(r * (1 - amount))
|
||||
g = int(g * (1 - amount))
|
||||
b = int(b * (1 - amount))
|
||||
|
||||
return Color.from_triplet(ColorTriplet(r, g, b))
|
||||
|
||||
|
||||
def fade_color(
|
||||
color: Color, background_color: Color, brightness_multiplier: float
|
||||
) -> Color:
|
||||
"""
|
||||
Fade a color towards the background color based on a brightness multiplier.
|
||||
|
||||
Args:
|
||||
color: The original color (Rich Color object)
|
||||
background_color: The background color to fade towards
|
||||
brightness_multiplier: Float between 0.0 and 1.0 where:
|
||||
- 1.0 = original color (no fading)
|
||||
- 0.0 = completely faded to background color
|
||||
|
||||
Returns:
|
||||
A new Color object with the faded color
|
||||
"""
|
||||
# Extract RGB components from the original color
|
||||
|
||||
color_triplet = color.triplet
|
||||
|
||||
if color_triplet is None:
|
||||
color_triplet = color.get_truecolor()
|
||||
|
||||
r, g, b = color_triplet
|
||||
|
||||
assert background_color.triplet is not None
|
||||
# Extract RGB components from the background color
|
||||
bg_r, bg_g, bg_b = background_color.triplet
|
||||
|
||||
# Blend the original color with the background color based on the brightness multiplier
|
||||
new_r = int(r * brightness_multiplier + bg_r * (1 - brightness_multiplier))
|
||||
new_g = int(g * brightness_multiplier + bg_g * (1 - brightness_multiplier))
|
||||
new_b = int(b * brightness_multiplier + bg_b * (1 - brightness_multiplier))
|
||||
|
||||
# Ensure values are within valid RGB range (0-255)
|
||||
new_r = max(0, min(255, new_r))
|
||||
new_g = max(0, min(255, new_g))
|
||||
new_b = max(0, min(255, new_b))
|
||||
|
||||
# Return a new Color object with the calculated RGB values
|
||||
return Color.from_rgb(new_r, new_g, new_b)
|
||||
|
||||
|
||||
def fade_text(
|
||||
text: Text,
|
||||
text_color: Color,
|
||||
background_color: str,
|
||||
brightness_multiplier: float,
|
||||
) -> Text:
|
||||
bg_color = Color.parse(background_color)
|
||||
|
||||
new_spans = []
|
||||
for span in text._spans:
|
||||
style: Style | str = span.style
|
||||
|
||||
if isinstance(style, str):
|
||||
style = Style.parse(style)
|
||||
|
||||
if style.color:
|
||||
color = style.color
|
||||
|
||||
if color == Color.default():
|
||||
color = text_color
|
||||
|
||||
style = style.copy()
|
||||
style._color = fade_color(color, bg_color, brightness_multiplier)
|
||||
|
||||
new_spans.append(span._replace(style=style))
|
||||
text = text.copy()
|
||||
text._spans = new_spans
|
||||
text.style = Style(color=fade_color(text_color, bg_color, brightness_multiplier))
|
||||
return text
|
||||
|
||||
|
||||
def _get_terminal_color(
|
||||
color_type: Literal["text", "background"], default_color: str
|
||||
) -> str:
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
|
||||
# Set appropriate OSC code and default color based on color_type
|
||||
if color_type.lower() == "text":
|
||||
osc_code = "10"
|
||||
elif color_type.lower() == "background":
|
||||
osc_code = "11"
|
||||
else:
|
||||
raise ValueError("color_type must be either 'text' or 'background'")
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
import termios
|
||||
import tty
|
||||
except ImportError:
|
||||
# Not on a Unix-like system
|
||||
return default_color
|
||||
|
||||
# Use a dedicated fd via /dev/tty instead of sys.stdin so we don't
|
||||
# affect the process's stdin/stdout/stderr. On Linux, fds 0/1/2 share
|
||||
# the same open file description when connected to the same terminal,
|
||||
# so setting non-blocking or raw mode on stdin would also affect stdout,
|
||||
# breaking logging in forked worker processes (e.g. uvicorn with --workers).
|
||||
try:
|
||||
tty_fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
|
||||
except OSError:
|
||||
return default_color
|
||||
|
||||
try:
|
||||
# Serialize access across forked workers. termios settings are
|
||||
# per-terminal-device, not per-fd, so concurrent setcbreak/restore
|
||||
# calls from different processes race and can cause the terminal's
|
||||
# OSC response to be echoed visibly.
|
||||
fcntl.flock(tty_fd, fcntl.LOCK_EX)
|
||||
except OSError:
|
||||
os.close(tty_fd)
|
||||
return default_color
|
||||
|
||||
# Only proceed if we're the foreground process group for this terminal.
|
||||
# Calling tcsetattr (via setcbreak) from a background process group
|
||||
# generates SIGTTOU, which by default stops the process. This happens
|
||||
# e.g. when the program is launched in the background (`prog &`) or run
|
||||
# under a job-control shell that isn't giving us the terminal.
|
||||
try:
|
||||
if os.tcgetpgrp(tty_fd) != os.getpgrp():
|
||||
os.close(tty_fd)
|
||||
return default_color
|
||||
except OSError:
|
||||
os.close(tty_fd)
|
||||
return default_color
|
||||
|
||||
old_settings = termios.tcgetattr(tty_fd)
|
||||
|
||||
try:
|
||||
# Use setcbreak instead of setraw to keep ISIG enabled so that
|
||||
# Ctrl+C continues to generate SIGINT. setraw disables ISIG which
|
||||
# breaks signal handling in multi-process contexts (e.g. uvicorn
|
||||
# workers). setcbreak only disables ECHO and ICANON, which is all
|
||||
# we need for reading the OSC response character-by-character.
|
||||
tty.setcbreak(tty_fd)
|
||||
|
||||
# Send OSC escape sequence to query color
|
||||
os.write(tty_fd, f"\033]{osc_code};?\033\\".encode())
|
||||
|
||||
# Wait for response with timeout
|
||||
if select.select([tty_fd], [], [], 1.0)[0]:
|
||||
# Read response
|
||||
response = b""
|
||||
while True:
|
||||
if select.select([tty_fd], [], [], 1.0)[0]:
|
||||
data = os.read(tty_fd, 32)
|
||||
if not data:
|
||||
break
|
||||
response += data
|
||||
# Terminal response ends with BEL (\a) or ST (\033\\)
|
||||
if b"\a" in response or b"\033\\" in response:
|
||||
break
|
||||
if len(response) > 50: # Safety limit
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
# Parse the response (format: \033]10;rgb:RRRR/GGGG/BBBB\033\\)
|
||||
# Color components can be 1-4 hex digits depending on terminal
|
||||
match = re.search(
|
||||
rb"rgb:([0-9a-f]+)/([0-9a-f]+)/([0-9a-f]+)",
|
||||
response,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
r_hex, g_hex, b_hex = match.groups()
|
||||
# Convert to 8-bit by taking the first 2 hex digits
|
||||
r = int(r_hex[:2], 16)
|
||||
g = int(g_hex[:2], 16)
|
||||
b = int(b_hex[:2], 16)
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
return default_color
|
||||
else:
|
||||
return default_color
|
||||
except KeyboardInterrupt:
|
||||
# This can happen when a worker process is interrupted (Ctrl+C)
|
||||
# while in the middle of querying the terminal. Return the default
|
||||
# color gracefully — the interrupt will be handled by the caller.
|
||||
return default_color
|
||||
finally:
|
||||
# Restore terminal settings using TCSAFLUSH to discard any
|
||||
# unread response bytes left in the input buffer, then release
|
||||
# the lock and close our dedicated fd.
|
||||
termios.tcsetattr(tty_fd, termios.TCSAFLUSH, old_settings)
|
||||
fcntl.flock(tty_fd, fcntl.LOCK_UN)
|
||||
os.close(tty_fd)
|
||||
|
||||
|
||||
def get_terminal_text_color(default_color: str = "#FFFFFF") -> str:
|
||||
"""Get the terminal text (foreground) color."""
|
||||
return _get_terminal_color("text", default_color)
|
||||
|
||||
|
||||
def get_terminal_background_color(default_color: str = "#000000") -> str:
|
||||
"""Get the terminal background color."""
|
||||
return _get_terminal_color("background", default_color)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(get_terminal_background_color())
|
||||
print(get_terminal_text_color())
|
||||
@@ -0,0 +1,12 @@
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def map_range(
|
||||
value: float, input_range: Tuple[float, float], output_range: Tuple[float, float]
|
||||
) -> float:
|
||||
min_input, max_input = input_range
|
||||
min_output, max_output = output_range
|
||||
|
||||
return ((value - min_input) / (max_input - min_input)) * (
|
||||
max_output - min_output
|
||||
) + min_output
|
||||
Reference in New Issue
Block a user