From 906cdd013bf2f797222fed6e3e3dcaa2defafdb2 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Tue, 9 Jan 2024 23:25:12 -0500 Subject: [PATCH] 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. --- toot/cli/__init__.py | 2 +- toot/cli/tui.py | 11 +++++- toot/tui/app.py | 3 +- toot/tui/images.py | 2 + toot/tui/overlays.py | 25 +++++------- toot/tui/timeline.py | 32 ++++++--------- toot/tui/utils.py | 18 +++++++-- toot/tui/widgets.py | 92 -------------------------------------------- 8 files changed, 48 insertions(+), 137 deletions(-) diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index a6af85a..a4698ff 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -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, diff --git a/toot/cli/tui.py b/toot/cli/tui.py index e5456a1..84670a4 100644 --- a/toot/cli/tui.py +++ b/toot/cli/tui.py @@ -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() diff --git a/toot/tui/app.py b/toot/tui/app.py index 9c0d9af..ad835f7 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -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", ) diff --git a/toot/tui/images.py b/toot/tui/images.py index 4edb3f0..3b84c40 100644 --- a/toot/tui/images.py +++ b/toot/tui/images.py @@ -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 diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 95072ba..0ee6400 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -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) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index aabea14..bc64b3b 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -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) diff --git a/toot/tui/utils.py b/toot/tui/utils.py index 7955399..bae8ae8 100644 --- a/toot/tui/utils.py +++ b/toot/tui/utils.py @@ -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'(? 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): diff --git a/toot/tui/widgets.py b/toot/tui/widgets.py index 616bd85..2c8f907 100644 --- a/toot/tui/widgets.py +++ b/toot/tui/widgets.py @@ -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)