box.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import sys
  2. from typing import TYPE_CHECKING, Iterable, List
  3. if sys.version_info >= (3, 8):
  4. from typing import Literal
  5. else:
  6. from typing_extensions import Literal # pragma: no cover
  7. from ._loop import loop_last
  8. if TYPE_CHECKING:
  9. from rich.console import ConsoleOptions
  10. class Box:
  11. """Defines characters to render boxes.
  12. ┌─┬┐ top
  13. │ ││ head
  14. ├─┼┤ head_row
  15. │ ││ mid
  16. ├─┼┤ row
  17. ├─┼┤ foot_row
  18. │ ││ foot
  19. └─┴┘ bottom
  20. Args:
  21. box (str): Characters making up box.
  22. ascii (bool, optional): True if this box uses ascii characters only. Default is False.
  23. """
  24. def __init__(self, box: str, *, ascii: bool = False) -> None:
  25. self._box = box
  26. self.ascii = ascii
  27. line1, line2, line3, line4, line5, line6, line7, line8 = box.splitlines()
  28. # top
  29. self.top_left, self.top, self.top_divider, self.top_right = iter(line1)
  30. # head
  31. self.head_left, _, self.head_vertical, self.head_right = iter(line2)
  32. # head_row
  33. (
  34. self.head_row_left,
  35. self.head_row_horizontal,
  36. self.head_row_cross,
  37. self.head_row_right,
  38. ) = iter(line3)
  39. # mid
  40. self.mid_left, _, self.mid_vertical, self.mid_right = iter(line4)
  41. # row
  42. self.row_left, self.row_horizontal, self.row_cross, self.row_right = iter(line5)
  43. # foot_row
  44. (
  45. self.foot_row_left,
  46. self.foot_row_horizontal,
  47. self.foot_row_cross,
  48. self.foot_row_right,
  49. ) = iter(line6)
  50. # foot
  51. self.foot_left, _, self.foot_vertical, self.foot_right = iter(line7)
  52. # bottom
  53. self.bottom_left, self.bottom, self.bottom_divider, self.bottom_right = iter(
  54. line8
  55. )
  56. def __repr__(self) -> str:
  57. return "Box(...)"
  58. def __str__(self) -> str:
  59. return self._box
  60. def substitute(self, options: "ConsoleOptions", safe: bool = True) -> "Box":
  61. """Substitute this box for another if it won't render due to platform issues.
  62. Args:
  63. options (ConsoleOptions): Console options used in rendering.
  64. safe (bool, optional): Substitute this for another Box if there are known problems
  65. displaying on the platform (currently only relevant on Windows). Default is True.
  66. Returns:
  67. Box: A different Box or the same Box.
  68. """
  69. box = self
  70. if options.legacy_windows and safe:
  71. box = LEGACY_WINDOWS_SUBSTITUTIONS.get(box, box)
  72. if options.ascii_only and not box.ascii:
  73. box = ASCII
  74. return box
  75. def get_plain_headed_box(self) -> "Box":
  76. """If this box uses special characters for the borders of the header, then
  77. return the equivalent box that does not.
  78. Returns:
  79. Box: The most similar Box that doesn't use header-specific box characters.
  80. If the current Box already satisfies this criterion, then it's returned.
  81. """
  82. return PLAIN_HEADED_SUBSTITUTIONS.get(self, self)
  83. def get_top(self, widths: Iterable[int]) -> str:
  84. """Get the top of a simple box.
  85. Args:
  86. widths (List[int]): Widths of columns.
  87. Returns:
  88. str: A string of box characters.
  89. """
  90. parts: List[str] = []
  91. append = parts.append
  92. append(self.top_left)
  93. for last, width in loop_last(widths):
  94. append(self.top * width)
  95. if not last:
  96. append(self.top_divider)
  97. append(self.top_right)
  98. return "".join(parts)
  99. def get_row(
  100. self,
  101. widths: Iterable[int],
  102. level: Literal["head", "row", "foot", "mid"] = "row",
  103. edge: bool = True,
  104. ) -> str:
  105. """Get the top of a simple box.
  106. Args:
  107. width (List[int]): Widths of columns.
  108. Returns:
  109. str: A string of box characters.
  110. """
  111. if level == "head":
  112. left = self.head_row_left
  113. horizontal = self.head_row_horizontal
  114. cross = self.head_row_cross
  115. right = self.head_row_right
  116. elif level == "row":
  117. left = self.row_left
  118. horizontal = self.row_horizontal
  119. cross = self.row_cross
  120. right = self.row_right
  121. elif level == "mid":
  122. left = self.mid_left
  123. horizontal = " "
  124. cross = self.mid_vertical
  125. right = self.mid_right
  126. elif level == "foot":
  127. left = self.foot_row_left
  128. horizontal = self.foot_row_horizontal
  129. cross = self.foot_row_cross
  130. right = self.foot_row_right
  131. else:
  132. raise ValueError("level must be 'head', 'row' or 'foot'")
  133. parts: List[str] = []
  134. append = parts.append
  135. if edge:
  136. append(left)
  137. for last, width in loop_last(widths):
  138. append(horizontal * width)
  139. if not last:
  140. append(cross)
  141. if edge:
  142. append(right)
  143. return "".join(parts)
  144. def get_bottom(self, widths: Iterable[int]) -> str:
  145. """Get the bottom of a simple box.
  146. Args:
  147. widths (List[int]): Widths of columns.
  148. Returns:
  149. str: A string of box characters.
  150. """
  151. parts: List[str] = []
  152. append = parts.append
  153. append(self.bottom_left)
  154. for last, width in loop_last(widths):
  155. append(self.bottom * width)
  156. if not last:
  157. append(self.bottom_divider)
  158. append(self.bottom_right)
  159. return "".join(parts)
  160. # fmt: off
  161. ASCII: Box = Box(
  162. "+--+\n"
  163. "| ||\n"
  164. "|-+|\n"
  165. "| ||\n"
  166. "|-+|\n"
  167. "|-+|\n"
  168. "| ||\n"
  169. "+--+\n",
  170. ascii=True,
  171. )
  172. ASCII2: Box = Box(
  173. "+-++\n"
  174. "| ||\n"
  175. "+-++\n"
  176. "| ||\n"
  177. "+-++\n"
  178. "+-++\n"
  179. "| ||\n"
  180. "+-++\n",
  181. ascii=True,
  182. )
  183. ASCII_DOUBLE_HEAD: Box = Box(
  184. "+-++\n"
  185. "| ||\n"
  186. "+=++\n"
  187. "| ||\n"
  188. "+-++\n"
  189. "+-++\n"
  190. "| ||\n"
  191. "+-++\n",
  192. ascii=True,
  193. )
  194. SQUARE: Box = Box(
  195. "┌─┬┐\n"
  196. "│ ││\n"
  197. "├─┼┤\n"
  198. "│ ││\n"
  199. "├─┼┤\n"
  200. "├─┼┤\n"
  201. "│ ││\n"
  202. "└─┴┘\n"
  203. )
  204. SQUARE_DOUBLE_HEAD: Box = Box(
  205. "┌─┬┐\n"
  206. "│ ││\n"
  207. "╞═╪╡\n"
  208. "│ ││\n"
  209. "├─┼┤\n"
  210. "├─┼┤\n"
  211. "│ ││\n"
  212. "└─┴┘\n"
  213. )
  214. MINIMAL: Box = Box(
  215. " ╷ \n"
  216. " │ \n"
  217. "╶─┼╴\n"
  218. " │ \n"
  219. "╶─┼╴\n"
  220. "╶─┼╴\n"
  221. " │ \n"
  222. " ╵ \n"
  223. )
  224. MINIMAL_HEAVY_HEAD: Box = Box(
  225. " ╷ \n"
  226. " │ \n"
  227. "╺━┿╸\n"
  228. " │ \n"
  229. "╶─┼╴\n"
  230. "╶─┼╴\n"
  231. " │ \n"
  232. " ╵ \n"
  233. )
  234. MINIMAL_DOUBLE_HEAD: Box = Box(
  235. " ╷ \n"
  236. " │ \n"
  237. " ═╪ \n"
  238. " │ \n"
  239. " ─┼ \n"
  240. " ─┼ \n"
  241. " │ \n"
  242. " ╵ \n"
  243. )
  244. SIMPLE: Box = Box(
  245. " \n"
  246. " \n"
  247. " ── \n"
  248. " \n"
  249. " \n"
  250. " ── \n"
  251. " \n"
  252. " \n"
  253. )
  254. SIMPLE_HEAD: Box = Box(
  255. " \n"
  256. " \n"
  257. " ── \n"
  258. " \n"
  259. " \n"
  260. " \n"
  261. " \n"
  262. " \n"
  263. )
  264. SIMPLE_HEAVY: Box = Box(
  265. " \n"
  266. " \n"
  267. " ━━ \n"
  268. " \n"
  269. " \n"
  270. " ━━ \n"
  271. " \n"
  272. " \n"
  273. )
  274. HORIZONTALS: Box = Box(
  275. " ── \n"
  276. " \n"
  277. " ── \n"
  278. " \n"
  279. " ── \n"
  280. " ── \n"
  281. " \n"
  282. " ── \n"
  283. )
  284. ROUNDED: Box = Box(
  285. "╭─┬╮\n"
  286. "│ ││\n"
  287. "├─┼┤\n"
  288. "│ ││\n"
  289. "├─┼┤\n"
  290. "├─┼┤\n"
  291. "│ ││\n"
  292. "╰─┴╯\n"
  293. )
  294. HEAVY: Box = Box(
  295. "┏━┳┓\n"
  296. "┃ ┃┃\n"
  297. "┣━╋┫\n"
  298. "┃ ┃┃\n"
  299. "┣━╋┫\n"
  300. "┣━╋┫\n"
  301. "┃ ┃┃\n"
  302. "┗━┻┛\n"
  303. )
  304. HEAVY_EDGE: Box = Box(
  305. "┏━┯┓\n"
  306. "┃ │┃\n"
  307. "┠─┼┨\n"
  308. "┃ │┃\n"
  309. "┠─┼┨\n"
  310. "┠─┼┨\n"
  311. "┃ │┃\n"
  312. "┗━┷┛\n"
  313. )
  314. HEAVY_HEAD: Box = Box(
  315. "┏━┳┓\n"
  316. "┃ ┃┃\n"
  317. "┡━╇┩\n"
  318. "│ ││\n"
  319. "├─┼┤\n"
  320. "├─┼┤\n"
  321. "│ ││\n"
  322. "└─┴┘\n"
  323. )
  324. DOUBLE: Box = Box(
  325. "╔═╦╗\n"
  326. "║ ║║\n"
  327. "╠═╬╣\n"
  328. "║ ║║\n"
  329. "╠═╬╣\n"
  330. "╠═╬╣\n"
  331. "║ ║║\n"
  332. "╚═╩╝\n"
  333. )
  334. DOUBLE_EDGE: Box = Box(
  335. "╔═╤╗\n"
  336. "║ │║\n"
  337. "╟─┼╢\n"
  338. "║ │║\n"
  339. "╟─┼╢\n"
  340. "╟─┼╢\n"
  341. "║ │║\n"
  342. "╚═╧╝\n"
  343. )
  344. MARKDOWN: Box = Box(
  345. " \n"
  346. "| ||\n"
  347. "|-||\n"
  348. "| ||\n"
  349. "|-||\n"
  350. "|-||\n"
  351. "| ||\n"
  352. " \n",
  353. ascii=True,
  354. )
  355. # fmt: on
  356. # Map Boxes that don't render with raster fonts on to equivalent that do
  357. LEGACY_WINDOWS_SUBSTITUTIONS = {
  358. ROUNDED: SQUARE,
  359. MINIMAL_HEAVY_HEAD: MINIMAL,
  360. SIMPLE_HEAVY: SIMPLE,
  361. HEAVY: SQUARE,
  362. HEAVY_EDGE: SQUARE,
  363. HEAVY_HEAD: SQUARE,
  364. }
  365. # Map headed boxes to their headerless equivalents
  366. PLAIN_HEADED_SUBSTITUTIONS = {
  367. HEAVY_HEAD: SQUARE,
  368. SQUARE_DOUBLE_HEAD: SQUARE,
  369. MINIMAL_DOUBLE_HEAD: MINIMAL,
  370. MINIMAL_HEAVY_HEAD: MINIMAL,
  371. ASCII_DOUBLE_HEAD: ASCII2,
  372. }
  373. if __name__ == "__main__": # pragma: no cover
  374. from rich.columns import Columns
  375. from rich.panel import Panel
  376. from . import box as box
  377. from .console import Console
  378. from .table import Table
  379. from .text import Text
  380. console = Console(record=True)
  381. BOXES = [
  382. "ASCII",
  383. "ASCII2",
  384. "ASCII_DOUBLE_HEAD",
  385. "SQUARE",
  386. "SQUARE_DOUBLE_HEAD",
  387. "MINIMAL",
  388. "MINIMAL_HEAVY_HEAD",
  389. "MINIMAL_DOUBLE_HEAD",
  390. "SIMPLE",
  391. "SIMPLE_HEAD",
  392. "SIMPLE_HEAVY",
  393. "HORIZONTALS",
  394. "ROUNDED",
  395. "HEAVY",
  396. "HEAVY_EDGE",
  397. "HEAVY_HEAD",
  398. "DOUBLE",
  399. "DOUBLE_EDGE",
  400. "MARKDOWN",
  401. ]
  402. console.print(Panel("[bold green]Box Constants", style="green"), justify="center")
  403. console.print()
  404. columns = Columns(expand=True, padding=2)
  405. for box_name in sorted(BOXES):
  406. table = Table(
  407. show_footer=True, style="dim", border_style="not dim", expand=True
  408. )
  409. table.add_column("Header 1", "Footer 1")
  410. table.add_column("Header 2", "Footer 2")
  411. table.add_row("Cell", "Cell")
  412. table.add_row("Cell", "Cell")
  413. table.box = getattr(box, box_name)
  414. table.title = Text(f"box.{box_name}", style="magenta")
  415. columns.add_renderable(table)
  416. console.print(columns)
  417. # console.save_svg("box.svg")