diff --git a/.gitignore b/.gitignore index 3d6775f..338fbcd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ /toot-*.tar.gz debug.log /pyrightconfig.json +/venv/ diff --git a/requirements.txt b/requirements.txt index 67ddf98..995934e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ requests>=2.13,<3.0 beautifulsoup4>=4.5.0,<5.0 wcwidth>=0.1.7 urwid>=2.0.0,<3.0 +pillow>=8.4.0 +libsixel-python>=0.5.0 diff --git a/setup.py b/setup.py index 079bcc6..3c7aea4 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,9 @@ setup( "beautifulsoup4>=4.5.0,<5.0", "wcwidth>=0.1.7", "urwid>=2.0.0,<3.0", + "pillow>=8.4.0", + "libsixel-python>=0.5.0" + ], entry_points={ 'console_scripts': [ diff --git a/toot/console.py b/toot/console.py index 38e0656..c393531 100644 --- a/toot/console.py +++ b/toot/console.py @@ -250,11 +250,16 @@ AUTH_COMMANDS = [ ), ] +tui_arg = (["--256"], { + "help": "Use 256 colors for image display, rather than truecolor", + "action": "store_true" +}) + TUI_COMMANDS = [ Command( name="tui", description="Launches the toot terminal user interface", - arguments=[], + arguments=[tui_arg], require_auth=True, ), ] diff --git a/toot/tui/ansiwidget.py b/toot/tui/ansiwidget.py new file mode 100644 index 0000000..3449f0d --- /dev/null +++ b/toot/tui/ansiwidget.py @@ -0,0 +1,152 @@ +from typing import Tuple, List, Optional, Any, Iterable +import logging +import re +import urwid +from PIL import Image + +logger = logging.getLogger("toot") + + +class ANSIGraphicsCanvas(urwid.canvas.Canvas): + def __init__(self, size: Tuple[int, int], img: Image) -> None: + super().__init__() + + self.maxcol = size[0] + if len(size) > 1: + self.maxrow = size[1] + + self.img = img + self.text_lines = [] + + # for performance, these regexes are simplified + # and only match the ANSI escapes we generate + # in the content(...) method below. + self.ansi_escape = re.compile(r"\x1b[^m]*m") + self.ansi_escape_capture = re.compile(r"(\x1b[^m]*m)") + + def cols(self) -> int: + return self.maxcol + + def rows(self) -> int: + return self.maxrow + + def strip_ansi_codes(self, ansi_text): + return self.ansi_escape.sub("", ansi_text) + + def truncate_ansi_safe(self, ansi_text, trim_left, maxlength): + if len(self.strip_ansi_codes(ansi_text)) <= maxlength: + return ansi_text + + trunc_text = "" + real_len = 0 + token_pt_len = 0 + + for token in re.split(self.ansi_escape_capture, ansi_text): + if token and token[0] == "\x1b": + # this token is an ANSI sequence so just add it + trunc_text += token + else: + # this token is plaintext so add chars if we can + if token_pt_len + len(token) < trim_left: + # this token is entirely within trim zone + # skip it + token_pt_len += len(token) + continue + if token_pt_len < trim_left: + # this token is partially within trim zone + # partially skip, partially add + token_pt_len += len(token) + token = token[trim_left - token_pt_len + 1:] + + token_slice = token[:maxlength - real_len + 1] + trunc_text += token_slice + real_len += len(token_slice) + + if real_len >= maxlength + trim_left: + break + + return trunc_text + + def content( + self, + trim_left: int = 0, + trim_top: int = 0, + cols: Optional[int] = None, + rows: Optional[int] = None, + attr_map: Optional[Any] = None, + ) -> Iterable[List[Tuple[None, str, bytes]]]: + + maxcol, maxrow = self.cols(), self.rows() + if not cols: + cols = maxcol - trim_left + if not rows: + rows = maxrow - trim_top + + assert trim_left >= 0 and trim_left < maxcol + assert cols > 0 and trim_left + cols <= maxcol + assert trim_top >= 0 and trim_top < maxrow + assert rows > 0 and trim_top + rows <= maxrow + + ansi_reset = "\x1b[0m".encode("utf-8") + + if len(self.text_lines) == 0: + width, height = self.img.size + pixels = self.img.load() + if (self.img.mode == 'P'): + # palette-mode image; 256 colors or fewer + for y in range(1, height - 1, 2): + line = "" + for x in range(1, width): + pa = pixels[x, y] + pb = pixels[x, y + 1] + # render via unicode half-blocks, 256 color ANSI syntax + line += f"\x1b[48;5;{pa}m\x1b[38;5;{pb}m\u2584" + self.text_lines.append(line) + else: + # truecolor image (RGB) + # note: we don't attempt to support mode 'L' greyscale images + # nor do we support mode '1' single bit depth images. + for y in range(1, height - 1, 2): + line = "" + for x in range(1, width): + ra, ga, ba = pixels[x, y] + rb, gb, bb = pixels[x, y + 1] + # render via unicode half-blocks, truecolor ANSI syntax + line += f"\x1b[48;2;{ra};{ga};{ba}m\x1b[38;2;{rb};{gb};{bb}m\u2584" + self.text_lines.append(line) + + if trim_top or rows <= self.maxrow: + self.render_lines = self.text_lines[trim_top:trim_top + rows] + else: + self.render_lines = self.text_lines + + for i in range(rows): + if i < len(self.render_lines): + text = self.truncate_ansi_safe( + self.render_lines[i], trim_left, cols - 1 + ) + real_len = len(self.strip_ansi_codes(text)) + text_bytes = text.encode("utf-8") + else: + text_bytes = b"" + real_len = 0 + + padding = bytes().rjust(max(0, cols - real_len)) + line = [(None, "U", text_bytes + ansi_reset + padding)] + yield line + + +class ANSIGraphicsWidget(urwid.Widget): + _sizing = frozenset([urwid.widget.BOX]) + ignore_focus = True + + def __init__(self, img: Image) -> None: + self.img = img + + def set_content(self, img: Image) -> None: + self.img = img + self.text_lines = [] + self._invalidate() + + def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.canvas.Canvas: + return ANSIGraphicsCanvas(size, self.img) diff --git a/toot/tui/app.py b/toot/tui/app.py index 7429a73..024e0e1 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -1,5 +1,7 @@ import logging import urwid +import requests +import sys from concurrent.futures import ThreadPoolExecutor @@ -14,8 +16,12 @@ from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusL from .overlays import StatusDeleteConfirmation from .timeline import Timeline from .utils import parse_content_links, show_media +from .palette import convert_to_xterm_256_palette + +from PIL import Image logger = logging.getLogger(__name__) +truecolor = '--256' not in sys.argv # TBD make this a config option urwid.set_encoding('UTF-8') @@ -215,6 +221,7 @@ class TUI(urwid.Frame): urwid.connect_signal(timeline, "links", _links) urwid.connect_signal(timeline, "zoom", _zoom) urwid.connect_signal(timeline, "translate", self.async_translate) + urwid.connect_signal(timeline, "load-image", self.async_load_image) urwid.connect_signal(timeline, "clear-screen", _clear) def build_timeline(self, name, statuses, local): @@ -615,6 +622,24 @@ class TUI(urwid.Frame): return self.run_in_thread(_delete, done_callback=_done) + def async_load_image(self, self2, timeline, status, path): + def _load(): + if not hasattr(status, "images"): + status.images = dict() + img = Image.open(requests.get(path, stream=True).raw) + + if img.format == 'PNG' and img.mode != 'RGBA': + img = img.convert("RGBA") + if not truecolor: + img = convert_to_xterm_256_palette(img) + + status.images[str(hash(path))] = img + + def _done(loop): + timeline.update_status(status) + + return self.run_in_thread(_load, done_callback=_done) + # --- Overlay handling ----------------------------------------------------- default_overlay_options = dict( diff --git a/toot/tui/palette.py b/toot/tui/palette.py new file mode 100644 index 0000000..3effb35 --- /dev/null +++ b/toot/tui/palette.py @@ -0,0 +1,293 @@ +from PIL import Image + + +def convert_to_xterm_256_palette(img): + # temp image, just to hold our palette + pal_image = Image.new("P", (16, 16)) + pal_image.putpalette(XTERM_256_PALETTE) + + if img.mode == "RGBA": + # can't quantize RGBA mode image so convert to RGB + # this assumes a black background + img = rgba_to_rgb(img, color=(0, 0, 0)) + + return img.quantize(colors=len(XTERM_256_PALETTE), palette=pal_image, dither=0) + + +def rgba_to_rgb(image, color=(255, 255, 255)): + """Alpha composite an RGBA Image with a specified color. + + Source: http://stackoverflow.com/a/9459208/284318 + + Keyword Arguments: + image -- PIL RGBA Image object + color -- Tuple r, g, b (default 255, 255, 255) + + """ + image.load() # needed for split() + background = Image.new('RGB', image.size, color) + background.paste(image, mask=image.split()[3]) # 3 is the alpha channel + return background + + +# xterm 256 color palette +# values from: https://www.ditig.com/256-colors-cheat-sheet\ + +XTERM_256_PALETTE = [ + 0, 0, 0, + 128, 0, 0, + 0, 128, 0, + 128, 128, 0, + 0, 0, 128, + 128, 0, 128, + 0, 128, 128, + 192, 192, 192, + 128, 128, 128, + 255, 0, 0, + 0, 255, 0, + 255, 255, 0, + 0, 0, 255, + 255, 0, 255, + 0, 255, 255, + 255, 255, 255, + 0, 0, 0, + 0, 0, 95, + 0, 0, 135, + 0, 0, 175, + 0, 0, 215, + 0, 0, 255, + 0, 95, 0, + 0, 95, 95, + 0, 95, 135, + 0, 95, 175, + 0, 95, 215, + 0, 95, 255, + 0, 135, 0, + 0, 135, 95, + 0, 135, 135, + 0, 135, 175, + 0, 135, 215, + 0, 135, 255, + 0, 175, 0, + 0, 175, 95, + 0, 175, 135, + 0, 175, 175, + 0, 175, 215, + 0, 175, 255, + 0, 215, 0, + 0, 215, 95, + 0, 215, 135, + 0, 215, 175, + 0, 215, 215, + 0, 215, 255, + 0, 255, 0, + 0, 255, 95, + 0, 255, 135, + 0, 255, 175, + 0, 255, 215, + 0, 255, 255, + 95, 0, 0, + 95, 0, 95, + 95, 0, 135, + 95, 0, 175, + 95, 0, 215, + 95, 0, 255, + 95, 95, 0, + 95, 95, 95, + 95, 95, 135, + 95, 95, 175, + 95, 95, 215, + 95, 95, 255, + 95, 135, 0, + 95, 135, 95, + 95, 135, 135, + 95, 135, 175, + 95, 135, 215, + 95, 135, 255, + 95, 175, 0, + 95, 175, 95, + 95, 175, 135, + 95, 175, 175, + 95, 175, 215, + 95, 175, 255, + 95, 215, 0, + 95, 215, 95, + 95, 215, 135, + 95, 215, 175, + 95, 215, 215, + 95, 215, 255, + 95, 255, 0, + 95, 255, 95, + 95, 255, 135, + 95, 255, 175, + 95, 255, 215, + 95, 255, 255, + 135, 0, 0, + 135, 0, 95, + 135, 0, 135, + 135, 0, 175, + 135, 0, 215, + 135, 0, 255, + 135, 95, 0, + 135, 95, 95, + 135, 95, 135, + 135, 95, 175, + 135, 95, 215, + 135, 95, 255, + 135, 135, 0, + 135, 135, 95, + 135, 135, 135, + 135, 135, 175, + 135, 135, 215, + 135, 135, 255, + 135, 175, 0, + 135, 175, 95, + 135, 175, 135, + 135, 175, 175, + 135, 175, 215, + 135, 175, 255, + 135, 215, 0, + 135, 215, 95, + 135, 215, 135, + 135, 215, 175, + 135, 215, 215, + 135, 215, 255, + 135, 255, 0, + 135, 255, 95, + 135, 255, 135, + 135, 255, 175, + 135, 255, 215, + 135, 255, 255, + 175, 0, 0, + 175, 0, 95, + 175, 0, 135, + 175, 0, 175, + 175, 0, 215, + 175, 0, 255, + 175, 95, 0, + 175, 95, 95, + 175, 95, 135, + 175, 95, 175, + 175, 95, 215, + 175, 95, 255, + 175, 135, 0, + 175, 135, 95, + 175, 135, 135, + 175, 135, 175, + 175, 135, 215, + 175, 135, 255, + 175, 175, 0, + 175, 175, 95, + 175, 175, 135, + 175, 175, 175, + 175, 175, 215, + 175, 175, 255, + 175, 215, 0, + 175, 215, 95, + 175, 215, 135, + 175, 215, 175, + 175, 215, 215, + 175, 215, 255, + 175, 255, 0, + 175, 255, 95, + 175, 255, 135, + 175, 255, 175, + 175, 255, 215, + 175, 255, 255, + 215, 0, 0, + 215, 0, 95, + 215, 0, 135, + 215, 0, 175, + 215, 0, 215, + 215, 0, 255, + 215, 95, 0, + 215, 95, 95, + 215, 95, 135, + 215, 95, 175, + 215, 95, 215, + 215, 95, 255, + 215, 135, 0, + 215, 135, 95, + 215, 135, 135, + 215, 135, 175, + 215, 135, 215, + 215, 135, 255, + 215, 175, 0, + 215, 175, 95, + 215, 175, 135, + 215, 175, 175, + 215, 175, 215, + 215, 175, 255, + 215, 215, 0, + 215, 215, 95, + 215, 215, 135, + 215, 215, 175, + 215, 215, 215, + 215, 215, 255, + 215, 255, 0, + 215, 255, 95, + 215, 255, 135, + 215, 255, 175, + 215, 255, 215, + 215, 255, 255, + 255, 0, 0, + 255, 0, 95, + 255, 0, 135, + 255, 0, 175, + 255, 0, 215, + 255, 0, 255, + 255, 95, 0, + 255, 95, 95, + 255, 95, 135, + 255, 95, 175, + 255, 95, 215, + 255, 95, 255, + 255, 135, 0, + 255, 135, 95, + 255, 135, 135, + 255, 135, 175, + 255, 135, 215, + 255, 135, 255, + 255, 175, 0, + 255, 175, 95, + 255, 175, 135, + 255, 175, 175, + 255, 175, 215, + 255, 175, 255, + 255, 215, 0, + 255, 215, 95, + 255, 215, 135, + 255, 215, 175, + 255, 215, 215, + 255, 215, 255, + 255, 255, 0, + 255, 255, 95, + 255, 255, 135, + 255, 255, 175, + 255, 255, 215, + 255, 255, 255, + 8, 8, 8, + 18, 18, 18, + 28, 28, 28, + 38, 38, 38, + 48, 48, 48, + 58, 58, 58, + 68, 68, 68, + 78, 78, 78, + 88, 88, 88, + 98, 98, 98, + 108, 108, 108, + 118, 118, 118, + 128, 128, 128, + 138, 138, 138, + 148, 148, 148, + 158, 158, 158, + 168, 168, 168, + 178, 178, 178, + 188, 188, 188, + 198, 198, 198, + 208, 208, 208, + 218, 218, 218, + 228, 228, 228, + 238, 238, 238 +] diff --git a/toot/tui/sixelwidget.py b/toot/tui/sixelwidget.py new file mode 100644 index 0000000..58591dc --- /dev/null +++ b/toot/tui/sixelwidget.py @@ -0,0 +1,166 @@ +from typing import Tuple, List, Optional, Any, Iterable +import math +import urwid +from PIL import Image +from libsixel import ( + sixel_output_new, + sixel_dither_new, + sixel_dither_initialize, + sixel_encode, + sixel_dither_unref, + sixel_output_unref, + sixel_dither_get, + sixel_dither_set_palette, + sixel_dither_set_pixelformat, + SIXEL_PIXELFORMAT_RGBA8888, + SIXEL_PIXELFORMAT_G1, + SIXEL_PIXELFORMAT_PAL8, + SIXEL_BUILTIN_G8, + SIXEL_PIXELFORMAT_G8, + SIXEL_PIXELFORMAT_RGB888, + SIXEL_BUILTIN_G1, +) + +from io import BytesIO + + +class SIXELGraphicsCanvas(urwid.canvas.Canvas): + cacheable = False + _change_state = 0 + + def __init__( + self, size: Tuple[int, int], img: Image, cell_width: int, cell_height: int + ) -> None: + super().__init__() + + self.maxcol = size[0] + if len(size) > 1: + self.maxrow = size[1] + + self.img = img + self.cell_width = cell_width + self.cell_height = cell_height + self.text_lines = [] + + def cols(self) -> int: + return self.maxcol + + def rows(self) -> int: + return self.maxrow + + def content( + self, + trim_left: int = 0, + trim_top: int = 0, + cols: Optional[int] = None, + rows: Optional[int] = None, + attr_map: Optional[Any] = None, + ) -> Iterable[List[Tuple[None, str, bytes]]]: + + maxcol, maxrow = self.cols(), self.rows() + if not cols: + cols = maxcol - trim_left + if not rows: + rows = maxrow - trim_top + + assert trim_left >= 0 and trim_left < maxcol + assert cols > 0 and trim_left + cols <= maxcol + assert trim_top >= 0 and trim_top < maxrow + assert rows > 0 and trim_top + rows <= maxrow + + ansi_save_cursor = "\x1b7" + ansi_restore_cursor = "\x1b8" + + if trim_left > 0: + trim_left_pix = min(trim_left * self.cell_width, self.img.size[0]) + else: + trim_left_pix = 0 + + trim_img = self.img.crop( + ( + trim_left_pix, + self.cell_height * trim_top, + self.img.size[0], + self.cell_height * (trim_top + rows), + ) + ) + + sixels = six_encode(trim_img) + img_cols = math.ceil(trim_img.size[0] / self.cell_width) + + firstTime = True + for i in range(rows): + if firstTime: + text = ansi_save_cursor + sixels + ansi_restore_cursor + text_bytes = text.encode("utf-8") + padding = f"\x1b[{img_cols}C" + " " * (cols - img_cols) + padding = padding.encode("utf-8") + firstTime = False + else: + text_bytes = b"" + padding = f"\x1b[{img_cols}C" + " " * (cols - img_cols) + padding = padding.encode("utf-8") + + line = [(None, "U", text_bytes + padding)] + yield line + + +class SIXELGraphicsWidget(urwid.Widget): + _sizing = frozenset([urwid.widget.BOX]) + ignore_focus = True + + def __init__(self, img: Image, cell_width: int, cell_height: int) -> None: + self.img = img + self.cell_width = cell_width + self.cell_height = cell_height + + def set_content(self, img: Image) -> None: + self.img = img + self.text_lines = [] + self._invalidate() + + def render(self, size: Tuple[int, int], focus: bool = False) -> urwid.canvas.Canvas: + return SIXELGraphicsCanvas(size, self.img, self.cell_width, self.cell_height) + + +def six_encode(image: Image) -> str: + + s = BytesIO() + + width, height = image.size + data = image.tobytes() + output = sixel_output_new(lambda data, s: s.write(data), s) + sixel_str = "" + + try: + if image.mode == "RGBA": + dither = sixel_dither_new(256) + sixel_dither_initialize( + dither, data, width, height, SIXEL_PIXELFORMAT_RGBA8888 + ) + elif image.mode == "RGB": + dither = sixel_dither_new(256) + sixel_dither_initialize( + dither, data, width, height, SIXEL_PIXELFORMAT_RGB888 + ) + elif image.mode == "P": + palette = image.getpalette() + dither = sixel_dither_new(256) + sixel_dither_set_palette(dither, palette) + sixel_dither_set_pixelformat(dither, SIXEL_PIXELFORMAT_PAL8) + elif image.mode == "L": + dither = sixel_dither_get(SIXEL_BUILTIN_G8) + sixel_dither_set_pixelformat(dither, SIXEL_PIXELFORMAT_G8) + elif image.mode == "1": + dither = sixel_dither_get(SIXEL_BUILTIN_G1) + sixel_dither_set_pixelformat(dither, SIXEL_PIXELFORMAT_G1) + else: + raise RuntimeError("unexpected image mode") + try: + sixel_encode(data, width, height, 1, dither, output) + sixel_str = s.getvalue().decode("ascii") + finally: + sixel_dither_unref(dither) + finally: + sixel_output_unref(output) + return sixel_str diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index b3bbe85..dae3fab 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -1,4 +1,5 @@ import logging +import math import urwid import webbrowser @@ -6,18 +7,33 @@ from typing import Optional from .entities import Status from .scroll import Scrollable, ScrollBar -from .utils import highlight_hashtags, parse_datetime, highlight_keys +from .utils import highlight_hashtags, parse_datetime, highlight_keys, resize_image, get_cols_rows_pixh_pixw from .widgets import SelectableText, SelectableColumns from toot.utils import format_content from toot.utils.language import language_name +from toot.tui.sixelwidget import SIXELGraphicsWidget logger = logging.getLogger("toot") +screen = urwid.raw_display.Screen() + +SCREEN_COLS, SCREEN_ROWS, SCREEN_PIX_WIDTH, SCREEN_PIX_HEIGHT = get_cols_rows_pixh_pixw(screen) + +# VT340 default +CELL_WIDTH = 10 +CELL_HEIGHT = 20 + +if SCREEN_COLS > 0 and SCREEN_PIX_WIDTH > 0: + CELL_WIDTH = SCREEN_PIX_WIDTH / SCREEN_COLS + +if SCREEN_ROWS > 0 and SCREEN_PIX_HEIGHT > 0: + CELL_HEIGHT = SCREEN_PIX_HEIGHT / SCREEN_ROWS class Timeline(urwid.Columns): """ Displays a list of statuses to the left, and status details on the right. """ + signals = [ "close", # Close thread "compose", # Compose a new toot @@ -37,6 +53,7 @@ class Timeline(urwid.Columns): "save", # Save current timeline "zoom", # Open status in scrollable popup window "clear-screen", # Clear the screen (used internally) + "load-image", # used internally. asynchronously load image ] def __init__(self, name, statuses, can_translate, followed_tags=[], focus=0, is_thread=False): @@ -47,6 +64,9 @@ class Timeline(urwid.Columns): self.status_list = self.build_status_list(statuses, focus=focus) self.followed_tags = followed_tags +# CELL_HEIGHT = 24.54 +# CELL_WIDTH = 11 + try: focused_status = statuses[focus] except IndexError: @@ -293,22 +313,41 @@ class StatusDetails(urwid.Pile): def __init__(self, timeline: Timeline, status: Optional[Status]): self.status = status self.followed_tags = timeline.followed_tags + self.timeline = timeline 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 author_header(self): + rows = 2 + avatar_url = self.status.data["account"]["avatar_static"] + img = None + aimg = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows) + + if avatar_url: + if hasattr(self.status, "images"): + img = self.status.images.get(str(hash(avatar_url))) + + if img: + img = resize_image(math.ceil(5 * CELL_WIDTH), img) + aimg = urwid.BoxAdapter(SIXELGraphicsWidget(img, CELL_WIDTH, CELL_HEIGHT), rows) + else: + self.timeline._emit("load-image", self.timeline, self.status, avatar_url) + + atxt = urwid.Pile([("pack", urwid.Text(("green", self.status.author.display_name))), + ("pack", urwid.Text(("yellow", self.status.author.account)))]) + c = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5) + return c + 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(("gray", text))) yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray")) - if status.author.display_name: - yield ("pack", urwid.Text(("green", status.author.display_name))) - - yield ("pack", urwid.Text(("yellow", status.author.account))) + yield ("pack", self.author_header()) yield ("pack", urwid.Divider()) if status.data["spoiler_text"]: @@ -330,7 +369,27 @@ class StatusDetails(urwid.Pile): yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"])) if m["description"]: yield ("pack", urwid.Text(m["description"])) - yield ("pack", urwid.Text(("link", m["url"]))) + if m["preview_url"]: + if m["preview_url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')): + yield urwid.Text("") + +# cols = math.floor(0.55 * screen.get_cols_rows()[0]) + rows = math.ceil(m["meta"]["small"]["height"] / CELL_HEIGHT) + 1 + + img = None + if hasattr(self.status, "images"): + img = self.status.images.get(str(hash(m["preview_url"]))) + if img: + img = resize_image(math.ceil(CELL_WIDTH * 40), img) + yield ("pack", + urwid.BoxAdapter(SIXELGraphicsWidget( + img, CELL_WIDTH, CELL_HEIGHT), + rows) + ) + else: + self.timeline._emit("load-image", self.timeline, self.status, m["preview_url"]) + yield ("pack", urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows)) + yield ("pack", urwid.Text(("link", m["url"]))) poll = status.data.get("poll") if poll: @@ -389,8 +448,38 @@ 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("") + + max_cols = math.floor(0.55 * screen.get_cols_rows()[0]) + max_cols -= max_cols % 2 + + rows = math.ceil(card["height"] / CELL_HEIGHT) + + # sanity check for embedded youtube stills etc. + # that lie about their size + if (rows < 2): + rows = 20 + + img = None + if hasattr(self.status, "images"): + img = self.status.images.get(str(hash(card["image"]))) + if img: + img = resize_image(math.ceil(CELL_WIDTH * 40), img) + yield ("pack", urwid.BoxAdapter(SIXELGraphicsWidget(img, CELL_WIDTH, CELL_HEIGHT), rows)) + else: + self.timeline._emit( + "load-image", self.timeline, self.status, card["image"] + ) + yield ( + "pack", + urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows), + ) + def poll_generator(self, poll): for idx, option in enumerate(poll["options"]): perc = (round(100 * option["votes_count"] / poll["votes_count"]) diff --git a/toot/tui/utils.py b/toot/tui/utils.py index 441c4a8..178303a 100644 --- a/toot/tui/utils.py +++ b/toot/tui/utils.py @@ -3,8 +3,12 @@ import os import re import shutil import subprocess - +import fcntl +import termios +import struct +import urwid from datetime import datetime, timezone +from PIL import Image HASHTAG_PATTERN = re.compile(r'(? Image: + wpercent = (basewidth / float(img.size[0])) + hsize = int((float(img.size[1]) * float(wpercent))) + img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS) + img = img.convert('RGB') + return img + + +def get_cols_rows_pixh_pixw(screen: urwid.raw_display.Screen): + """Return the terminal dimensions (num columns, num rows, pixel height, pixel width).""" +# TBD: fallback to writing CSI 14t code and read response if the ioctl fails? +# some terminals support CSI 14t that may not support the ioctl. +# unfortunately Windows Terminal is among those that supports neither +# but until WT supports SIXEL, it's a non-issue. + y, x = 24, 80 + h, w = 480, 800 # VT340 default ¯\_(ツ)_/¯ + try: + if hasattr(screen._term_output_file, 'fileno'): + buf = fcntl.ioctl(screen._term_output_file.fileno(), + termios.TIOCGWINSZ, ' ' * 8) + y, x, h, w = struct.unpack('hhhh', buf) + except IOError: + # Term size could not be determined + pass + return x, y, h, w