Changed code to support older Python versions

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

View file

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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)
}

View file

@ -0,0 +1,156 @@
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,
**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.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__()
@property
def placeholder(self) -> str:
if self.default_as_placeholder and self.default:
return self.default
return self._placeholder or ""
@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

View file

@ -0,0 +1,308 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Generic, List, Optional, Tuple, TypeVar
import click
from rich.console import Console, RenderableType
from rich.text import Text
from typing_extensions import Any, Literal, TypedDict
from ._input_handler import TextInputHandler
from .element import CursorOffset, Element
if TYPE_CHECKING:
from .styles.base import BaseStyle
ReturnValue = TypeVar("ReturnValue")
class Option(TypedDict, Generic[ReturnValue]):
name: str
value: ReturnValue
class Menu(Generic[ReturnValue], TextInputHandler, Element):
DOWN_KEYS = [TextInputHandler.DOWN_KEY, "j"]
UP_KEYS = [TextInputHandler.UP_KEY, "k"]
LEFT_KEYS = [TextInputHandler.LEFT_KEY, "h"]
RIGHT_KEYS = [TextInputHandler.RIGHT_KEY, "l"]
current_selection_char = ""
selection_char = ""
filter_prompt = "Filter: "
# Scroll indicators
MORE_ABOVE_INDICATOR = " ↑ more"
MORE_BELOW_INDICATOR = " ↓ more"
def __init__(
self,
label: str,
options: List[Option[ReturnValue]],
inline: bool = False,
allow_filtering: bool = False,
*,
style: Optional[BaseStyle] = None,
cursor_offset: int = 0,
max_visible: Optional[int] = None,
**metadata: Any,
):
self.label = Text.from_markup(label)
self.inline = inline
self.allow_filtering = allow_filtering
self.selected = 0
self.metadata = metadata
self._options = options
self._padding_bottom = 1
self.valid = None
# Scrolling state
self._scroll_offset: int = 0
self._max_visible: Optional[int] = max_visible
cursor_offset = cursor_offset + len(self.filter_prompt)
Element.__init__(self, style=style, metadata=metadata)
super().__init__()
def get_key(self) -> Optional[str]:
char = click.getchar()
if char == "\r":
return "enter"
if self.allow_filtering:
left_keys, right_keys = [[self.LEFT_KEY], [self.RIGHT_KEY]]
down_keys, up_keys = [[self.DOWN_KEY], [self.UP_KEY]]
else:
left_keys, right_keys = self.LEFT_KEYS, self.RIGHT_KEYS
down_keys, up_keys = self.DOWN_KEYS, self.UP_KEYS
next_keys, prev_keys = (
(right_keys, left_keys) if self.inline else (down_keys, up_keys)
)
if char in next_keys:
return "next"
if char in prev_keys:
return "prev"
if self.allow_filtering:
return char
return None
@property
def options(self) -> List[Option[ReturnValue]]:
if self.allow_filtering:
return [
option
for option in self._options
if self.text.lower() in option["name"].lower()
]
return self._options
def get_max_visible(self, console: Optional[Console] = None) -> Optional[int]:
"""Calculate the maximum number of visible options based on terminal height.
Args:
console: Console to get terminal height from. If None, uses default.
Returns:
Maximum number of visible options, or None if no limit needed.
"""
if self._max_visible is not None:
return self._max_visible
if self.inline:
# Inline menus don't need scrolling
return None
if console is None:
console = Console()
# Reserve space for: label (1), filter line if enabled (1),
# scroll indicators (2), validation message (1), margins (2)
reserved_lines = 6
if self.allow_filtering:
reserved_lines += 1
available_height = console.height - reserved_lines
# At least show 3 options
return max(3, available_height)
@property
def visible_options_range(self) -> Tuple[int, int]:
"""Returns (start, end) indices for visible options."""
max_visible = self.get_max_visible()
total_options = len(self.options)
if max_visible is None or total_options <= max_visible:
return (0, total_options)
start = self._scroll_offset
end = min(start + max_visible, total_options)
return (start, end)
@property
def has_more_above(self) -> bool:
"""Check if there are more options above the visible window."""
return self._scroll_offset > 0
@property
def has_more_below(self) -> bool:
"""Check if there are more options below the visible window."""
max_visible = self.get_max_visible()
if max_visible is None:
return False
return self._scroll_offset + max_visible < len(self.options)
def _ensure_selection_visible(self) -> None:
"""Adjust scroll offset to ensure the selected item is visible."""
max_visible = self.get_max_visible()
if max_visible is None:
return
# If selection is above visible window, scroll up
if self.selected < self._scroll_offset:
self._scroll_offset = self.selected
# If selection is below visible window, scroll down
elif self.selected >= self._scroll_offset + max_visible:
self._scroll_offset = self.selected - max_visible + 1
def _reset_scroll(self) -> None:
"""Reset scroll offset (used when filter changes)."""
self._scroll_offset = 0
def _update_selection(self, key: Literal["next", "prev"]) -> None:
if key == "next":
self.selected += 1
elif key == "prev":
self.selected -= 1
if self.selected < 0:
self.selected = len(self.options) - 1
if self.selected >= len(self.options):
self.selected = 0
# Ensure the selected item is visible after navigation
self._ensure_selection_visible()
def render_result(self) -> RenderableType:
result_text = Text()
result_text.append(self.label)
result_text.append(" ")
result_text.append(
self.options[self.selected]["name"],
style=self.console.get_style("result"),
)
return result_text
def is_next_key(self, key: str) -> bool:
keys = self.RIGHT_KEYS if self.inline else self.DOWN_KEYS
if self.allow_filtering:
keys = [keys[0]]
return key in keys
def is_prev_key(self, key: str) -> bool:
keys = self.LEFT_KEYS if self.inline else self.UP_KEYS
if self.allow_filtering:
keys = [keys[0]]
return key in keys
def handle_key(self, key: str) -> None:
current_selection: Optional[str] = None
previous_filter_text = self.text
if self.is_next_key(key):
self._update_selection("next")
elif self.is_prev_key(key):
self._update_selection("prev")
else:
if self.options:
current_selection = self.options[self.selected]["name"]
super().handle_key(key)
if current_selection:
matching_index = next(
(
index
for index, option in enumerate(self.options)
if option["name"] == current_selection
),
0,
)
self.selected = matching_index
# Reset scroll when filter text changes
if self.allow_filtering and self.text != previous_filter_text:
self._reset_scroll()
self._ensure_selection_visible()
def _handle_enter(self) -> bool:
if self.allow_filtering and self.text and len(self.options) == 0:
return False
return True
@property
def validation_message(self) -> Optional[str]:
if self.valid is False:
return "This field is required"
return None
def on_blur(self):
self.on_validate()
def on_validate(self):
self.valid = len(self.options) > 0
@property
def should_show_cursor(self) -> bool:
return self.allow_filtering
def ask(self) -> ReturnValue:
from .container import Container
container = Container(style=self.style, metadata=self.metadata)
container.elements = [self]
container.run()
return self.options[self.selected]["value"]
@property
def cursor_offset(self) -> CursorOffset:
# For non-inline menus with filtering, cursor is on the filter line
# top = 2 accounts for: label (1) + filter line position (1 from start)
# The filter line comes BEFORE scroll indicators, so no adjustment needed
top = 2
left_offset = len(self.filter_prompt) + self.cursor_left
return CursorOffset(top=top, left=left_offset)
def _needs_scrolling(self) -> bool:
"""Check if scrolling is needed (more options than can be displayed)."""
max_visible = self.get_max_visible()
if max_visible is None:
return False
return len(self.options) > max_visible

