Changed code to support older Python versions
This commit is contained in:
parent
eb92d2d36f
commit
582458cdd0
5027 changed files with 794942 additions and 4 deletions
|
|
@ -0,0 +1,4 @@
|
|||
from .input import Validator
|
||||
from .toolkit import RichToolkit, RichToolkitTheme
|
||||
|
||||
__all__ = ["RichToolkit", "RichToolkitTheme", "Validator"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
157
venv/lib/python3.11/site-packages/rich_toolkit/_getchar.py
Normal file
157
venv/lib/python3.11/site-packages/rich_toolkit/_getchar.py
Normal 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)
|
||||
132
venv/lib/python3.11/site-packages/rich_toolkit/_input_handler.py
Normal file
132
venv/lib/python3.11/site-packages/rich_toolkit/_input_handler.py
Normal 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)
|
||||
|
|
@ -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
|
||||
29
venv/lib/python3.11/site-packages/rich_toolkit/button.py
Normal file
29
venv/lib/python3.11/site-packages/rich_toolkit/button.py
Normal 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
|
||||
202
venv/lib/python3.11/site-packages/rich_toolkit/container.py
Normal file
202
venv/lib/python3.11/site-packages/rich_toolkit/container.py
Normal 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)
|
||||
43
venv/lib/python3.11/site-packages/rich_toolkit/element.py
Normal file
43
venv/lib/python3.11/site-packages/rich_toolkit/element.py
Normal 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
|
||||
78
venv/lib/python3.11/site-packages/rich_toolkit/form.py
Normal file
78
venv/lib/python3.11/site-packages/rich_toolkit/form.py
Normal 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)
|
||||
}
|
||||
156
venv/lib/python3.11/site-packages/rich_toolkit/input.py
Normal file
156
venv/lib/python3.11/site-packages/rich_toolkit/input.py
Normal 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
|
||||
308
venv/lib/python3.11/site-packages/rich_toolkit/menu.py
Normal file
308
venv/lib/python3.11/site-packages/rich_toolkit/menu.py
Normal 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
|
||||
68
venv/lib/python3.11/site-packages/rich_toolkit/progress.py
Normal file
68
venv/lib/python3.11/site-packages/rich_toolkit/progress.py
Normal 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
|
||||
0
venv/lib/python3.11/site-packages/rich_toolkit/py.typed
Normal file
0
venv/lib/python3.11/site-packages/rich_toolkit/py.typed
Normal file
7
venv/lib/python3.11/site-packages/rich_toolkit/spacer.py
Normal file
7
venv/lib/python3.11/site-packages/rich_toolkit/spacer.py
Normal file
|
|
@ -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"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
467
venv/lib/python3.11/site-packages/rich_toolkit/styles/base.py
Normal file
467
venv/lib/python3.11/site-packages/rich_toolkit/styles/base.py
Normal 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,
|
||||
)
|
||||
261
venv/lib/python3.11/site-packages/rich_toolkit/styles/border.py
Normal file
261
venv/lib/python3.11/site-packages/rich_toolkit/styles/border.py
Normal 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)
|
||||
170
venv/lib/python3.11/site-packages/rich_toolkit/styles/fancy.py
Normal file
170
venv/lib/python3.11/site-packages/rich_toolkit/styles/fancy.py
Normal 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,
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from .base import BaseStyle
|
||||
|
||||
|
||||
class MinimalStyle(BaseStyle):
|
||||
pass
|
||||
145
venv/lib/python3.11/site-packages/rich_toolkit/styles/tagged.py
Normal file
145
venv/lib/python3.11/site-packages/rich_toolkit/styles/tagged.py
Normal 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,
|
||||
)
|
||||
148
venv/lib/python3.11/site-packages/rich_toolkit/toolkit.py
Normal file
148
venv/lib/python3.11/site-packages/rich_toolkit/toolkit.py
Normal 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,
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
228
venv/lib/python3.11/site-packages/rich_toolkit/utils/colors.py
Normal file
228
venv/lib/python3.11/site-packages/rich_toolkit/utils/colors.py
Normal 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())
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue