mirror of https://github.com/Textualize/rich.git
functionality update
This commit is contained in:
parent
065f0a7ac7
commit
a6db216788
|
@ -1,4 +1,5 @@
|
|||
.DS_Store
|
||||
.vscode
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
from typing import List, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Stack(List[T]):
|
||||
"""A small shim over builtin list."""
|
||||
|
||||
@property
|
||||
def top(self) -> T:
|
||||
"""Get top of stack."""
|
||||
return self[-1]
|
||||
|
||||
def push(self, item: T) -> None:
|
||||
self.append(item)
|
|
@ -419,14 +419,15 @@ class Color(NamedTuple):
|
|||
|
||||
def get_ansi_codes(self, foreground: bool = True) -> List[str]:
|
||||
"""Get the ANSI escape codes for this color."""
|
||||
if self.type == ColorType.DEFAULT:
|
||||
_type = self.type
|
||||
if _type == ColorType.DEFAULT:
|
||||
return ["39" if foreground else "49"]
|
||||
|
||||
elif self.type == ColorType.STANDARD:
|
||||
elif _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:
|
||||
elif _type == ColorType.EIGHT_BIT:
|
||||
assert self.number is not None
|
||||
return ["38" if foreground else "48", "5", str(self.number)]
|
||||
|
||||
|
|
184
rich/console.py
184
rich/console.py
|
@ -25,6 +25,7 @@ from typing import (
|
|||
from .default_styles import DEFAULT_STYLES
|
||||
from . import errors
|
||||
from .style import Style
|
||||
from .styled_text import StyledText
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -32,15 +33,11 @@ class ConsoleOptions:
|
|||
"""Options for __console__ method."""
|
||||
|
||||
max_width: int
|
||||
is_terminal: bool
|
||||
encoding: str
|
||||
min_width: int = 1
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsConsole(Protocol):
|
||||
def __console__(self) -> StyledText:
|
||||
...
|
||||
|
||||
|
||||
class SupportsStr(Protocol):
|
||||
def __str__(self) -> str:
|
||||
...
|
||||
|
@ -50,21 +47,14 @@ class SupportsStr(Protocol):
|
|||
class ConsoleRenderable(Protocol):
|
||||
"""An object that supports the console protocol."""
|
||||
|
||||
def __console_render__(
|
||||
def __console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> Iterable[Union[SupportsConsole, StyledText]]:
|
||||
) -> Iterable[Union[ConsoleRenderable, 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})"
|
||||
RenderableType = Union[ConsoleRenderable, SupportsStr]
|
||||
RenderResult = Iterable[Union[ConsoleRenderable, StyledText]]
|
||||
|
||||
|
||||
class ConsoleDimensions(NamedTuple):
|
||||
|
@ -82,13 +72,13 @@ class StyleContext:
|
|||
self.style = style
|
||||
|
||||
def __enter__(self) -> Console:
|
||||
self.console._enter_buffer()
|
||||
self.console.push_style(self.style)
|
||||
self.console._enter_buffer()
|
||||
return self.console
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
||||
self.console.pop_style()
|
||||
self.console._exit_buffer()
|
||||
self.console.pop_style()
|
||||
|
||||
|
||||
class Console:
|
||||
|
@ -96,14 +86,27 @@ class Console:
|
|||
|
||||
default_style = Style.reset()
|
||||
|
||||
def __init__(self, styles: Dict[str, Style] = DEFAULT_STYLES, file: IO = None):
|
||||
def __init__(
|
||||
self,
|
||||
styles: Dict[str, Style] = DEFAULT_STYLES,
|
||||
file: IO = None,
|
||||
width: int = None,
|
||||
height: int = None,
|
||||
markup: str = "markdown",
|
||||
):
|
||||
self._styles = ChainMap(styles)
|
||||
self.file = file or sys.stdout
|
||||
self.style_stack: List[Style] = [Style()]
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._markup = markup
|
||||
|
||||
self.buffer: List[StyledText] = []
|
||||
self.current_style = Style()
|
||||
self._buffer_index = 0
|
||||
|
||||
default_style = Style()
|
||||
self.style_stack: List[Style] = [default_style]
|
||||
self.current_style = default_style
|
||||
|
||||
# def push_styles(self, styles: Dict[str, Style]) -> None:
|
||||
# """Push a new set of styles on to the style stack.
|
||||
|
||||
|
@ -132,6 +135,71 @@ class Console:
|
|||
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
||||
self._exit_buffer()
|
||||
|
||||
@property
|
||||
def encoding(self) -> str:
|
||||
"""Get the encoding of the console file.
|
||||
|
||||
Returns:
|
||||
str: A standard encoding string.
|
||||
"""
|
||||
return getattr(self.file, "encoding", "utf-8")
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
"""Check if the console is writing to a terminal.
|
||||
|
||||
Returns:
|
||||
bool: True if the console writting to a device capable of
|
||||
understanding terminal codes, otherwise False.
|
||||
"""
|
||||
isatty = getattr(self.file, "isatty", None)
|
||||
return False if isatty is None else isatty()
|
||||
|
||||
@property
|
||||
def options(self) -> ConsoleOptions:
|
||||
"""Get default console options."""
|
||||
return ConsoleOptions(
|
||||
max_width=self.width, encoding=self.encoding, is_terminal=self.is_terminal
|
||||
)
|
||||
|
||||
def render(
|
||||
self, renderable: RenderableType, options: ConsoleOptions
|
||||
) -> Iterable[StyledText]:
|
||||
"""Render an object in to an iterable of `StyledText` instances.
|
||||
|
||||
This method contains the logic for rendering objects with the console protocol.
|
||||
You are unlikely to need to use it directly, unless you are extending the library.
|
||||
|
||||
|
||||
Args:
|
||||
renderable (RenderableType): An object supporting the console protocol, or
|
||||
an object that may be converted to a string.
|
||||
options (ConsoleOptions, optional): An options objects. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Iterable[StyledText]: An iterable of styled text that may be rendered.
|
||||
"""
|
||||
|
||||
if isinstance(renderable, ConsoleRenderable):
|
||||
render_iterable = renderable.__console__(self, options)
|
||||
else:
|
||||
render_iterable = self.render_str(str(renderable), options)
|
||||
|
||||
for render_output in render_iterable:
|
||||
if isinstance(render_output, StyledText):
|
||||
yield render_output
|
||||
else:
|
||||
yield from self.render(render_output, options)
|
||||
|
||||
def render_str(self, text: str, options: ConsoleOptions) -> Iterable[StyledText]:
|
||||
"""Render a string."""
|
||||
if self._markup == "markdown":
|
||||
from .markdown import Markdown
|
||||
|
||||
yield Markdown(text)
|
||||
else:
|
||||
yield StyledText(text, self.current_style)
|
||||
|
||||
def get_style(self, name: str) -> Optional[Style]:
|
||||
"""Get a named style, or `None` if it doesn't exist.
|
||||
|
||||
|
@ -143,6 +211,21 @@ class Console:
|
|||
"""
|
||||
return self._styles.get(name, None)
|
||||
|
||||
def parse_style(self, name: str) -> Optional[Style]:
|
||||
"""Get a named style, or parse a style definition.
|
||||
|
||||
Args:
|
||||
name (str): The name of a style.
|
||||
|
||||
Returns:
|
||||
Optional[Style]: A Style object or `None` if it couldn't be found / parsed.
|
||||
|
||||
"""
|
||||
try:
|
||||
return self._styles.get(name, None) or Style.parse(name)
|
||||
except errors.StyleSyntaxError:
|
||||
return None
|
||||
|
||||
def push_style(self, style: Union[str, Style]) -> None:
|
||||
"""Push a style on to the stack.
|
||||
|
||||
|
@ -202,27 +285,22 @@ class Console:
|
|||
Returns:
|
||||
None:
|
||||
"""
|
||||
write_style = self.current_style or self.get_style(style)
|
||||
write_style = self.current_style or self.get_style(style or "none")
|
||||
self.buffer.append(StyledText(text, write_style))
|
||||
self._check_buffer()
|
||||
|
||||
def print(self, *objects: Union[ConsoleRenderable, SupportsStr]) -> None:
|
||||
options = ConsoleOptions(max_width=self.width)
|
||||
def print(self, *objects: RenderableType, sep: str = " ", end="\n") -> None:
|
||||
options = self.options
|
||||
buffer_append = self.buffer.append
|
||||
buffer_extend = self.buffer.extend
|
||||
separator = StyledText(sep)
|
||||
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"))
|
||||
last_index = len(objects) - 1
|
||||
for index, console_object in enumerate(objects):
|
||||
buffer_extend(self.render(console_object, options))
|
||||
if sep and index != last_index:
|
||||
buffer_append(separator)
|
||||
buffer_append(StyledText(end))
|
||||
|
||||
def _check_buffer(self) -> None:
|
||||
"""Check if the buffer may be rendered."""
|
||||
|
@ -236,10 +314,12 @@ class Console:
|
|||
append = output.append
|
||||
for text, style in self.buffer:
|
||||
if style:
|
||||
style = self.current_style.apply(style)
|
||||
append(style.render(text, reset=True))
|
||||
else:
|
||||
append(text)
|
||||
rendered = "".join(output)
|
||||
|
||||
del self.buffer[:]
|
||||
return rendered
|
||||
|
||||
|
@ -250,8 +330,14 @@ class Console:
|
|||
Returns:
|
||||
ConsoleDimensions: A named tuple containing the dimensions.
|
||||
"""
|
||||
if self._width is not None and self._height is not None:
|
||||
return ConsoleDimensions(self._width, self._height)
|
||||
|
||||
width, height = shutil.get_terminal_size()
|
||||
return ConsoleDimensions(width, height)
|
||||
return ConsoleDimensions(
|
||||
width if self._width is None else self._width,
|
||||
height if self._height is None else self._height,
|
||||
)
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
|
@ -260,7 +346,7 @@ class Console:
|
|||
Returns:
|
||||
int: The width (in characters) of the console.
|
||||
"""
|
||||
width, _ = shutil.get_terminal_size()
|
||||
width, _ = self.size
|
||||
return width
|
||||
|
||||
# def write(self, console_object: Any) -> Console:
|
||||
|
@ -291,16 +377,20 @@ class Console:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
console = Console()
|
||||
console = Console(width=80)
|
||||
# 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("!")
|
||||
# console.write("Hello ")
|
||||
# with console.style("bold blue"):
|
||||
# console.write("World ")
|
||||
# with console.style("italic"):
|
||||
# console.write("in style")
|
||||
# console.write("!")
|
||||
|
||||
with console.style("dim on black"):
|
||||
console.print("**Hello**, *World*!")
|
||||
console.print("Hello, *World*!")
|
||||
|
||||
|
|
|
@ -3,49 +3,53 @@ from typing import Dict
|
|||
from .style import Style
|
||||
|
||||
DEFAULT_STYLES: Dict[str, Style] = {
|
||||
"none": Style(),
|
||||
"none": Style("none"),
|
||||
"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"),
|
||||
"dim": Style("dim", dim=True),
|
||||
"bright": Style("bright", dim=False),
|
||||
"bold": Style("bold", bold=True),
|
||||
"b": Style("bold", bold=True),
|
||||
"italic": Style("italic", italic=True),
|
||||
"i": Style("italic", italic=True),
|
||||
"underline": Style("underline", underline=True),
|
||||
"u": Style("underline", underline=True),
|
||||
"blink": Style("blink", blink=True),
|
||||
"blink2": Style("blink2", blink2=True),
|
||||
"reverse": Style("reverse", reverse=True),
|
||||
"strike": Style("strike", strike=True),
|
||||
"s": Style("strike", strike=True),
|
||||
"black": Style("black", color="black"),
|
||||
"red": Style("red", color="red"),
|
||||
"green": Style("green", color="green"),
|
||||
"yellow": Style("yellow", color="yellow"),
|
||||
"magenta": Style("magenta", color="magenta"),
|
||||
"cyan": Style("cyan", color="cyan"),
|
||||
"white": Style("white", color="white"),
|
||||
"on_black": Style("on_black", back="black"),
|
||||
"on_red": Style("on_red", back="red"),
|
||||
"on_green": Style("on_green", back="green"),
|
||||
"on_yellow": Style("on_yellow", back="yellow"),
|
||||
"on_magenta": Style("on_magenta", back="magenta"),
|
||||
"on_cyan": Style("on_cyan", back="cyan"),
|
||||
"on_white": Style("on_white", 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),
|
||||
"markdown.paragraph": Style("markdown.paragraph"),
|
||||
"markdown.text": Style("markdown.text"),
|
||||
"markdown.emph": Style("markdown.emph", italic=True),
|
||||
"markdown.strong": Style("markdown.strong", bold=True),
|
||||
"markdown.code": Style("markdown.code", dim=True),
|
||||
"markdown.code_block": Style(
|
||||
"markdown.code_block", dim=True, color="cyan", back="black"
|
||||
),
|
||||
"markdown.hr": Style("markdown.hr"),
|
||||
"markdown.h1": Style("markdown.h1", bold=True, underline=True),
|
||||
"markdown.h2": Style("markdown.h2", bold=True),
|
||||
"markdown.h3": Style("markdown.h3", bold=True, dim=True),
|
||||
"markdown.h4": Style("markdown.h4", bold=True, italic=True),
|
||||
"markdown.h5": Style("markdown.h5", bold=True),
|
||||
"markdown.h6": Style("markdown.h6", bold=True),
|
||||
"markdown.h7": Style("markdown.h7", bold=True),
|
||||
}
|
||||
DEFAULT_STYLES.update(MARKDOWN_STYLES)
|
||||
|
|
131
rich/markdown.py
131
rich/markdown.py
|
@ -3,20 +3,16 @@ from typing import Dict, Iterable, List, Optional
|
|||
|
||||
from commonmark.blocks import Parser
|
||||
|
||||
from .console import Console, ConsoleOptions, StyledText
|
||||
from .console import Console, ConsoleOptions, RenderResult, StyledText
|
||||
from .style import Style
|
||||
from .text import Text
|
||||
from ._stack import Stack
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkdownHeading:
|
||||
"""A Markdown document heading."""
|
||||
|
||||
text: str
|
||||
level: int
|
||||
width: int
|
||||
|
||||
def __console__(self) -> str:
|
||||
pass
|
||||
class Heading(Text):
|
||||
def __init__(self, level: int) -> None:
|
||||
super().__init__()
|
||||
self.level = level
|
||||
|
||||
|
||||
class Markdown:
|
||||
|
@ -25,58 +21,119 @@ class Markdown:
|
|||
def __init__(self, markup):
|
||||
self.markup = markup
|
||||
|
||||
def __console_render__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> Iterable[StyledText]:
|
||||
def __console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
||||
|
||||
width = options.max_width
|
||||
parser = Parser()
|
||||
|
||||
nodes = parser.parse(self.markup).walker()
|
||||
|
||||
rendered: List[StyledText] = []
|
||||
append = rendered.append
|
||||
stack = [Style()]
|
||||
style_stack: Stack[Style] = Stack()
|
||||
stack: Stack[Text] = Stack()
|
||||
style_stack.push(Style())
|
||||
|
||||
style: Optional[Style]
|
||||
null_style = Style()
|
||||
|
||||
def push_style(name: str) -> Style:
|
||||
|
||||
style = console.get_style(name) or null_style
|
||||
style = style_stack.top.apply(style)
|
||||
style_stack.push(style)
|
||||
return style
|
||||
|
||||
def pop_style() -> Style:
|
||||
return style_stack.pop()
|
||||
|
||||
paragraph_count = 0
|
||||
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))
|
||||
style = push_style("markdown.text")
|
||||
stack.top.append(current.literal, style)
|
||||
pop_style()
|
||||
elif node_type == "paragraph":
|
||||
if not entering:
|
||||
append(StyledText("\n\n", stack[-1]))
|
||||
if entering:
|
||||
if paragraph_count:
|
||||
yield StyledText("\n\n")
|
||||
paragraph_count += 1
|
||||
|
||||
push_style("markdown.paragraph")
|
||||
stack.push(Text())
|
||||
else:
|
||||
pop_style()
|
||||
text = stack.pop()
|
||||
yield text.wrap(options.max_width)
|
||||
# yield StyledText("\n")
|
||||
|
||||
# yield StyledText("\n")
|
||||
elif node_type == "heading":
|
||||
if entering:
|
||||
push_style(f"markdown.h{current.level}")
|
||||
stack.push(Heading(current.level))
|
||||
else:
|
||||
pop_style()
|
||||
text = stack.pop()
|
||||
yield text.wrap(options.max_width, justify="center")
|
||||
yield StyledText("\n\n")
|
||||
elif node_type == "code_block":
|
||||
style = push_style("markdown.code_block")
|
||||
text = Text(current.literal.rstrip(), style=style)
|
||||
wrapped_text = text.wrap(options.max_width, justify="left")
|
||||
yield StyledText("\n\n")
|
||||
yield wrapped_text
|
||||
pop_style()
|
||||
|
||||
elif node_type == "code":
|
||||
style = push_style("markdown.code")
|
||||
stack.top.append(current.literal, style)
|
||||
pop_style()
|
||||
elif node_type == "softbreak":
|
||||
stack.top.append("\n")
|
||||
elif node_type == "thematic_break":
|
||||
style = push_style("markdown.hr")
|
||||
yield StyledText(f"\n{'—' * options.max_width}\n", style)
|
||||
paragraph_count = 0
|
||||
pop_style()
|
||||
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]))
|
||||
push_style(f"markdown.{node_type}")
|
||||
else:
|
||||
stack.pop()
|
||||
pop_style()
|
||||
|
||||
print(rendered)
|
||||
return rendered
|
||||
yield from rendered
|
||||
|
||||
|
||||
markup = """*hello*, **world**!
|
||||
markup = """
|
||||
# This is a header
|
||||
|
||||
# Hi
|
||||
The main area where I think *Django's models* are `missing` out is the lack of type hinting (hardly surprising since **Django** pre-dates type hints). Adding type hints allows Mypy to detect bugs before you even run your code. It may only save you minutes each time, but multiply that by the number of code + run iterations you do each day, and it can save hours of development time. Multiply that by the lifetime of your project, and it could save weeks or months. A clear win.
|
||||
|
||||
```python
|
||||
code
|
||||
```
|
||||
@property
|
||||
def width(self) -> int:
|
||||
\"\"\"Get the width of the console.
|
||||
|
||||
Returns:
|
||||
int: The width (in characters) of the console.
|
||||
\"\"\"
|
||||
width, _ = self.size
|
||||
return width
|
||||
```
|
||||
|
||||
The main area where I think Django's models are missing out is the lack of type hinting (hardly surprising since Django pre-dates type hints). Adding type hints allows Mypy to detect bugs before you even run your code. It may only save you minutes each time, but multiply that by the number of code + run iterations you do each day, and it can save hours of development time. Multiply that by the lifetime of your project, and it could save weeks or months. A clear win.
|
||||
|
||||
---
|
||||
|
||||
> This is a *block* quote
|
||||
> With another line
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .console import Console
|
||||
|
||||
console = Console()
|
||||
console = Console(width=79)
|
||||
print(console.size)
|
||||
md = Markdown(markup)
|
||||
|
||||
console.print(md)
|
||||
|
|
|
@ -13,6 +13,7 @@ from .color import Color
|
|||
class Style:
|
||||
"""A terminal style."""
|
||||
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
back: Optional[str] = None
|
||||
bold: Optional[bool] = None
|
||||
|
@ -55,7 +56,11 @@ class Style:
|
|||
return " ".join(attributes) or "none"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<style '{self}'>"
|
||||
"""Render a named style differently from an anonymous style."""
|
||||
if self.name is None:
|
||||
return f'<style "{self}">'
|
||||
else:
|
||||
return f'<style {self.name} "{self}">'
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.color:
|
||||
|
@ -67,6 +72,7 @@ class Style:
|
|||
def reset(cls) -> Style:
|
||||
"""Get a style to reset all attributes."""
|
||||
return Style(
|
||||
"reset",
|
||||
color="default",
|
||||
back="default",
|
||||
dim=False,
|
||||
|
@ -80,7 +86,7 @@ class Style:
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, style_definition: str) -> Style:
|
||||
def parse(cls, style_definition: str, name: str = None) -> Style:
|
||||
"""Parse style name(s) in to style object."""
|
||||
style_attributes = {
|
||||
"dim",
|
||||
|
@ -126,7 +132,7 @@ class Style:
|
|||
f"unknown word {original_word!r} in style {style_definition!r}"
|
||||
)
|
||||
color = word
|
||||
style = Style(color=color, back=back, **attributes)
|
||||
style = Style(name, color=color, back=back, **attributes)
|
||||
return style
|
||||
|
||||
@classmethod
|
||||
|
@ -196,7 +202,10 @@ class Style:
|
|||
append("9" if self.strike else "29")
|
||||
|
||||
reset = "\x1b[0m" if reset else ""
|
||||
return f"\x1b[{';'.join(attrs)}m{text or ''}{reset}"
|
||||
if attrs:
|
||||
return f"\x1b[{';'.join(attrs)}m{text or ''}{reset}"
|
||||
else:
|
||||
return f"{text or ''}{reset}"
|
||||
|
||||
def test(self, text: Optional[str] = None) -> None:
|
||||
"""Write test text with style to terminal.
|
||||
|
@ -250,3 +259,7 @@ if __name__ == "__main__":
|
|||
|
||||
style.test()
|
||||
|
||||
style = Style.parse("bold on black", name="markdown.header")
|
||||
print(style)
|
||||
print(repr(style))
|
||||
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
from typing import NamedTuple, Optional
|
||||
|
||||
from .style import Style
|
||||
|
||||
|
||||
class StyledText(NamedTuple):
|
||||
"""A piece of text with associated style."""
|
||||
|
||||
text: str
|
||||
style: Optional[Style] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Simplified repr."""
|
||||
return f"StyledText({self.text!r}, {self.style!r})"
|
340
rich/text.py
340
rich/text.py
|
@ -1,11 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from operator import itemgetter
|
||||
from typing import Iterable, NamedTuple, Optional, List
|
||||
from typing import Iterable, NamedTuple, Optional, List, Tuple, Union
|
||||
from typing_extensions import Literal
|
||||
|
||||
from .console import Console, ConsoleOptions, StyledText
|
||||
from .console import Console, ConsoleOptions, RenderResult, RenderableType
|
||||
from .style import Style
|
||||
from .styled_text import StyledText
|
||||
|
||||
|
||||
class TextSpan(NamedTuple):
|
||||
|
@ -13,22 +14,65 @@ class TextSpan(NamedTuple):
|
|||
|
||||
start: int
|
||||
end: int
|
||||
style: str
|
||||
style: Union[str, Style]
|
||||
|
||||
def adjust_offset(self, offset: int, max_length) -> TextSpan:
|
||||
def __repr__(self) -> str:
|
||||
return f'<textspan {self.start}:{self.end} "{self.style}">'
|
||||
|
||||
def split(self, offset: int) -> Tuple[Optional[TextSpan], Optional[TextSpan]]:
|
||||
"""Split a span in to 2 from a given offset."""
|
||||
|
||||
if offset < self.start:
|
||||
return self, None
|
||||
if offset >= self.end:
|
||||
return self, None
|
||||
|
||||
span1 = TextSpan(self.start, min(self.end, offset), self.style)
|
||||
span2 = TextSpan(span1.end, self.end, self.style)
|
||||
return span1, span2
|
||||
|
||||
def overlaps(self, start: int, end: int) -> bool:
|
||||
"""Check if a range overlaps this span.
|
||||
|
||||
Args:
|
||||
start (int): Start offset.
|
||||
end (int): End offset.
|
||||
|
||||
Returns:
|
||||
bool: True if stat and end overlaps this span, otherwise False.
|
||||
"""
|
||||
return (
|
||||
(self.start <= end < self.end)
|
||||
or (self.start <= start < self.end)
|
||||
or (start <= self.start and end > self.end)
|
||||
)
|
||||
|
||||
def adjust_offset(self, offset: int, max_length: int) -> TextSpan:
|
||||
"""Get a new `TextSpan` with start and end adjusted.
|
||||
|
||||
Args:
|
||||
offset (int): Number of characters to adjust offset by.
|
||||
max_length(int): Maximum length of new span.
|
||||
|
||||
Returns:
|
||||
TextSpan: A new text span.
|
||||
"""
|
||||
return TextSpan(
|
||||
min(max_length, max(0, self.start + offset)),
|
||||
min(max_length, max(0, self.end + offset)),
|
||||
self.style,
|
||||
)
|
||||
|
||||
start, end, style = self
|
||||
start = max(0, start + offset)
|
||||
return TextSpan(start, min(start + max_length, max(0, end + offset)), style)
|
||||
|
||||
def move(self, offset: int) -> TextSpan:
|
||||
"""Move start and end by a given offset.
|
||||
|
||||
Args:
|
||||
offset (int): Number of characters to add to start and end.
|
||||
|
||||
Returns:
|
||||
TextSpan: A new TextSpan with adjusted position.
|
||||
"""
|
||||
start, end, style = self
|
||||
return TextSpan(start + offset, end + offset, style)
|
||||
|
||||
def slice_text(self, text: str) -> str:
|
||||
"""Slice the text according to the start and end offsets.
|
||||
|
@ -41,19 +85,21 @@ class TextSpan(NamedTuple):
|
|||
"""
|
||||
return text[self.start : self.end]
|
||||
|
||||
def right_crop(self, offset: int) -> TextSpan:
|
||||
start, end, style = self
|
||||
if offset > end:
|
||||
return self
|
||||
return TextSpan(start, max(offset, end), style)
|
||||
|
||||
class RichText:
|
||||
|
||||
class Text:
|
||||
"""Text with colored spans."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str = "",
|
||||
wrap=True,
|
||||
align: Literal["left", "center", "right"] = "left",
|
||||
self, text: str = "", style: Union[str, Style] = None, end: str = "",
|
||||
) -> None:
|
||||
self._text: List[str] = [text]
|
||||
self._wrap = wrap
|
||||
self._align = align
|
||||
self._style = style
|
||||
self._text_str: Optional[str] = text
|
||||
self._spans: List[TextSpan] = []
|
||||
self._length: int = len(text)
|
||||
|
@ -65,9 +111,15 @@ class RichText:
|
|||
return self.text
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"RichText({self.text!r})"
|
||||
return f"<text {self.text!r} {self._spans!r}>"
|
||||
|
||||
def stylize(self, start: int, end: int, style: str) -> None:
|
||||
def copy(self) -> Text:
|
||||
"""Return a copy of this instance."""
|
||||
copy_self = Text(self.text, style=self._style)
|
||||
copy_self._spans = self._spans[:]
|
||||
return copy_self
|
||||
|
||||
def stylize(self, start: int, end: int, style: Union[str, Style]) -> None:
|
||||
"""Apply a style to a portion of the text.
|
||||
|
||||
Args:
|
||||
|
@ -84,7 +136,7 @@ class RichText:
|
|||
return
|
||||
self._spans.append(TextSpan(max(0, start), min(length, end), style))
|
||||
|
||||
def __console_render__(
|
||||
def __console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> Iterable[StyledText]:
|
||||
"""Render the rich text to the console.
|
||||
|
@ -100,8 +152,16 @@ class RichText:
|
|||
text = self.text
|
||||
stack: List[Style] = []
|
||||
|
||||
get_style = console.get_style
|
||||
null_style = Style()
|
||||
|
||||
def get_style(style: Union[str, Style]) -> Style:
|
||||
|
||||
if isinstance(style, str):
|
||||
return console.parse_style(style)
|
||||
return style
|
||||
|
||||
stack.append(get_style(self._style) if self._style is not None else Style())
|
||||
|
||||
start_spans = (
|
||||
(span.start, True, get_style(span.style) or null_style)
|
||||
for span in self._spans
|
||||
|
@ -119,8 +179,9 @@ class RichText:
|
|||
]
|
||||
spans.sort(key=itemgetter(0))
|
||||
|
||||
current_style = Style()
|
||||
for (offset, entering, style), (next_offset, _, _) in zip(spans, spans[1:]):
|
||||
current_style = stack[-1]
|
||||
for (offset, entering, _style), (next_offset, _, _) in zip(spans, spans[1:]):
|
||||
style = get_style(_style)
|
||||
if entering:
|
||||
stack.append(style)
|
||||
current_style = current_style.apply(style)
|
||||
|
@ -132,6 +193,10 @@ class RichText:
|
|||
span_text = text[offset:next_offset]
|
||||
yield StyledText(span_text, current_style)
|
||||
|
||||
while stack:
|
||||
style = stack.pop()
|
||||
yield StyledText("", style)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Get the text as a single string."""
|
||||
|
@ -139,7 +204,37 @@ class RichText:
|
|||
self._text_str = "".join(self._text)
|
||||
return self._text_str
|
||||
|
||||
def append(self, text: str, style: str = None) -> None:
|
||||
@text.setter
|
||||
def text(self, new_text: str) -> Text:
|
||||
"""Set the text to a new value."""
|
||||
self._text[:] = [new_text]
|
||||
self._text_str = new_text
|
||||
return self
|
||||
|
||||
def pad_left(self, count: int, character: str = " ") -> None:
|
||||
"""Pad the left with a given character.
|
||||
|
||||
Args:
|
||||
count (int): Number of characters to pad.
|
||||
character (str, optional): Character to pad with. Defaults to " ".
|
||||
"""
|
||||
assert len(character) == 1, "Character must be a string of length 1"
|
||||
if count:
|
||||
self.text = f"{character * count}{self.text}"
|
||||
self._spans[:] = [span.move(count) for span in self._spans]
|
||||
|
||||
def pad_right(self, count: int, character: str = " ") -> None:
|
||||
"""Pad the right with a given character.
|
||||
|
||||
Args:
|
||||
count (int): Number of characters to pad.
|
||||
character (str, optional): Character to pad with. Defaults to " ".
|
||||
"""
|
||||
assert len(character) == 1, "Character must be a string of length 1"
|
||||
if count:
|
||||
self.text = f"{self.text}{character * count}"
|
||||
|
||||
def append(self, text: str, style: Union[str, Style] = None) -> None:
|
||||
"""Add text with an optional style.
|
||||
|
||||
Args:
|
||||
|
@ -149,11 +244,12 @@ class RichText:
|
|||
self._text.append(text)
|
||||
offset = len(self)
|
||||
text_length = len(text)
|
||||
self._spans.append(TextSpan(offset, offset + text_length, style or "none"))
|
||||
if style is not None:
|
||||
self._spans.append(TextSpan(offset, offset + text_length, style))
|
||||
self._length += text_length
|
||||
self._text_str = None
|
||||
|
||||
def split(self, separator="\n") -> List[RichText]:
|
||||
def split(self, separator="\n") -> List[Text]:
|
||||
"""Split rich text in to lines, preserving styles.
|
||||
|
||||
Args:
|
||||
|
@ -162,38 +258,180 @@ class RichText:
|
|||
Returns:
|
||||
List[RichText]: A list of rich text, one per line of the original.
|
||||
"""
|
||||
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)
|
||||
assert separator, "separator must not be empty"
|
||||
|
||||
text = self.text
|
||||
if separator not in text:
|
||||
return [self.copy()]
|
||||
offsets: List[int] = []
|
||||
append = offsets.append
|
||||
offset = 0
|
||||
while True:
|
||||
try:
|
||||
offset = text.index(separator, offset) + len(separator)
|
||||
except ValueError:
|
||||
break
|
||||
append(offset)
|
||||
return self.divide(offsets)
|
||||
|
||||
def divide(self, offsets: Iterable[int]) -> Lines:
|
||||
"""Divide text in to a number of lines at given offsets.
|
||||
|
||||
Args:
|
||||
offsets (Iterable[int]): Offsets used to divide text.
|
||||
|
||||
Returns:
|
||||
Lines: New RichText instances between offsets.
|
||||
"""
|
||||
|
||||
if not offsets:
|
||||
return Lines([self.copy()])
|
||||
|
||||
text = self.text
|
||||
text_length = len(text)
|
||||
divide_offsets = [0, *offsets, text_length]
|
||||
line_ranges = list(zip(divide_offsets, divide_offsets[1:]))
|
||||
average_line_length = text_length // (len(divide_offsets) - 1)
|
||||
|
||||
new_lines = Lines(
|
||||
Text(text[start:end].rstrip(), style=self._style)
|
||||
for start, end in line_ranges
|
||||
)
|
||||
|
||||
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, len(line)))
|
||||
line_index = span.start // average_line_length
|
||||
line_start, line_end = line_ranges[line_index]
|
||||
if span.start < line_start:
|
||||
while True:
|
||||
line_index -= 1
|
||||
line_start, line_end = line_ranges[line_index]
|
||||
if span.end >= line_start:
|
||||
break
|
||||
elif span.start > line_end:
|
||||
while True:
|
||||
line_index += 1
|
||||
line_start, line_end = line_ranges[line_index]
|
||||
if span.start <= line_end:
|
||||
break
|
||||
|
||||
while True:
|
||||
span, new_span = span.split(line_end)
|
||||
new_lines[line_index]._spans.append(span.move(-line_start))
|
||||
if new_span is None:
|
||||
break
|
||||
span = new_span
|
||||
line_index += 1
|
||||
line_start, line_end = line_ranges[line_index]
|
||||
|
||||
return new_lines
|
||||
|
||||
def wrap(
|
||||
self, width: int, justify: Literal["left", "center", "right"] = "left"
|
||||
) -> Lines:
|
||||
"""Word wrap the text.
|
||||
|
||||
Args:
|
||||
width (int): Number of characters per line.
|
||||
justify (bool, optional): True to pad lines with spaces. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Lines: Number of lines.
|
||||
"""
|
||||
|
||||
lines: Lines = Lines()
|
||||
for line in self.split():
|
||||
text = line.text
|
||||
text_length = len(text)
|
||||
line_start = 0
|
||||
line_end = width
|
||||
offsets: List[int] = []
|
||||
while line_end < text_length:
|
||||
break_offset = text.rfind(" ", line_start, line_end)
|
||||
if break_offset != -1:
|
||||
line_end = break_offset + 1
|
||||
line_start = line_end
|
||||
line_end = line_start + width + 1
|
||||
offsets.append(line_start)
|
||||
new_lines = line.divide(offsets)
|
||||
if justify:
|
||||
new_lines.justify(width, align=justify)
|
||||
lines.extend(new_lines)
|
||||
return lines
|
||||
|
||||
|
||||
class Lines(List[Text]):
|
||||
"""A list subclass which can render to the console."""
|
||||
|
||||
def __console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
||||
"""Console render method to insert line-breaks."""
|
||||
length = len(self)
|
||||
last_index = length - 1
|
||||
for index, line in enumerate(self):
|
||||
yield line
|
||||
if index != last_index:
|
||||
yield StyledText("\n")
|
||||
|
||||
def justify(
|
||||
self, width: int, align: Literal["left", "center", "right"] = "left"
|
||||
) -> None:
|
||||
"""Pad each line with spaces to a given width.
|
||||
|
||||
Args:
|
||||
width (int): Number of characters per line.
|
||||
|
||||
"""
|
||||
if align == "left":
|
||||
for line in self:
|
||||
line.pad_right(width - len(line.text))
|
||||
elif align == "center":
|
||||
for line in self:
|
||||
line.pad_left((width - len(line.text)) // 2)
|
||||
line.pad_right(width - len(line.text))
|
||||
elif align == "right":
|
||||
for line in self:
|
||||
line.pad_left(width - len(line.text))
|
||||
|
||||
|
||||
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")
|
||||
|
||||
text = """\
|
||||
The main area where I think Django's models are missing out is the lack of type hinting (hardly surprising since Django pre-dates type hints). Adding type hints allows Mypy to detect bugs before you even run your code. It may only save you minutes each time, but multiply that by the number of code + run iterations you do each day, and it can save hours of development time. Multiply that by the lifetime of your project, and it could save weeks or months. A clear win.
|
||||
""".rstrip()
|
||||
console = Console()
|
||||
# for line in rich_text.split("\n"):
|
||||
# console.print(line)
|
||||
rtext = Text(text, style=Style.parse("on black"))
|
||||
rtext.stylize(20, 60, "bold yellow")
|
||||
rtext.stylize(28, 36, "underline")
|
||||
rtext.stylize(259, 357, "yellow on blue")
|
||||
print("\n")
|
||||
console.print(rtext)
|
||||
print("\n")
|
||||
print(repr(rtext))
|
||||
print("\n")
|
||||
|
||||
console.print(rich_text)
|
||||
# console.print(rtext)
|
||||
|
||||
lines = rtext.wrap(50, justify="left")
|
||||
for line in lines:
|
||||
print(repr(line))
|
||||
print("-" * 50)
|
||||
|
||||
with console.style(Style()):
|
||||
console.print(lines)
|
||||
# console.wrap(50)
|
||||
|
||||
# if __name__ == "__main__":
|
||||
|
||||
# rich_text = RichText("0123456789012345")
|
||||
# rich_text.stylize(0, 100, "magenta")
|
||||
# rich_text.stylize(1, 5, "cyan")
|
||||
# rich_text.stylize(2, 8, "bold")
|
||||
|
||||
|
||||
# console = Console()
|
||||
|
||||
# console.print(rich_text)
|
||||
|
||||
# for line in rich_text.divide([4, 8]):
|
||||
# console.print(line)
|
||||
|
||||
# for line in rich_text.split("3"):
|
||||
# console.print(line)
|
||||
|
|
Loading…
Reference in New Issue