Make urwidgets optional - if not available, use urwid.Text

This commit is contained in:
Daniel Schwarz 2023-09-22 18:11:06 -04:00
parent f45dfa1480
commit 5d6d916f1f
9 changed files with 487 additions and 5 deletions

View File

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

View File

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

185
toot/tui/stub_hyperlink.py Normal file
View File

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

215
toot/tui/stub_text_embed.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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]+)'):

8
toot/tui/urwidgets.py Normal file
View File

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