View file

@ -0,0 +1,68 @@
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,
**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.logs: List[ProgressLine] = []
self.metadata = metadata
self._cancelled = False
Element.__init__(self, style=style)
super().__init__(console=console, refresh_per_second=8, transient=transient)
# TODO: remove this once rich uses "Self"
def __enter__(self) -> "Progress":
self.start(refresh=self._renderable is not None)
return self
def get_renderable(self) -> RenderableType:
return self.style.render_element(self, done=not self._started)
def log(self, text: str | Text) -> None:
if self._inline_logs:
self.logs.append(ProgressLine(text, self))
else:
self.current_message = text
def set_error(self, text: str) -> None:
self.current_message = text
self.is_error = True
self.transient = self._transient_on_error

View file

@ -0,0 +1,7 @@
from __future__ import annotations
from .element import Element
class Spacer(Element):
focusable = False

View file

@ -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"]

View file

@ -0,0 +1,467 @@
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",
# 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": "grey62 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.console = Console(theme=base_theme)
if theme:
self.console.push_theme(Theme(theme))
def empty_line(self) -> 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 get_cursor_offset_for_element(
self, element: Element, parent: Optional[Element] = None
) -> CursorOffset:
return element.cursor_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.append(validation_message)
# TODO: do we need this?
element._height = len(contents)
return Group(*contents)
def render_validation_message(self, element: Union[Input, Menu]) -> Optional[str]:
if element._cancelled:
return "[cancelled]Cancelled.[/]"
if element.valid is False:
return 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
# Check if this is a password field and mask it
if isinstance(input, Input) and input.password and text:
text = "*" * len(text)
if not text:
placeholder = ""
if isinstance(input, Input):
placeholder = input.placeholder
if input.default_as_placeholder and input.default:
return f"[placeholder]{input.default}[/]"
if input._cancelled:
return f"[placeholder.cancelled]{placeholder}[/]"
elif not done:
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 render_menu(
self,
element: Menu,
is_active: bool = False,
done: bool = False,
parent: Optional[Element] = None,
) -> RenderableType:
menu = Text(justify="left")
selected_prefix = Text(element.current_selection_char + " ")
not_selected_prefix = Text(element.selection_char + " ")
separator = Text("\t" if element.inline else "\n")
if done:
result_content = Text()
result_content.append(
self.render_input_label(element, is_active=is_active, parent=parent)
)
result_content.append(" ")
result_content.append(
element.options[element.selected]["name"],
style=self.console.get_style("result"),
)
return result_content
# Get visible range for scrolling
all_options = element.options
start, end = element.visible_options_range
visible_options = all_options[start:end]
# Check if scrolling is needed (to reserve consistent space for indicators)
needs_scrolling = element._needs_scrolling()
# Always reserve space for "more above" indicator when scrolling is enabled
# This prevents the menu from shifting when scrolling starts
if needs_scrolling:
if element.has_more_above:
menu.append(Text(element.MORE_ABOVE_INDICATOR + "\n", style="dim"))
else:
# Empty line to reserve space (same length as indicator for consistency)
menu.append(Text(" " * len(element.MORE_ABOVE_INDICATOR) + "\n"))
for idx, option in enumerate(visible_options):
# Calculate actual index in full options list
actual_idx = start + idx
if actual_idx == element.selected:
prefix = selected_prefix
style = self.console.get_style("selected")
else:
prefix = not_selected_prefix
style = self.console.get_style("text")
is_last = idx == len(visible_options) - 1
menu.append(
Text.assemble(
prefix,
option["name"],
separator if not is_last else "",
style=style,
)
)
# Always reserve space for "more below" indicator when scrolling is enabled
if needs_scrolling:
if element.has_more_below:
menu.append(Text("\n" + element.MORE_BELOW_INDICATOR, style="dim"))
else:
# Empty line to reserve space (same length as indicator for consistency)
menu.append(Text("\n" + " " * len(element.MORE_BELOW_INDICATOR)))
if not element.options:
menu = Text("No results found", style=self.console.get_style("text"))
filter = (
[
Text.assemble(
(element.filter_prompt, self.console.get_style("text")),
(element.text, self.console.get_style("text")),
"\n",
)
]
if element.allow_filtering
else []
)
content: list[RenderableType] = []
content.append(self.render_input_label(element))
content.extend(filter)
content.append(menu)
if message := self.render_validation_message(element):
content.append(Text(""))
content.append(message)
return Group(*content)
def render_progress(
self,
element: Progress,
is_active: bool = False,
done: bool = False,
parent: Optional[Element] = None,
) -> 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 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,
)

