ansi.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. from __future__ import annotations
  2. from string import Formatter
  3. from typing import Generator
  4. from prompt_toolkit.output.vt100 import BG_ANSI_COLORS, FG_ANSI_COLORS
  5. from prompt_toolkit.output.vt100 import _256_colors as _256_colors_table
  6. from .base import StyleAndTextTuples
  7. __all__ = [
  8. "ANSI",
  9. "ansi_escape",
  10. ]
  11. class ANSI:
  12. """
  13. ANSI formatted text.
  14. Take something ANSI escaped text, for use as a formatted string. E.g.
  15. ::
  16. ANSI('\\x1b[31mhello \\x1b[32mworld')
  17. Characters between ``\\001`` and ``\\002`` are supposed to have a zero width
  18. when printed, but these are literally sent to the terminal output. This can
  19. be used for instance, for inserting Final Term prompt commands. They will
  20. be translated into a prompt_toolkit '[ZeroWidthEscape]' fragment.
  21. """
  22. def __init__(self, value: str) -> None:
  23. self.value = value
  24. self._formatted_text: StyleAndTextTuples = []
  25. # Default style attributes.
  26. self._color: str | None = None
  27. self._bgcolor: str | None = None
  28. self._bold = False
  29. self._dim = False
  30. self._underline = False
  31. self._strike = False
  32. self._italic = False
  33. self._blink = False
  34. self._reverse = False
  35. self._hidden = False
  36. # Process received text.
  37. parser = self._parse_corot()
  38. parser.send(None) # type: ignore
  39. for c in value:
  40. parser.send(c)
  41. def _parse_corot(self) -> Generator[None, str, None]:
  42. """
  43. Coroutine that parses the ANSI escape sequences.
  44. """
  45. style = ""
  46. formatted_text = self._formatted_text
  47. while True:
  48. # NOTE: CSI is a special token within a stream of characters that
  49. # introduces an ANSI control sequence used to set the
  50. # style attributes of the following characters.
  51. csi = False
  52. c = yield
  53. # Everything between \001 and \002 should become a ZeroWidthEscape.
  54. if c == "\001":
  55. escaped_text = ""
  56. while c != "\002":
  57. c = yield
  58. if c == "\002":
  59. formatted_text.append(("[ZeroWidthEscape]", escaped_text))
  60. c = yield
  61. break
  62. else:
  63. escaped_text += c
  64. # Check for CSI
  65. if c == "\x1b":
  66. # Start of color escape sequence.
  67. square_bracket = yield
  68. if square_bracket == "[":
  69. csi = True
  70. else:
  71. continue
  72. elif c == "\x9b":
  73. csi = True
  74. if csi:
  75. # Got a CSI sequence. Color codes are following.
  76. current = ""
  77. params = []
  78. while True:
  79. char = yield
  80. # Construct number
  81. if char.isdigit():
  82. current += char
  83. # Eval number
  84. else:
  85. # Limit and save number value
  86. params.append(min(int(current or 0), 9999))
  87. # Get delimiter token if present
  88. if char == ";":
  89. current = ""
  90. # Check and evaluate color codes
  91. elif char == "m":
  92. # Set attributes and token.
  93. self._select_graphic_rendition(params)
  94. style = self._create_style_string()
  95. break
  96. # Check and evaluate cursor forward
  97. elif char == "C":
  98. for i in range(params[0]):
  99. # add <SPACE> using current style
  100. formatted_text.append((style, " "))
  101. break
  102. else:
  103. # Ignore unsupported sequence.
  104. break
  105. else:
  106. # Add current character.
  107. # NOTE: At this point, we could merge the current character
  108. # into the previous tuple if the style did not change,
  109. # however, it's not worth the effort given that it will
  110. # be "Exploded" once again when it's rendered to the
  111. # output.
  112. formatted_text.append((style, c))
  113. def _select_graphic_rendition(self, attrs: list[int]) -> None:
  114. """
  115. Taken a list of graphics attributes and apply changes.
  116. """
  117. if not attrs:
  118. attrs = [0]
  119. else:
  120. attrs = list(attrs[::-1])
  121. while attrs:
  122. attr = attrs.pop()
  123. if attr in _fg_colors:
  124. self._color = _fg_colors[attr]
  125. elif attr in _bg_colors:
  126. self._bgcolor = _bg_colors[attr]
  127. elif attr == 1:
  128. self._bold = True
  129. elif attr == 2:
  130. self._dim = True
  131. elif attr == 3:
  132. self._italic = True
  133. elif attr == 4:
  134. self._underline = True
  135. elif attr == 5:
  136. self._blink = True # Slow blink
  137. elif attr == 6:
  138. self._blink = True # Fast blink
  139. elif attr == 7:
  140. self._reverse = True
  141. elif attr == 8:
  142. self._hidden = True
  143. elif attr == 9:
  144. self._strike = True
  145. elif attr == 22:
  146. self._bold = False # Normal intensity
  147. self._dim = False
  148. elif attr == 23:
  149. self._italic = False
  150. elif attr == 24:
  151. self._underline = False
  152. elif attr == 25:
  153. self._blink = False
  154. elif attr == 27:
  155. self._reverse = False
  156. elif attr == 28:
  157. self._hidden = False
  158. elif attr == 29:
  159. self._strike = False
  160. elif not attr:
  161. # Reset all style attributes
  162. self._color = None
  163. self._bgcolor = None
  164. self._bold = False
  165. self._dim = False
  166. self._underline = False
  167. self._strike = False
  168. self._italic = False
  169. self._blink = False
  170. self._reverse = False
  171. self._hidden = False
  172. elif attr in (38, 48) and len(attrs) > 1:
  173. n = attrs.pop()
  174. # 256 colors.
  175. if n == 5 and len(attrs) >= 1:
  176. if attr == 38:
  177. m = attrs.pop()
  178. self._color = _256_colors.get(m)
  179. elif attr == 48:
  180. m = attrs.pop()
  181. self._bgcolor = _256_colors.get(m)
  182. # True colors.
  183. if n == 2 and len(attrs) >= 3:
  184. try:
  185. color_str = (
  186. f"#{attrs.pop():02x}{attrs.pop():02x}{attrs.pop():02x}"
  187. )
  188. except IndexError:
  189. pass
  190. else:
  191. if attr == 38:
  192. self._color = color_str
  193. elif attr == 48:
  194. self._bgcolor = color_str
  195. def _create_style_string(self) -> str:
  196. """
  197. Turn current style flags into a string for usage in a formatted text.
  198. """
  199. result = []
  200. if self._color:
  201. result.append(self._color)
  202. if self._bgcolor:
  203. result.append("bg:" + self._bgcolor)
  204. if self._bold:
  205. result.append("bold")
  206. if self._dim:
  207. result.append("dim")
  208. if self._underline:
  209. result.append("underline")
  210. if self._strike:
  211. result.append("strike")
  212. if self._italic:
  213. result.append("italic")
  214. if self._blink:
  215. result.append("blink")
  216. if self._reverse:
  217. result.append("reverse")
  218. if self._hidden:
  219. result.append("hidden")
  220. return " ".join(result)
  221. def __repr__(self) -> str:
  222. return f"ANSI({self.value!r})"
  223. def __pt_formatted_text__(self) -> StyleAndTextTuples:
  224. return self._formatted_text
  225. def format(self, *args: str, **kwargs: str) -> ANSI:
  226. """
  227. Like `str.format`, but make sure that the arguments are properly
  228. escaped. (No ANSI escapes can be injected.)
  229. """
  230. return ANSI(FORMATTER.vformat(self.value, args, kwargs))
  231. def __mod__(self, value: object) -> ANSI:
  232. """
  233. ANSI('<b>%s</b>') % value
  234. """
  235. if not isinstance(value, tuple):
  236. value = (value,)
  237. value = tuple(ansi_escape(i) for i in value)
  238. return ANSI(self.value % value)
  239. # Mapping of the ANSI color codes to their names.
  240. _fg_colors = {v: k for k, v in FG_ANSI_COLORS.items()}
  241. _bg_colors = {v: k for k, v in BG_ANSI_COLORS.items()}
  242. # Mapping of the escape codes for 256colors to their 'ffffff' value.
  243. _256_colors = {}
  244. for i, (r, g, b) in enumerate(_256_colors_table.colors):
  245. _256_colors[i] = f"#{r:02x}{g:02x}{b:02x}"
  246. def ansi_escape(text: object) -> str:
  247. """
  248. Replace characters with a special meaning.
  249. """
  250. return str(text).replace("\x1b", "?").replace("\b", "?")
  251. class ANSIFormatter(Formatter):
  252. def format_field(self, value: object, format_spec: str) -> str:
  253. return ansi_escape(format(value, format_spec))
  254. FORMATTER = ANSIFormatter()