diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfb0c09..aa337e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.2.0] - 2020-09-13 + +### Added + +- Added inline code highlighting to Markdown + ## [6.1.2] - 2020-09-11 ### Added diff --git a/examples/group2.py b/examples/group2.py index f48ad15e..13be0704 100644 --- a/examples/group2.py +++ b/examples/group2.py @@ -2,9 +2,11 @@ from rich import print from rich.console import render_group from rich.panel import Panel + @render_group() def get_panels(): yield Panel("Hello", style="on blue") yield Panel("World", style="on red") + print(Panel(get_panels())) diff --git a/examples/padding.py b/examples/padding.py index 024ff5d1..b01d2743 100644 --- a/examples/padding.py +++ b/examples/padding.py @@ -1,4 +1,5 @@ from rich import print from rich.padding import Padding + test = Padding("Hello", (2, 4), style="on blue", expand=False) print(test) diff --git a/pyproject.toml b/pyproject.toml index dda3ad1c..ec97f67e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rich" homepage = "https://github.com/willmcgugan/rich" documentation = "https://rich.readthedocs.io/en/latest/" -version = "6.1.2" +version = "6.2.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" authors = ["Will McGugan "] license = "MIT" diff --git a/rich/console.py b/rich/console.py index f61f4290..9cf98955 100644 --- a/rich/console.py +++ b/rich/console.py @@ -182,7 +182,7 @@ class RenderGroup: def render_group(fit: bool = False) -> Callable: """A decorator that turns an iterable of renderables in to a group.""" - + def decorator(method): """Convert a method that returns an iterable of renderables in to a RenderGroup.""" diff --git a/rich/markdown.py b/rich/markdown.py index 1e622776..8074356f 100644 --- a/rich/markdown.py +++ b/rich/markdown.py @@ -5,20 +5,14 @@ from commonmark.blocks import Parser from . import box from ._loop import loop_first from ._stack import Stack -from .console import ( - Console, - ConsoleOptions, - JustifyMethod, - RenderResult, - Segment, -) +from .console import Console, ConsoleOptions, JustifyMethod, RenderResult, Segment from .containers import Renderables from .jupyter import JupyterMixin from .panel import Panel from .rule import Rule from .style import Style, StyleStack from .syntax import Syntax -from .text import Text +from .text import Text, TextType class MarkdownElement: @@ -45,7 +39,7 @@ class MarkdownElement: context (MarkdownContext): The markdown context. """ - def on_text(self, context: "MarkdownContext", text: str) -> None: + def on_text(self, context: "MarkdownContext", text: TextType) -> None: """Called when text is parsed. Args: @@ -99,8 +93,8 @@ class TextElement(MarkdownElement): self.style = context.enter_style(self.style_name) self.text = Text(justify="left") - def on_text(self, context: "MarkdownContext", text: str) -> None: - self.text.append(text, context.current_style) + def on_text(self, context: "MarkdownContext", text: TextType) -> None: + self.text.append(text, context.current_style if isinstance(text, str) else None) def on_leave(self, context: "MarkdownContext") -> None: context.leave_style() @@ -349,20 +343,38 @@ class ImageItem(TextElement): class MarkdownContext: """Manages the console render state.""" - def __init__(self, console: Console, options: ConsoleOptions, style: Style) -> None: + def __init__( + self, + console: Console, + options: ConsoleOptions, + style: Style, + inline_code_lexer: str = None, + inline_code_theme: str = "monokai", + ) -> None: self.console = console self.options = options self.style_stack: StyleStack = StyleStack(style) self.stack: Stack[MarkdownElement] = Stack() + self._syntax: Optional[Syntax] = None + if inline_code_lexer is not None: + self._syntax = Syntax("", inline_code_lexer, theme=inline_code_theme) + @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: + def on_text(self, text: str, node_type: str) -> None: """Called when the parser visits text.""" - self.stack.top.on_text(self, text) + if node_type == "code" and self._syntax is not None: + highlight_text = self._syntax.highlight(text) + highlight_text.rstrip() + self.stack.top.on_text( + self, Text.assemble(highlight_text, style=self.style_stack.current) + ) + else: + self.stack.top.on_text(self, text) def enter_style(self, style_name: Union[str, Style]) -> Style: """Enter a style context.""" @@ -385,6 +397,10 @@ class Markdown(JupyterMixin): justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None. style (Union[str, Style], optional): Optional style to apply to markdown. hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``. + inline_code_lexer: (str, optional): Lexer to use if inline code highlighting is + enabled. Defaults to "python". + inline_code_theme: (Optional[str], optional): Pygments theme for inline code + highlighting, or None for no highlighting. Defaults to None. """ elements: ClassVar[Dict[str, Type[MarkdownElement]]] = { @@ -406,6 +422,8 @@ class Markdown(JupyterMixin): justify: JustifyMethod = None, style: Union[str, Style] = "none", hyperlinks: bool = True, + inline_code_lexer: str = None, + inline_code_theme: str = None, ) -> None: self.markup = markup parser = Parser() @@ -414,26 +432,34 @@ class Markdown(JupyterMixin): self.justify = justify self.style = style self.hyperlinks = hyperlinks + self.inline_code_lexer = inline_code_lexer + self.inline_code_theme = inline_code_theme or code_theme def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: """Render markdown to the console.""" style = console.get_style(self.style, default="none") - context = MarkdownContext(console, options, style) + context = MarkdownContext( + console, + options, + style, + inline_code_lexer=self.inline_code_lexer, + inline_code_theme=self.inline_code_theme, + ) nodes = self.parsed.walker() inlines = self.inlines new_line = False for current, entering in nodes: node_type = current.t if node_type in ("html_inline", "html_block", "text"): - context.on_text(current.literal.replace("\n", " ")) + context.on_text(current.literal.replace("\n", " "), node_type) elif node_type == "linebreak": if entering: - context.on_text("\n") + context.on_text("\n", node_type) elif node_type == "softbreak": if entering: - context.on_text(" ") + context.on_text(" ", node_type) elif node_type == "link": if entering: link_style = console.get_style("markdown.link", default="none") @@ -443,14 +469,14 @@ class Markdown(JupyterMixin): else: context.leave_style() if not self.hyperlinks: - context.on_text(" (") + context.on_text(" (", node_type) style = Style(underline=True) + console.get_style( "markdown.link_url", default="none" ) context.enter_style(style) - context.on_text(current.destination) + context.on_text(current.destination, node_type) context.leave_style() - context.on_text(")") + context.on_text(")", node_type) elif node_type in inlines: if current.is_container(): if entering: @@ -460,7 +486,7 @@ class Markdown(JupyterMixin): else: context.enter_style(f"markdown.{node_type}") if current.literal: - context.on_text(current.literal) + context.on_text(current.literal, node_type) context.leave_style() else: element_class = self.elements.get(node_type) or UnknownElement @@ -521,6 +547,13 @@ if __name__ == "__main__": # pragma: no cover default="monokai", help="pygments code theme", ) + parser.add_argument( + "-i", + "--inline-code-lexer", + dest="inline_code_lexer", + default=None, + help="inline_code_lexer", + ) parser.add_argument( "-y", "--hyperlinks", @@ -560,6 +593,7 @@ if __name__ == "__main__": # pragma: no cover justify="full" if args.justify else "left", code_theme=args.code_theme, hyperlinks=args.hyperlinks, + inline_code_lexer=args.inline_code_lexer, ) if args.page: import pydoc diff --git a/rich/syntax.py b/rich/syntax.py index c5dc951a..d060ab30 100644 --- a/rich/syntax.py +++ b/rich/syntax.py @@ -11,7 +11,7 @@ from pygments.util import ClassNotFound from ._loop import loop_first from .color import Color, blend_rgb, parse_rgb_hex -from .console import Console, ConsoleOptions, RenderResult, Segment +from .console import Console, ConsoleOptions, JustifyMethod, RenderResult, Segment from .jupyter import JupyterMixin from .measure import Measurement from .style import Style @@ -173,18 +173,27 @@ class Syntax(JupyterMixin): style = style + Style(bgcolor=self._pygments_style_class.background_color) return style - def _highlight(self, lexer_name: str) -> Text: + def highlight(self, code: str) -> Text: + """Highlight code and return a Text instance. + + Args: + code (str). Code to highlight. + + Returns: + Text: A text instance containing syntax highlight. + """ + default_style = self._get_default_style() try: - lexer = get_lexer_by_name(lexer_name) + lexer = get_lexer_by_name(self.lexer_name) except ClassNotFound: return Text( - self.code, justify="left", style=default_style, tab_size=self.tab_size + code, justify="left", style=default_style, tab_size=self.tab_size ) text = Text(justify="left", style=default_style, tab_size=self.tab_size) append = text.append _get_theme_style = self._get_theme_style - for token_type, token in lexer.get_tokens(self.code): + for token_type, token in lexer.get_tokens(code): append(token, _get_theme_style(token_type)) return text @@ -244,7 +253,7 @@ class Syntax(JupyterMixin): code = self.code if self.dedent: code = textwrap.dedent(code) - text = self._highlight(self.lexer_name) + text = self.highlight(self.code) if text.plain.endswith("\n"): text.plain = text.plain[:-1] if not self.line_numbers: diff --git a/rich/text.py b/rich/text.py index d8a5b139..bd3c7ce5 100644 --- a/rich/text.py +++ b/rich/text.py @@ -1,3 +1,4 @@ +from functools import partial import re from operator import itemgetter from typing import ( @@ -363,7 +364,7 @@ class Text(JupyterMixin): offset = len(self) + offset get_style = console.get_style - style = console.get_style(self.style).copy() + style = get_style(self.style).copy() for start, end, span_style in self._spans: if offset >= start and offset < end: style += get_style(span_style) @@ -506,14 +507,9 @@ class Text(JupyterMixin): """ text = self.plain - style_map: Dict[int, Style] = {} null_style = Style() - - def get_style(style: Union[str, Style]) -> Style: - return console.get_style(style, default=null_style) - enumerated_spans = list(enumerate(self._spans, 1)) - + get_style = partial(console.get_style, default=null_style) style_map = {index: get_style(span.style) for index, span in enumerated_spans} style_map[0] = get_style(self.style) @@ -530,14 +526,14 @@ class Text(JupyterMixin): stack_pop = stack.remove _Segment = Segment - style_cache: Dict[Tuple[int, ...], Style] = {} + style_cache_get = style_cache.get combine = Style.combine def get_current_style() -> Style: """Construct current style from stack.""" style_ids = tuple(sorted(stack)) - cached_style = style_cache.get(style_ids) + cached_style = style_cache_get(style_ids) if cached_style is not None: return cached_style current_style = combine(style_map[_style_id] for _style_id in style_ids) @@ -879,7 +875,6 @@ class Text(JupyterMixin): (offset, offset + len(line)) for offset, line in zip(divide_offsets, new_lines) ] - for span in self._spans: line_index = (span.start // average_line_length) % len(line_ranges) diff --git a/tests/test_markdown.py b/tests/test_markdown.py index af008f11..2e09b6f2 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -97,6 +97,19 @@ def test_markdown_render(): assert rendered_markdown == expected +def test_inline_code(): + markdown = Markdown( + "inline `import this` code", + inline_code_lexer="python", + inline_code_theme="emacs", + ) + result = render(markdown) + expected = "inline \x1b[1;38;2;170;34;255;48;2;248;248;248mimport\x1b[0m\x1b[38;2;0;0;0;48;2;248;248;248m \x1b[0m\x1b[1;38;2;0;0;255;48;2;248;248;248mthis\x1b[0m code \n" + print(result) + print(repr(result)) + assert result == expected + + if __name__ == "__main__": markdown = Markdown(MARKDOWN) rendered = render(markdown)