inital hyperlinks support

This commit is contained in:
Will McGugan 2020-05-09 12:47:32 +01:00
parent 1cee954797
commit 68a4d99c35
3 changed files with 107 additions and 43 deletions

View File

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

View File

@ -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[/]")

View File

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