diff --git a/setup.py b/setup.py index decc45f..33fb5ce 100644 --- a/setup.py +++ b/setup.py @@ -40,10 +40,13 @@ setup( "wcwidth>=0.1.7", "urwid>=2.0.0,<3.0", "tomlkit>=0.10.0,<1.0", - "pillow>=9.5.0", - "term-image==0.7.0", ], extras_require={ + # Required to display images in the TUI + "images": [ + "pillow>=9.5.0", + "term-image==0.7.0", + ], # Required to display rich text in the TUI "richtext": [ "urwidgets>=0.1,<0.2" @@ -62,6 +65,7 @@ setup( "setuptools", "vermin", "typing-extensions", + "pillow>=9.5.0", ], }, entry_points={ diff --git a/tests/test_utils.py b/tests/test_utils.py index fd9069b..bb5586e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,7 +4,7 @@ import sys from toot.cli.validators import validate_duration from toot.wcstring import wc_wrap, trunc, pad, fit_text -from toot.tui.utils import ImageCache +from toot.tui.utils import LRUCache from PIL import Image from collections import namedtuple from toot.utils import urlencode_url @@ -213,7 +213,7 @@ def test_duration(): def test_cache_null(): """Null dict is null.""" - cache = ImageCache(cache_max_bytes=1024) + cache = LRUCache(cache_max_bytes=1024) assert cache.__len__() == 0 @@ -236,9 +236,9 @@ img_size = sys.getsizeof(img.tobytes()) def test_cache_init(case, method): """Check that the # of elements is right, given # given and cache_len.""" if method == "init": - cache = ImageCache(case.init, cache_max_bytes=img_size * case.cache_len) + cache = LRUCache(case.init, cache_max_bytes=img_size * case.cache_len) elif method == "assign": - cache = ImageCache(cache_max_bytes=img_size * case.cache_len) + cache = LRUCache(cache_max_bytes=img_size * case.cache_len) for (key, val) in case.init: cache[key] = val else: @@ -258,9 +258,9 @@ def test_cache_init(case, method): def test_cache_overflow_default(method): """Test default overflow logic.""" if method == "init": - cache = ImageCache([("one", img), ("two", img), ("three", img)], cache_max_bytes=img_size * 2) + cache = LRUCache([("one", img), ("two", img), ("three", img)], cache_max_bytes=img_size * 2) elif method == "assign": - cache = ImageCache(cache_max_bytes=img_size * 2) + cache = LRUCache(cache_max_bytes=img_size * 2) cache["one"] = img cache["two"] = img cache["three"] = img @@ -279,7 +279,7 @@ def test_cache_lru_overflow(mode, add_third): """Test that key access resets LRU logic.""" - cache = ImageCache([("one", img), ("two", img)], cache_max_bytes=img_size * 2) + cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 2) if mode == "get": dummy = cache["one"] @@ -301,13 +301,13 @@ def test_cache_lru_overflow(mode, add_third): def test_cache_keyerror(): - cache = ImageCache() + cache = LRUCache() with pytest.raises(KeyError): cache["foo"] def test_cache_miss_doesnt_eject(): - cache = ImageCache([("one", img), ("two", img)], cache_max_bytes=img_size * 3) + cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 3) with pytest.raises(KeyError): cache["foo"] diff --git a/toot/tui/app.py b/toot/tui/app.py index caca86b..de92967 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -1,8 +1,7 @@ import logging import subprocess import urwid -import requests -import warnings + from concurrent.futures import ThreadPoolExecutor from typing import NamedTuple, Optional @@ -17,13 +16,12 @@ from toot.utils.datetime import parse_datetime from .compose import StatusComposer from .constants import PALETTE from .entities import Status -from .images import TuiScreen +from .images import TuiScreen, load_image from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom from .overlays import StatusDeleteConfirmation, Account from .poll import Poll from .timeline import Timeline -from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, ImageCache -from PIL import Image +from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, LRUCache from .widgets import ModalBox, RoundedLineBox logger = logging.getLogger(__name__) @@ -773,16 +771,11 @@ class TUI(urwid.Frame): return if not hasattr(timeline, "images"): - timeline.images = ImageCache(cache_max_bytes=self.cache_max) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") # suppress "corrupt exif" output from PIL - try: - img = Image.open(requests.get(path, stream=True).raw) - if img.format == 'PNG' and img.mode != 'RGBA': - img = img.convert("RGBA") - timeline.images[str(hash(path))] = img - except Exception: - pass # ignore errors; if we can't load an image, just show blank + timeline.images = LRUCache(cache_max_bytes=self.cache_max) + + img = load_image(path) + if img: + timeline.images[str(hash(path))] = img def _done(loop): # don't bother loading images for statuses we are not viewing now diff --git a/toot/tui/images.py b/toot/tui/images.py index 3b84c40..77e5bbc 100644 --- a/toot/tui/images.py +++ b/toot/tui/images.py @@ -1,9 +1,104 @@ +import urwid +import math +import requests +import warnings + # If term_image is loaded use their screen implementation which handles images try: - from term_image.widget import UrwidImageScreen + from term_image.widget import UrwidImageScreen, UrwidImage + from term_image.image import BaseImage, KittyImage, ITerm2Image, BlockImage from term_image import disable_queries # prevent phantom keystrokes + from PIL import Image, ImageDraw + TuiScreen = UrwidImageScreen disable_queries() + + def image_support_enabled(): + return True + + def can_render_pixels(image_format): + return image_format in ['kitty', 'iterm'] + + def get_base_image(image, image_format) -> BaseImage: + # we don't autodetect kitty, iterm; we choose based on option switches + BaseImage.forced_support = True + if image_format == 'kitty': + return KittyImage(image) + elif image_format == 'iterm': + return ITerm2Image(image) + else: + return BlockImage(image) + + def resize_image(basewidth: int, baseheight: int, img: Image.Image) -> Image.Image: + if baseheight and not basewidth: + hpercent = baseheight / float(img.size[1]) + width = math.ceil(img.size[0] * hpercent) + img = img.resize((width, baseheight), Image.Resampling.LANCZOS) + elif basewidth and not baseheight: + wpercent = (basewidth / float(img.size[0])) + hsize = int((float(img.size[1]) * float(wpercent))) + img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS) + else: + img = img.resize((basewidth, baseheight), Image.Resampling.LANCZOS) + + if img.mode != 'P': + img = img.convert('RGB') + return img + + def add_corners(img, rad): + circle = Image.new('L', (rad * 2, rad * 2), 0) + draw = ImageDraw.Draw(circle) + draw.ellipse((0, 0, rad * 2, rad * 2), fill=255) + alpha = Image.new('L', img.size, "white") + w, h = img.size + alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0)) + alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad)) + alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0)) + alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad)) + img.putalpha(alpha) + return img + + def load_image(url): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # suppress "corrupt exif" output from PIL + try: + img = Image.open(requests.get(url, stream=True).raw) + if img.format == 'PNG' and img.mode != 'RGBA': + img = img.convert("RGBA") + return img + except Exception: + return None + + def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget: + if not img: + return urwid.SolidFill(fill_char=" ") + + if can_render_pixels(image_format) and corner_radius > 0: + render_img = add_corners(img, 10) + else: + render_img = img + + return UrwidImage(get_base_image(render_img, image_format), '<', upscale=True) + # "<" means left-justify the image + except ImportError: from urwid.raw_display import Screen TuiScreen = Screen + + def image_support_enabled(): + return False + + def can_render_pixels(image_format: str): + return False + + def get_base_image(image, image_format: str): + return None + + def add_corners(img, rad): + return None + + def load_image(url): + return None + + def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget: + return urwid.SolidFill(fill_char=" ") diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 0ee6400..c0f5d8d 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -1,5 +1,4 @@ import json -import requests import traceback import urwid import webbrowser @@ -7,11 +6,10 @@ import webbrowser from toot import __version__ from toot import api -from toot.tui.utils import highlight_keys, add_corners, get_base_image +from toot.tui.utils import highlight_keys +from toot.tui.images import image_support_enabled, load_image, graphics_widget from toot.tui.widgets import Button, EditBox, SelectableText from toot.tui.richtext import html_to_widgets -from PIL import Image -from term_image.widget import UrwidImage class StatusSource(urwid.Padding): @@ -261,26 +259,18 @@ class Account(urwid.ListBox): super().__init__(walker) def account_header(self, account): - if account['avatar'] and not account["avatar"].endswith("missing.png"): - img = Image.open(requests.get(account['avatar'], stream=True).raw) - - if img.format == 'PNG' and img.mode != 'RGBA': - img = img.convert("RGBA") + if image_support_enabled() and account['avatar'] and not account["avatar"].endswith("missing.png"): + img = load_image(account['avatar']) aimg = urwid.BoxAdapter( - UrwidImage( - get_base_image( - add_corners(img, 10), self.options.image_format), upscale=True), - 10) + graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10) else: aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10) - if account['header'] and not account["header"].endswith("missing.png"): - img = Image.open(requests.get(account['header'], stream=True).raw) + if image_support_enabled() and account['header'] and not account["header"].endswith("missing.png"): + img = load_image(account['header']) - if img.format == 'PNG' and img.mode != 'RGBA': - img = img.convert("RGBA") - himg = (urwid.BoxAdapter(UrwidImage(get_base_image( - add_corners(img, 10), self.options.image_format), upscale=True), 10)) + himg = (urwid.BoxAdapter( + graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10)) else: himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 596adaa..fca9d96 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -7,7 +7,6 @@ from typing import List, Optional from toot.tui import app -from toot.tui.utils import add_corners from toot.tui.richtext import html_to_widgets, url_to_widget from toot.utils.datetime import parse_datetime, time_ago from toot.utils.language import language_name @@ -15,9 +14,9 @@ from toot.utils.language import language_name from toot.entities import Status from toot.tui.scroll import Scrollable, ScrollBar -from toot.tui.utils import highlight_keys, get_base_image, can_render_pixels +from toot.tui.utils import highlight_keys +from toot.tui.images import image_support_enabled, graphics_widget, can_render_pixels from toot.tui.widgets import SelectableText, SelectableColumns, RoundedLineBox -from term_image.widget import UrwidImage logger = logging.getLogger("toot") @@ -150,7 +149,16 @@ class Timeline(urwid.Columns): def modified(self): """Called when the list focus switches to a new status""" status, index, count = self.get_focused_status_with_counts() - self.tui.screen.clear_images() + + if image_support_enabled: + clear_op = getattr(self.tui.screen, "clear_images", None) + # term-image's screen implementation has clear_images(), + # urwid's implementation does not. + # TODO: it would be nice not to check this each time thru + + if callable(clear_op): + self.tui.screen.clear_images() + self.draw_status_details(status) self._emit("focus") @@ -330,11 +338,8 @@ class Timeline(urwid.Columns): pass if img: try: - render_img = add_corners(img, 10) if self.can_render_pixels else img - status.placeholders[placeholder_index]._set_original_widget( - UrwidImage(get_base_image(render_img, self.tui.options.image_format), '<', upscale=True)) - # "<" means left-justify the image + graphics_widget(img, image_format=self.tui.options.image_format, corner_radius=10)) except IndexError: # ignore IndexErrors. @@ -402,20 +407,19 @@ class StatusDetails(urwid.Pile): except KeyError: pass if img: - render_img = add_corners(img, 10) if self.timeline.can_render_pixels else img return (urwid.BoxAdapter( - UrwidImage(get_base_image(render_img, self.timeline.tui.options.image_format), "<", upscale=True), - rows)) + graphics_widget(img, image_format=self.timeline.tui.options.image_format, corner_radius=10), rows)) else: placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows) self.status.placeholders.append(placeholder) - self.timeline.tui.async_load_image(self.timeline, self.status, path, len(self.status.placeholders) - 1) + if image_support_enabled(): + self.timeline.tui.async_load_image(self.timeline, self.status, path, len(self.status.placeholders) - 1) return placeholder def author_header(self, reblogged_by): avatar_url = self.status.original.data["account"]["avatar"] - if avatar_url: + if avatar_url and image_support_enabled(): aimg = self.image_widget(avatar_url, 2) else: aimg = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), 2) @@ -472,7 +476,8 @@ class StatusDetails(urwid.Pile): aspect = float(m["meta"]["original"]["aspect"]) except Exception: aspect = None - yield self.image_widget(m["url"], aspect=aspect) + if image_support_enabled(): + yield self.image_widget(m["url"], aspect=aspect) yield urwid.Divider() # video media may include a preview URL, show that as a fallback elif m["preview_url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')): @@ -481,7 +486,8 @@ class StatusDetails(urwid.Pile): aspect = float(m["meta"]["small"]["aspect"]) except Exception: aspect = None - yield self.image_widget(m["preview_url"], aspect=aspect) + if image_support_enabled(): + yield self.image_widget(m["preview_url"], aspect=aspect) yield urwid.Divider() yield ("pack", url_to_widget(m["url"])) @@ -547,7 +553,7 @@ class StatusDetails(urwid.Pile): yield urwid.Text("") yield url_to_widget(card["url"]) - if card["image"]: + if card["image"] and image_support_enabled(): if card["image"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')): yield urwid.Text("") try: diff --git a/toot/tui/utils.py b/toot/tui/utils.py index bae8ae8..c7df968 100644 --- a/toot/tui/utils.py +++ b/toot/tui/utils.py @@ -2,15 +2,11 @@ import base64 import re import sys import urwid -import math from collections import OrderedDict from functools import reduce from html.parser import HTMLParser from typing import List -from PIL import Image, ImageDraw -from term_image.image import BaseImage, KittyImage, ITerm2Image, BlockImage - HASHTAG_PATTERN = re.compile(r'(? Image.Image: - if baseheight and not basewidth: - hpercent = baseheight / float(img.size[1]) - width = math.ceil(img.size[0] * hpercent) - img = img.resize((width, baseheight), Image.Resampling.LANCZOS) - elif basewidth and not baseheight: - wpercent = (basewidth / float(img.size[0])) - hsize = int((float(img.size[1]) * float(wpercent))) - img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS) - else: - img = img.resize((basewidth, baseheight), Image.Resampling.LANCZOS) - - if img.mode != 'P': - img = img.convert('RGB') - return img - - -def add_corners(img, rad): - circle = Image.new('L', (rad * 2, rad * 2), 0) - draw = ImageDraw.Draw(circle) - draw.ellipse((0, 0, rad * 2, rad * 2), fill=255) - alpha = Image.new('L', img.size, "white") - w, h = img.size - alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0)) - alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad)) - alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0)) - alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad)) - img.putalpha(alpha) - return img - - -def can_render_pixels(image_format: str): - return image_format in ['kitty', 'iterm'] - - -def get_base_image(image, image_format: str) -> BaseImage: - # we don't autodetect kitty, iterm, we force based on option switches - BaseImage.forced_support = True - if image_format == 'kitty': - return KittyImage(image) - elif image_format == 'iterm': - return ITerm2Image(image) - else: - return BlockImage(image) - - def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str): """ copy text to clipboard using OSC 52 This escape sequence is documented @@ -162,7 +112,7 @@ def deep_get(adict: dict, path: List[str], default=None): ) -class ImageCache(OrderedDict): +class LRUCache(OrderedDict): """Dict with a limited size, ejecting LRUs as needed. Default max size = 10Mb""" @@ -173,7 +123,7 @@ class ImageCache(OrderedDict): super().__init__(*args, **kwargs) - def __setitem__(self, key: str, value: Image.Image): + def __setitem__(self, key: str, value): if key in self: self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes()) self.total_value_size += sys.getsizeof(value.tobytes())