functionality update

This commit is contained in:
Will McGugan 2019-11-15 21:47:29 +00:00
parent 065f0a7ac7
commit a6db216788
9 changed files with 615 additions and 182 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.DS_Store
.vscode
# Byte-compiled / optimized / DLL files
__pycache__/

15
rich/_stack.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

14
rich/styled_text.py Normal file
View File

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

View File

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