467 lines
14 KiB
Python
467 lines
14 KiB
Python
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,
|
|
)
|