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:
parent
bdc0c06fbe
commit
5343bccb15
8
setup.py
8
setup.py
@ -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={
|
||||||
|
@ -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"]
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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=" ")
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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())
|
||||||
|
Loading…
Reference in New Issue
Block a user