View file

@ -0,0 +1,261 @@
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
def empty_line(self) -> RenderableType:
return ""
def _box(
self,
content: RenderableType,
title: Union[str, Text, None],
is_active: bool,
border_color: Color,
after: Tuple[str, ...] = (),
) -> 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[str, ...] = ()
if isinstance(parent, Form):
return super().render_input(element, is_active, done, parent, **metadata)
if message := self.render_validation_message(element):
validation_message = (message,)
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[str, ...] = ()
menu = Text(justify="left")
selected_prefix = Text(element.current_selection_char + " ")
not_selected_prefix = Text(element.selection_char + " ")
separator = Text("\t" if element.inline else "\n")
content: list[RenderableType] = []
if done:
content.append(
Text(
element.options[element.selected]["name"],
style=self.console.get_style("result"),
)
)
else:
# Get visible range for scrolling
all_options = element.options
start, end = element.visible_options_range
visible_options = all_options[start:end]
# Check if scrolling is needed (to reserve consistent space for indicators)
needs_scrolling = element._needs_scrolling()
# Always reserve space for "more above" indicator when scrolling is enabled
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
if actual_idx == element.selected:
prefix = selected_prefix
style = self.console.get_style("selected")
else:
prefix = not_selected_prefix
style = self.console.get_style("text")
is_last = idx == len(visible_options) - 1
menu.append(
Text.assemble(
prefix,
option["name"],
separator if not is_last else "",
style=style,
)
)
# Always reserve space for "more below" indicator when scrolling is enabled
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"))
filter = (
[
Text.assemble(
(element.filter_prompt, self.console.get_style("text")),
(element.text, self.console.get_style("text")),
"\n",
)
]
if element.allow_filtering
else []
)
content.extend(filter)
content.append(menu)
if message := self.render_validation_message(element):
validation_message = (message,)
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:
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)

