initial commit

This commit is contained in:
Will McGugan 2019-11-10 15:39:13 +00:00
parent 7a5d8184e8
commit 7481db402b
16 changed files with 1397 additions and 21 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

21
LICENSE
View File

@ -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.

1
README.md Normal file
View File

@ -0,0 +1 @@
# WIP

7
poetry.lock generated Normal file
View File

@ -0,0 +1,7 @@
package = []
[metadata]
content-hash = "fafb334cb038533f851c23d0b63254223abf72ce4f02987e7064b0c95566699a"
python-versions = "^3.8"
[metadata.hashes]

15
pyproject.toml Normal file
View File

@ -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
rich/__init__.py Normal file
View File

1
rich/_version.py Normal file
View File

@ -0,0 +1 @@
VERSION = "0.0.1a"

490
rich/color.py Normal file
View File

@ -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")

6
rich/colors.py Normal file

File diff suppressed because one or more lines are too long

306
rich/console.py Normal file
View File

@ -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("!")

51
rich/default_styles.py Normal file
View File

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

18
rich/errors.py Normal file
View File

@ -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."""

10
rich/escape.py Normal file
View File

@ -0,0 +1,10 @@
ESC = "\x1b"
CSI = f"{ESC}["
BOLD = f"{CSI}21m"
import sys
sys.stdout.write(BOLD)
sys.stdout.write("hello world")

83
rich/markdown.py Normal file
View File

@ -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())

252
rich/style.py Normal file
View File

@ -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()

155
rich/text.py Normal file
View File

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