""".. Widgets for urwid"""
from __future__ import annotations
__all__ = ("UrwidImage", "UrwidImageCanvas", "UrwidImageScreen")
from typing import Optional, Tuple
import urwid
from .. import _ctlseqs as ctlseqs
# These sequences are used during performance-critical operations that occur often
from .._ctlseqs import BEGIN_SYNCED_UPDATE, END_SYNCED_UPDATE, ESC_b, SGR_NORMAL_b
from ..exceptions import UrwidImageError
from ..image import BaseImage, ITerm2Image, KittyImage, Size, TextImage
from ..utils import arg_type_error, get_terminal_name_version, lock_tty, write_tty
# NOTE: Any new "private" attribute of any subclass of an urwid class should be
# prepended with "_ti" to prevent clashes with names used by urwid itself.
[docs]
class UrwidImage(urwid.Widget):
"""Image widget (box/flow) for the urwid TUI framework.
Args:
image: The image to be rendered by the widget.
format_spec: :ref:`Render format specifier <format-spec>`. Padding width and
height are ignored.
upscale: If ``True``, the image will be upscaled to fit maximally within the
available size, if necessary, while still preserving the aspect ratio.
Otherwise, the image is never upscaled.
Raises:
TypeError: An argument is of an inappropriate type.
ValueError: An argument is of an appropriate type but has an
unexpected/invalid value.
term_image.exceptions.StyleError: Invalid style-specific format specifier.
term_image.exceptions.UrwidImageError: Too many image widgets rendering images
with the *kitty* render style.
| Any ample space in the widget's render size is filled with spaces.
| For animated images, the current frame (at render-time) is rendered.
TIP:
If *image* is of a :ref:`graphics-based <graphics-based>` render style and the
widget is being used as or within a **flow** widget, with overlays or in any
other case where the canvas will require vertical trimming, make sure to use a
render method that splits images across lines such as the **LINES** render
method for *kitty* and *iterm2* render styles.
NOTE:
* The `z-index` style-specific format spec field for
:py:class:`~term_image.image.KittyImage` is ignored as this is used
internally.
* A **maximum** of ``2**32 - 2`` instances initialized with
:py:class:`~term_image.image.KittyImage` instances may exist at the same time.
IMPORTANT:
This is defined if and only if the ``urwid`` package is available.
"""
_sizing = frozenset((urwid.BOX, urwid.FLOW))
ignore_focus = True
_ti_error_placeholder = None
# For kitty images
_ti_disguise_state = 0
_ti_free_z_indexes = set()
# Progresses thus: 1, -1, 2, -2, 3, ..., 2**31 - 1, -(2**31 - 1)
# This sequence results in shorter image escape sequences compared to starting
# from -(2**31)
_ti_next_z_index = 1
def __init__(
self, image: BaseImage, format_spec: str = "", *, upscale: bool = False
) -> None:
if not isinstance(image, BaseImage):
raise arg_type_error("image", image)
if not isinstance(format_spec, str):
raise arg_type_error("format_spec", format_spec)
*fmt, alpha, style_args = image._check_format_spec(format_spec)
if not isinstance(upscale, bool):
raise arg_type_error("upscale", upscale)
super().__init__()
self._ti_image = image
self._ti_h_align, _, self._ti_v_align, _ = fmt
self._ti_alpha = alpha
self._ti_style_args = style_args
self._ti_sizing = Size.FIT if upscale else Size.AUTO
if isinstance(image, TextImage):
style_args["split_cells"] = True
elif isinstance(image, KittyImage):
style_args["z_index"] = self._ti_z_index = self._ti_get_z_index()
# Since Konsole doesn't blend images placed at the same location and
# z-index, unlike Kitty (and potentially others), `blend=True` is
# better on Konsole as it reduces/eliminates flicker.
if get_terminal_name_version()[0] != "konsole":
# To clear directly overlapped images when urwid redraws a line without
# a change in image position
style_args["blend"] = False
def __del__(self) -> None:
if hasattr(self, "_ti_z_index"):
__class__._ti_free_z_indexes.add(self._ti_z_index)
image = property(
lambda self: self._ti_image,
doc="""
The image rendered by the widget
:type: BaseImage
GET:
Returns the image instance rendered by the widget.
""",
)
def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas:
image = self._ti_image
if len(size) == 2: # box
image.set_size(self._ti_sizing, frame_size=size)
elif len(size) == 1: # flow
if self._ti_sizing is Size.FIT:
image.set_size(size[0])
else:
fit_size = self._ti_image._valid_size(size[0])
ori_size = self._ti_image._valid_size(Size.ORIGINAL)
image._size = (
ori_size
if ori_size[0] <= fit_size[0] and ori_size[1] <= fit_size[1]
else fit_size
)
size = (size[0], image._size[1])
else: # fixed
raise UrwidImageError("Not a fixed widget")
try:
render = image._format_render(
image._renderer(
image._render_image, self._ti_alpha, **self._ti_style_args
),
self._ti_h_align,
size[0],
self._ti_v_align,
size[1],
)
except Exception:
if type(self)._ti_error_placeholder is None:
raise
canv = type(self)._ti_error_placeholder.render(size, focus)
else:
canv = UrwidImageCanvas(render, size, image._size)
return canv
def rows(self, size: Tuple[int], focus: bool = False) -> int:
fit_size = self._ti_image._valid_size(size[0])
if self._ti_sizing is Size.FIT:
n_rows = fit_size[1]
else:
ori_size = self._ti_image._valid_size(Size.ORIGINAL)
n_rows = (
ori_size[1]
if ori_size[0] <= fit_size[0] and ori_size[1] <= fit_size[1]
else fit_size[1]
)
return n_rows
[docs]
@classmethod
def set_error_placeholder(cls, widget: Optional[urwid.Widget]) -> None:
"""Sets the widget to be rendered in place of an image when rendering fails.
Args:
widget: The placeholder widget or ``None`` to remove the placeholder.
Raises:
TypeError: *widget* is not an urwid widget.
If set, any exception raised during rendering is **suppressed** and the
placeholder is rendered in place of the image.
"""
if not isinstance(widget, urwid.Widget):
raise arg_type_error("widget", widget)
cls._ti_error_placeholder = widget
@staticmethod
def _ti_get_z_index() -> int:
if __class__._ti_free_z_indexes:
return __class__._ti_free_z_indexes.pop()
z_index = __class__._ti_next_z_index
if z_index == 2**31:
raise UrwidImageError("Too many image widgets with the kitty render style")
__class__._ti_next_z_index = -z_index if z_index > 0 else -z_index + 1
return z_index
def _ti_change_disguise(self) -> None:
"""See :py:meth`UrwidImageCanvas._ti_change_disguise`."""
self._ti_disguise_state = (self._ti_disguise_state + 1) % 3
[docs]
class UrwidImageCanvas(urwid.Canvas):
"""Image canvas for the urwid TUI framework.
Args:
render: The rendered image.
size: The canvas size. Also, the size of the rendered (and formatted) image.
image_size: The size with which the image was rendered (excluding padding).
NOTE:
The canvas outputs blanks (spaces) for :ref:`graphics-based <graphics-based>`
images when horizontal trimming is required (e.g when a widget is laid over
an image). This is temporary as horizontal trimming will be implemented in the
future.
This canvas is intended to be rendered by :py:class:`UrwidImage` (or a subclass
of it) only. Otherwise, the output isn't guaranteed to be as expected.
WARNING:
The constructor of this class performs NO argument validation at all for the
sake of performance. If instantiating this class directly, make sure to pass
appropriate arguments or create subclass, override the constructor and perform
the validation.
IMPORTANT:
This is defined if and only if the ``urwid`` package is available.
"""
_ti_disguise_state = 0
def __init__(
self, render: str, size: Tuple[int, int], image_size: Tuple[int, int]
) -> None:
super().__init__()
self.size = size
self._ti_image_size = image_size
# On the last row of the screen, urwid inserts the second to the last
# character after writing the last (though placed before it i.e inserted),
# thereby messing up an escape sequence occurring at the end.
# See `urwid.raw_display.Screen._last_row()`.
# Any line of the image could potentially be the last on the screen as a result
# of trimming.
self._ti_lines = [line + b"\0\0" for line in render.encode().split(b"\n")]
def cols(self) -> int:
return self.size[0]
def content(self, trim_left=0, trim_top=0, cols=None, rows=None, attr_map=None):
size = self.size
image_size = self._ti_image_size
visible_rows = rows or size[1]
trim_bottom = size[1] - trim_top - visible_rows
visible_cols = cols or size[0]
trim_right = size[0] - trim_left - visible_cols
widget = self.widget_info[0]
try:
image = widget._ti_image
h_align = widget._ti_h_align
v_align = widget._ti_v_align
except AttributeError: # the canvas wasn't rendered by `UrwidImage`
for line in self._ti_lines[trim_top : -trim_bottom or None]:
yield [(None, "U", line)]
return
if isinstance(image, TextImage):
if trim_left == 0 == trim_right:
for line in self._ti_lines[trim_top : -trim_bottom or None]:
yield [(None, "U", line.replace(b"\0", b"")), (None, "U", b"\0\0")]
return
pad = size[1] - image_size[1]
if v_align == "^":
pad_top = 0
pad_bottom = pad
elif v_align == "_":
pad_top = pad
pad_bottom = 0
else:
pad_top = pad // 2
pad_bottom = pad - pad_top
(
new_pad_top,
trim_image_top,
trim_image_bottom,
new_pad_bottom,
) = self._ti_calc_trim(
size[1], image_size[1], trim_top, pad_top, trim_bottom, pad_bottom
)
image_is_empty = image_size[1] in (trim_image_top, trim_image_bottom)
image_is_partial = trim_image_top != image_size[1] != trim_image_bottom
# Adding "\0\0" for consistency with output without horizontal trim
padding_line = b" " * visible_cols + b"\0\0"
if not image_is_empty:
pad = size[0] - image_size[0]
if h_align == "<":
pad_left = 0
pad_right = pad
elif h_align == ">":
pad_left = pad
pad_right = 0
else:
pad_left = pad // 2
pad_right = pad - pad_left
(
new_pad_left,
trim_image_left,
trim_image_right,
new_pad_right,
) = self._ti_calc_trim(
size[0], image_size[0], trim_left, pad_left, trim_right, pad_right
)
image_line_is_full = trim_image_left == 0 == trim_image_right
image_line_is_partial = (
trim_image_left != image_size[0] != trim_image_right
)
pad_right += 2 # For "\0\0"
left_padding = (
((None, "U", b" " * new_pad_left),) if new_pad_left else ()
)
right_padding = (
((None, "U", b" " * new_pad_right),) if new_pad_right else ()
)
color_reset = (
((None, "U", SGR_NORMAL_b),)
if image_size[0] > trim_image_right > 0
else ()
)
last_row_workaround = ((None, "U", b"\0\0"),)
if image_is_empty:
image_lines = []
else:
image_lines = self._ti_lines[pad_top : -pad_bottom or None]
if image_is_partial:
image_lines = image_lines[
trim_image_top : -trim_image_bottom or None
]
# top padding
for _ in range(new_pad_top):
yield [(None, "U", padding_line)]
# image
for line in image_lines:
first_color = ()
if image_line_is_full:
image_line = line[pad_left:-pad_right].replace(b"\0", b"")
elif image_line_is_partial:
line = line[pad_left:-pad_right].split(b"\0")
image_line = b"".join(
line[trim_image_left : -trim_image_right or None]
)
# Exclude non-colored images when the time comes
if not line[trim_image_left].startswith(ESC_b):
for cell in line[trim_image_left - 1 :: -1]:
if cell.startswith(ESC_b):
first_color = (
(None, "U", cell[: cell.rindex(b"m") + 1]),
)
break
image_line = (
(*first_color, (None, "U", image_line))
if image_line_is_full or image_line_is_partial
else ()
)
yield [
*left_padding,
*image_line,
*color_reset,
*right_padding,
*last_row_workaround,
]
# bottom padding
for _ in range(new_pad_bottom):
yield [(None, "U", padding_line)]
elif trim_left or trim_right:
line = b" " * visible_cols
for _ in range(visible_rows):
yield [(None, "U", line)]
else:
disguise = (
b"\b "
* (self._ti_disguise_state + widget._ti_disguise_state)
* (
isinstance(image, KittyImage)
or isinstance(image, ITerm2Image)
and get_terminal_name_version()[0] == "konsole"
)
)
for line in self._ti_lines[trim_top : -trim_bottom or None]:
yield [(None, "U", line + disguise)]
def rows(self) -> int:
return self.size[1]
@classmethod
def _ti_change_disguise(cls) -> None:
"""Changes the hidden text embedded on every line, such that every line of the
canvas is different in every state.
The reason for this is, ``urwid`` will not redraw lines that have not changed
since the last screen update. So this is to trick ``urwid`` into taking every
line containing a part of an image as different in each state.
This is used to force redraws of all images on screen, particularly when
graphics-based images are cleared and their positions have not change so
much.
"""
cls._ti_disguise_state = (cls._ti_disguise_state + 1) % 3
@staticmethod
def _ti_calc_trim(
size: int,
image_size: int,
trim_side1: int,
pad_side1: int,
trim_side2: int,
pad_side2: int,
) -> Tuple[int, int, int, int]:
"""Calculates the new padding size on both sides after trimming and size to be
trimmed off the rendered image from both ends, all **along the same axis**.
Args:
size: Canvas size.
image_size: Size with which the image was rendered (excluding padding).
trim_side1: Size to trim off the canvas (image with padding) from one size.
pad_side1: Padding size on one side of the image.
trim_side2: Size to trim off the canvas (image with padding) from the
opposite size.
pad_side2: Padding size on the opposite side of the image.
Returns:
A 4-tuple containing the following dimensions, in the given order:
- new_pad_side1: The trimmed padding size on one side.
- trim_image_side1: The size to be trimmed off the image on one side.
- trim_image_side2: The size to be trimmed off the image on the opposite
side.
- new_pad_side2: The trimmed padding size on the opposite side.
The dimensions given as arguments must be along the **same axis** (vertical or
horizontal).
"""
image_end = size - pad_side2
if trim_side1 >= image_end: # within side2 padding
new_pad_side1 = 0
trim_image_side1 = image_size
new_pad_side2 = size - trim_side1
elif trim_side1 >= pad_side1: # within the image
new_pad_side1 = 0
trim_image_side1 = trim_side1 - pad_side1
new_pad_side2 = pad_side2
else: # within side1 padding
new_pad_side1 = pad_side1 - trim_side1
trim_image_side1 = 0
new_pad_side2 = pad_side2
image_end = size - pad_side1
if trim_side2 >= image_end: # within side1 padding
new_pad_side2 = 0
trim_image_side2 = image_size
new_pad_side1 -= trim_side2 - image_end
elif trim_side2 >= pad_side2: # within the image
new_pad_side2 = 0
trim_image_side2 = trim_side2 - pad_side2
else: # within side2 padding
new_pad_side2 -= trim_side2
trim_image_side2 = 0
return new_pad_side1, trim_image_side1, trim_image_side2, new_pad_side2
[docs]
class UrwidImageScreen(urwid.raw_display.Screen):
"""A screen that supports drawing images.
It monitors images of some :ref:`graphics-based <graphics-based>` render styles
and clears them off the screen when necessary (e.g at startup, when scrolling,
upon terminal resize and at exit).
See the baseclass for further description.
IMPORTANT:
This is defined if and only if the ``urwid`` package is available.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._ti_screen_canv = None
self._ti_image_cviews = frozenset()
def clear(self):
self.clear_images()
return super().clear()
[docs]
def clear_images(self, *widgets: UrwidImage, now: bool = False) -> None:
"""Clears on-screen images of :ref:`graphics-based <graphics-based>`
styles **that support/require such an operation**.
Args:
widgets: Image widgets to clear.
All on-screen images rendered by each of the widgets are cleared,
provided the widget was initialized with a
:py:class:`term_image.image.KittyImage` instance.
If none is given, all images (of styles **that support/require such an
operation**) on-screen are cleared.
now: If ``True`` the images are cleared immediately.
Otherwise, they're cleared when next the output buffer is flushed,
such as at the next screen redraw.
"""
# Also takes care of iterm2 images on Konsole
if not (KittyImage.forced_support or KittyImage.is_supported()):
return
if widgets:
# Better to send the delete commands in a batch than individually
kitty_widgets = []
for index, widget in enumerate(widgets):
if not isinstance(widget, UrwidImage):
raise arg_type_error(f"widgets[{index}]", widget)
if isinstance(widget._ti_image, KittyImage):
kitty_widgets.append(widget)
widget._ti_change_disguise()
if kitty_widgets:
if now:
write_tty(
b"".join(
ctlseqs.KITTY_DELETE_Z_INDEX_b % widget._ti_z_index
for widget in kitty_widgets
)
)
else:
self.write(
"".join(
ctlseqs.KITTY_DELETE_Z_INDEX % widget._ti_z_index
for widget in kitty_widgets
)
)
else:
if now:
write_tty(ctlseqs.KITTY_DELETE_ALL_b)
else:
self.write(ctlseqs.KITTY_DELETE_ALL)
UrwidImageCanvas._ti_change_disguise()
# `@lock_tty` prevents queries during a synced update.
# Otherwise, responses would be delayed until the synced update ends and that might
# be after the query has timed out.
[docs]
@lock_tty
def draw_screen(self, maxres, canvas):
"""See the description of the baseclass' method.
Synchronizes output on terminal emulators that support the feature to
reduce/eliminate image flickering and screen tearing.
"""
self.write(BEGIN_SYNCED_UPDATE)
try:
if canvas is not self._ti_screen_canv:
self._ti_screen_canv = canvas
self._ti_clear_images()
return super().draw_screen(maxres, canvas)
finally:
self.write(END_SYNCED_UPDATE)
self.flush()
[docs]
@lock_tty
def flush(self):
"""See the baseclass' method for the description."""
return super().flush()
[docs]
@lock_tty
def write(self, data):
"""See the baseclass' method for the description."""
return super().write(data)
def _start(self, *args, **kwargs):
ret = super()._start(*args, **kwargs)
self.clear_images()
return ret
def _stop(self):
self.clear_images()
return super()._stop()
def _ti_clear_images(self):
if not (
KittyImage.forced_support
or KittyImage.is_supported()
or ITerm2Image.is_supported()
and get_terminal_name_version()[0] == "konsole"
):
return
screen_canv = self._ti_screen_canv
if not isinstance(screen_canv, urwid.CompositeCanvas):
if self._ti_image_cviews:
self.clear_images()
self._ti_image_cviews.clear()
return
def process_shard_tails():
nonlocal col
while col in shard_tails:
*trim, cols, rows, canv = shard_tails[col]
if rows > n_rows:
shard_tails[col] = (*trim, cols, rows - n_rows, canv)
else:
del shard_tails[col]
col += cols
image_cviews = set()
shard_tails = {}
row = 1
for n_rows, cviews in screen_canv.shards:
col = 1
for cview in cviews:
process_shard_tails()
*trim, cols, rows, _, canv = cview
if isinstance(canv, UrwidImageCanvas):
try:
widget = canv.widget_info[0]
except TypeError:
pass
else:
if (
isinstance(widget._ti_image, KittyImage)
or isinstance(widget._ti_image, ITerm2Image)
and get_terminal_name_version()[0] == "konsole"
):
image_cviews.add((canv, row, col, *trim, cols, rows))
if rows > n_rows:
shard_tails[col] = (*trim, cols, rows - n_rows, canv)
col += cols
process_shard_tails()
row += n_rows
kitty_widgets = []
for canv, *_ in self._ti_image_cviews - image_cviews:
widget = canv.widget_info[0]
if isinstance(widget._ti_image, KittyImage):
kitty_widgets.append(widget)
else:
self.clear_images()
# Multiple `clear_images()`s messes up the canvas disguise
# A single `clear_images()` takes care of all images anyways
break
else:
if kitty_widgets:
self.clear_images(*kitty_widgets)
self._ti_image_cviews = frozenset(image_cviews)