View file

@ -0,0 +1,170 @@
from typing import Any, Dict, List, Optional
from rich._loop import loop_first_last
from rich.console import Console, ConsoleOptions, 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)
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 self._title
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 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
"""
if isinstance(element, Form):
return element.cursor_offset
else:
return CursorOffset(
top=element.cursor_offset.top,
left=self.decoration_size + element.cursor_offset.left,
)

View file

@ -0,0 +1,5 @@
from .base import BaseStyle
class MinimalStyle(BaseStyle):
pass

View file

@ -0,0 +1,145 @@
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,
) -> 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:
animation_status: Literal["started", "stopped", "error"] = (
"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,
) -> Group:
tag_segments, left_padding = self._get_tag_segments(metadata, is_animated, done)
left = [Segment(" " * left_padding), *tag_segments]
return Group(*left)
def _tag_element(
self,
child: RenderableType,
is_animated: bool = False,
done: bool = False,
**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), 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:
rendered = self._tag_element(
rendered,
is_animated=is_animated,
done=done,
**metadata,
)
return rendered
def get_cursor_offset_for_element(
self, element: Element, parent: Optional[Element] = None
) -> CursorOffset:
return CursorOffset(
top=element.cursor_offset.top,
left=self.tag_width + element.cursor_offset.left + 2,
)

View file

@ -0,0 +1,148 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Union
from rich.console import Console, RenderableType
from rich.theme import Theme
from .input import Input
from .menu import Menu, Option, ReturnValue
from .progress import Progress
from .styles.base import BaseStyle
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,
) -> None:
# TODO: deprecate this
self.theme = theme
if theme is not None:
self.style = theme.style
self.style.theme = theme.rich_theme
self.style.console = Console(theme=theme.rich_theme)
else:
assert style is not None
self.style = style
self.console = self.style.console
self.handle_keyboard_interrupts = handle_keyboard_interrupts
def __enter__(self):
self.console.print()
return self
def __exit__(
self, exc_type: Any, exc_value: Any, traceback: Any
) -> Union[bool, None]:
if self.handle_keyboard_interrupts and exc_type is KeyboardInterrupt:
# we want to handle keyboard interrupts gracefully, instead of showing a traceback
# or any other error message
return True
self.console.print()
return None
def print_title(self, title: str, **metadata: Any) -> None:
self.console.print(self.style.render_element(title, title=True, **metadata))
def print(self, *renderables: RenderableType, **metadata: Any) -> None:
self.console.print(
*[
self.style.render_element(renderable, **metadata)
for renderable in renderables
]
)
def print_as_string(self, *renderables: RenderableType, **metadata: Any) -> str:
with self.console.capture() as capture:
self.print(*renderables, **metadata)
return capture.get().rstrip()
def print_line(self) -> None:
self.console.print(self.style.empty_line())
def confirm(self, label: str, **metadata: Any) -> bool:
return self.ask(
label=label,
options=[
Option({"value": True, "name": "Yes"}),
Option({"value": False, "name": "No"}),
],
inline=True,
**metadata,
)
def ask(
self,
label: str,
options: List[Option[ReturnValue]],
inline: bool = False,
allow_filtering: bool = False,
**metadata: Any,
) -> ReturnValue:
return Menu(
label=label,
options=options,
console=self.console,
style=self.style,
inline=inline,
allow_filtering=allow_filtering,
**metadata,
).ask()
def input(
self,
title: str,
default: str = "",
placeholder: str = "",
password: bool = False,
required: bool = False,
required_message: str = "",
inline: bool = False,
**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,
**metadata,
).ask()
def progress(
self,
title: str,
transient: bool = False,
transient_on_error: bool = False,
inline_logs: bool = False,
lines_to_show: int = -1,
) -> Progress:
return Progress(
title=title,
console=self.console,
style=self.style,
transient=transient,
transient_on_error=transient_on_error,
inline_logs=inline_logs,
lines_to_show=lines_to_show,
)

View file

@ -0,0 +1,228 @@
import atexit
import io
import signal
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
import sys
# 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 termios
import tty
except ImportError:
# Not on Unix-like systems (probably Windows), so we return the default color
return default_color
try:
if not os.isatty(sys.stdin.fileno()):
return default_color
except (AttributeError, IOError, io.UnsupportedOperation):
# Handle cases where stdin is redirected or not a real TTY (like in tests)
return default_color
# Save terminal settings so we can restore them
old_settings = termios.tcgetattr(sys.stdin)
old_blocking = os.get_blocking(sys.stdin.fileno())
def restore_terminal():
try:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
os.set_blocking(sys.stdin.fileno(), old_blocking)
except Exception:
pass
atexit.register(restore_terminal)
signal.signal(
signal.SIGTERM, lambda signum, frame: (restore_terminal(), sys.exit(0))
)
try:
# Set terminal to raw mode
tty.setraw(sys.stdin)
# Send OSC escape sequence to query color
sys.stdout.write(f"\033]{osc_code};?\033\\")
sys.stdout.flush()
# Wait for response with timeout
if select.select([sys.stdin], [], [], 1.0)[0]:
os.set_blocking(sys.stdin.fileno(), False)
# Read response
response = ""
while True:
try:
char = sys.stdin.read(1)
except io.BlockingIOError:
char = ""
except TypeError:
char = ""
if char is None or char == "": # No more response data available
if select.select([sys.stdin], [], [], 1.0)[0]:
continue
else:
break
response += char
if char == "\\": # End of OSC response
break
if len(response) > 50: # Safety limit
break
# Parse the response (format: \033]10;rgb:RRRR/GGGG/BBBB\033\\)
match = re.search(
r"rgb:([0-9a-f]+)/([0-9a-f]+)/([0-9a-f]+)", response, re.IGNORECASE
)
if match:
r, g, b = match.groups()
# Convert to standard hex format
r = int(r[0:2], 16)
g = int(g[0:2], 16)
b = int(b[0:2], 16)
return f"#{r:02x}{g:02x}{b:02x}"
return default_color
else:
return default_color
finally:
# Restore terminal settings
restore_terminal()
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())

View file

@ -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