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