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

removed autodetection of image format; now uses cmd line option

--image-format='kitty'|'iterm'|'block' (default is block)
autodetection was causing intermittent phantom character
output to the terminal in some configurations, generally
over SSH connections. Switching to a command line option
eliminates the problematic autodetection code.

As a side effect, EmojiText widget had to be removed.
This commit is contained in:
Daniel Schwarz 2024-01-09 23:25:12 -05:00
parent d2ea1f0c77
commit 906cdd013b
8 changed files with 48 additions and 137 deletions

View File

@ -22,7 +22,7 @@ T = t.TypeVar("T")
PRIVACY_CHOICES = ["public", "unlisted", "private"] PRIVACY_CHOICES = ["public", "unlisted", "private"]
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
IMAGE_FORMAT_CHOICES = ["block", "iterm", "kitty"]
TUI_COLORS = { TUI_COLORS = {
"1": 1, "1": 1,
"16": 16, "16": 16,

View File

@ -1,7 +1,7 @@
import click import click
from typing import Optional from typing import Optional
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, Context, cli, pass_context from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, IMAGE_FORMAT_CHOICES, Context, cli, pass_context
from toot.cli.validators import validate_tui_colors, validate_cache_size from toot.cli.validators import validate_tui_colors, validate_cache_size
from toot.tui.app import TUI, TuiOptions from toot.tui.app import TUI, TuiOptions
@ -40,6 +40,11 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
is_flag=True, is_flag=True,
help="Expand toots with content warnings automatically" help="Expand toots with content warnings automatically"
) )
@click.option(
"-f", "--image-format",
type=click.Choice(IMAGE_FORMAT_CHOICES),
help="Image output format; support varies across terminals. Default: block"
)
@pass_context @pass_context
def tui( def tui(
ctx: Context, ctx: Context,
@ -48,7 +53,8 @@ def tui(
always_show_sensitive: bool, always_show_sensitive: bool,
relative_datetimes: bool, relative_datetimes: bool,
cache_size: Optional[int], cache_size: Optional[int],
default_visibility: Optional[str] default_visibility: Optional[str],
image_format: Optional[str]
): ):
"""Launches the toot terminal user interface""" """Launches the toot terminal user interface"""
if colors is None: if colors is None:
@ -61,6 +67,7 @@ def tui(
cache_size=cache_size, cache_size=cache_size,
default_visibility=default_visibility, default_visibility=default_visibility,
always_show_sensitive=always_show_sensitive, always_show_sensitive=always_show_sensitive,
image_format=image_format,
) )
tui = TUI.create(ctx.app, ctx.user, options) tui = TUI.create(ctx.app, ctx.user, options)
tui.run() tui.run()

View File

@ -41,6 +41,7 @@ class TuiOptions(NamedTuple):
relative_datetimes: bool relative_datetimes: bool
cache_size: int cache_size: int
default_visibility: Optional[bool] default_visibility: Optional[bool]
image_format: Optional[str]
class Header(urwid.WidgetWrap): class Header(urwid.WidgetWrap):
@ -656,7 +657,7 @@ class TUI(urwid.Frame):
account = api.whois(self.app, self.user, account_id) account = api.whois(self.app, self.user, account_id)
relationship = api.get_relationship(self.app, self.user, account_id) relationship = api.get_relationship(self.app, self.user, account_id)
self.open_overlay( self.open_overlay(
widget=Account(self.app, self.user, account, relationship), widget=Account(self.app, self.user, account, relationship, self.options),
title="Account", title="Account",
) )

View File

@ -1,7 +1,9 @@
# 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
from term_image import disable_queries # prevent phantom keystrokes
TuiScreen = UrwidImageScreen TuiScreen = UrwidImageScreen
disable_queries()
except ImportError: except ImportError:
from urwid.raw_display import Screen from urwid.raw_display import Screen
TuiScreen = Screen TuiScreen = Screen

View File

@ -7,11 +7,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 from toot.tui.utils import highlight_keys, add_corners, get_base_image
from toot.tui.widgets import Button, EditBox, SelectableText, EmojiText 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 PIL import Image
from term_image.image import AutoImage
from term_image.widget import UrwidImage from term_image.widget import UrwidImage
@ -247,11 +246,12 @@ class Help(urwid.Padding):
class Account(urwid.ListBox): class Account(urwid.ListBox):
"""Shows account data and provides various actions""" """Shows account data and provides various actions"""
def __init__(self, app, user, account, relationship): def __init__(self, app, user, account, relationship, options):
self.app = app self.app = app
self.user = user self.user = user
self.account = account self.account = account
self.relationship = relationship self.relationship = relationship
self.options = options
self.last_action = None self.last_action = None
self.setup_listbox() self.setup_listbox()
@ -268,8 +268,8 @@ class Account(urwid.ListBox):
img = img.convert("RGBA") img = img.convert("RGBA")
aimg = urwid.BoxAdapter( aimg = urwid.BoxAdapter(
UrwidImage( UrwidImage(
AutoImage( get_base_image(
add_corners(img, 10)), upscale=True), add_corners(img, 10), self.options.image_format), upscale=True),
10) 10)
else: else:
aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10) aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
@ -279,20 +279,13 @@ class Account(urwid.ListBox):
if img.format == 'PNG' and img.mode != 'RGBA': if img.format == 'PNG' and img.mode != 'RGBA':
img = img.convert("RGBA") img = img.convert("RGBA")
himg = (urwid.BoxAdapter( himg = (urwid.BoxAdapter(UrwidImage(get_base_image(
UrwidImage( add_corners(img, 10), self.options.image_format), upscale=True), 10))
AutoImage(
add_corners(img, 10)
), upscale=True),
10)
)
else: else:
himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10) himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
atxt = urwid.Pile([urwid.Divider(), atxt = urwid.Pile([urwid.Divider(),
urwid.AttrMap( (urwid.Text(("account", account["display_name"]))),
EmojiText(account["display_name"], account["emojis"]),
"account"),
(urwid.Text(("highlight", "@" + self.account['acct'])))]) (urwid.Text(("highlight", "@" + self.account['acct'])))])
columns = urwid.Columns([aimg, ("weight", 9999, himg)], dividechars=2, min_width=20) columns = urwid.Columns([aimg, ("weight", 9999, himg)], dividechars=2, min_width=20)

