mirror of
https://github.com/ihabunek/toot.git
synced 2024-09-29 04:35:54 -04:00
Sixel display of avatars, media attachments, etc.
This commit is contained in:
parent
7cada43e2f
commit
9f7c85b89c
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,3 +14,4 @@
|
||||
/toot-*.tar.gz
|
||||
debug.log
|
||||
/pyrightconfig.json
|
||||
/venv/
|
||||
|
@ -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
setup.py
3
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': [
|
||||
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
152
toot/tui/ansiwidget.py
Normal file
152
toot/tui/ansiwidget.py
Normal file
@ -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)
|
@ -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(
|
||||
|
293
toot/tui/palette.py
Normal file
293
toot/tui/palette.py
Normal file
@ -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
|
||||
]
|
166
toot/tui/sixelwidget.py
Normal file
166
toot/tui/sixelwidget.py
Normal file
@ -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
|
@ -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,6 +369,26 @@ class StatusDetails(urwid.Pile):
|
||||
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
|
||||
if m["description"]:
|
||||
yield ("pack", urwid.Text(m["description"]))
|
||||
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")
|
||||
@ -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"])
|
||||
|
@ -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'(?<!\w)(#\w+)\b')
|
||||
|
||||
@ -116,3 +120,30 @@ def parse_content_links(content):
|
||||
parser = LinkParser()
|
||||
parser.feed(content)
|
||||
return parser.links[:]
|
||||
|
||||
|
||||
def resize_image(basewidth: int, img: Image) -> 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
|
||||
|
Loading…
Reference in New Issue
Block a user