text formatting

This commit is contained in:
Will McGugan 2019-11-20 15:29:18 +00:00
parent 8708f78689
commit 724d3d8ae1
6 changed files with 326 additions and 82 deletions

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from collections import ChainMap
from contextlib import contextmanager
from dataclasses import dataclass
from dataclasses import dataclass, replace
from enum import Enum
import re
import shutil
@ -37,6 +37,9 @@ class ConsoleOptions:
encoding: str
min_width: int = 1
def copy(self) -> ConsoleOptions:
return replace(self)
class SupportsStr(Protocol):
def __str__(self) -> str:
@ -92,7 +95,7 @@ class Console:
file: IO = None,
width: int = None,
height: int = None,
markup: str = "markdown",
markup: Optional[str] = "markdown",
):
self._styles = ChainMap(styles)
self.file = file or sys.stdout
@ -167,7 +170,7 @@ class Console:
self._check_buffer()
def render(
self, renderable: RenderableType, options: ConsoleOptions
self, renderable: RenderableType, options: Optional[ConsoleOptions]
) -> Iterable[StyledText]:
"""Render an object in to an iterable of `StyledText` instances.
@ -184,18 +187,46 @@ class Console:
Iterable[StyledText]: An iterable of styled text that may be rendered.
"""
render_iterable: Iterable[RenderableType]
render_options = options or self.options
if isinstance(renderable, StyledText):
yield renderable
elif isinstance(renderable, ConsoleRenderable):
render_iterable = renderable.__console__(self, options)
render_iterable = renderable.__console__(self, render_options)
else:
render_iterable = self.render_str(str(renderable), options)
render_iterable = self.render_str(str(renderable), render_options)
for render_output in render_iterable:
if isinstance(render_output, StyledText):
yield render_output
else:
yield from self.render(render_output, options)
yield from self.render(render_output, render_options)
def render_all(
self, renderables: Iterable[RenderableType], options: Optional[ConsoleOptions]
) -> Iterable[StyledText]:
render_options = options or self.options
for renderable in renderables:
yield from self.render(renderable, render_options)
def render_lines(
self, renderables: Iterable[RenderableType], options: Optional[ConsoleOptions]
) -> List[List[StyledText]]:
from .text import Text
contents: List[StyledText] = []
render_options = options or self.options
for renderable in renderables:
contents.extend(self.render(renderable, render_options))
new_text = Text.from_styled_text(contents)
split_text = new_text.split()
lines: List[List[StyledText]] = []
for line in split_text:
line.end = ""
line.set_length(render_options.max_width)
lines.append(list(line.__console__(self, render_options)))
return lines
def render_str(
self, text: str, options: ConsoleOptions

View File

@ -46,13 +46,13 @@ MARKDOWN_STYLES = {
"markdown.block_quote": Style("markdown.code_block", color="magenta"),
"markdown.list": Style("markdown.list", color="cyan"),
"markdown.item": Style("markdown.item", underline=True),
"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),
"markdown.hr": Style("markdown.hr", dim=True),
"markdown.h1": Style("markdown.h1", bold=True),
"markdown.h2": Style("markdown.h2", bold=True, underline=True),
"markdown.h3": Style("markdown.h3", bold=True),
"markdown.h4": Style("markdown.h4", bold=True, dim=True),
"markdown.h5": Style("markdown.h5", underline=True),
"markdown.h6": Style("markdown.h6", italic=True),
"markdown.h7": Style("markdown.h7", italic=True, dim=True),
}
DEFAULT_STYLES.update(MARKDOWN_STYLES)

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Union
from typing import Any, ClassVar, Dict, Iterable, List, Optional, Union
from commonmark.blocks import Parser
@ -12,12 +12,16 @@ from .console import (
RenderResult,
StyledText,
)
from .panel import Panel
from .style import Style, StyleStack
from .text import Lines, Text
from ._stack import Stack
class MarkdownElement:
new_line: ClassVar[bool] = True
@classmethod
def create(cls, node: Any) -> MarkdownElement:
return cls()
@ -32,8 +36,8 @@ class MarkdownElement:
return
yield
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> None:
pass
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
return True
class UnknownElement(MarkdownElement):
@ -44,22 +48,17 @@ class TextElement(MarkdownElement):
style_name = "none"
def __init__(self) -> None:
self.text = Text()
def on_enter(self, context: MarkdownContext) -> None:
context.enter_style(self.style_name)
self.text = Text(style=context.current_style)
def on_text(self, context: MarkdownContext, text: str) -> None:
self.text.append(text, context.current_style)
def on_leave(self, context: MarkdownContext) -> Iterable[Lines]:
def on_leave(self, context: MarkdownContext) -> RenderResult:
context.leave_style()
yield self.text
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> None:
pass
class Paragraph(TextElement):
style_name = "markdown.paragraph"
@ -67,7 +66,7 @@ class Paragraph(TextElement):
def on_leave(self, context: MarkdownContext) -> Iterable[Lines]:
context.leave_style()
lines = self.text.wrap(context.options.max_width)
yield lines
yield from lines
class Heading(TextElement):
@ -76,16 +75,63 @@ class Heading(TextElement):
heading = Heading(node.level)
return heading
def on_enter(self, context: MarkdownContext) -> None:
self.text = Text(style=context.current_style, end="")
context.enter_style(self.style_name)
def __init__(self, level: int) -> None:
self.level = level
self.style_name = f"markdown.h{level}"
super().__init__()
def on_leave(self, context: MarkdownContext) -> Iterable[Lines]:
context.leave_style()
lines = self.text.wrap(context.options.max_width, justify="center")
self.text.justify = "center"
if self.level == 1:
yield Panel(self.text)
else:
yield self.text
yield StyledText("\n")
# lines = self.text.wrap(context.options.max_width, justify="center")
# yield lines
class CodeBlock(TextElement):
style_name = "markdown.code_block"
def on_leave(self, context: MarkdownContext) -> Iterable[Lines]:
context.leave_style()
lines = self.text.wrap(context.options.max_width, justify="left")
yield lines
class BlockQuote(TextElement):
style_name = "markdown.block_quote"
def __init__(self) -> None:
self.elements = []
def on_enter(self, context: MarkdownContext) -> None:
context.enter_style(self.style_name)
def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool:
self.elements.append(child)
return False
def on_leave(self, context: MarkdownContext) -> Iterable[Lines]:
context.leave_style()
for element in self.elements:
yield from element.on_leave(context)
class HorizontalRule(MarkdownElement):
new_line = False
def on_leave(self, context: MarkdownContext) -> Iterable[StyledText]:
style = context.console.get_style("markdown.hr")
yield StyledText("" * context.options.max_width, style)
class MarkdownContext:
"""Manages the console render state."""
@ -97,24 +143,34 @@ class MarkdownContext:
@property
def current_style(self) -> Style:
"""Current style which is the product of all styles on the stack."""
return self.style_stack.current
def on_text(self, text: str) -> None:
"""Called when the parser visits text."""
self.stack.top.on_text(self, text)
def enter_style(self, style_name: str) -> None:
style = console.get_style(style_name) or console.get_style("none")
"""Enter a style context."""
style = self.console.get_style(style_name) or self.console.get_style("none")
self.style_stack.push(style)
def leave_style(self) -> Style:
"""Leave a style context."""
style = self.style_stack.pop()
return style
class Markdown:
elements = {"paragraph": Paragraph, "heading": Heading}
inlines = {"emph", "strong"}
elements: ClassVar[Dict[str, MarkdownElement]] = {
"paragraph": Paragraph,
"heading": Heading,
"code_block": CodeBlock,
"block_quote": BlockQuote,
"thematic_break": HorizontalRule,
}
inlines = {"emph", "strong", "code"}
def __init__(self, markup: str) -> None:
"""Parses the markup."""
@ -126,36 +182,56 @@ class Markdown:
"""Render markdown to the console."""
context = MarkdownContext(console, options)
nodes = self.parsed.walker()
inlines = self.inlines
for current, entering in nodes:
# print(dir(current))
print(current, entering)
print(current.is_container())
node_type = current.t
if node_type == "text":
context.on_text(current.literal)
elif node_type == "softbreak":
if entering:
context.on_text("\n")
elif node_type in self.inlines:
if entering:
context.enter_style(f"markdown.{node_type}")
elif node_type in inlines:
if current.is_container():
if entering:
context.enter_style(f"markdown.{node_type}")
else:
context.leave_style()
else:
context.enter_style(f"markdown.{node_type}")
if current.literal:
context.on_text(current.literal)
context.leave_style()
else:
element_class = self.elements.get(node_type) or UnknownElement
if current.is_container():
if entering:
element = element_class.create(current)
context.stack.push(element)
element.on_enter(context)
else:
element = context.stack.pop()
if entering:
if context.stack:
if context.stack.top.on_child_close(context, element):
if element.new_line:
yield StyledText("\n")
yield from element.on_leave(context)
else:
yield from element.on_leave(context)
else:
element = element_class.create(current)
context.stack.push(element)
element.on_enter(context)
else:
element = context.stack.pop()
if context.stack:
if current.literal:
element.on_text(context, current.literal.rstrip())
context.stack.pop()
if context.stack and element.new_line:
yield StyledText("\n")
yield from element.on_leave(context)
if context.stack:
context.stack.top.on_child_close(context, element)
# class Markdown:
# """Render markdown to the console."""
@ -276,6 +352,16 @@ class Markdown:
markup = """
# This is a header
## This is a header L2
### This is a header L3
#### This is a header L4
##### This is a header L5
###### This is a header L6
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.
```
@ -301,13 +387,14 @@ The main area where I think Django's models are missing out is the lack of type
* baz
"""
markup = """\
# Heading
# markup = """\
# # Heading
Hello, *World*!
**Bold**
# This is `code`!
# Hello, *World*!
# **Bold**
"""
# """
if __name__ == "__main__":
from .console import Console

50
rich/panel.py Normal file
View File

@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Tuple
from .console import Console, ConsoleOptions, ConsoleRenderable, RenderResult
from .text import Text
from .styled_text import StyledText
BOX = """\
"""
class Panel:
def __init__(self, *contents: ConsoleRenderable) -> None:
self.contents: Tuple[ConsoleRenderable, ...] = contents
def __console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
width = options.max_width
contents_width = width - 2
child_options = options.copy()
child_options.max_width = contents_width
lines = console.render_lines(self.contents, child_options)
# contents = list(console.render_all(self.contents, child_options))
# lines = Text.reformat(contents_width, contents)
box = BOX
top_left = box[0]
top = box[1]
top_right = box[2]
left = box[4]
right = box[6]
bottom_left = box[8]
bottom = box[9]
bottom_right = box[10]
line_start = StyledText(left)
line_end = StyledText(f"{right}\n")
yield StyledText(f"{top_left}{top * contents_width}{top_right}\n")
for line in lines:
yield line_start
yield from line
yield line_end
yield StyledText(f"{bottom_left}{bottom * contents_width}{bottom_right}\n")

View File

@ -117,6 +117,14 @@ class Style:
else:
return f'<style {self.name} "{self}">'
def __eq__(self, other) -> bool:
return (
self._color == other._color
and self._back == self._back
and self._set_attributes == other._set_attributes
and self._attributes == other._attributes,
)
@property
def color(self) -> Optional[str]:
return self._color.name if self._color is not None else None
@ -281,6 +289,7 @@ class Style:
return self
new_style = style.__new__(Style)
new_style.name = self.name
new_style._color = self._color if style._color is None else style._color
new_style._back = self._back if style._back is None else style._back
new_style._attributes = (style._attributes & ~self._set_attributes) | (

View File

@ -8,6 +8,8 @@ from .console import Console, ConsoleOptions, RenderResult, RenderableType
from .style import Style
from .styled_text import StyledText
JustifyValues = Optional[Literal["left", "center", "right"]]
class TextSpan(NamedTuple):
"""A marked up region in some text."""
@ -19,7 +21,7 @@ class TextSpan(NamedTuple):
def __repr__(self) -> str:
return f'<textspan {self.start}:{self.end} "{self.style}">'
def split(self, offset: int) -> Tuple[Optional[TextSpan], Optional[TextSpan]]:
def split(self, offset: int) -> Tuple[TextSpan, Optional[TextSpan]]:
"""Split a span in to 2 from a given offset."""
if offset < self.start:
@ -87,19 +89,25 @@ class TextSpan(NamedTuple):
def right_crop(self, offset: int) -> TextSpan:
start, end, style = self
if offset > end:
if offset >= end:
return self
return TextSpan(start, max(offset, end), style)
return TextSpan(start, min(offset, end), style)
class Text:
"""Text with colored spans."""
def __init__(
self, text: str = "", style: Union[str, Style] = None, end: str = "",
self,
text: str = "",
style: Union[str, Style] = None,
justify: JustifyValues = "left",
end: str = "\n",
) -> None:
self._text: List[str] = [text]
self._style = style
self._text: List[str] = [text] if text else []
self.style = style
self.justify = justify
self.end = end
self._text_str: Optional[str] = text
self._spans: List[TextSpan] = []
self._length: int = len(text)
@ -113,9 +121,24 @@ class Text:
def __repr__(self) -> str:
return f"<text {self.text!r} {self._spans!r}>"
@classmethod
def from_styled_text(cls, styled_text: Iterable[StyledText]) -> Text:
text = Text(justify=None)
append_span = text._spans.append
append_text = text._text.append
offset = 0
for text_str, style in styled_text:
span_length = len(text_str)
append_span(TextSpan(offset, offset + span_length, style or "none"))
append_text(text_str)
offset += span_length
text._length += span_length
text._text_str = None
return text
def copy(self) -> Text:
"""Return a copy of this instance."""
copy_self = Text(self.text, style=self._style)
copy_self = Text(self.text, style=self.style)
copy_self._spans = self._spans[:]
return copy_self
@ -136,8 +159,32 @@ class Text:
return
self._spans.append(TextSpan(max(0, start), min(length, end), style))
def set_length(self, new_length: int) -> None:
"""Set new length of the text, clipping or padding is required."""
length = len(self)
if length == new_length:
return
if length < new_length:
self.pad_right(new_length - length)
else:
text = self.text[:new_length]
self.text = text
new_spans = []
for span in self._spans:
if span.start >= new_length:
break
new_spans.append(span.right_crop(new_length))
self._spans[:] = new_spans
def __console__(
self, console: Console, options: ConsoleOptions
) -> Iterable[StyledText]:
lines = self.wrap(options.max_width, justify=self.justify)
for line in lines:
yield from self._render_line(line, console, options)
def _render_line(
self, line: Text, console: Console, options: ConsoleOptions
) -> Iterable[StyledText]:
"""Render the rich text to the console.
@ -149,26 +196,26 @@ class Text:
Iterable[StyledText]: An iterable of styled text.
"""
text = self.text
text = line.text
stack: List[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())
stack.append(get_style(line.style) if line.style is not None else Style())
start_spans = (
(span.start, True, get_style(span.style) or null_style)
for span in self._spans
for span in line._spans
)
end_spans = (
(span.end, False, get_style(span.style) or null_style)
for span in self._spans
for span in line._spans
)
spans = [
@ -177,11 +224,13 @@ class Text:
*end_spans,
(len(text), False, null_style),
]
spans.sort(key=itemgetter(0))
spans.sort(key=itemgetter(0, 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)
@ -190,12 +239,26 @@ class Text:
stack.remove(style)
stack.reverse()
current_style = Style.combine(stack)
span_text = text[offset:next_offset]
yield StyledText(span_text, current_style)
if next_offset > offset:
span_text = text[offset:next_offset]
yield StyledText(span_text, current_style)
while stack:
style = stack.pop()
yield StyledText("", style)
# while stack:
# style = stack.pop()
# yield StyledText("", style)
if self.end:
yield StyledText(self.end)
@classmethod
def join(cls, lines: Iterable[Text]) -> Text:
"""Join lines in to a new text instance."""
new_text = Text()
new_text.text = "\n".join(line.text for line in lines)
offset = 0
for line in lines:
new_text._spans.extend(span.move(offset) for span in line._spans)
offset += len(line.text)
return new_text
@property
def text(self) -> str:
@ -209,6 +272,7 @@ class Text:
"""Set the text to a new value."""
self._text[:] = [new_text]
self._text_str = new_text
self._length = len(new_text)
return self
def pad_left(self, count: int, character: str = " ") -> None:
@ -260,7 +324,7 @@ class Text:
"""
assert separator, "separator must not be empty"
text = self.text
text = self.text.rstrip("\n")
if separator not in text:
return [self.copy()]
offsets: List[int] = []
@ -285,7 +349,8 @@ class Text:
"""
if not offsets:
return Lines([self.copy()])
line = self.copy()
return Lines([line])
text = self.text
text_length = len(text)
@ -294,7 +359,7 @@ class Text:
average_line_length = -(-text_length // len(line_ranges))
new_lines = Lines(
Text(text[start:end].rstrip(), style=self._style)
Text(text[start:end].rstrip(), style=self.style)
for start, end in line_ranges
)
@ -325,9 +390,7 @@ class Text:
return new_lines
def wrap(
self, width: int, justify: Literal["left", "center", "right"] = "left"
) -> Lines:
def wrap(self, width: int, justify: JustifyValues = "left") -> Lines:
"""Word wrap the text.
Args:
@ -366,7 +429,6 @@ class Lines(List[Text]):
"""Console render method to insert line-breaks."""
for line in self:
yield line
yield StyledText("\n")
def justify(
self, width: int, align: Literal["left", "center", "right"] = "left"
@ -393,26 +455,31 @@ if __name__ == "__main__":
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()
console = Console(width=50, markup=None)
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(rtext)
# lines = rtext.wrap(console.width, justify="left")
# for line in lines:
# print(repr(line))
# print("-" * 50)
lines = rtext.wrap(50, justify="left")
for line in lines:
print(repr(line))
print("-" * 50)
# with console.style(Style()):
# console.print(lines)
from .panel import Panel
print("--")
panel = Panel(rtext)
console.print(panel)
p = Text("hello")
console.print(Panel(p))
with console.style(Style()):
console.print(lines)
# console.wrap(50)
# if __name__ == "__main__":