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

Show images

This commit is contained in:
Daniel Schwarz 2023-06-22 12:13:23 +02:00 committed by Ivan Habunek
parent 219225ba8a
commit 6d0edaf16f
No known key found for this signature in database
GPG Key ID: F5F0623FF5EBCB3D
7 changed files with 358 additions and 21 deletions

View File

@ -2,4 +2,5 @@ requests>=2.13,<3.0
beautifulsoup4>=4.5.0,<5.0
wcwidth>=0.1.7
urwid>=2.0.0,<3.0
pillow>=9.5.0
term-image==0.6.1

View File

@ -39,6 +39,8 @@ setup(
"wcwidth>=0.1.7",
"urwid>=2.0.0,<3.0",
"tomlkit>=0.10.0,<1.0"
"pillow>=9.5.0",
"term-image==0.6.1",
],
entry_points={
'console_scripts': [

View File

@ -1,5 +1,6 @@
import logging
import urwid
import requests
from concurrent.futures import ThreadPoolExecutor
@ -16,6 +17,10 @@ from .poll import Poll
from .timeline import Timeline
from .utils import get_max_toot_chars, parse_content_links, show_media, copy_to_clipboard
from PIL import Image
from term_image.widget import UrwidImageScreen
logger = logging.getLogger(__name__)
urwid.set_encoding('UTF-8')
@ -91,6 +96,7 @@ class TUI(urwid.Frame):
loop = urwid.MainLoop(
tui,
palette=MONO_PALETTE if args.no_color else PALETTE,
screen=UrwidImageScreen(), # like urwid.raw_display.Screen, but clears Kitty + iTerm2 images on startup
event_loop=urwid.AsyncioEventLoop(),
unhandled_input=tui.unhandled_input,
screen=screen,
@ -649,6 +655,30 @@ class TUI(urwid.Frame):
return self.run_in_thread(_delete, done_callback=_done)
def async_load_image(self, timeline, status, path, placeholder_index):
def _load():
# don't bother loading images for statuses we are not viewing now
if timeline.get_focused_status().id != status.id:
return
if not hasattr(timeline, "images"):
timeline.images = dict()
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: # noqa E722
pass # ignore errors; if we can't load an image, just show blank
def _done(loop):
# don't bother loading images for statuses we are not viewing now
if timeline.get_focused_status().id != status.id:
return
timeline.update_status_image(status, path, placeholder_index)
return self.run_in_thread(_load, done_callback=_done)
def copy_status(self, status):
# TODO: copy a better version of status content
# including URLs

View File

@ -1,13 +1,17 @@
import json
import requests
import traceback
import urwid
import webbrowser
from toot import __version__
from toot.utils import format_content
from .utils import highlight_hashtags, highlight_keys
from .widgets import Button, EditBox, SelectableText
from .utils import highlight_hashtags, highlight_keys, add_corners
from .widgets import Button, EditBox, SelectableText, EmojiText
from toot import api
from PIL import Image
from term_image.image import AutoImage
from term_image.widget import UrwidImage
class StatusSource(urwid.Padding):
@ -254,6 +258,45 @@ class Account(urwid.ListBox):
walker = urwid.SimpleListWalker(actions)
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")
aimg = urwid.BoxAdapter(
UrwidImage(
AutoImage(
add_corners(img, 10)), upscale=True),
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 img.format == 'PNG' and img.mode != 'RGBA':
img = img.convert("RGBA")
himg = (urwid.BoxAdapter(
UrwidImage(
AutoImage(
add_corners(img, 10)
), upscale=True),
10)
)
else:
himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
atxt = urwid.Pile([urwid.Divider(),
urwid.AttrMap(
EmojiText(account["display_name"], account["emojis"]),
"account"),
(urwid.Text(("highlight", "@" + self.account['acct'])))])
columns = urwid.Columns([aimg, ("weight", 9999, himg)], dividechars=2, min_width=20)
header = urwid.Pile([columns, urwid.Divider(), atxt])
return header
def generate_contents(self, account, relationship=None, last_action=None):
if self.last_action and not self.last_action.startswith("Confirm"):
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
@ -275,16 +318,13 @@ class Account(urwid.ListBox):
yield urwid.Divider("")
yield urwid.Divider()
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
yield self.account_header(account)
if account["note"]:
yield urwid.Divider()
for line in format_content(account["note"]):
yield urwid.Text(highlight_hashtags(line, followed_tags=set()))
yield urwid.Divider()
yield urwid.Text(["ID: ", ("highlight", f"{account['id']}")])
yield urwid.Text(["Since: ", ("highlight", f"{account['created_at'][:10]}")])
yield urwid.Divider()
if account["bot"]:

View File

@ -1,4 +1,5 @@
import logging
import math
import sys
import urwid
import webbrowser
@ -6,6 +7,7 @@ import webbrowser
from typing import List, Optional
from toot.tui import app
from toot.tui.utils import can_render_pixels, add_corners
from toot.utils import format_content
from toot.utils.datetime import parse_datetime, time_ago
from toot.utils.language import language_name
@ -13,15 +15,20 @@ from toot.utils.language import language_name
from .entities import Status
from .scroll import Scrollable, ScrollBar
from .utils import highlight_hashtags, highlight_keys
from .widgets import SelectableText, SelectableColumns
from .widgets import SelectableText, SelectableColumns, EmojiText
from term_image.image import AutoImage
from term_image.widget import UrwidImage
logger = logging.getLogger("toot")
screen = urwid.raw_display.Screen()
class Timeline(urwid.Columns):
"""
Displays a list of statuses to the left, and status details on the right.
"""
signals = [
"close", # Close thread
"focus", # Focus changed
@ -42,6 +49,7 @@ class Timeline(urwid.Columns):
self.is_thread = is_thread
self.statuses = statuses
self.status_list = self.build_status_list(statuses, focus=focus)
self.can_render_pixels = can_render_pixels()
try:
focused_status = statuses[focus]
@ -275,7 +283,7 @@ class Timeline(urwid.Columns):
def get_status_index(self, id):
# TODO: This is suboptimal, consider a better way
for n, status in enumerate(self.statuses):
for n, status in enumerate(self.statuses.copy()):
if status.id == id:
return n
raise ValueError("Status with ID {} not found".format(id))
@ -299,6 +307,26 @@ class Timeline(urwid.Columns):
if index == self.status_list.body.focus:
self.draw_status_details(status)
def update_status_image(self, status, path, placeholder_index):
"""Replace image placeholder with image widget and redraw"""
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
# get the image and replace the placeholder with a graphics widget
if hasattr(self, "images"):
img = self.images.get(str(hash(path)))
if img:
try:
render_img = add_corners(img, 10) if self.can_render_pixels else img
status.placeholders[placeholder_index]._set_original_widget(
UrwidImage(
AutoImage(render_img),
"<", upscale=True),
) # "<" means left-justify the image
except IndexError:
# ignore IndexErrors.
pass
def remove_status(self, status):
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
@ -311,25 +339,101 @@ class Timeline(urwid.Columns):
class StatusDetails(urwid.Pile):
def __init__(self, timeline: Timeline, status: Optional[Status]):
self.status = status
self.followed_tags = timeline.tui.followed_tags
self.followed_accounts = timeline.tui.followed_accounts
self.timeline = timeline
if self.status:
self.status.placeholders = []
reblogged_by = status.author if status and status.reblog else None
widget_list = list(self.content_generator(status.original, reblogged_by)
if status else ())
return super().__init__(widget_list)
def image_widget(self, path, rows=None, aspect=None) -> urwid.Widget:
"""Returns a widget capable of displaying the image
path is required; URL to image
rows, if specfied, sets a fixed number of rows. Or:
aspect, if specified, calculates rows based on pane width
and the aspect ratio provided"""
if not rows:
if not aspect:
aspect = 3 / 2 # reasonable default
screen_rows = screen.get_cols_rows()[1]
if self.timeline.can_render_pixels:
# for pixel-rendered images,
# image rows should be 33% of the available screen
# but in no case fewer than 10
rows = max(10, math.floor(screen_rows * .33))
else:
# for cell-rendered images,
# use the max available columns
# and calculate rows based on the image
# aspect ratio
cols = math.floor(0.55 * screen.get_cols_rows()[0])
rows = math.ceil((cols / 2) / aspect)
# if the calculated rows are more than will
# fit on one screen, reduce to one screen of rows
rows = min(screen_rows - 6, rows)
# but in no case fewer than 10 rows
rows = max(rows, 10)
img = None
if hasattr(self.timeline, "images"):
img = self.timeline.images.get(str(hash(path)))
if img:
render_img = add_corners(img, 10) if self.timeline.can_render_pixels else img
return (urwid.BoxAdapter(
UrwidImage(
AutoImage(render_img),
"<", upscale=True),
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)
return placeholder
def author_header(self, reblogged_by):
avatar_url = self.status.original.data["account"]["avatar"]
if avatar_url:
aimg = self.image_widget(avatar_url, 2)
else:
aimg = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), 2)
account_color = (
"highlight"
if self.status.original.author.account in self.timeline.tui.followed_accounts
else "account"
)
atxt = urwid.Pile([("pack",
urwid.AttrMap(
EmojiText(self.status.original.author.display_name,
self.status.original.data["account"]["emojis"]),
"account")),
("pack", urwid.Text((account_color, self.status.original.author.account)))])
columns = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5)
return columns
def content_generator(self, status, reblogged_by):
if reblogged_by:
text = "{} boosted".format(reblogged_by.display_name or reblogged_by.username)
yield ("pack", urwid.Text(("dim", text)))
reblogger_name = (reblogged_by.display_name
if reblogged_by.display_name
else reblogged_by.username)
text = f"{reblogger_name} boosted"
yield urwid.AttrMap(
EmojiText(text, status.data["account"]["emojis"], make_gray=True),
"dim"
)
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
if status.author.display_name:
yield ("pack", urwid.Text(("status_detail_author", status.author.display_name)))
yield self.author_header(reblogged_by)
account_color = "highlight" if status.author.account in self.followed_accounts else "dim"
yield ("pack", urwid.Text((account_color, status.author.account)))
yield ("pack", urwid.Divider())
if status.data["spoiler_text"]:
@ -342,7 +446,7 @@ class StatusDetails(urwid.Pile):
else:
content = status.original.translation if status.original.show_translation else status.data["content"]
for line in format_content(content):
yield ("pack", urwid.Text(highlight_hashtags(line, self.followed_tags)))
yield ("pack", urwid.Text(highlight_hashtags(line, self.timeline.tui.followed_tags)))
media = status.data["media_attachments"]
if media:
@ -351,6 +455,24 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
if m["description"]:
yield ("pack", urwid.Text(m["description"]))
if m["url"]:
if m["url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("")
try:
aspect = float(m["meta"]["original"]["aspect"])
except Exception:
aspect = None
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')):
yield urwid.Text("")
try:
aspect = float(m["meta"]["small"]["aspect"])
except Exception:
aspect = None
yield self.image_widget(m["preview_url"], aspect=aspect)
yield urwid.Divider()
yield ("pack", urwid.Text(("link", m["url"])))
poll = status.original.data.get("poll")
@ -411,8 +533,18 @@ class StatusDetails(urwid.Pile):
if card["description"]:
yield urwid.Text(card["description"].strip())
yield urwid.Text("")
yield urwid.Text(("link", card["url"]))
if card["image"]:
if card["image"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("")
try:
aspect = int(card["width"]) / int(card["height"])
except Exception:
aspect = None
yield self.image_widget(card["image"], aspect=aspect)
def poll_generator(self, poll):
for idx, option in enumerate(poll["options"]):
perc = (round(100 * option["votes_count"] / poll["votes_count"])

View File

@ -3,11 +3,15 @@ import re
import shutil
import subprocess
import urwid
import math
from functools import reduce
from html.parser import HTMLParser
from typing import List
from PIL import Image, ImageDraw
from term_image.image import auto_image_class, GraphicsImage
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
@ -102,6 +106,42 @@ def parse_content_links(content):
return parser.links[:]
def resize_image(basewidth: int, baseheight: int, img: 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():
# subclasses of GraphicsImage render to pixels
return issubclass(auto_image_class(), GraphicsImage)
def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str):
""" copy text to clipboard using OSC 52
This escape sequence is documented

View File

@ -1,4 +1,11 @@
from typing import List
import urwid
import re
import requests
from PIL import Image, ImageOps
from term_image.image import AutoImage
from term_image.widget import UrwidImage
from .utils import can_render_pixels
from wcwidth import wcswidth
@ -67,3 +74,88 @@ class RadioButton(urwid.AttrWrap):
button = urwid.RadioButton(*args, **kwargs)
padding = urwid.Padding(button, width=len(args[1]) + 4)
return super().__init__(padding, "button", "button_focused")
class EmojiText(urwid.Padding):
"""Widget to render text with embedded custom emojis
Note, these are Mastodon custom server emojis
which are indicated by :shortcode: in the text
and rendered as images on supporting clients.
For clients that do not support pixel rendering,
they are rendered as plain text :shortcode:
This widget was designed for use with displaynames
but could be used with any string of text.
However, due to the internal use of columns,
this widget will not wrap multi-line text
correctly.
Note, you can embed this widget in AttrWrap to style
the text as desired.
Parameters:
text -- text string (with or without embedded shortcodes)
emojis -- list of emojis with nested lists of associated
shortcodes and URLs
make_gray -- if True, convert emojis to grayscale
"""
image_cache = {}
def __init__(self, text: str, emojis: List, make_gray=False):
columns = []
if not can_render_pixels():
return self.plain(text, columns)
# build a regex to find all available shortcodes
regex = '|'.join(f':{emoji["shortcode"]}:' for emoji in emojis)
if 0 == len(regex):
# if no shortcodes, just output plain Text
return self.plain(text, columns)
regex = f"({regex})"
for word in re.split(regex, text):
if word.startswith(":") and word.endswith(":"):
shortcode = word[1:-1]
found = False
for emoji in emojis:
if emoji["shortcode"] == shortcode:
try:
img = EmojiText.image_cache.get(str(hash(emoji["url"])))
if not img:
# TODO: consider asynchronous loading in future
img = Image.open(requests.get(emoji["url"], stream=True).raw)
EmojiText.image_cache[str(hash(emoji["url"]))] = img
if make_gray:
img = ImageOps.grayscale(img)
image_widget = urwid.BoxAdapter(UrwidImage(AutoImage(img), upscale=True), 1)
columns.append(image_widget)
except Exception:
columns.append(("pack", urwid.Text(word)))
finally:
found = True
break
if found is False:
columns.append(("pack", urwid.Text(word)))
else:
columns.append(("pack", urwid.Text(word)))
columns.append(("weight", 9999, urwid.Text("")))
column_widget = urwid.Columns(columns, dividechars=0, min_width=2)
super().__init__(column_widget)
def plain(self, text, columns):
# if can't render pixels, just output plain Text
columns.append(("pack", urwid.Text(text)))
columns.append(("weight", 9999, urwid.Text("")))
column_widget = urwid.Columns(columns, dividechars=1, min_width=2)
super().__init__(column_widget)