1
0
mirror of https://github.com/ihabunek/toot.git synced 2024-11-03 04:17:21 -05:00

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", "wcwidth>=0.1.7",
"urwid>=2.0.0,<3.0", "urwid>=2.0.0,<3.0",
"tomlkit>=0.10.0,<1.0", "tomlkit>=0.10.0,<1.0",
"pillow>=9.5.0",
"term-image==0.7.0",
], ],
extras_require={ 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 # Required to display rich text in the TUI
"richtext": [ "richtext": [
"urwidgets>=0.1,<0.2" "urwidgets>=0.1,<0.2"
@ -62,6 +65,7 @@ setup(
"setuptools", "setuptools",
"vermin", "vermin",
"typing-extensions", "typing-extensions",
"pillow>=9.5.0",
], ],
}, },
entry_points={ entry_points={

View File

@ -4,7 +4,7 @@ import sys
from toot.cli.validators import validate_duration from toot.cli.validators import validate_duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text 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 PIL import Image
from collections import namedtuple from collections import namedtuple
from toot.utils import urlencode_url from toot.utils import urlencode_url
@ -213,7 +213,7 @@ def test_duration():
def test_cache_null(): def test_cache_null():
"""Null dict is null.""" """Null dict is null."""
cache = ImageCache(cache_max_bytes=1024) cache = LRUCache(cache_max_bytes=1024)
assert cache.__len__() == 0 assert cache.__len__() == 0
@ -236,9 +236,9 @@ img_size = sys.getsizeof(img.tobytes())
def test_cache_init(case, method): def test_cache_init(case, method):
"""Check that the # of elements is right, given # given and cache_len.""" """Check that the # of elements is right, given # given and cache_len."""
if method == "init": 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": 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: for (key, val) in case.init:
cache[key] = val cache[key] = val
else: else:
@ -258,9 +258,9 @@ def test_cache_init(case, method):
def test_cache_overflow_default(method): def test_cache_overflow_default(method):
"""Test default overflow logic.""" """Test default overflow logic."""
if method == "init": 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": elif method == "assign":
cache = ImageCache(cache_max_bytes=img_size * 2) cache = LRUCache(cache_max_bytes=img_size * 2)
cache["one"] = img cache["one"] = img
cache["two"] = img cache["two"] = img
cache["three"] = img cache["three"] = img
@ -279,7 +279,7 @@ def test_cache_lru_overflow(mode, add_third):
"""Test that key access resets LRU logic.""" """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": if mode == "get":
dummy = cache["one"] dummy = cache["one"]
@ -301,13 +301,13 @@ def test_cache_lru_overflow(mode, add_third):
def test_cache_keyerror(): def test_cache_keyerror():
cache = ImageCache() cache = LRUCache()
with pytest.raises(KeyError): with pytest.raises(KeyError):
cache["foo"] cache["foo"]
def test_cache_miss_doesnt_eject(): 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): with pytest.raises(KeyError):
cache["foo"] cache["foo"]

View File

@ -1,8 +1,7 @@
import logging import logging
import subprocess import subprocess
import urwid import urwid
import requests
import warnings
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional from typing import NamedTuple, Optional
@ -17,13 +16,12 @@ from toot.utils.datetime import parse_datetime
from .compose import StatusComposer from .compose import StatusComposer
from .constants import PALETTE from .constants import PALETTE
from .entities import Status 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 ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
from .overlays import StatusDeleteConfirmation, Account from .overlays import StatusDeleteConfirmation, Account
from .poll import Poll from .poll import Poll
from .timeline import Timeline from .timeline import Timeline
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, ImageCache from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, LRUCache
from PIL import Image
from .widgets import ModalBox, RoundedLineBox from .widgets import ModalBox, RoundedLineBox
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -773,16 +771,11 @@ class TUI(urwid.Frame):
return return
if not hasattr(timeline, "images"): if not hasattr(timeline, "images"):
timeline.images = ImageCache(cache_max_bytes=self.cache_max) timeline.images = LRUCache(cache_max_bytes=self.cache_max)
with warnings.catch_warnings():
warnings.simplefilter("ignore") # suppress "corrupt exif" output from PIL img = load_image(path)
try: if img:
img = Image.open(requests.get(path, stream=True).raw) timeline.images[str(hash(path))] = img
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
def _done(loop): def _done(loop):
# don't bother loading images for statuses we are not viewing now # 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 # If term_image is loaded use their screen implementation which handles images
try: 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 term_image import disable_queries # prevent phantom keystrokes
from PIL import Image, ImageDraw
TuiScreen = UrwidImageScreen TuiScreen = UrwidImageScreen
disable_queries() 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: except ImportError:
from urwid.raw_display import Screen from urwid.raw_display import Screen
TuiScreen = 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 json
import requests
import traceback import traceback
import urwid import urwid
import webbrowser import webbrowser
@ -7,11 +6,10 @@ import webbrowser
from toot import __version__ from toot import __version__
from toot import api 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.widgets import Button, EditBox, SelectableText
from toot.tui.richtext import html_to_widgets from toot.tui.richtext import html_to_widgets
from PIL import Image
from term_image.widget import UrwidImage
class StatusSource(urwid.Padding): class StatusSource(urwid.Padding):
@ -261,26 +259,18 @@ class Account(urwid.ListBox):
super().__init__(walker) super().__init__(walker)
def account_header(self, account): def account_header(self, account):
if account['avatar'] and not account["avatar"].endswith("missing.png"): if image_support_enabled() and account['avatar'] and not account["avatar"].endswith("missing.png"):
img = Image.open(requests.get(account['avatar'], stream=True).raw) img = load_image(account['avatar'])
if img.format == 'PNG' and img.mode != 'RGBA':
img = img.convert("RGBA")
aimg = urwid.BoxAdapter( aimg = urwid.BoxAdapter(
UrwidImage( graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10)
get_base_image(
add_corners(img, 10), self.options.image_format), upscale=True),
10)
else: else:
aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10) aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
if account['header'] and not account["header"].endswith("missing.png"): if image_support_enabled() and account['header'] and not account["header"].endswith("missing.png"):
img = Image.open(requests.get(account['header'], stream=True).raw) img = load_image(account['header'])
if img.format == 'PNG' and img.mode != 'RGBA': himg = (urwid.BoxAdapter(
img = img.convert("RGBA") graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10))
himg = (urwid.BoxAdapter(UrwidImage(get_base_image(
add_corners(img, 10), self.options.image_format), upscale=True), 10))
else: else:
himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10) 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 import app
from toot.tui.utils import add_corners
from toot.tui.richtext import html_to_widgets, url_to_widget from toot.tui.richtext import html_to_widgets, url_to_widget
from toot.utils.datetime import parse_datetime, time_ago from toot.utils.datetime import parse_datetime, time_ago
from toot.utils.language import language_name 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.entities import Status
from toot.tui.scroll import Scrollable, ScrollBar 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 toot.tui.widgets import SelectableText, SelectableColumns, RoundedLineBox
from term_image.widget import UrwidImage
logger = logging.getLogger("toot") logger = logging.getLogger("toot")
@ -150,7 +149,16 @@ class Timeline(urwid.Columns):
def modified(self): def modified(self):
"""Called when the list focus switches to a new status""" """Called when the list focus switches to a new status"""
status, index, count = self.get_focused_status_with_counts() 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.draw_status_details(status)
self._emit("focus") self._emit("focus")
@ -330,11 +338,8 @@ class Timeline(urwid.Columns):
pass pass
if img: if img:
try: try:
render_img = add_corners(img, 10) if self.can_render_pixels else img
status.placeholders[placeholder_index]._set_original_widget( status.placeholders[placeholder_index]._set_original_widget(
UrwidImage(get_base_image(render_img, self.tui.options.image_format), '<', upscale=True)) graphics_widget(img, image_format=self.tui.options.image_format, corner_radius=10))
# "<" means left-justify the image
except IndexError: except IndexError:
# ignore IndexErrors. # ignore IndexErrors.
@ -402,20 +407,19 @@ class StatusDetails(urwid.Pile):
except KeyError: except KeyError:
pass pass
if img: if img:
render_img = add_corners(img, 10) if self.timeline.can_render_pixels else img
return (urwid.BoxAdapter( return (urwid.BoxAdapter(
UrwidImage(get_base_image(render_img, self.timeline.tui.options.image_format), "<", upscale=True), graphics_widget(img, image_format=self.timeline.tui.options.image_format, corner_radius=10), rows))
rows))
else: else:
placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows) placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows)
self.status.placeholders.append(placeholder) 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 return placeholder
def author_header(self, reblogged_by): def author_header(self, reblogged_by):
avatar_url = self.status.original.data["account"]["avatar"] 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) aimg = self.image_widget(avatar_url, 2)
else: else:
aimg = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), 2) aimg = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), 2)
@ -472,7 +476,8 @@ class StatusDetails(urwid.Pile):
aspect = float(m["meta"]["original"]["aspect"]) aspect = float(m["meta"]["original"]["aspect"])
except Exception: except Exception:
aspect = None 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() yield urwid.Divider()
# video media may include a preview URL, show that as a fallback # video media may include a preview URL, show that as a fallback
elif m["preview_url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')): 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"]) aspect = float(m["meta"]["small"]["aspect"])
except Exception: except Exception:
aspect = None 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 urwid.Divider()
yield ("pack", url_to_widget(m["url"])) yield ("pack", url_to_widget(m["url"]))
@ -547,7 +553,7 @@ class StatusDetails(urwid.Pile):
yield urwid.Text("") yield urwid.Text("")
yield url_to_widget(card["url"]) 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')): if card["image"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("") yield urwid.Text("")
try: try:

View File

@ -2,15 +2,11 @@ import base64
import re import re
import sys import sys
import urwid import urwid
import math
from collections import OrderedDict from collections import OrderedDict
from functools import reduce from functools import reduce
from html.parser import HTMLParser from html.parser import HTMLParser
from typing import List 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') HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
@ -76,52 +72,6 @@ def parse_content_links(content):
return parser.links[:] 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): def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str):
""" copy text to clipboard using OSC 52 """ copy text to clipboard using OSC 52
This escape sequence is documented 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. """Dict with a limited size, ejecting LRUs as needed.
Default max size = 10Mb""" Default max size = 10Mb"""
@ -173,7 +123,7 @@ class ImageCache(OrderedDict):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def __setitem__(self, key: str, value: Image.Image): def __setitem__(self, key: str, value):
if key in self: if key in self:
self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes()) self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes())
self.total_value_size += sys.getsizeof(value.tobytes()) self.total_value_size += sys.getsizeof(value.tobytes())