Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement synchronized output for urwid images #80

Merged
merged 10 commits into from
Mar 6, 2023
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `term_image.widget` subpackage
- `term_image.widget.UrwidImage`
- `term_image.widget.UrwidImageCanvas`
- `term_image.widget.UrwidImageJanitor`
- `term_image.widget.UrwidImageScreen`
- Support for terminal-synchronized output ([#80]).

### Changed
- **(BREAKING!)** Redefined `KittyImage.clear()` ([97eceab]).
Expand All @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#73]: https://github.com/AnonymouX47/term-image/pull/73
[#74]: https://github.com/AnonymouX47/term-image/pull/74
[#78]: https://github.com/AnonymouX47/term-image/pull/78
[#80]: https://github.com/AnonymouX47/term-image/pull/80
[b4533d5]: https://github.com/AnonymouX47/term-image/commit/b4533d5697d41fe0742c2ac895077da3b8d889dc
[97eceab]: https://github.com/AnonymouX47/term-image/commit/97eceab77e7448a18281aa6edb3fa8ec9e6564c5
[807a9ec]: https://github.com/AnonymouX47/term-image/commit/807a9ecad717e46621a5214dbf849369d3afbc0b
Expand Down
11 changes: 11 additions & 0 deletions src/term_image/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,17 @@ def _process_run_wrapper(self, *args, set_tty_lock: bool = True, **kwargs):
FG_FMT_b = FG_FMT.encode()
COLOR_RESET = f"{CSI}m"
COLOR_RESET_b = COLOR_RESET.encode()
DECSET = f"{CSI}?%dh"
DECSET_b = DECSET.encode()
DECRST = f"{CSI}?%dl"
DECRST_b = DECRST.encode()

# Terminal Synchronized Output
# See https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036
BEGIN_SYNCED_UPDATE = DECSET % 2026
BEGIN_SYNCED_UPDATE_b = BEGIN_SYNCED_UPDATE.encode()
END_SYNCED_UPDATE = DECRST % 2026
END_SYNCED_UPDATE_b = END_SYNCED_UPDATE.encode()

# Private internal variables
_query_timeout = DEFAULT_QUERY_TIMEOUT
Expand Down
9 changes: 2 additions & 7 deletions src/term_image/widget/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,7 @@
except ImportError:
pass
else:
from .urwid import UrwidImage, UrwidImageCanvas, UrwidImageJanitor, UrwidImageScreen
from .urwid import UrwidImage, UrwidImageCanvas, UrwidImageScreen

del urwid
__all__ += [
"UrwidImage",
"UrwidImageCanvas",
"UrwidImageJanitor",
"UrwidImageScreen",
]
__all__ += ["UrwidImage", "UrwidImageCanvas", "UrwidImageScreen"]
129 changes: 63 additions & 66 deletions src/term_image/widget/urwid.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

from __future__ import annotations

__all__ = ("UrwidImage", "UrwidImageCanvas", "UrwidImageJanitor")
__all__ = ("UrwidImage", "UrwidImageCanvas", "UrwidImageScreen")

from typing import Optional, Tuple

import urwid

from ..exceptions import UrwidImageError
from ..image import BaseImage, ITerm2Image, KittyImage, Size, TextImage, kitty
from ..utils import COLOR_RESET_b, ESC_b
from ..utils import BEGIN_SYNCED_UPDATE, END_SYNCED_UPDATE, COLOR_RESET_b, ESC_b

# 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.
Expand Down Expand Up @@ -500,49 +500,66 @@ def _ti_calc_trim(
return new_pad_side1, trim_image_side1, trim_image_side2, new_pad_side2


class UrwidImageJanitor(urwid.WidgetWrap):
"""A widget wrapper that monitors images of some :ref:`graphics-based
<graphics-based>` render styles and clears them off the screen when necessary.
class UrwidImageScreen(urwid.raw_display.Screen):
"""A screen that supports drawing images.

Args:
widget: A widget containing image widgets (possibly recursively).
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).

NOTE:
For this widget to function properly, ensure that the position of its top-left
corner, **relative to the screen** can **never** change.
It also synchronizes output on terminal emulators that support the feature to
reduce/eliminate image flickering/tearing.

It's most advisable to use this widget to wrap the topmost widget but it may
be used to wrap any other widget and will function properly as long as the
condition mentioned earlier is met.
See the `baseclass
<http://urwid.org/reference/display_modules.html#urwid.raw_display.Screen>`_
for futher description.
"""

no_cache = ["render"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._ti_screen_canv = None
self._ti_image_cviews = frozenset()

def __init__(self, widget: urwid.Widget) -> None:
if not isinstance(widget, urwid.Widget):
raise TypeError(f"Invalid type for 'widget' (got: {type(widget).__name__})")
def draw_screen(self, maxres, canvas):
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()

super().__init__(widget)
self._ti_image_cviews = frozenset()
def _start(self, *args, **kwargs):
ret = super()._start(*args, **kwargs)
if KittyImage._forced_support or KittyImage.is_supported():
self.write(kitty.DELETE_ALL_IMAGES)

widget = property(lambda self: self._w, doc="The wrapped widget")
return ret

def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.Canvas:
main_canv = super().render(size, focus)
def _stop(self):
if KittyImage._forced_support or KittyImage.is_supported():
self.write(kitty.DELETE_ALL_IMAGES)

return super()._stop()

def _ti_clear_images(self):
if not (
KittyImage._forced_support
or KittyImage.is_supported()
or ITerm2Image.is_supported()
and ITerm2Image._TERM == "konsole"
):
return main_canv
return

if not isinstance(main_canv, urwid.CompositeCanvas):
screen_canv = self._ti_screen_canv

if not isinstance(screen_canv, urwid.CompositeCanvas):
if self._ti_image_cviews:
UrwidImage.clear_all()
self._ti_image_cviews.clear()
return main_canv
return

def process_shard_tails():
nonlocal col
Expand All @@ -555,67 +572,47 @@ def process_shard_tails():
del shard_tails[col]
col += cols

images = set()
image_cviews = set()
shard_tails = {}
row = 1

for n_rows, cviews in main_canv.shards:
for n_rows, cviews in screen_canv.shards:
col = 1
for cview in cviews:
process_shard_tails()
*trim, cols, rows, _, canv = cview

try:
widget = canv.widget_info[0]
except TypeError:
pass
else:
if isinstance(canv, UrwidImageCanvas) and (
isinstance(widget._ti_image, KittyImage)
or isinstance(widget._ti_image, ITerm2Image)
and ITerm2Image._TERM == "konsole"
):
images.add((canv, row, col, *trim, cols, rows))
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 ITerm2Image._TERM == "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

for canv, *_ in self._ti_image_cviews - images:
kitty_widgets = []
for canv, *_ in self._ti_image_cviews - image_cviews:
widget = canv.widget_info[0]
if isinstance(widget._ti_image, KittyImage):
widget.clear()
kitty_widgets.append(widget)
else:
UrwidImage.clear_all()
# Multiple `clear_all()`s messes up the canvas disguise
# Also, a single `clear_all()` takes care of all images
break
self._ti_image_cviews = frozenset(images)

return main_canv


class UrwidImageScreen(urwid.raw_display.Screen):
"""A screen that clears all visible images of the
:py:class:`kitty <term_image.image.KittyImage>` render style immediately after
it starts and immediately before it stops.

See the `baseclass
<http://urwid.org/reference/display_modules.html#urwid.raw_display.Screen>`_
for futher description.
"""

def _start(self, *args, **kwargs):
ret = super()._start(*args, **kwargs)
if KittyImage._forced_support or KittyImage.is_supported():
self.write(kitty.DELETE_ALL_IMAGES)

return ret

def _stop(self):
if KittyImage._forced_support or KittyImage.is_supported():
self.write(kitty.DELETE_ALL_IMAGES)
else:
for widget in kitty_widgets:
widget.clear()

return super()._stop()
self._ti_image_cviews = frozenset(image_cviews)
Loading