diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0b4c8e..7358c82f 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). +## [1.1.0] - Unreleased + +### Added + +- Added hyperlinks to Style and markup + ## [1.0.3] - 2020-05-08 ### Added diff --git a/rich/markup.py b/rich/markup.py index 2054bcb7..2cb3d61a 100644 --- a/rich/markup.py +++ b/rich/markup.py @@ -1,7 +1,7 @@ from collections import defaultdict from operator import itemgetter import re -from typing import Dict, Iterable, List, Match, Optional, Tuple, Union +from typing import Dict, Iterable, List, Match, NamedTuple, Optional, Tuple, Union from .errors import MarkupError from .style import Style @@ -9,7 +9,24 @@ from .text import Span, Text from ._emoji_replace import _emoji_replace -re_tags = re.compile(r"(\[\[)|(\]\])|(\[.*?\])") +re_tags = re.compile(r"(\[\[)|(\]\])|\[(\w+?=\".*?\")\]|\[(.*?)\]") + + +class Tag(NamedTuple): + name: str + parameters: Optional[str] + + def __str__(self) -> str: + if self.parameters is None: + return self.name + else: + return f"{self.name} {self.parameters}" + + def __repr__(self) -> str: + if self.parameters is None: + return f"[{self.name}]" + else: + return f"[{self.name} {self.parameters}]" def escape(markup: str) -> str: @@ -24,26 +41,30 @@ def escape(markup: str) -> str: return markup.replace("[", "[[").replace("]", "]]") -def _parse(markup: str) -> Iterable[Tuple[Optional[str], Optional[str]]]: - """Parse markup in to an iterable of pairs of text, tag. +def _parse(markup: str) -> Iterable[Tuple[int, Optional[str], Optional[Tag]]]: + """Parse markup in to an iterable of tuples of (position, text, tag). Args: markup (str): A string containing console markup """ position = 0 + normalize = Style.normalize for match in re_tags.finditer(markup): - escape_open, escape_close, tag_text = match.groups() + escape_open, escape_close, tag_parameters, tag_text = match.groups() start, end = match.span() if start > position: - yield markup[position:start], None - if tag_text is not None: - yield None, tag_text + yield start, markup[position:start], None + if tag_parameters: + text, _, parameters = tag_parameters.partition("=") + yield start, None, Tag(text, parameters.strip('"')) + elif tag_text: + yield start, None, Tag(normalize(tag_text.strip()), None) else: - yield (escape_open and "[") or (escape_close and "]"), None # type: ignore + yield start, (escape_open and "[") or (escape_close and "]"), None # type: ignore position = end if position < len(markup): - yield markup[position:], None + yield start, markup[position:], None def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Text: @@ -62,9 +83,8 @@ def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Te text = Text(style=style) append = text.append - styles: Dict[str, List[int]] = defaultdict(list) - style_stack: List[str] = [] - normalize = Style.normalize + style_stack: List[Tuple[int, Tag]] = [] + pop = style_stack.pop emoji_replace = _emoji_replace spans: List[Span] = [] @@ -72,51 +92,59 @@ def render(markup: str, style: Union[str, Style] = "", emoji: bool = True) -> Te _Span = Span - for plain_text, tag in _parse(markup): + def pop_style(style_name: str) -> Tuple[int, Tag]: + """Pop tag matching given style name.""" + for index, (_, tag) in enumerate(reversed(style_stack), 1): + if tag.name == style_name: + return pop(-index) + raise KeyError(style_name) + + for position, plain_text, tag in _parse(markup): if plain_text is not None: append(emoji_replace(plain_text) if emoji else plain_text) - if tag is not None: - if tag.startswith("[/"): # Closing tag - style_name = tag[2:-1].strip() + elif tag is not None: + if tag.name.startswith("/"): # Closing tag + style_name = tag.name[1:].strip() if style_name: # explicit close - style_name = normalize(style_name) + try: + start, open_tag = pop_style(style_name) + except KeyError: + raise MarkupError( + f"closing tag '{tag!r}' at position {position} doesn't match any open tag" + ) else: # implicit close try: - style_name = style_stack[-1] + start, open_tag = pop() except IndexError: raise MarkupError( - f"closing tag '[/]' at position {len(text)} has nothing to close" + f"closing tag '[/]' at position {position} has nothing to close" ) - try: - style_position = styles[style_name].pop() - except (KeyError, IndexError): - raise MarkupError( - f"closing tag {tag!r} at position {len(text)} doesn't match open tag" - ) - style_stack.remove(style_name) - append_span(_Span(style_position, len(text), style_name)) + + append_span(_Span(start, len(text), str(open_tag))) else: # Opening tag - style_name = normalize(tag[1:-1].strip()) - styles[style_name].append(len(text)) - style_stack.append(style_name) + style_stack.append((len(text), tag)) text_length = len(text) while style_stack: - style_name = style_stack.pop() - style_position = styles[style_name].pop() - append_span(_Span(style_position, text_length, style_name)) + start, tag = style_stack.pop() + append_span(_Span(start, text_length, str(tag))) text.spans = sorted(spans) - return text if __name__ == "__main__": # pragma: no cover - from rich import print + # from rich import print from rich.console import Console + from rich.text import Text console = Console(highlight=False) - console.print("[bold]1 [not bold]2[/] 3[/]") + # t = Text.from_markup('Hello [link="https://www.willmcgugan.com"]W[b]o[/b]rld[/]!') + # print(repr(t._spans)) - console.print("[green]XXX[blue]XXX[/]XXX[/]") + console.print('Hello [link="https://www.willmcgugan.com"]W[b]o[/b]rld[/]!') + + # console.print("[bold]1 [not bold]2[/] 3[/]") + + # console.print("[green]XXX[blue]XXX[/]XXX[/]") diff --git a/rich/style.py b/rich/style.py index 8cdd900b..4ca7c5c1 100644 --- a/rich/style.py +++ b/rich/style.py @@ -50,7 +50,7 @@ class Style: _attributes: int _set_attributes: int - __slots__ = ["_color", "_bgcolor", "_attributes", "_set_attributes"] + __slots__ = ["_color", "_bgcolor", "_attributes", "_set_attributes", "link"] def __init__( self, @@ -66,6 +66,7 @@ class Style: reverse: bool = None, conceal: bool = None, strike: bool = None, + link: str = None, ): def _make_color(color: Union[Color, str]) -> Color: return color if isinstance(color, Color) else Color.parse(color) @@ -94,6 +95,7 @@ class Style: | (conceal is not None) << 7 | (strike is not None) << 8 ) + self.link = link bold = _Bit(0) dim = _Bit(1) @@ -132,6 +134,9 @@ class Style: if self._bgcolor is not None: append("on") append(self._bgcolor.name) + if self.link: + append("link") + append(self.link) return " ".join(attributes) or "none" @classmethod @@ -171,11 +176,18 @@ class Style: and self._bgcolor == other._bgcolor and self._set_attributes == other._set_attributes and self._attributes == other._attributes + and self.link == other.link ) def __hash__(self) -> int: return hash( - (self._color, self._bgcolor, self._attributes, self._set_attributes) + ( + self._color, + self._bgcolor, + self._attributes, + self._set_attributes, + self.link, + ) ) @property @@ -213,6 +225,7 @@ class Style: color: Optional[str] = None bgcolor: Optional[str] = None attributes: Dict[str, Optional[bool]] = {} + link: Optional[str] = None words = iter(style_definition.split()) for original_word in words: @@ -237,6 +250,12 @@ class Style: ) attributes[word] = False + elif word == "link": + word = next(words, "") + if not word: + raise errors.StyleSyntaxError("URL expected after 'link'") + link = word + elif word in style_attributes: attributes[word] = True @@ -248,7 +267,7 @@ class Style: f"unable to parse {word!r} in style {style_definition!r}; {error}" ) color = word - style = Style(color=color, bgcolor=bgcolor, **attributes) + style = Style(color=color, bgcolor=bgcolor, link=link, **attributes) return style @lru_cache(maxsize=1024) @@ -329,6 +348,7 @@ class Style: style._bgcolor = self._bgcolor style._attributes = self._attributes style._set_attributes = self._set_attributes + style.link = self.link return style def render( @@ -347,7 +367,10 @@ class Style: Returns: str: A string containing ANSI style codes. """ - if color_system is None or not text: + # Hyperlink spec: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda + if not text: + return "" + if color_system is None and not self.link: return text _attributes = self._attributes & self._set_attributes attrs = [__STYLE_TABLE[_attributes]] if _attributes else [] @@ -357,7 +380,12 @@ class Style: attrs.extend( self._bgcolor.downgrade(color_system).get_ansi_codes(foreground=False) ) - return f"\x1b[{';'.join(attrs)}m{text}\x1b[0m" if attrs else text + rendered = f"\x1b[{';'.join(attrs)}m{text}\x1b[0m" if attrs else text + if self.link is not None: + rendered = ( + f"\x1b]8;id={self.link};{self.link}\x1b\\{rendered}\x1b]8;;\x1b\\" + ) + return rendered def test(self, text: Optional[str] = None) -> None: """Write text with style directly to terminal. @@ -391,6 +419,7 @@ class Style: style._attributes & style._set_attributes ) new_style._set_attributes = self._set_attributes | style._set_attributes + new_style.link = style.link or self.link return new_style def _update(self, style: "Style") -> None: @@ -405,6 +434,7 @@ class Style: style._attributes & style._set_attributes ) self._set_attributes = self._set_attributes | style._set_attributes + self.link = style.link or self.link def __add__(self, style: Optional["Style"]) -> "Style": if style is None: