Refactored all image code into tui/images.py

All image code is now a soft dependency. If the term-image
and/or pillow libraries are not loaded, the tui will work
fine without displaying images.

Note that tests/test_utils.py still has a dependency on pillow
due to its use of Image for tsting the LRUCache.
This commit is contained in:
Daniel Schwarz 2024-01-19 17:57:50 -05:00
parent bdc0c06fbe
commit 5343bccb15
7 changed files with 152 additions and 114 deletions

View File

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

View File

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

View File

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

View File

@ -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=" ")

View File

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

View File

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

View File

@ -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'(?<!\w)(#\w+)\b')
@ -76,52 +72,6 @@ def parse_content_links(content):
return parser.links[:]
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 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())