mirror of https://github.com/Textualize/rich.git
initial commit
This commit is contained in:
parent
7a5d8184e8
commit
7481db402b
|
@ -1,3 +1,5 @@
|
|||
.DS_Store
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
21
LICENSE
21
LICENSE
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2019 Will McGugan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,7 @@
|
|||
package = []
|
||||
|
||||
[metadata]
|
||||
content-hash = "fafb334cb038533f851c23d0b63254223abf72ce4f02987e7064b0c95566699a"
|
||||
python-versions = "^3.8"
|
||||
|
||||
[metadata.hashes]
|
|
@ -0,0 +1,15 @@
|
|||
[tool.poetry]
|
||||
name = "rich"
|
||||
version = "0.1.0"
|
||||
description = "Rich Console"
|
||||
authors = ["Will McGugan <willmcgugan@gmail.com>"]
|
||||
license = "MIT"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
|
@ -0,0 +1 @@
|
|||
VERSION = "0.0.1a"
|
|
@ -0,0 +1,490 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from functools import lru_cache
|
||||
from math import sqrt
|
||||
from typing import Iterable, List, NamedTuple, Tuple, Optional
|
||||
|
||||
|
||||
STANDARD_COLORS_NAMES = {
|
||||
"black": 0,
|
||||
"red": 1,
|
||||
"green": 2,
|
||||
"yellow": 3,
|
||||
"blue": 4,
|
||||
"magenta": 5,
|
||||
"cyan": 6,
|
||||
"white": 7,
|
||||
}
|
||||
|
||||
STANDARD_PALLETTE = (
|
||||
(0, 0, 0),
|
||||
(255, 0, 0),
|
||||
(0, 255, 0),
|
||||
(255, 255, 0),
|
||||
(0, 0, 255),
|
||||
(255, 0, 255),
|
||||
(0, 255, 255),
|
||||
(255, 255, 255),
|
||||
)
|
||||
|
||||
EIGHT_BIT_COLORS = {
|
||||
0: (0, 0, 0),
|
||||
1: (128, 0, 0),
|
||||
2: (0, 128, 0),
|
||||
3: (128, 128, 0),
|
||||
4: (0, 0, 128),
|
||||
5: (128, 0, 128),
|
||||
6: (0, 128, 128),
|
||||
7: (192, 192, 192),
|
||||
8: (128, 128, 128),
|
||||
9: (255, 0, 0),
|
||||
10: (0, 255, 0),
|
||||
11: (255, 255, 0),
|
||||
12: (0, 0, 255),
|
||||
13: (255, 0, 255),
|
||||
14: (0, 255, 255),
|
||||
15: (255, 255, 255),
|
||||
16: (0, 0, 0),
|
||||
17: (0, 0, 95),
|
||||
18: (0, 0, 135),
|
||||
19: (0, 0, 175),
|
||||
20: (0, 0, 215),
|
||||
21: (0, 0, 255),
|
||||
22: (0, 95, 0),
|
||||
23: (0, 95, 95),
|
||||
24: (0, 95, 135),
|
||||
25: (0, 95, 175),
|
||||
26: (0, 95, 215),
|
||||
27: (0, 95, 255),
|
||||
28: (0, 135, 0),
|
||||
29: (0, 135, 95),
|
||||
30: (0, 135, 135),
|
||||
31: (0, 135, 175),
|
||||
32: (0, 135, 215),
|
||||
33: (0, 135, 255),
|
||||
34: (0, 175, 0),
|
||||
35: (0, 175, 95),
|
||||
36: (0, 175, 135),
|
||||
37: (0, 175, 175),
|
||||
38: (0, 175, 215),
|
||||
39: (0, 175, 255),
|
||||
40: (0, 215, 0),
|
||||
41: (0, 215, 95),
|
||||
42: (0, 215, 135),
|
||||
43: (0, 215, 175),
|
||||
44: (0, 215, 215),
|
||||
45: (0, 215, 255),
|
||||
46: (0, 255, 0),
|
||||
47: (0, 255, 95),
|
||||
48: (0, 255, 135),
|
||||
49: (0, 255, 175),
|
||||
50: (0, 255, 215),
|
||||
51: (0, 255, 255),
|
||||
52: (95, 0, 0),
|
||||
53: (95, 0, 95),
|
||||
54: (95, 0, 135),
|
||||
55: (95, 0, 175),
|
||||
56: (95, 0, 215),
|
||||
57: (95, 0, 255),
|
||||
58: (95, 95, 0),
|
||||
59: (95, 95, 95),
|
||||
60: (95, 95, 135),
|
||||
61: (95, 95, 175),
|
||||
62: (95, 95, 215),
|
||||
63: (95, 95, 255),
|
||||
64: (95, 135, 0),
|
||||
65: (95, 135, 95),
|
||||
66: (95, 135, 135),
|
||||
67: (95, 135, 175),
|
||||
68: (95, 135, 215),
|
||||
69: (95, 135, 255),
|
||||
70: (95, 175, 0),
|
||||
71: (95, 175, 95),
|
||||
72: (95, 175, 135),
|
||||
73: (95, 175, 175),
|
||||
74: (95, 175, 215),
|
||||
75: (95, 175, 255),
|
||||
76: (95, 215, 0),
|
||||
77: (95, 215, 95),
|
||||
78: (95, 215, 135),
|
||||
79: (95, 215, 175),
|
||||
80: (95, 215, 215),
|
||||
81: (95, 215, 255),
|
||||
82: (95, 255, 0),
|
||||
83: (95, 255, 95),
|
||||
84: (95, 255, 135),
|
||||
85: (95, 255, 175),
|
||||
86: (95, 255, 215),
|
||||
87: (95, 255, 255),
|
||||
88: (135, 0, 0),
|
||||
89: (135, 0, 95),
|
||||
90: (135, 0, 135),
|
||||
91: (135, 0, 175),
|
||||
92: (135, 0, 215),
|
||||
93: (135, 0, 255),
|
||||
94: (135, 95, 0),
|
||||
95: (135, 95, 95),
|
||||
96: (135, 95, 135),
|
||||
97: (135, 95, 175),
|
||||
98: (135, 95, 215),
|
||||
99: (135, 95, 255),
|
||||
100: (135, 135, 0),
|
||||
101: (135, 135, 95),
|
||||
102: (135, 135, 135),
|
||||
103: (135, 135, 175),
|
||||
104: (135, 135, 215),
|
||||
105: (135, 135, 255),
|
||||
106: (135, 175, 0),
|
||||
107: (135, 175, 95),
|
||||
108: (135, 175, 135),
|
||||
109: (135, 175, 175),
|
||||
110: (135, 175, 215),
|
||||
111: (135, 175, 255),
|
||||
112: (135, 215, 0),
|
||||
113: (135, 215, 95),
|
||||
114: (135, 215, 135),
|
||||
115: (135, 215, 175),
|
||||
116: (135, 215, 215),
|
||||
117: (135, 215, 255),
|
||||
118: (135, 255, 0),
|
||||
119: (135, 255, 95),
|
||||
120: (135, 255, 135),
|
||||
121: (135, 255, 175),
|
||||
122: (135, 255, 215),
|
||||
123: (135, 255, 255),
|
||||
124: (175, 0, 0),
|
||||
125: (175, 0, 95),
|
||||
126: (175, 0, 135),
|
||||
127: (175, 0, 175),
|
||||
128: (175, 0, 215),
|
||||
129: (175, 0, 255),
|
||||
130: (175, 95, 0),
|
||||
131: (175, 95, 95),
|
||||
132: (175, 95, 135),
|
||||
133: (175, 95, 175),
|
||||
134: (175, 95, 215),
|
||||
135: (175, 95, 255),
|
||||
136: (175, 135, 0),
|
||||
137: (175, 135, 95),
|
||||
138: (175, 135, 135),
|
||||
139: (175, 135, 175),
|
||||
140: (175, 135, 215),
|
||||
141: (175, 135, 255),
|
||||
142: (175, 175, 0),
|
||||
143: (175, 175, 95),
|
||||
144: (175, 175, 135),
|
||||
145: (175, 175, 175),
|
||||
146: (175, 175, 215),
|
||||
147: (175, 175, 255),
|
||||
148: (175, 215, 0),
|
||||
149: (175, 215, 95),
|
||||
150: (175, 215, 135),
|
||||
151: (175, 215, 175),
|
||||
152: (175, 215, 215),
|
||||
153: (175, 215, 255),
|
||||
154: (175, 255, 0),
|
||||
155: (175, 255, 95),
|
||||
156: (175, 255, 135),
|
||||
157: (175, 255, 175),
|
||||
158: (175, 255, 215),
|
||||
159: (175, 255, 255),
|
||||
160: (215, 0, 0),
|
||||
161: (215, 0, 95),
|
||||
162: (215, 0, 135),
|
||||
163: (215, 0, 175),
|
||||
164: (215, 0, 215),
|
||||
165: (215, 0, 255),
|
||||
166: (215, 95, 0),
|
||||
167: (215, 95, 95),
|
||||
168: (215, 95, 135),
|
||||
169: (215, 95, 175),
|
||||
170: (215, 95, 215),
|
||||
171: (215, 95, 255),
|
||||
172: (215, 135, 0),
|
||||
173: (215, 135, 95),
|
||||
174: (215, 135, 135),
|
||||
175: (215, 135, 175),
|
||||
176: (215, 135, 215),
|
||||
177: (215, 135, 255),
|
||||
178: (215, 175, 0),
|
||||
179: (215, 175, 95),
|
||||
180: (215, 175, 135),
|
||||
181: (215, 175, 175),
|
||||
182: (215, 175, 215),
|
||||
183: (215, 175, 255),
|
||||
184: (215, 215, 0),
|
||||
185: (215, 215, 95),
|
||||
186: (215, 215, 135),
|
||||
187: (215, 215, 175),
|
||||
188: (215, 215, 215),
|
||||
189: (215, 215, 255),
|
||||
190: (215, 255, 0),
|
||||
191: (215, 255, 95),
|
||||
192: (215, 255, 135),
|
||||
193: (215, 255, 175),
|
||||
194: (215, 255, 215),
|
||||
195: (215, 255, 255),
|
||||
196: (255, 0, 0),
|
||||
197: (255, 0, 95),
|
||||
198: (255, 0, 135),
|
||||
199: (255, 0, 175),
|
||||
200: (255, 0, 215),
|
||||
201: (255, 0, 255),
|
||||
202: (255, 95, 0),
|
||||
203: (255, 95, 95),
|
||||
204: (255, 95, 135),
|
||||
205: (255, 95, 175),
|
||||
206: (255, 95, 215),
|
||||
207: (255, 95, 255),
|
||||
208: (255, 135, 0),
|
||||
209: (255, 135, 95),
|
||||
210: (255, 135, 135),
|
||||
211: (255, 135, 175),
|
||||
212: (255, 135, 215),
|
||||
213: (255, 135, 255),
|
||||
214: (255, 175, 0),
|
||||
215: (255, 175, 95),
|
||||
216: (255, 175, 135),
|
||||
217: (255, 175, 175),
|
||||
218: (255, 175, 215),
|
||||
219: (255, 175, 255),
|
||||
220: (255, 215, 0),
|
||||
221: (255, 215, 95),
|
||||
222: (255, 215, 135),
|
||||
223: (255, 215, 175),
|
||||
224: (255, 215, 215),
|
||||
225: (255, 215, 255),
|
||||
226: (255, 255, 0),
|
||||
227: (255, 255, 95),
|
||||
228: (255, 255, 135),
|
||||
229: (255, 255, 175),
|
||||
230: (255, 255, 215),
|
||||
231: (255, 255, 255),
|
||||
232: (8, 8, 8),
|
||||
233: (18, 18, 18),
|
||||
234: (28, 28, 28),
|
||||
235: (38, 38, 38),
|
||||
236: (48, 48, 48),
|
||||
237: (58, 58, 58),
|
||||
238: (68, 68, 68),
|
||||
239: (78, 78, 78),
|
||||
240: (88, 88, 88),
|
||||
241: (98, 98, 98),
|
||||
242: (108, 108, 108),
|
||||
243: (118, 118, 118),
|
||||
244: (128, 128, 128),
|
||||
245: (138, 138, 138),
|
||||
246: (148, 148, 148),
|
||||
247: (158, 158, 158),
|
||||
248: (168, 168, 168),
|
||||
249: (178, 178, 178),
|
||||
250: (188, 188, 188),
|
||||
251: (198, 198, 198),
|
||||
252: (208, 208, 208),
|
||||
253: (218, 218, 218),
|
||||
254: (228, 228, 228),
|
||||
255: (238, 238, 238),
|
||||
}
|
||||
|
||||
|
||||
class ColorSystem(IntEnum):
|
||||
"""One of the 3 color system supported by terminals."""
|
||||
|
||||
STANDARD = 1
|
||||
EIGHT_BIT = 2
|
||||
FULL = 3
|
||||
|
||||
|
||||
class ColorType(IntEnum):
|
||||
"""Type of color stored in Color class."""
|
||||
|
||||
DEFAULT = 0
|
||||
STANDARD = 1
|
||||
EIGHT_BIT = 2
|
||||
FULL = 3
|
||||
|
||||
|
||||
class ColorTriplet(NamedTuple):
|
||||
"""The red, green, and blue components of a color."""
|
||||
|
||||
red: int
|
||||
green: int
|
||||
blue: int
|
||||
|
||||
|
||||
class ColorParseError(Exception):
|
||||
"""The color could not be parsed."""
|
||||
|
||||
|
||||
RE_COLOR = re.compile(
|
||||
r"""^
|
||||
\#([0-9a-f]{6})$|
|
||||
([0-9]{1,3})$|
|
||||
rgb\(([\d\s,]+)\)$
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
class Color(NamedTuple):
|
||||
"""Terminal color definition."""
|
||||
|
||||
name: str
|
||||
type: ColorType
|
||||
number: Optional[int] = None
|
||||
triplet: Optional[ColorTriplet] = None
|
||||
|
||||
def __str__(self):
|
||||
"""Render the color to the terminal."""
|
||||
attrs = self.get_ansi_codes(foreground=True)
|
||||
return (
|
||||
f"<color {self.name!r} ({self.type.name.lower()})>"
|
||||
f"\x1b[{';'.join(attrs)}m ⬤ \x1b[0m"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<color {self.name!r} ({self.type.name.lower()})>"
|
||||
|
||||
@property
|
||||
def system(self) -> ColorSystem:
|
||||
if self.type == ColorType.DEFAULT:
|
||||
return ColorSystem.STANDARD
|
||||
return ColorSystem(int(self.type))
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1000)
|
||||
def parse(cls, color: str) -> Optional[Color]:
|
||||
"""Parse a color definition."""
|
||||
color = color.lower().strip()
|
||||
|
||||
if color == "default":
|
||||
return cls(color, type=ColorType.DEFAULT)
|
||||
|
||||
standard_color_number = STANDARD_COLORS_NAMES.get(color)
|
||||
if standard_color_number is not None:
|
||||
return cls(color, type=ColorType.STANDARD, number=standard_color_number)
|
||||
|
||||
color_match = RE_COLOR.match(color)
|
||||
if color_match is None:
|
||||
return None
|
||||
|
||||
color_24, color_8, color_rgb = color_match.groups()
|
||||
if color_8:
|
||||
return cls(color, ColorType.EIGHT_BIT, number=int(color_8))
|
||||
|
||||
elif color_24:
|
||||
triplet = ColorTriplet(
|
||||
int(color_24[0:2], 16), int(color_24[2:4], 16), int(color_24[4:6], 16)
|
||||
)
|
||||
if not all(component <= 255 for component in triplet):
|
||||
raise ColorParseError(f"color components must be <= 255 in {color!r}")
|
||||
return cls(color, ColorType.FULL, triplet=triplet)
|
||||
|
||||
else: # color_rgb:
|
||||
components = color_rgb.split(",")
|
||||
if len(components) != 3:
|
||||
raise ColorParseError(f"expected three components in {color!r}")
|
||||
red, green, blue = components
|
||||
triplet = ColorTriplet(int(red), int(green), int(blue))
|
||||
if not all(component <= 255 for component in triplet):
|
||||
raise ColorParseError(f"color components must be <= 255 in {color!r}")
|
||||
return cls(color, ColorType.FULL, triplet=triplet)
|
||||
|
||||
@classmethod
|
||||
def _match_color(
|
||||
cls, color: ColorTriplet, pallette: Iterable[Tuple[int, int, int]]
|
||||
) -> int:
|
||||
"""Find a color from a pallette that most closely matches a given color"""
|
||||
red1, green1, blue1 = color
|
||||
_sqrt = sqrt
|
||||
|
||||
def get_color_distance(color: Tuple[int, int, int]) -> float:
|
||||
"""Get the distance to a color."""
|
||||
red2, green2, blue2 = color
|
||||
distance = _sqrt(
|
||||
(red2 - red1) * (red2 - red1)
|
||||
+ (green2 - green1) * (green2 - green1)
|
||||
+ (blue2 - blue1) * (blue2 - blue1)
|
||||
)
|
||||
return distance
|
||||
|
||||
min_index, _min_color = min(
|
||||
enumerate(pallette),
|
||||
key=lambda pallette_color: get_color_distance(pallette_color[1]),
|
||||
)
|
||||
return min_index
|
||||
|
||||
def get_ansi_codes(self, foreground: bool = True) -> List[str]:
|
||||
"""Get the ANSI escape codes for this color."""
|
||||
if self.type == ColorType.DEFAULT:
|
||||
return ["39" if foreground else "49"]
|
||||
|
||||
elif self.type == ColorType.STANDARD:
|
||||
assert self.number is not None
|
||||
return [str(30 + self.number if foreground else 40 + self.number)]
|
||||
|
||||
elif self.type == ColorType.EIGHT_BIT:
|
||||
assert self.number is not None
|
||||
return ["38" if foreground else "48", "5", str(self.number)]
|
||||
|
||||
else: # self.standard == ColorStandard.FULL:
|
||||
assert self.triplet is not None
|
||||
red, green, blue = self.triplet
|
||||
return ["38" if foreground else "48", "2", str(red), str(green), str(blue)]
|
||||
|
||||
def downgrade(self, system: ColorSystem) -> Color:
|
||||
"""Downgrade a color system to a system with fewer colors."""
|
||||
if system >= self.system:
|
||||
return self
|
||||
|
||||
# Convert to 8-bit color from full color
|
||||
if system == ColorSystem.EIGHT_BIT and self.system == ColorSystem.FULL:
|
||||
|
||||
assert self.triplet is not None
|
||||
red, green, blue = self.triplet
|
||||
if red == green and green == blue:
|
||||
if red < 8:
|
||||
color_number = 16
|
||||
elif red > 248:
|
||||
color_number = 231
|
||||
else:
|
||||
color_number = round(((red - 8) / 247.0) * 24) + 232
|
||||
return Color(self.name, ColorType.EIGHT_BIT, number=color_number)
|
||||
ansi_red = 36 * round(red / 255.0 * 5.0)
|
||||
ansi_green = 6 * round(green / 255.0 * 5.0)
|
||||
ansi_blue = round(blue / 255.0 * 5.0)
|
||||
color_number = 16 + ansi_red + ansi_green + ansi_blue
|
||||
return Color(self.name, ColorType.EIGHT_BIT, number=color_number)
|
||||
|
||||
# Convert to standard from full color or 8-bit
|
||||
elif system == ColorSystem.STANDARD:
|
||||
if self.system == ColorSystem.FULL:
|
||||
assert self.triplet is not None
|
||||
triplet = self.triplet
|
||||
else: # self.system == ColorSystem.EIGHT_BUT
|
||||
assert self.number is not None
|
||||
triplet = ColorTriplet(*EIGHT_BIT_COLORS[self.number])
|
||||
color_number = self._match_color(triplet, STANDARD_PALLETTE)
|
||||
return Color(self.name, ColorType.STANDARD, number=color_number)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(Color.parse("default"))
|
||||
print(Color.parse("red"))
|
||||
print(Color.parse("#ff0000"))
|
||||
print(Color.parse("0"))
|
||||
print(Color.parse("100"))
|
||||
print(Color.parse("rgb( 12, 130, 200)"))
|
||||
|
||||
color = Color.parse("#339a2e")
|
||||
print(color)
|
||||
print(color.downgrade(ColorSystem.EIGHT_BIT))
|
||||
print(color.downgrade(ColorSystem.STANDARD))
|
||||
import sys
|
||||
|
||||
# sys.stdout.write(Color.parse("#00ffff").foreground_sequence + "Hello")
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,306 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
from collections import ChainMap
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
IO,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
NamedTuple,
|
||||
overload,
|
||||
Protocol,
|
||||
runtime_checkable,
|
||||
Union,
|
||||
)
|
||||
|
||||
from .default_styles import DEFAULT_STYLES
|
||||
from . import errors
|
||||
from .style import Style
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsoleOptions:
|
||||
"""Options for __console__ method."""
|
||||
|
||||
max_width: int
|
||||
min_width: int = 1
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsConsole(Protocol):
|
||||
def __console__(self) -> StyledText:
|
||||
...
|
||||
|
||||
|
||||
class SupportsStr(Protocol):
|
||||
def __str__(self) -> str:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConsoleRenderable(Protocol):
|
||||
"""An object that supports the console protocol."""
|
||||
|
||||
def __console_render__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> Iterable[Union[SupportsConsole, StyledText]]:
|
||||
...
|
||||
|
||||
|
||||
class StyledText(NamedTuple):
|
||||
"""A piece of text with associated style."""
|
||||
|
||||
text: str
|
||||
style: Optional[Style] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Simplified repr."""
|
||||
return f"StyleText({self.text!r}, {self.style!r})"
|
||||
|
||||
|
||||
class ConsoleDimensions(NamedTuple):
|
||||
"""Size of the terminal."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
class StyleContext:
|
||||
"""A context manager to manage a style."""
|
||||
|
||||
def __init__(self, console: Console, style: Style):
|
||||
self.console = console
|
||||
self.style = style
|
||||
|
||||
def __enter__(self) -> Console:
|
||||
self.console._enter_buffer()
|
||||
self.console.push_style(self.style)
|
||||
return self.console
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
||||
self.console.pop_style()
|
||||
self.console._exit_buffer()
|
||||
|
||||
|
||||
class Console:
|
||||
"""A high level console interface."""
|
||||
|
||||
default_style = Style.reset()
|
||||
|
||||
def __init__(self, styles: Dict[str, Style] = DEFAULT_STYLES, file: IO = None):
|
||||
self._styles = ChainMap(styles)
|
||||
self.file = file or sys.stdout
|
||||
self.style_stack: List[Style] = [Style()]
|
||||
self.buffer: List[StyledText] = []
|
||||
self.current_style = Style()
|
||||
self._buffer_index = 0
|
||||
|
||||
# def push_styles(self, styles: Dict[str, Style]) -> None:
|
||||
# """Push a new set of styles on to the style stack.
|
||||
|
||||
# Args:
|
||||
# styles (Dict[str, Style]): A mapping of styles.
|
||||
|
||||
# """
|
||||
# self._styles.maps.insert(0, styles)
|
||||
|
||||
# def pop_styles(self) -> None:
|
||||
# if len(self._styles.maps) == 1:
|
||||
# raise StyleError("Can't pop default styles")
|
||||
# self._styles.maps.pop(0)
|
||||
|
||||
def _enter_buffer(self) -> None:
|
||||
self._buffer_index += 1
|
||||
|
||||
def _exit_buffer(self) -> None:
|
||||
self._buffer_index -= 1
|
||||
self._check_buffer()
|
||||
|
||||
def __enter__(self) -> Console:
|
||||
self._enter_buffer()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
||||
self._exit_buffer()
|
||||
|
||||
def get_style(self, name: str) -> Optional[Style]:
|
||||
"""Get a named style, or `None` if it doesn't exist.
|
||||
|
||||
Args:
|
||||
name (str): The name of a style.
|
||||
|
||||
Returns:
|
||||
Optional[Style]: A Style object for the given name, or `None`.
|
||||
"""
|
||||
return self._styles.get(name, None)
|
||||
|
||||
def push_style(self, style: Union[str, Style]) -> None:
|
||||
"""Push a style on to the stack.
|
||||
|
||||
The new style will be applied to all `write` calls, until
|
||||
`pop_style` is called.
|
||||
|
||||
Args:
|
||||
style (Union[str, Style]): New style to merge with current style.
|
||||
|
||||
Returns:
|
||||
None: [description]
|
||||
"""
|
||||
if isinstance(style, str):
|
||||
style = Style.parse(style)
|
||||
self.current_style = self.current_style.apply(style)
|
||||
self.style_stack.append(self.current_style)
|
||||
|
||||
def pop_style(self) -> Style:
|
||||
"""Pop a style from the stack.
|
||||
|
||||
This will revert to the style applied prior to the corresponding `push_style`.
|
||||
|
||||
Returns:
|
||||
Style: The previously applied style.
|
||||
"""
|
||||
if len(self.style_stack) == 1:
|
||||
raise errors.StyleStackError(
|
||||
"Can't pop the default style (check there is `push_style` for every `pop_style`)"
|
||||
)
|
||||
style = self.style_stack.pop()
|
||||
self.current_style = self.style_stack[-1]
|
||||
return style
|
||||
|
||||
def style(self, style: Union[str, Style]) -> StyleContext:
|
||||
"""A context manager to apply a new style.
|
||||
|
||||
Example:
|
||||
with context.style("bold red"):
|
||||
context.print("Danger Will Robinson!")
|
||||
|
||||
Args:
|
||||
style (Union[str, Style]): New style to apply.
|
||||
|
||||
Returns:
|
||||
StyleContext: A style context manager.
|
||||
"""
|
||||
if isinstance(style, str):
|
||||
style = Style.parse(style)
|
||||
return StyleContext(self, style)
|
||||
|
||||
def write(self, text: str, style: str = None) -> None:
|
||||
"""Write text in the current style.
|
||||
|
||||
Args:
|
||||
text (str): Text to write
|
||||
|
||||
Returns:
|
||||
None:
|
||||
"""
|
||||
write_style = self.current_style or self.get_style(style)
|
||||
self.buffer.append(StyledText(text, write_style))
|
||||
self._check_buffer()
|
||||
|
||||
def print(self, *objects: Union[ConsoleRenderable, SupportsStr]) -> None:
|
||||
options = ConsoleOptions(max_width=self.width)
|
||||
buffer_append = self.buffer.append
|
||||
with self:
|
||||
for console_object in objects:
|
||||
if isinstance(console_object, ConsoleRenderable):
|
||||
render = console_object.__console_render__(self, options)
|
||||
for console_output in render:
|
||||
if isinstance(console_output, SupportsConsole):
|
||||
styled_text = console_output.__console__()
|
||||
else:
|
||||
styled_text = console_output
|
||||
buffer_append(styled_text)
|
||||
else:
|
||||
styled_text = StyledText(str(console_object), None)
|
||||
buffer_append(styled_text)
|
||||
buffer_append(StyledText("\n"))
|
||||
|
||||
def _check_buffer(self) -> None:
|
||||
"""Check if the buffer may be rendered."""
|
||||
if self._buffer_index == 0:
|
||||
text = self._render_buffer()
|
||||
self.file.write(text)
|
||||
|
||||
def _render_buffer(self) -> str:
|
||||
"""Render buffered output, and clear buffer."""
|
||||
output: List[str] = []
|
||||
append = output.append
|
||||
for text, style in self.buffer:
|
||||
if style:
|
||||
append(style.render(text, reset=True))
|
||||
else:
|
||||
append(text)
|
||||
rendered = "".join(output)
|
||||
del self.buffer[:]
|
||||
return rendered
|
||||
|
||||
@property
|
||||
def size(self) -> ConsoleDimensions:
|
||||
"""Get the size of the console.
|
||||
|
||||
Returns:
|
||||
ConsoleDimensions: A named tuple containing the dimensions.
|
||||
"""
|
||||
width, height = shutil.get_terminal_size()
|
||||
return ConsoleDimensions(width, height)
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Get the width of the console.
|
||||
|
||||
Returns:
|
||||
int: The width (in characters) of the console.
|
||||
"""
|
||||
width, _ = shutil.get_terminal_size()
|
||||
return width
|
||||
|
||||
# def write(self, console_object: Any) -> Console:
|
||||
# if isinstance(console_object, SupportsConsole):
|
||||
# return self.write_object(console_object)
|
||||
# else:
|
||||
# text = str(console_object)
|
||||
# return self.write_text(text)
|
||||
|
||||
# def write_object(self, console_object: SupportsConsole) -> Console:
|
||||
# console_object.__console__(self)
|
||||
# return self
|
||||
|
||||
# def write_text(
|
||||
# self, text: str, style: Union[str, Style] = None, *, end="\n"
|
||||
# ) -> Console:
|
||||
# if isinstance(style, str):
|
||||
# render_style = Style.parse(style)
|
||||
# elif isinstance(style, Style):
|
||||
# render_style = style
|
||||
|
||||
# prefix = render_style.render(Style())
|
||||
# self.file.write(f"{prefix}{text}\x1b[0m{end}")
|
||||
# return self
|
||||
|
||||
# def new_line(self) -> Console:
|
||||
# return self
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console = Console()
|
||||
# console.write_text("Hello", style="bold magenta on white", end="").write_text(
|
||||
# " World!", "italic blue"
|
||||
# )
|
||||
# "[b]This is bold [style not bold]This is not[/style] this is[/b]"
|
||||
|
||||
console.write("Hello ")
|
||||
with console.style("bold blue"):
|
||||
console.write("World ")
|
||||
with console.style("italic"):
|
||||
console.write("in style")
|
||||
console.write("!")
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
from typing import Dict
|
||||
|
||||
from .style import Style
|
||||
|
||||
DEFAULT_STYLES: Dict[str, Style] = {
|
||||
"none": Style(),
|
||||
"reset": Style.reset(),
|
||||
"dim": Style(dim=True),
|
||||
"bright": Style(dim=False),
|
||||
"bold": Style(bold=True),
|
||||
"b": Style(bold=True),
|
||||
"italic": Style(italic=True),
|
||||
"i": Style(italic=True),
|
||||
"underline": Style(underline=True),
|
||||
"u": Style(underline=True),
|
||||
"blink": Style(blink=True),
|
||||
"blink2": Style(blink2=True),
|
||||
"reverse": Style(reverse=True),
|
||||
"strike": Style(strike=True),
|
||||
"s": Style(strike=True),
|
||||
"black": Style(color="black"),
|
||||
"red": Style(color="red"),
|
||||
"green": Style(color="green"),
|
||||
"yellow": Style(color="yellow"),
|
||||
"magenta": Style(color="magenta"),
|
||||
"cyan": Style(color="cyan"),
|
||||
"white": Style(color="white"),
|
||||
"on_black": Style(back="black"),
|
||||
"on_red": Style(back="red"),
|
||||
"on_green": Style(back="green"),
|
||||
"on_yellow": Style(back="yellow"),
|
||||
"on_magenta": Style(back="magenta"),
|
||||
"on_cyan": Style(back="cyan"),
|
||||
"on_white": Style(back="white"),
|
||||
}
|
||||
|
||||
MARKDOWN_STYLES = {
|
||||
"markdown.text": Style(),
|
||||
"markdown.emph": Style(italic=True),
|
||||
"markdown.strong": Style(bold=True),
|
||||
"markdown.code": Style(dim=True),
|
||||
"markdown.code_block": Style(dim=True),
|
||||
"markdown.heading1": Style(bold=True),
|
||||
"markdown.heading2": Style(bold=True, dim=True),
|
||||
"markdown.heading3": Style(bold=True),
|
||||
"markdown.heading4": Style(bold=True),
|
||||
"markdown.heading5": Style(bold=True),
|
||||
"markdown.heading6": Style(bold=True),
|
||||
"markdown.heading7": Style(bold=True),
|
||||
}
|
||||
DEFAULT_STYLES.update(MARKDOWN_STYLES)
|
|
@ -0,0 +1,18 @@
|
|||
class ConsoleError(Exception):
|
||||
"""An error in console operation."""
|
||||
|
||||
|
||||
class StyleError(Exception):
|
||||
"""An error in styles."""
|
||||
|
||||
|
||||
class StyleSyntaxError(ConsoleError):
|
||||
"""Style was badly formatted."""
|
||||
|
||||
|
||||
class MissingStyle(StyleError):
|
||||
"""No such style."""
|
||||
|
||||
|
||||
class StyleStackError(ConsoleError):
|
||||
"""Style stack is invalid."""
|
|
@ -0,0 +1,10 @@
|
|||
ESC = "\x1b"
|
||||
CSI = f"{ESC}["
|
||||
|
||||
BOLD = f"{CSI}21m"
|
||||
|
||||
import sys
|
||||
|
||||
sys.stdout.write(BOLD)
|
||||
|
||||
sys.stdout.write("hello world")
|
|
@ -0,0 +1,83 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from commonmark.blocks import Parser
|
||||
|
||||
from .console import Console, ConsoleOptions, StyledText
|
||||
from .style import Style
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkdownHeading:
|
||||
"""A Markdown document heading."""
|
||||
|
||||
text: str
|
||||
level: int
|
||||
width: int
|
||||
|
||||
def __console__(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class Markdown:
|
||||
"""Render markdown to the console."""
|
||||
|
||||
def __init__(self, markup):
|
||||
self.markup = markup
|
||||
|
||||
def __console_render__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> Iterable[StyledText]:
|
||||
|
||||
width = options.max_width
|
||||
parser = Parser()
|
||||
|
||||
nodes = parser.parse(self.markup).walker()
|
||||
|
||||
rendered: List[StyledText] = []
|
||||
append = rendered.append
|
||||
stack = [Style()]
|
||||
|
||||
style: Optional[Style]
|
||||
for current, entering in nodes:
|
||||
node_type = current.t
|
||||
if node_type == "text":
|
||||
style = stack[-1].apply(console.get_style("markdown.text"))
|
||||
append(StyledText(current.literal, style))
|
||||
elif node_type == "paragraph":
|
||||
if not entering:
|
||||
append(StyledText("\n\n", stack[-1]))
|
||||
else:
|
||||
if entering:
|
||||
style = console.get_style(f"markdown.{node_type}")
|
||||
if style is not None:
|
||||
stack.append(stack[-1].apply(style))
|
||||
else:
|
||||
stack.append(stack[-1])
|
||||
if current.literal:
|
||||
append(StyledText(current.literal, stack[-1]))
|
||||
else:
|
||||
stack.pop()
|
||||
|
||||
print(rendered)
|
||||
return rendered
|
||||
|
||||
|
||||
markup = """*hello*, **world**!
|
||||
|
||||
# Hi
|
||||
|
||||
```python
|
||||
code
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .console import Console
|
||||
|
||||
console = Console()
|
||||
md = Markdown(markup)
|
||||
|
||||
console.print(md)
|
||||
# print(console.render_spans())
|
|
@ -0,0 +1,252 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace, InitVar
|
||||
from functools import lru_cache
|
||||
import sys
|
||||
from typing import Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from . import errors
|
||||
from .color import Color
|
||||
|
||||
|
||||
@dataclass
|
||||
class Style:
|
||||
"""A terminal style."""
|
||||
|
||||
color: Optional[str] = None
|
||||
back: Optional[str] = None
|
||||
bold: Optional[bool] = None
|
||||
dim: Optional[bool] = None
|
||||
italic: Optional[bool] = None
|
||||
underline: Optional[bool] = None
|
||||
blink: Optional[bool] = None
|
||||
blink2: Optional[bool] = None
|
||||
reverse: Optional[bool] = None
|
||||
strike: Optional[bool] = None
|
||||
|
||||
_color: Optional[Color] = field(init=False, default=None, repr=False)
|
||||
_back: Optional[Color] = field(init=False, default=None, repr=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Re-generate style definition from attributes."""
|
||||
attributes: List[str] = []
|
||||
append = attributes.append
|
||||
if self.bold is not None:
|
||||
append("bold" if self.bold else "not bold")
|
||||
if self.dim is not None:
|
||||
append("dim" if self.dim else "not dim")
|
||||
if self.italic is not None:
|
||||
append("italic" if self.italic else "not italic")
|
||||
if self.underline is not None:
|
||||
append("underline" if self.underline else "not underline")
|
||||
if self.blink is not None:
|
||||
append("blink" if self.blink else "not blink")
|
||||
if self.blink2 is not None:
|
||||
append("blink2" if self.blink2 else "not blink2")
|
||||
if self.reverse is not None:
|
||||
append("reverse" if self.reverse else "not reverse")
|
||||
if self.strike is not None:
|
||||
append("strike" if self.strike else "not strike")
|
||||
if self._color is not None:
|
||||
append(self._color.name)
|
||||
if self._back is not None:
|
||||
append("on")
|
||||
append(self._back.name)
|
||||
return " ".join(attributes) or "none"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<style '{self}'>"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.color:
|
||||
self._color = Color.parse(self.color)
|
||||
if self.back:
|
||||
self._back = Color.parse(self.back)
|
||||
|
||||
@classmethod
|
||||
def reset(cls) -> Style:
|
||||
"""Get a style to reset all attributes."""
|
||||
return Style(
|
||||
color="default",
|
||||
back="default",
|
||||
dim=False,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
blink=False,
|
||||
blink2=False,
|
||||
reverse=False,
|
||||
strike=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, style_definition: str) -> Style:
|
||||
"""Parse style name(s) in to style object."""
|
||||
style_attributes = {
|
||||
"dim",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"blink",
|
||||
"blink2",
|
||||
"reverse",
|
||||
"strike",
|
||||
}
|
||||
color: Optional[str] = None
|
||||
back: Optional[str] = None
|
||||
attributes: Dict[str, Optional[bool]] = {}
|
||||
|
||||
words = iter(style_definition.split())
|
||||
for original_word in words:
|
||||
word = original_word.lower()
|
||||
if word == "on":
|
||||
word = next(words, "")
|
||||
if not word:
|
||||
raise errors.StyleSyntaxError("color expected after 'on'")
|
||||
if Color.parse(word) is None:
|
||||
raise errors.StyleSyntaxError(
|
||||
f"color expected after 'on', found {original_word!r}"
|
||||
)
|
||||
back = word
|
||||
|
||||
elif word == "not":
|
||||
word = next(words, "")
|
||||
if word not in style_attributes:
|
||||
raise errors.StyleSyntaxError(
|
||||
f"expected style attribute after 'not', found {original_word!r}"
|
||||
)
|
||||
attributes[word] = False
|
||||
|
||||
elif word in style_attributes:
|
||||
attributes[word] = True
|
||||
|
||||
else:
|
||||
if Color.parse(word) is None:
|
||||
raise errors.StyleSyntaxError(
|
||||
f"unknown word {original_word!r} in style {style_definition!r}"
|
||||
)
|
||||
color = word
|
||||
style = Style(color=color, back=back, **attributes)
|
||||
return style
|
||||
|
||||
@classmethod
|
||||
def combine(self, styles: Iterable[Style]) -> Style:
|
||||
"""Combine styles and get result.
|
||||
|
||||
Args:
|
||||
styles (Iterable[Style]): Styles to combine.
|
||||
|
||||
Returns:
|
||||
Style: A new style instance.
|
||||
"""
|
||||
|
||||
style = Style()
|
||||
for _style in styles:
|
||||
style = style.apply(_style)
|
||||
return style
|
||||
|
||||
def copy(self) -> Style:
|
||||
"""Get a copy of this style.
|
||||
|
||||
Returns:
|
||||
Style: A new Style instance with identical attributes.
|
||||
"""
|
||||
return replace(self)
|
||||
|
||||
def render(
|
||||
self, text: str = "", *, current_style: Style = None, reset=False
|
||||
) -> str:
|
||||
"""Render the ANSI codes to implement the style."""
|
||||
attrs: List[str] = []
|
||||
append = attrs.append
|
||||
|
||||
if current_style is None:
|
||||
current = RESET_STYLE
|
||||
else:
|
||||
current = current_style
|
||||
|
||||
if self._color is not None and current._color != self._color:
|
||||
attrs.extend(self._color.get_ansi_codes())
|
||||
|
||||
if self._back is not None and current._back != self._back:
|
||||
attrs.extend(self._back.get_ansi_codes(foreground=False))
|
||||
|
||||
if self.bold is not None and current.bold != self.bold:
|
||||
append("1" if self.bold else "21")
|
||||
|
||||
if self.dim is not None and current.dim != self.dim:
|
||||
append("2" if self.dim else "22")
|
||||
|
||||
if self.italic is not None and current.italic != self.italic:
|
||||
append("3" if self.italic else "23")
|
||||
|
||||
if self.underline is not None and current.underline != self.underline:
|
||||
append("4" if self.underline else "24")
|
||||
|
||||
if self.blink is not None and current.blink != self.blink:
|
||||
append("5" if self.blink else "25")
|
||||
|
||||
if self.blink2 is not None and current.blink2 != self.blink2:
|
||||
append("6" if self.blink2 else "26")
|
||||
|
||||
if self.reverse is not None and current.reverse != self.reverse:
|
||||
append("7" if self.reverse else "27")
|
||||
|
||||
if self.strike is not None and current.strike != self.strike:
|
||||
append("9" if self.strike else "29")
|
||||
|
||||
reset = "\x1b[0m" if reset else ""
|
||||
return f"\x1b[{';'.join(attrs)}m{text or ''}{reset}"
|
||||
|
||||
def test(self, text: Optional[str] = None) -> None:
|
||||
"""Write test text with style to terminal.
|
||||
|
||||
Args:
|
||||
text (Optional[str], optional): Text to style or None for style name.
|
||||
|
||||
Returns:
|
||||
None:
|
||||
"""
|
||||
text = text or str(self)
|
||||
sys.stdout.write(f"{self.render(text)}\x1b[0m\n")
|
||||
|
||||
def apply(self, style: Optional[Style]) -> Style:
|
||||
"""Merge this style with another.
|
||||
|
||||
Args:
|
||||
style (Optional[Style]): A style object to copy attributes from.
|
||||
If `style` is `None`, then a copy of this style will be returned.
|
||||
|
||||
Returns:
|
||||
(Style): A new style with combined attributes.
|
||||
|
||||
"""
|
||||
if style is None:
|
||||
return self
|
||||
style = Style(
|
||||
color=self.color if style.color is None else style.color,
|
||||
back=self.back if style.back is None else style.back,
|
||||
dim=self.dim if style.dim is None else style.dim,
|
||||
bold=self.bold if style.bold is None else style.bold,
|
||||
italic=self.italic if style.italic is None else style.italic,
|
||||
underline=self.underline if style.underline is None else style.underline,
|
||||
blink=self.blink if style.blink is None else style.blink,
|
||||
blink2=self.blink2 if style.blink2 is None else style.blink2,
|
||||
reverse=self.reverse if style.reverse is None else style.reverse,
|
||||
)
|
||||
return style
|
||||
|
||||
|
||||
RESET_STYLE = Style.reset()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# style = Style(color="blue", bold=True, italic=True, reverse=False, dim=True)
|
||||
|
||||
style = Style.parse("bold")
|
||||
print(style)
|
||||
print(repr(style))
|
||||
|
||||
style.test()
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from operator import itemgetter
|
||||
from typing import Iterable, NamedTuple, Optional, List
|
||||
from typing_extensions import Literal
|
||||
|
||||
from .console import Console, ConsoleOptions, StyledText
|
||||
from .style import Style
|
||||
|
||||
|
||||
class TextSpan(NamedTuple):
|
||||
"""A marked up region in some text."""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
style: Optional[str]
|
||||
|
||||
def adjust_offset(self, offset: int) -> TextSpan:
|
||||
"""Get a new `TextSpan` with start and end adjusted.
|
||||
|
||||
Args:
|
||||
offset (int): Number of characters to adjust offset by.
|
||||
|
||||
Returns:
|
||||
TextSpan: A new text span.
|
||||
"""
|
||||
return TextSpan(self.start + offset, self.end + offset, self.style)
|
||||
|
||||
def slice_text(self, text: str) -> str:
|
||||
"""Slice the text according to the start and end offsets.
|
||||
|
||||
Args:
|
||||
text (str): A string to slice.
|
||||
|
||||
Returns:
|
||||
str: A slice of the original string.
|
||||
"""
|
||||
return text[self.start : self.end]
|
||||
|
||||
|
||||
class RichText:
|
||||
"""Text with colored spans."""
|
||||
|
||||
def __init__(
|
||||
self, text: str = "", align: Literal["left", "center", "right"] = "left"
|
||||
) -> None:
|
||||
self._text: List[str] = [text]
|
||||
self._text_str: Optional[str] = text
|
||||
self._spans: List[TextSpan] = []
|
||||
self._length: int = len(text)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self._length
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.text
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"RichText({self.text!r})"
|
||||
|
||||
def stylize(self, start: int, end: int, style: str) -> None:
|
||||
self._spans.append(TextSpan(start, end, style))
|
||||
|
||||
def __console_render__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> Iterable[StyledText]:
|
||||
|
||||
print(self._spans)
|
||||
text = self.text
|
||||
stack: List[Style] = []
|
||||
|
||||
get_style = console.get_style
|
||||
spans = [
|
||||
(span.start, True, get_style(span.style or "none")) for span in self._spans
|
||||
]
|
||||
spans.extend(
|
||||
(span.end, False, get_style(span.style or "none")) for span in self._spans
|
||||
)
|
||||
spans.sort(key=itemgetter(0))
|
||||
spans.insert(0, (0, True, get_style("none")))
|
||||
spans.append((len(text), False, get_style("none")))
|
||||
|
||||
null_style = Style()
|
||||
current_style = Style()
|
||||
|
||||
for (offset, entering, style), (next_offset, _, _) in zip(spans, spans[1:]):
|
||||
style = style or null_style
|
||||
if entering:
|
||||
stack.append(style)
|
||||
current_style = current_style.apply(style)
|
||||
else:
|
||||
stack.reverse()
|
||||
stack.remove(style)
|
||||
stack.reverse()
|
||||
current_style = Style.combine(stack)
|
||||
span_text = text[offset:next_offset]
|
||||
yield StyledText(span_text, current_style)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Get the text as a single string."""
|
||||
if self._text_str is None:
|
||||
self._text_str = "".join(self._text)
|
||||
return self._text_str
|
||||
|
||||
def append(self, text: str, style: str = None) -> None:
|
||||
"""Add text with an optional style.
|
||||
|
||||
Args:
|
||||
text (str): Text to append.
|
||||
style (str, optional): A style name. Defaults to None.
|
||||
"""
|
||||
self._text.append(text)
|
||||
offset = len(self)
|
||||
text_length = len(text)
|
||||
self._spans.append(TextSpan(offset, offset + text_length, style))
|
||||
self._length += text_length
|
||||
self._text_str = None
|
||||
|
||||
def split(self, separator="\n") -> List[RichText]:
|
||||
lines = self.text.split(separator)
|
||||
offset = 0
|
||||
offsets: List[int] = []
|
||||
offset_append = offsets.append
|
||||
for _line in lines:
|
||||
offset_append(offset)
|
||||
offset += len(_line) + len(separator)
|
||||
|
||||
new_lines: List[RichText] = [RichText(line + separator) for line in lines]
|
||||
for span in self._spans:
|
||||
for offset, line in zip(offsets, new_lines):
|
||||
if span.end <= offset or span.start >= offset + len(line):
|
||||
continue
|
||||
line._spans.append(span.adjust_offset(-offset))
|
||||
|
||||
return new_lines
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
rich_text = RichText("0123456789012345")
|
||||
rich_text.stylize(0, 100, "dim")
|
||||
rich_text.stylize(0, 5, "bright")
|
||||
rich_text.stylize(2, 8, "bold")
|
||||
|
||||
# rich_text.append("Hello\n", style="bold")
|
||||
# rich_text.append("World!\n", style="italic")
|
||||
# rich_text.append("1 2 3 ", style="red")
|
||||
# rich_text.append("4 5 6", style="green")
|
||||
|
||||
console = Console()
|
||||
# for line in rich_text.split("\n"):
|
||||
# console.print(line)
|
||||
|
||||
console.print(rich_text)
|
Loading…
Reference in New Issue