base.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. from __future__ import annotations
  2. from typing import Any, Dict, Optional, Type, TypeVar, Union
  3. from rich.color import Color
  4. from rich.console import Console, ConsoleRenderable, Group, RenderableType
  5. from rich.text import Text
  6. from rich.theme import Theme
  7. from typing_extensions import Literal
  8. from rich_toolkit.button import Button
  9. from rich_toolkit.container import Container
  10. from rich_toolkit.element import CursorOffset, Element
  11. from rich_toolkit.input import Input
  12. from rich_toolkit.menu import Menu
  13. from rich_toolkit.progress import Progress, ProgressLine
  14. from rich_toolkit.spacer import Spacer
  15. from rich_toolkit.utils.colors import (
  16. fade_text,
  17. get_terminal_background_color,
  18. get_terminal_text_color,
  19. lighten,
  20. )
  21. ConsoleRenderableClass = TypeVar(
  22. "ConsoleRenderableClass", bound=Type[ConsoleRenderable]
  23. )
  24. class BaseStyle:
  25. brightness_multiplier = 0.1
  26. base_theme = {
  27. "tag.title": "bold",
  28. "tag": "bold",
  29. "text": "#ffffff",
  30. "selected": "green",
  31. "result": "white",
  32. "progress": "on #893AE3",
  33. "error": "red",
  34. "cancelled": "red",
  35. # is there a way to make nested styles?
  36. # like label.active uses active style if not set?
  37. "active": "green",
  38. "title.error": "white",
  39. "title.cancelled": "white",
  40. "placeholder": "grey62",
  41. "placeholder.cancelled": "grey62 strike",
  42. }
  43. _should_show_progress_title = True
  44. def __init__(
  45. self,
  46. theme: Optional[Dict[str, str]] = None,
  47. background_color: str = "#000000",
  48. text_color: str = "#FFFFFF",
  49. ):
  50. self.background_color = get_terminal_background_color(background_color)
  51. self.text_color = get_terminal_text_color(text_color)
  52. self.animation_counter = 0
  53. base_theme = Theme(self.base_theme)
  54. self.console = Console(theme=base_theme)
  55. if theme:
  56. self.console.push_theme(Theme(theme))
  57. def empty_line(self) -> RenderableType:
  58. return " "
  59. def _get_animation_colors(
  60. self,
  61. steps: int = 5,
  62. breathe: bool = False,
  63. animation_status: Literal["started", "stopped", "error"] = "started",
  64. **metadata: Any,
  65. ) -> list[Color]:
  66. animated = animation_status == "started"
  67. if animation_status == "error":
  68. base_color = self.console.get_style("error").color
  69. if base_color is None:
  70. base_color = Color.parse("red")
  71. else:
  72. base_color = self.console.get_style("progress").bgcolor
  73. if not base_color:
  74. base_color = Color.from_rgb(255, 255, 255)
  75. if breathe:
  76. steps = steps // 2
  77. if animated and base_color.triplet is not None:
  78. colors = [
  79. lighten(base_color, self.brightness_multiplier * i)
  80. for i in range(0, steps)
  81. ]
  82. else:
  83. colors = [base_color] * steps
  84. if breathe:
  85. colors = colors + colors[::-1]
  86. return colors
  87. def get_cursor_offset_for_element(
  88. self, element: Element, parent: Optional[Element] = None
  89. ) -> CursorOffset:
  90. return element.cursor_offset
  91. def render_element(
  92. self,
  93. element: Any,
  94. is_active: bool = False,
  95. done: bool = False,
  96. parent: Optional[Element] = None,
  97. **kwargs: Any,
  98. ) -> RenderableType:
  99. if isinstance(element, str):
  100. return self.render_string(element, is_active, done, parent)
  101. elif isinstance(element, Button):
  102. return self.render_button(element, is_active, done, parent)
  103. elif isinstance(element, Container):
  104. return self.render_container(element, is_active, done, parent)
  105. elif isinstance(element, Input):
  106. return self.render_input(element, is_active, done, parent)
  107. elif isinstance(element, Menu):
  108. return self.render_menu(element, is_active, done, parent)
  109. elif isinstance(element, Progress):
  110. self.animation_counter += 1
  111. return self.render_progress(element, is_active, done, parent)
  112. elif isinstance(element, ProgressLine):
  113. return self.render_progress_log_line(
  114. element.text,
  115. parent=parent,
  116. index=kwargs.get("index", 0),
  117. max_lines=kwargs.get("max_lines", -1),
  118. total_lines=kwargs.get("total_lines", -1),
  119. )
  120. elif isinstance(element, Spacer):
  121. return self.render_spacer()
  122. elif isinstance(element, ConsoleRenderable):
  123. return element
  124. raise ValueError(f"Unknown element type: {type(element)}")
  125. def render_string(
  126. self,
  127. string: str,
  128. is_active: bool = False,
  129. done: bool = False,
  130. parent: Optional[Element] = None,
  131. ) -> RenderableType:
  132. return string
  133. def render_button(
  134. self,
  135. element: Button,
  136. is_active: bool = False,
  137. done: bool = False,
  138. parent: Optional[Element] = None,
  139. ) -> RenderableType:
  140. style = "black on blue" if is_active else "white on black"
  141. return Text(f" {element.label} ", style=style)
  142. def render_spacer(self) -> RenderableType:
  143. return ""
  144. def render_container(
  145. self,
  146. container: Container,
  147. is_active: bool = False,
  148. done: bool = False,
  149. parent: Optional[Element] = None,
  150. ) -> RenderableType:
  151. content = []
  152. for i, element in enumerate(container.elements):
  153. content.append(
  154. self.render_element(
  155. element,
  156. is_active=i == container.active_element_index,
  157. done=done,
  158. parent=container,
  159. )
  160. )
  161. return Group(*content, "\n" if not done else "")
  162. def render_input(
  163. self,
  164. element: Input,
  165. is_active: bool = False,
  166. done: bool = False,
  167. parent: Optional[Element] = None,
  168. ) -> RenderableType:
  169. label = self.render_input_label(element, is_active=is_active, parent=parent)
  170. text = self.render_input_value(
  171. element, is_active=is_active, parent=parent, done=done
  172. )
  173. contents = []
  174. if element.inline or done:
  175. if done and element.password:
  176. text = "*" * len(element.text)
  177. if label:
  178. text = f"{label} {text}"
  179. contents.append(text)
  180. else:
  181. if label:
  182. contents.append(label)
  183. contents.append(text)
  184. if validation_message := self.render_validation_message(element):
  185. contents.append(validation_message)
  186. # TODO: do we need this?
  187. element._height = len(contents)
  188. return Group(*contents)
  189. def render_validation_message(self, element: Union[Input, Menu]) -> Optional[str]:
  190. if element._cancelled:
  191. return "[cancelled]Cancelled.[/]"
  192. if element.valid is False:
  193. return f"[error]{element.validation_message}[/]"
  194. return None
  195. # TODO: maybe don't reuse this for menus
  196. def render_input_value(
  197. self,
  198. input: Union[Menu, Input],
  199. is_active: bool = False,
  200. parent: Optional[Element] = None,
  201. done: bool = False,
  202. ) -> RenderableType:
  203. text = input.text
  204. # Check if this is a password field and mask it
  205. if isinstance(input, Input) and input.password and text:
  206. text = "*" * len(text)
  207. if not text:
  208. placeholder = ""
  209. if isinstance(input, Input):
  210. placeholder = input.placeholder
  211. if input.default_as_placeholder and input.default:
  212. return f"[placeholder]{input.default}[/]"
  213. if input._cancelled:
  214. return f"[placeholder.cancelled]{placeholder}[/]"
  215. elif not done:
  216. return f"[placeholder]{placeholder}[/]"
  217. return f"[text]{text}[/]"
  218. def render_input_label(
  219. self,
  220. input: Union[Input, Menu],
  221. is_active: bool = False,
  222. parent: Optional[Element] = None,
  223. ) -> Union[str, Text, None]:
  224. from rich_toolkit.form import Form
  225. label: Union[str, Text, None] = None
  226. if input.label:
  227. label = input.label
  228. if isinstance(parent, Form):
  229. if is_active:
  230. label = f"[active]{label}[/]"
  231. elif input.valid is False:
  232. label = f"[error]{label}[/]"
  233. return label
  234. def render_menu(
  235. self,
  236. element: Menu,
  237. is_active: bool = False,
  238. done: bool = False,
  239. parent: Optional[Element] = None,
  240. ) -> RenderableType:
  241. menu = Text(justify="left")
  242. selected_prefix = Text(element.current_selection_char + " ")
  243. not_selected_prefix = Text(element.selection_char + " ")
  244. separator = Text("\t" if element.inline else "\n")
  245. if done:
  246. result_content = Text()
  247. result_content.append(
  248. self.render_input_label(element, is_active=is_active, parent=parent)
  249. )
  250. result_content.append(" ")
  251. result_content.append(
  252. element.options[element.selected]["name"],
  253. style=self.console.get_style("result"),
  254. )
  255. return result_content
  256. for id_, option in enumerate(element.options):
  257. if id_ == element.selected:
  258. prefix = selected_prefix
  259. style = self.console.get_style("selected")
  260. else:
  261. prefix = not_selected_prefix
  262. style = self.console.get_style("text")
  263. is_last = id_ == len(element.options) - 1
  264. menu.append(
  265. Text.assemble(
  266. prefix,
  267. option["name"],
  268. separator if not is_last else "",
  269. style=style,
  270. )
  271. )
  272. if not element.options:
  273. menu = Text("No results found", style=self.console.get_style("text"))
  274. filter = (
  275. [
  276. Text.assemble(
  277. (element.filter_prompt, self.console.get_style("text")),
  278. (element.text, self.console.get_style("text")),
  279. "\n",
  280. )
  281. ]
  282. if element.allow_filtering
  283. else []
  284. )
  285. content: list[RenderableType] = []
  286. content.append(self.render_input_label(element))
  287. content.extend(filter)
  288. content.append(menu)
  289. if message := self.render_validation_message(element):
  290. content.append(Text(""))
  291. content.append(message)
  292. return Group(*content)
  293. def render_progress(
  294. self,
  295. element: Progress,
  296. is_active: bool = False,
  297. done: bool = False,
  298. parent: Optional[Element] = None,
  299. ) -> RenderableType:
  300. content: str | Group | Text = element.current_message
  301. if element.logs and element._inline_logs:
  302. lines_to_show = (
  303. element.logs[-element.lines_to_show :]
  304. if element.lines_to_show > 0
  305. else element.logs
  306. )
  307. start_content = [element.title, ""]
  308. if not self._should_show_progress_title:
  309. start_content = []
  310. content = Group(
  311. *start_content,
  312. *[
  313. self.render_element(
  314. line,
  315. index=index,
  316. max_lines=element.lines_to_show,
  317. total_lines=len(element.logs),
  318. parent=element,
  319. )
  320. for index, line in enumerate(lines_to_show)
  321. ],
  322. )
  323. return content
  324. def render_progress_log_line(
  325. self,
  326. line: str | Text,
  327. index: int,
  328. max_lines: int = -1,
  329. total_lines: int = -1,
  330. parent: Optional[Element] = None,
  331. ) -> Text:
  332. line = Text.from_markup(line) if isinstance(line, str) else line
  333. if max_lines == -1:
  334. return line
  335. shown_lines = min(total_lines, max_lines)
  336. # this is the minimum brightness based on the max_lines
  337. min_brightness = 0.4
  338. # but we want to have a slightly higher brightness if there's less than max_lines
  339. # otherwise you could get the something like this:
  340. # line 1 -> very dark
  341. # line 2 -> slightly darker
  342. # line 3 -> normal
  343. # which is ok, but not great, so we we increase the brightness if there's less than max_lines
  344. # so that the last line is always the brightest
  345. current_min_brightness = min_brightness + abs(shown_lines - max_lines) * 0.1
  346. current_min_brightness = min(max(current_min_brightness, min_brightness), 1.0)
  347. brightness_multiplier = ((index + 1) / shown_lines) * (
  348. 1.0 - current_min_brightness
  349. ) + current_min_brightness
  350. return fade_text(
  351. line,
  352. text_color=Color.parse(self.text_color),
  353. background_color=self.background_color,
  354. brightness_multiplier=brightness_multiplier,
  355. )