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