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"]
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
IMAGE_FORMAT_CHOICES = ["block", "iterm", "kitty"]
TUI_COLORS = {
"1": 1,
"16": 16,

View File

@ -1,7 +1,7 @@
import click
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.tui.app import TUI, TuiOptions
@ -40,6 +40,11 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
is_flag=True,
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
def tui(
ctx: Context,
@ -48,7 +53,8 @@ def tui(
always_show_sensitive: bool,
relative_datetimes: bool,
cache_size: Optional[int],
default_visibility: Optional[str]
default_visibility: Optional[str],
image_format: Optional[str]
):
"""Launches the toot terminal user interface"""
if colors is None:
@ -61,6 +67,7 @@ def tui(
cache_size=cache_size,
default_visibility=default_visibility,
always_show_sensitive=always_show_sensitive,
image_format=image_format,
)
tui = TUI.create(ctx.app, ctx.user, options)
tui.run()

View File

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

View File

@ -1,7 +1,9 @@
# If term_image is loaded use their screen implementation which handles images
try:
from term_image.widget import UrwidImageScreen
from term_image import disable_queries # prevent phantom keystrokes
TuiScreen = UrwidImageScreen
disable_queries()
except ImportError:
from urwid.raw_display import Screen
TuiScreen = Screen

View File

@ -7,11 +7,10 @@ import webbrowser
from toot import __version__
from toot import api
from toot.tui.utils import highlight_keys, add_corners
from toot.tui.widgets import Button, EditBox, SelectableText, EmojiText
from toot.tui.utils import highlight_keys, add_corners, get_base_image
from toot.tui.widgets import Button, EditBox, SelectableText
from toot.tui.richtext import html_to_widgets
from PIL import Image
from term_image.image import AutoImage
from term_image.widget import UrwidImage
@ -247,11 +246,12 @@ class Help(urwid.Padding):
class Account(urwid.ListBox):
"""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.user = user
self.account = account
self.relationship = relationship
self.options = options
self.last_action = None
self.setup_listbox()
@ -268,8 +268,8 @@ class Account(urwid.ListBox):
img = img.convert("RGBA")
aimg = urwid.BoxAdapter(
UrwidImage(
AutoImage(
add_corners(img, 10)), upscale=True),
get_base_image(
add_corners(img, 10), self.options.image_format), upscale=True),
10)
else:
aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
@ -279,20 +279,13 @@ class Account(urwid.ListBox):
if img.format == 'PNG' and img.mode != 'RGBA':
img = img.convert("RGBA")
himg = (urwid.BoxAdapter(
UrwidImage(
AutoImage(
add_corners(img, 10)
), upscale=True),
10)
)
himg = (urwid.BoxAdapter(UrwidImage(get_base_image(
add_corners(img, 10), self.options.image_format), 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(("account", account["display_name"]))),
(urwid.Text(("highlight", "@" + self.account['acct'])))])
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.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.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
from toot.tui.widgets import SelectableText, SelectableColumns, EmojiText
from term_image.image import AutoImage
from toot.tui.utils import highlight_keys, get_base_image, can_render_pixels
from toot.tui.widgets import SelectableText, SelectableColumns
from term_image.widget import UrwidImage
logger = logging.getLogger("toot")
@ -48,7 +47,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()
self.can_render_pixels = can_render_pixels(self.tui.options.image_format)
try:
focused_status = statuses[focus]
@ -330,11 +329,11 @@ class Timeline(urwid.Columns):
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
UrwidImage(get_base_image(render_img, self.tui.options.image_format), '<', upscale=True))
# "<" means left-justify the image
except IndexError:
# ignore IndexErrors.
pass
@ -403,9 +402,7 @@ class StatusDetails(urwid.Pile):
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),
UrwidImage(get_base_image(render_img, self.timeline.tui.options.image_format), "<", upscale=True),
rows))
else:
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
self.timeline.tui.followed_accounts else "account")
atxt = urwid.Pile([("pack",
urwid.AttrMap(
EmojiText(self.status.author.display_name,
self.status.data["account"]["emojis"]),
"bold")),
atxt = urwid.Pile([("pack", urwid.Text(("bold", self.status.author.display_name))),
("pack", urwid.Text((account_color, self.status.author.account)))])
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
else reblogged_by.username)
text = f"{reblogger_name} boosted"
yield urwid.AttrMap(
EmojiText(text, status.data["account"]["emojis"], make_gray=True),
"dim"
)
yield urwid.Text(("dim", text))
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
yield self.author_header(reblogged_by)

View File

@ -9,7 +9,7 @@ from html.parser import HTMLParser
from typing import List
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')
@ -107,9 +107,19 @@ def add_corners(img, rad):
return img
def can_render_pixels():
# subclasses of GraphicsImage render to pixels
return issubclass(auto_image_class(), GraphicsImage)
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):

View File

@ -1,11 +1,4 @@
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
@ -76,91 +69,6 @@ class RadioButton(urwid.AttrWrap):
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):
def __init__(self, message):
text = urwid.Text(message)