From 724d3d8ae1b1d90af8d69c0dc38ac6de44ed4275 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 20 Nov 2019 15:29:18 +0000 Subject: [PATCH] text formatting --- rich/console.py | 43 ++++++++++-- rich/default_styles.py | 16 ++--- rich/markdown.py | 149 ++++++++++++++++++++++++++++++++--------- rich/panel.py | 50 ++++++++++++++ rich/style.py | 9 +++ rich/text.py | 141 ++++++++++++++++++++++++++++---------- 6 files changed, 326 insertions(+), 82 deletions(-) create mode 100644 rich/panel.py diff --git a/rich/console.py b/rich/console.py index 1393e481..cbd91a6f 100644 --- a/rich/console.py +++ b/rich/console.py @@ -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 diff --git a/rich/default_styles.py b/rich/default_styles.py index a42b701b..12ca0f84 100644 --- a/rich/default_styles.py +++ b/rich/default_styles.py @@ -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) diff --git a/rich/markdown.py b/rich/markdown.py index 0983c500..4d1e7407 100644 --- a/rich/markdown.py +++ b/rich/markdown.py @@ -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 diff --git a/rich/panel.py b/rich/panel.py new file mode 100644 index 00000000..1f68803a --- /dev/null +++ b/rich/panel.py @@ -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") diff --git a/rich/style.py b/rich/style.py index c8be7859..46f05c15 100644 --- a/rich/style.py +++ b/rich/style.py @@ -117,6 +117,14 @@ class Style: else: return f'