Initial: Standalone mixer app with MixerPage, scene management, WS proxy
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
from .input import Validator
|
||||
from .toolkit import RichToolkit, RichToolkitTheme
|
||||
|
||||
__all__ = ["RichToolkit", "RichToolkitTheme", "Validator"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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,137 @@
|
||||
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,
|
||||
quiet: bool = False,
|
||||
**metadata: Dict[Any, Any],
|
||||
) -> None:
|
||||
self.title = title
|
||||
self.current_message = title
|
||||
self.is_error = False
|
||||
self._transient_on_error = transient_on_error
|
||||
self._inline_logs = inline_logs
|
||||
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)
|
||||
|
||||
# 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 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"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
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 _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",
|
||||
) -> None:
|
||||
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
|
||||
|
||||
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,
|
||||
**metadata: Any,
|
||||
) -> Progress:
|
||||
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,
|
||||
quiet=self.mode == "json",
|
||||
**metadata,
|
||||
)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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