Skip to content

Commit

Permalink
Refactored all image code into tui/images.py
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
danschwarz committed Jan 19, 2024
1 parent bdc0c06 commit b44c3a1
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 114 deletions.
7 changes: 5 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@
"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"
Expand Down
18 changes: 9 additions & 9 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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
Expand Down Expand Up @@ -213,7 +213,7 @@ def duration(value):

def test_cache_null():
"""Null dict is null."""
cache = ImageCache(cache_max_bytes=1024)
cache = LRUCache(cache_max_bytes=1024)
assert cache.__len__() == 0


Expand All @@ -236,9 +236,9 @@ def test_cache_null():
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:
Expand All @@ -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
Expand All @@ -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"]
Expand All @@ -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"]

Expand Down
23 changes: 8 additions & 15 deletions toot/tui/app.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import logging
import subprocess
import urwid
import requests
import warnings


from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional
Expand All @@ -17,13 +16,12 @@
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__)
Expand Down Expand Up @@ -773,16 +771,11 @@ def _load():
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
Expand Down
97 changes: 96 additions & 1 deletion toot/tui/images.py
Original file line number Diff line number Diff line change
@@ -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=" ")
28 changes: 9 additions & 19 deletions toot/tui/overlays.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import json
import requests
import traceback
import urwid
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):
Expand Down Expand Up @@ -261,26 +259,18 @@ def setup_listbox(self):
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)

Expand Down
38 changes: 22 additions & 16 deletions toot/tui/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,16 @@

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

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")
Expand Down Expand Up @@ -150,7 +149,16 @@ def get_focused_status_with_counts(self):
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")

Expand Down Expand Up @@ -330,11 +338,8 @@ def update_status_image(self, status, path, placeholder_index):
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.
Expand Down Expand Up @@ -402,20 +407,19 @@ def image_widget(self, path, rows=None, aspect=None) -> urwid.Widget:
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)
Expand Down Expand Up @@ -472,7 +476,8 @@ def content_generator(self, status, reblogged_by):
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')):
Expand All @@ -481,7 +486,8 @@ def content_generator(self, status, reblogged_by):
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"]))

Expand Down Expand Up @@ -547,7 +553,7 @@ def card_generator(self, card):
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:
Expand Down
Loading

0 comments on commit b44c3a1

Please sign in to comment.