mirror of https://github.com/Textualize/rich.git
inital hyperlinks support
This commit is contained in:
parent
1cee954797
commit
68a4d99c35
|
@ -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
|
||||
|
|
104
rich/markup.py
104
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[/]")
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue