mirror of
https://github.com/ihabunek/toot.git
synced 2025-08-22 19:24:07 -04:00
251 lines
7.8 KiB
Python
251 lines
7.8 KiB
Python
from __future__ import annotations
|
|
|
|
__all__ = ("Hyperlink",)
|
|
|
|
from typing import Generator, List, Optional, Tuple
|
|
|
|
import urwid
|
|
|
|
from .text_embed import DisplayAttribute
|
|
|
|
# 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**, with *left* alignment and the
|
|
*ellipsis* wrap mode of :py:class:`urwid.Text` i.e 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 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 as well 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.
|
|
|
|
.. collapse:: Examples:
|
|
|
|
>>> from urwidgets import Hyperlink
|
|
>>>
|
|
>>> url = "https://urwid.org"
|
|
>>>
|
|
>>> # The hyperlinks in the outputs should be highlighted on mouse hover
|
|
>>> # and clickable (in the terminal), if supported.
|
|
>>>
|
|
>>> # Raw URI
|
|
>>> link = Hyperlink(url)
|
|
>>> canv = link.render(())
|
|
>>> print(canv.text[0].decode())
|
|
https://urwid.org
|
|
>>>
|
|
>>> # Clipped (with an ellipsis appended) when the render width (maxcols) is
|
|
>>> # shorter than the link text
|
|
>>> canv = link.render((len(url) - 4,))
|
|
>>> print(canv.text[0].decode())
|
|
https://urwid…
|
|
>>>
|
|
>>> # URI with custom text
|
|
>>> hyperlink = Hyperlink(url, text="Urwid Website")
|
|
>>> canv = hyperlink.render(())
|
|
>>> print(canv.text[0].decode())
|
|
Urwid Website
|
|
|
|
.. seealso::
|
|
|
|
`OSC 8 Specification \
|
|
<https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda>`_
|
|
Official specification for hyperlinks in terminals.
|
|
|
|
`OSC 8 adoption in terminal emulators \
|
|
<https://github.com/Alhadis/OSC8-Adoption>`_
|
|
Documentation of the adoption of the feature across terminal emulators and
|
|
terminal-based applications.
|
|
|
|
:py:class:`~urwidgets.TextEmbed`
|
|
A widget that enables the use of hyperlinks amidst normal text.
|
|
"""
|
|
|
|
no_cache = ["render"]
|
|
|
|
def __init__(
|
|
self,
|
|
uri: str,
|
|
attr: DisplayAttribute = None,
|
|
text: Optional[str] = None,
|
|
) -> None:
|
|
self._uw_set_uri(uri)
|
|
super().__init__(urwid.Text((_Attr(attr), ""), "left", "ellipsis"))
|
|
self._uw_set_text(uri if text is None else text)
|
|
|
|
def render(self, size: Tuple[int,], focus: bool = False) -> urwid.HyperlinkCanvas:
|
|
return HyperlinkCanvas(self._uw_uri, self._w.render(size, focus))
|
|
|
|
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 not text:
|
|
raise ValueError("Hyperlink text is empty")
|
|
if "\n" in text: # Other multi-line whitespace characters are escaped by urwid
|
|
raise ValueError(f"Multi-line text (got: {text!r})")
|
|
self._w.set_text((self._w.attrib[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[0][0].attr,
|
|
lambda self, attrib: self._w.set_text((_Attr(attrib), self._w.text)),
|
|
doc="""The display attirbute of the hyperlink.
|
|
|
|
:type: DisplayAttribute
|
|
|
|
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,
|
|
trim_left: int = 0,
|
|
trim_top: int = 0,
|
|
cols: int | None = None,
|
|
rows: int | None = None,
|
|
attr: DisplayAttribute = None,
|
|
) -> Generator[List[Tuple[DisplayAttribute, Optional[str], bytes]], None, None]:
|
|
# There can only be one line since wrap="ellipsis" and the text was checked
|
|
# to not contain "\n".
|
|
content_line = next(
|
|
self._uw_text_canv.content(trim_left, trim_top, cols, rows, attr)
|
|
)
|
|
|
|
if isinstance(content_line[0][0], _Attr):
|
|
hyperlink_text, *padding = content_line
|
|
link_attr = hyperlink_text[0].attr
|
|
yield [
|
|
(None, "U", START % (self._uw_id, self._uw_uri)),
|
|
(
|
|
attr.get(link_attr, link_attr) if attr else link_attr,
|
|
*hyperlink_text[1:],
|
|
),
|
|
(None, "U", END),
|
|
*padding, # if any
|
|
]
|
|
else: # A trim containing padding only
|
|
yield content_line
|
|
|
|
def rows(self):
|
|
return self._uw_text_canv.rows()
|
|
|
|
@staticmethod
|
|
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
|
|
|
|
|
|
class _Attr:
|
|
"""Wraps a text display attribute to ensure it's always distinguished from those of
|
|
neighbouring text runs.
|
|
"""
|
|
|
|
def __init__(self, attr: DisplayAttribute):
|
|
self.attr = attr
|