1
0
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:
Dan Schwarz 2023-01-25 18:36:10 -05:00
parent 7cada43e2f
commit 9f7c85b89c
10 changed files with 775 additions and 8 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@
/toot-*.tar.gz
debug.log
/pyrightconfig.json
/venv/

View File

@ -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

View File

@ -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': [

View File

@ -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
View 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)

View File

@ -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
View 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
View 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

View File

@ -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"])

View File

@ -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