Skip to content

Sixel display of avatars, media attachments, etc. #300

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
/toot-*.tar.gz
debug.log
/pyrightconfig.json
/venv/
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"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': [
Expand Down
7 changes: 6 additions & 1 deletion toot/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,11 +250,16 @@ def editor(value):
),
]

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,
),
]
Expand Down
152 changes: 152 additions & 0 deletions toot/tui/ansiwidget.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions toot/tui/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging
import urwid
import requests
import sys

from concurrent.futures import ThreadPoolExecutor

Expand All @@ -14,8 +16,12 @@
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')

Expand Down Expand Up @@ -215,6 +221,7 @@ def _clear(*args):
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):
Expand Down Expand Up @@ -615,6 +622,24 @@ def _done(loop):

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(
Expand Down
Loading