Make urwidgets optional - if not available, use urwid.Text
This commit is contained in:
parent
f45dfa1480
commit
5d6d916f1f
3
.flake8
3
.flake8
|
@ -1,4 +1,5 @@
|
|||
[flake8]
|
||||
exclude=build,tests,tmp,venv,toot/tui/scroll.py
|
||||
exclude=.tox,build,tests,tmp,venv,toot/tui/scroll.py
|
||||
per-file-ignores=toot/tui/stubs/urwidgets.py:F401
|
||||
ignore=E128,W503
|
||||
max-line-length=120
|
||||
|
|
|
@ -8,7 +8,7 @@ import unicodedata
|
|||
from .constants import PALETTE
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.element import NavigableString, Tag
|
||||
from urwidgets import TextEmbed, Hyperlink, parse_text
|
||||
from .stubs.urwidgets import TextEmbed, Hyperlink, parse_text, has_urwidgets
|
||||
from urwid.util import decompose_tagmarkup
|
||||
from toot.utils import urlencode_url
|
||||
|
||||
|
@ -101,7 +101,10 @@ class ContentParser:
|
|||
markups.append(child)
|
||||
return markups
|
||||
|
||||
def text_to_widget(self, attr, markup) -> TextEmbed:
|
||||
def text_to_widget(self, attr, markup) -> urwid.Widget:
|
||||
if not has_urwidgets:
|
||||
return urwid.Text((attr, markup))
|
||||
|
||||
TRANSFORM = {
|
||||
# convert http[s] URLs to Hyperlink widgets for nesting in a TextEmbed widget
|
||||
re.compile(r"(^.+)\x03(.+$)"): lambda g: (
|
||||
|
@ -232,7 +235,8 @@ class ContentParser:
|
|||
title, attrib_list = decompose_tagmarkup(markups)
|
||||
if not attrib_list:
|
||||
attrib_list = [tag]
|
||||
if href:
|
||||
if href and has_urwidgets:
|
||||
# only if we have urwidgets loaded for OCS 8 hyperlinks:
|
||||
# urlencode the path and query portions of the URL
|
||||
href = urlencode_url(href)
|
||||
# use ASCII ETX (end of record) as a
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
__all__ = ("Hyperlink",)
|
||||
|
||||
from typing import Generator, List, Optional, Tuple, Union
|
||||
|
||||
import urwid
|
||||
|
||||
# NOTE: Any new "private" attribute of any subclass of an urwid class should be
|
||||
# prepended with "_uw" to avoid clashes with names used by urwid itself.
|
||||
|
||||
|
||||
ESC = "\033"
|
||||
OSC = f"{ESC}]"
|
||||
ST = f"{ESC}\\"
|
||||
START = f"{OSC}8;id=%d;%s{ST}".encode()
|
||||
END = f"{OSC}8;;{ST}".encode()
|
||||
|
||||
valid_byte_range = range(32, 127)
|
||||
|
||||
|
||||
class Hyperlink(urwid.WidgetWrap):
|
||||
"""A widget containing hyperlinked text.
|
||||
|
||||
Args:
|
||||
uri: The target of the hyperlink in URI (Uniform Resource Identifier)-encoded
|
||||
form.
|
||||
|
||||
May be a web address (``http://...`` or ``https://...``), FTP address
|
||||
(``ftp://...``), local file (``file://...``), e-mail address (``mailto:``),
|
||||
etc.
|
||||
|
||||
Every byte of this string, after being encoded, must be within the range
|
||||
``32`` to ``126`` (both inclusive).
|
||||
|
||||
attr: Display attribute of the hyperlink text.
|
||||
text: Alternative hyperlink text. If not given or ``None``, the URI itself is
|
||||
used. Must be a single-line string.
|
||||
|
||||
Raises:
|
||||
TypeError: An argument is of an unexpected type.
|
||||
ValueError: An argument is of an expected type but of an unexpected value
|
||||
|
||||
This widget always renders a single line. If the widget is rendered with a width
|
||||
less than the length of the hyperlink text, it is clipped at the right end with an
|
||||
ellipsis (\u2026) appended. On the other hand, if rendered with a width greater,
|
||||
it is padded with spaces on the right end.
|
||||
|
||||
This widget is intended to be embedded in a :py:class:`~urwidgets.TextEmbed` widget
|
||||
to combine it with pure text or other widgets but may also be used otherwise.
|
||||
|
||||
This widget utilizes the ``OSC 8`` escape sequence implemented by a sizable number
|
||||
of mainstream terminal emulators. It utilizes the escape sequence in such a way that
|
||||
hyperlinks right next to one another should be detected, highlighted and treated as
|
||||
separate by any terminal emulator that correctly implements the feature. Also, if a
|
||||
hyperlink is wrapped or clipped, it shouldn't break.
|
||||
|
||||
.. seealso::
|
||||
- `OSC 8 Specification
|
||||
<https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda>`_
|
||||
- `OSC 8 adoption in terminal emulators
|
||||
<https://github.com/Alhadis/OSC8-Adoption>`_
|
||||
"""
|
||||
|
||||
no_cache = ["render"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uri: str,
|
||||
attr: Union[None, str, bytes, urwid.AttrSpec] = None,
|
||||
text: Optional[str] = None,
|
||||
) -> None:
|
||||
self._uw_set_uri(uri)
|
||||
super().__init__(urwid.Text((attr, ""), "left", "ellipsis"))
|
||||
self._uw_set_text(text or uri)
|
||||
|
||||
def render(self, size: Tuple[int, ], focus: bool = False):
|
||||
return None
|
||||
|
||||
def _uw_set_text(self, text: str):
|
||||
if not isinstance(text, str):
|
||||
raise TypeError(f"Invalid type for 'text' (got: {type(text).__name__!r})")
|
||||
if "\n" in text:
|
||||
raise ValueError(f"Multi-line text (got: {text!r})")
|
||||
self._w.set_text(((self._w.attrib or [(None, 0)])[0][0], text))
|
||||
|
||||
def _uw_set_uri(self, uri: str):
|
||||
if not isinstance(uri, str):
|
||||
raise TypeError(f"Invalid type for 'uri' (got: {type(uri).__name__!r})")
|
||||
if not uri:
|
||||
raise ValueError("URI is empty")
|
||||
invalid_bytes = frozenset(uri.encode()).difference(valid_byte_range)
|
||||
if invalid_bytes:
|
||||
raise ValueError(
|
||||
f"Invalid byte '\\x{tuple(invalid_bytes)[0]:02x}' found in URI: {uri!r}"
|
||||
)
|
||||
self._uw_uri = uri
|
||||
|
||||
attrib = property(
|
||||
lambda self: (self._w.attrib or [(None, 0)])[0][0],
|
||||
lambda self, attrib: self._w.set_text((attrib, self._w.text)),
|
||||
doc="""The display attirbute of the hyperlink.
|
||||
|
||||
:type: Union[None, str, bytes, urwid.AttrSpec]
|
||||
|
||||
GET:
|
||||
Returns the display attirbute.
|
||||
|
||||
SET:
|
||||
Sets the display attirbute.
|
||||
""",
|
||||
)
|
||||
|
||||
text = property(
|
||||
lambda self: self._w.text,
|
||||
_uw_set_text,
|
||||
doc="""The alternate text of the hyperlink.
|
||||
|
||||
:type: str
|
||||
|
||||
GET:
|
||||
Returns the alternate text.
|
||||
|
||||
SET:
|
||||
Sets the alternate text.
|
||||
""",
|
||||
)
|
||||
|
||||
uri = property(
|
||||
lambda self: self._uw_uri,
|
||||
_uw_set_uri,
|
||||
doc="""The target of the hyperlink.
|
||||
|
||||
:type: str
|
||||
|
||||
GET:
|
||||
Returns the target.
|
||||
|
||||
SET:
|
||||
Sets the target.
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
class HyperlinkCanvas(urwid.Canvas):
|
||||
cacheable = False
|
||||
|
||||
_uw_next_id = 0
|
||||
_uw_free_ids = set()
|
||||
|
||||
def __init__(self, uri: str, text_canv: urwid.TextCanvas) -> None:
|
||||
super().__init__()
|
||||
self._uw_text_canv = text_canv
|
||||
self._uw_uri = uri.encode()
|
||||
self._uw_id = self._uw_get_id()
|
||||
|
||||
def __del__(self):
|
||||
__class__._uw_free_ids.add(self._uw_id)
|
||||
|
||||
def cols(self):
|
||||
return self._uw_text_canv.cols()
|
||||
|
||||
def content(
|
||||
self, *args, **kwargs
|
||||
) -> Generator[
|
||||
List[Tuple[Union[None, str, bytes, urwid.AttrSpec], Optional[str], bytes]],
|
||||
None,
|
||||
None,
|
||||
]:
|
||||
yield [
|
||||
(None, "U", START % (self._uw_id, self._uw_uri)),
|
||||
# There can be only one line since wrap="ellipsis" and the text was checked
|
||||
# to not contain "\n".
|
||||
# There can be only one run since there was only one display attribute.
|
||||
next(self._uw_text_canv.content(*args, **kwargs))[0],
|
||||
(None, "U", END),
|
||||
]
|
||||
|
||||
def rows(self):
|
||||
return self._uw_text_canv.rows()
|
||||
|
||||
def _uw_get_id():
|
||||
if __class__._uw_free_ids:
|
||||
return __class__._uw_free_ids.pop()
|
||||
__class__._uw_next_id += 1
|
||||
|
||||
return __class__._uw_next_id - 1
|
|
@ -0,0 +1,215 @@
|
|||
__all__ = (
|
||||
"parse_text",
|
||||
"TextEmbed",
|
||||
# Type Aliases
|
||||
"Markup",
|
||||
"StringMarkup",
|
||||
"ListMarkup",
|
||||
"TupleMarkup",
|
||||
"NormalTupleMarkup",
|
||||
"DisplayAttribute",
|
||||
"WidgetTupleMarkup",
|
||||
"WidgetListMarkup",
|
||||
)
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable, List, Tuple, Union
|
||||
|
||||
import urwid
|
||||
|
||||
# NOTE: Any new "private" attribute of any subclass of an urwid class should be
|
||||
# prepended with "_uw" to avoid clashes with names used by urwid itself.
|
||||
|
||||
# I really hope these are correct :D
|
||||
Markup = Union["StringMarkup", "ListMarkup", "TupleMarkup"]
|
||||
StringMarkup = Union[str, bytes]
|
||||
ListMarkup = List["Markup"]
|
||||
TupleMarkup = Union["NormalTupleMarkup", "WidgetTupleMarkup"]
|
||||
NormalTupleMarkup = Tuple["DisplayAttribute", Union["StringMarkup", "ListMarkup"]]
|
||||
DisplayAttribute = Union[None, str, bytes, urwid.AttrSpec]
|
||||
WidgetTupleMarkup = Tuple[int, Union[urwid.Widget, "WidgetListMarkup"]]
|
||||
WidgetListMarkup = List[Union[urwid.Widget, "Markup", "WidgetListMarkup"]]
|
||||
|
||||
|
||||
class TextEmbed(urwid.Text):
|
||||
"""A text widget within which other widgets may be embedded.
|
||||
|
||||
This is an extension of the :py:class:`urwid.Text` widget. Every feature and
|
||||
interface of :py:class:`~urwid.Text` is supported and works essentially the same,
|
||||
**except for the "ellipsis" wrap mode** which is currently not implemented.
|
||||
Text markup format is essentially the same, except when embedding widgets.
|
||||
|
||||
**Embedding Widgets**
|
||||
A widget is embedded by specifying it as a markup element with an **integer
|
||||
display attribute**, where the display attribute is the number of screen
|
||||
columns the widget should occupy.
|
||||
|
||||
Examples:
|
||||
|
||||
>>> # w1 spans 2 columns
|
||||
>>> TextEmbed(["This widget (", (2, w1), ") spans two columns"])
|
||||
>>> # w1 and w2 span 2 columns
|
||||
>>> TextEmbed(["These widgets (", (2, [w1, w2]), ") span two columns each"])
|
||||
>>> # w1 and w2 span 2 columns, the text in-between has no display attribute
|
||||
>>> TextEmbed([(2, [w1, (None, "and"), w2]), " span two columns each"])
|
||||
>>> # w1 and w2 span 2 columns, text in the middle is red
|
||||
>>> TextEmbed((2, [w1, ("red", " i am red "), w2]))
|
||||
>>> # w1 and w3 span 2 columns, w2 spans 5 columns
|
||||
>>> TextEmbed((2, [w1, (5, w2), w3]))
|
||||
|
||||
Visible embedded widgets are always rendered (may be cached) whenever the
|
||||
``TextEmbed`` widget is re-rendered (i.e an uncached render). Hence, this
|
||||
allows for dynamic parts of text without updating the entire widget.
|
||||
Going a step further, embeddded widgets can be swapped by using
|
||||
``urwid.WidgetPlaceholder`` but their widths will remain the same.
|
||||
|
||||
NOTE:
|
||||
- Every embedded widget must be a box widget and is always rendered with
|
||||
size ``(width, 1)``. :py:class:`urwid.Filler` can be used to wrap flow
|
||||
widgets.
|
||||
- Each embedded widgets are treated as a single WORD (i.e containing no
|
||||
whitespace). Therefore, consecutive embedded widgets are also treated as
|
||||
a single WORD. This affects the "space" wrap mode.
|
||||
- After updating or swapping an embedded widget, this widget's canvases
|
||||
should be invalidated to ensure it re-renders.
|
||||
|
||||
Raises:
|
||||
TypeError: A widget markup element has a non-integer display attribute.
|
||||
ValueError: A widget doesn't support box sizing.
|
||||
ValueError: A widget has a non-positive width (display attribute).
|
||||
"""
|
||||
def get_text(
|
||||
self,
|
||||
) -> Tuple[str, List[Tuple[Union[DisplayAttribute, int], int]]]:
|
||||
"""Returns a representation of the widget's content.
|
||||
|
||||
Returns:
|
||||
A tuple ``(text, attrib)``, where
|
||||
|
||||
- *text* is the raw text content of the widget.
|
||||
|
||||
Each embedded widget is represented by a substring starting with a
|
||||
``"\\x00"`` character followed by zero or more ``"\\x01"`` characters,
|
||||
with length equal to the widget's width.
|
||||
|
||||
- *attrib* is the run-length encoding of display attributes.
|
||||
|
||||
Any entry containing a display attribute of the ``int`` type (e.g
|
||||
``(1, 4)``) denotes an embedded widget, where the display attirbute is
|
||||
the index of the widget within the :py:attr:`embedded` widgets list and
|
||||
the run length is the width of the widget.
|
||||
"""
|
||||
return super().get_text()
|
||||
|
||||
def render(
|
||||
self, size: Tuple[int, ], focus: bool = False
|
||||
) -> Union[urwid.TextCanvas, urwid.CompositeCanvas]:
|
||||
return None
|
||||
|
||||
def set_text(self, markup: Markup) -> None:
|
||||
"""Sets the widget's content.
|
||||
|
||||
Also supports widget markup elements. See the class description.
|
||||
"""
|
||||
pass
|
||||
|
||||
def set_wrap_mode(self, mode: str) -> None:
|
||||
if mode == "ellipsis":
|
||||
raise NotImplementedError("Wrap mode 'ellipsis' is not implemented.")
|
||||
super().set_wrap_mode(mode)
|
||||
|
||||
|
||||
def parse_text(
|
||||
text: str,
|
||||
patterns: Iterable[re.Pattern],
|
||||
repl,
|
||||
*repl_args: Any,
|
||||
**repl_kwargs: Any,
|
||||
) -> Markup:
|
||||
r"""Parses a string into a text/widget markup list.
|
||||
|
||||
Args:
|
||||
text: The string to parse.
|
||||
patterns: An iterable of RegEx pattern objects.
|
||||
repl: A callable to replace a substring of *text* matched by any of the given
|
||||
RegEx patterns.
|
||||
repl_args: Additional positional arguments to be passed to *repl* whenever it's
|
||||
called.
|
||||
repl_kwargs: keyword arguments to be passed to *repl* whenever it's called.
|
||||
|
||||
Returns:
|
||||
A text/widget markup (see :py:data:`Markup`) that should be compatible with
|
||||
:py:class:`TextEmbed` and/or :py:class:`urwid.Text`, depending on the values
|
||||
returned by *repl*.
|
||||
|
||||
Raises:
|
||||
TypeError: An argument is of an unexpected type.
|
||||
ValueError: *patterns* is empty.
|
||||
ValueError: A given pattern object was not compiled from a :py:class:`str`
|
||||
instance.
|
||||
|
||||
Whenever any of the given RegEx patterns matches a **non-empty** substring of
|
||||
*text*, *repl* is called with the following arguments (in the given order):
|
||||
|
||||
- the :py:class:`re.Pattern` object that matched the substring
|
||||
- a tuple containing the match groups
|
||||
|
||||
- starting with the whole match,
|
||||
- followed by the all the subgroups of the match, from 1 up to however many
|
||||
groups are in the pattern, if any (``None`` for each group that didn't
|
||||
participate in the match)
|
||||
|
||||
- a tuple containing the indexes of the start and end of the substring
|
||||
- *repl_args* unpacked
|
||||
- *repl_kwargs* unpacked
|
||||
|
||||
and *should* return a valid text/widget markup (see :py:data:`Markup`). If the
|
||||
value returned is *false* (such as ``None`` or an empty string), it is omitted
|
||||
from the result.
|
||||
|
||||
Example::
|
||||
|
||||
import re
|
||||
from urwid import Filler
|
||||
from urwidgets import Hyperlink, TextEmbed, parse_text
|
||||
|
||||
MARKDOWN = {
|
||||
re.compile(r"\*\*(.+?)\*\*"): lambda g: ("bold", g[1]),
|
||||
re.compile("https://[^ ]+"): (
|
||||
lambda g: (min(len(g[0]), 14), Filler(Hyperlink(g[0], "blue")))
|
||||
),
|
||||
re.compile(r"\[(.+)\]\((.+)\)"): (
|
||||
lambda g: (len(g[1]), Filler(Hyperlink(g[2], "blue", g[1])))
|
||||
),
|
||||
}
|
||||
|
||||
link = "https://urwid.org"
|
||||
text = f"[This]({link}) is a **link** to {link}"
|
||||
print(text)
|
||||
# Output: [This](https://urwid.org) is a **link** to https://urwid.org
|
||||
|
||||
markup = parse_text(
|
||||
text, MARKDOWN, lambda pattern, groups, span: MARKDOWN[pattern](groups)
|
||||
)
|
||||
print(markup)
|
||||
# Output:
|
||||
# [
|
||||
# (4, <Filler box widget <Hyperlink flow widget>>),
|
||||
# ' is a ',
|
||||
# ('bold', 'link'),
|
||||
# ' to ',
|
||||
# (14, <Filler box widget <Hyperlink flow widget>>),
|
||||
# ]
|
||||
|
||||
text_widget = TextEmbed(markup)
|
||||
canv = text_widget.render(text_widget.pack()[:1])
|
||||
print(canv.text[0].decode())
|
||||
# Output: This is a link to https://urwid…
|
||||
# The hyperlinks will be clickable if supported
|
||||
|
||||
NOTE:
|
||||
In the case of overlapping matches, the substring that occurs first is matched
|
||||
and if they start at the same index, the pattern that appears first in
|
||||
*patterns* takes precedence.
|
||||
"""
|
||||
return text
|
|
@ -0,0 +1,30 @@
|
|||
__all__ = ("Hyperlink",)
|
||||
|
||||
import urwid
|
||||
|
||||
|
||||
class Hyperlink(urwid.WidgetWrap):
|
||||
def __init__(
|
||||
self,
|
||||
uri,
|
||||
attr,
|
||||
text,
|
||||
):
|
||||
pass
|
||||
|
||||
def render(self, size, focus):
|
||||
return None
|
||||
|
||||
|
||||
class HyperlinkCanvas(urwid.Canvas):
|
||||
def __init__(self, uri: str, text_canv: urwid.TextCanvas):
|
||||
pass
|
||||
|
||||
def cols(self):
|
||||
return 0
|
||||
|
||||
def content(self, *args, **kwargs):
|
||||
yield [None]
|
||||
|
||||
def rows(self):
|
||||
return 0
|
|
@ -0,0 +1,29 @@
|
|||
__all__ = ("parse_text", "TextEmbed")
|
||||
|
||||
import urwid
|
||||
|
||||
|
||||
class TextEmbed(urwid.Text):
|
||||
def get_text(
|
||||
self,
|
||||
):
|
||||
return None
|
||||
|
||||
def render(self, size, focus):
|
||||
return None
|
||||
|
||||
def set_text(self, markup):
|
||||
pass
|
||||
|
||||
def set_wrap_mode(self, mode):
|
||||
pass
|
||||
|
||||
|
||||
def parse_text(
|
||||
text,
|
||||
patterns,
|
||||
repl,
|
||||
*repl_args,
|
||||
**repl_kwargs,
|
||||
):
|
||||
return None
|
|
@ -0,0 +1,8 @@
|
|||
# If urwidgets is loaded use it; otherwise use our stubs
|
||||
try:
|
||||
from urwidgets import Hyperlink, TextEmbed, parse_text
|
||||
has_urwidgets = True
|
||||
except ImportError:
|
||||
from .stub_hyperlink import Hyperlink
|
||||
from .stub_text_embed import TextEmbed, parse_text
|
||||
has_urwidgets = False
|
|
@ -15,7 +15,7 @@ from toot.tui import app
|
|||
from toot.tui.utils import time_ago
|
||||
from toot.utils.language import language_name
|
||||
from toot.utils import urlencode_url
|
||||
from urwidgets import Hyperlink, TextEmbed, parse_text
|
||||
from .stubs.urwidgets import Hyperlink, TextEmbed, parse_text, has_urwidgets
|
||||
|
||||
logger = logging.getLogger("toot")
|
||||
|
||||
|
@ -322,6 +322,8 @@ class StatusDetails(urwid.Pile):
|
|||
return super().__init__(widget_list)
|
||||
|
||||
def linkify_content(self, text) -> urwid.Widget:
|
||||
if not has_urwidgets:
|
||||
return urwid.Text(("link", text))
|
||||
TRANSFORM = {
|
||||
# convert http[s] URLs to Hyperlink widgets for nesting in a TextEmbed widget
|
||||
re.compile(r'(https?://[^\s]+)'):
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
# If urwidgets is loaded use it; otherwise use our stubs
|
||||
try:
|
||||
from urwidgets import Hyperlink, TextEmbed, parse_text # noqa: F401
|
||||
has_urwidgets = True
|
||||
except ImportError:
|
||||
from .stub_hyperlink import Hyperlink # noqa: F401
|
||||
from .stub_text_embed import TextEmbed, parse_text # noqa: F401
|
||||
has_urwidgets = False
|
Loading…
Reference in New Issue