View File

@ -7,16 +7,15 @@ from typing import List, Optional
from toot.tui import app from toot.tui import app
from toot.tui.utils import can_render_pixels, add_corners 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
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 from toot.tui.utils import highlight_keys, get_base_image, can_render_pixels
from toot.tui.widgets import SelectableText, SelectableColumns, EmojiText from toot.tui.widgets import SelectableText, SelectableColumns
from term_image.image import AutoImage
from term_image.widget import UrwidImage from term_image.widget import UrwidImage
logger = logging.getLogger("toot") logger = logging.getLogger("toot")
@ -48,7 +47,7 @@ class Timeline(urwid.Columns):
self.is_thread = is_thread self.is_thread = is_thread
self.statuses = statuses self.statuses = statuses
self.status_list = self.build_status_list(statuses, focus=focus) self.status_list = self.build_status_list(statuses, focus=focus)
self.can_render_pixels = can_render_pixels() self.can_render_pixels = can_render_pixels(self.tui.options.image_format)
try: try:
focused_status = statuses[focus] focused_status = statuses[focus]
@ -330,11 +329,11 @@ class Timeline(urwid.Columns):
if img: if img:
try: try:
render_img = add_corners(img, 10) if self.can_render_pixels else img 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( UrwidImage(get_base_image(render_img, self.tui.options.image_format), '<', upscale=True))
AutoImage(render_img), # "<" means left-justify the image
"<", upscale=True),
) # "<" means left-justify the image
except IndexError: except IndexError:
# ignore IndexErrors. # ignore IndexErrors.
pass pass
@ -403,9 +402,7 @@ class StatusDetails(urwid.Pile):
if img: if img:
render_img = add_corners(img, 10) if self.timeline.can_render_pixels else img render_img = add_corners(img, 10) if self.timeline.can_render_pixels else img
return (urwid.BoxAdapter( return (urwid.BoxAdapter(
UrwidImage( UrwidImage(get_base_image(render_img, self.timeline.tui.options.image_format), "<", upscale=True),
AutoImage(render_img),
"<", upscale=True),
rows)) rows))
else: else:
placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows) placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows)
@ -424,11 +421,7 @@ class StatusDetails(urwid.Pile):
account_color = ("highlight" if self.status.original.author.account in account_color = ("highlight" if self.status.original.author.account in
self.timeline.tui.followed_accounts else "account") self.timeline.tui.followed_accounts else "account")
atxt = urwid.Pile([("pack", atxt = urwid.Pile([("pack", urwid.Text(("bold", self.status.author.display_name))),
urwid.AttrMap(
EmojiText(self.status.author.display_name,
self.status.data["account"]["emojis"]),
"bold")),
("pack", urwid.Text((account_color, self.status.author.account)))]) ("pack", urwid.Text((account_color, self.status.author.account)))])
columns = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5) columns = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5)
@ -440,10 +433,7 @@ class StatusDetails(urwid.Pile):
if reblogged_by.display_name if reblogged_by.display_name
else reblogged_by.username) else reblogged_by.username)
text = f"{reblogger_name} boosted" text = f"{reblogger_name} boosted"
yield urwid.AttrMap( yield urwid.Text(("dim", text))
EmojiText(text, status.data["account"]["emojis"], make_gray=True),
"dim"
)
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim")) yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
yield self.author_header(reblogged_by) yield self.author_header(reblogged_by)

View File

@ -9,7 +9,7 @@ from html.parser import HTMLParser
from typing import List from typing import List
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from term_image.image import auto_image_class, GraphicsImage 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')
@ -107,9 +107,19 @@ def add_corners(img, rad):
return img return img
def can_render_pixels(): def can_render_pixels(image_format: str):
# subclasses of GraphicsImage render to pixels return image_format in ['kitty', 'iterm']
return issubclass(auto_image_class(), GraphicsImage)
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):

View File

@ -1,11 +1,4 @@
from typing import List
import urwid 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 from wcwidth import wcswidth
@ -76,91 +69,6 @@ class RadioButton(urwid.AttrWrap):
return super().__init__(padding, "button", "button_focused") 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)
class ModalBox(urwid.Frame): class ModalBox(urwid.Frame):
def __init__(self, message): def __init__(self, message):
text = urwid.Text(message) text = urwid.Text(message)