Compare commits

...

20 Commits

Author SHA1 Message Date
Daniel Schwarz 1accd662c9
Merge 31bbb20324 into 2ba90fc2d2 2024-04-13 10:14:59 +02:00
Ivan Habunek 2ba90fc2d2
Bump python version for vermin 2024-04-13 09:24:58 +02:00
Ivan Habunek 8243dbab34
Add changelog 2024-04-13 09:23:38 +02:00
Ivan Habunek 597dddf76d
Drop typing compat needed for python 3.7 2024-04-13 09:22:57 +02:00
Ivan Habunek b482dc20b4
Drop support for python 3.7 2024-04-13 09:21:41 +02:00
Ivan Habunek 211e501fbc
Update release docs 2024-04-13 09:16:04 +02:00
Ivan Habunek b9c671b5a8
Remove test dependency on psycopg2
No longer using database for testing.
2024-04-13 09:09:59 +02:00
Ivan Habunek 77d8e7d7b5
Use build for packaging 2024-04-13 09:09:17 +02:00
Ivan Habunek 880848fae3
Remove version checks from tag script
Not needed since we're using the version from scm.
2024-04-13 09:06:00 +02:00
Ivan Habunek f54b6ac9d7
Update changelog 2024-04-13 09:04:10 +02:00
Ivan Habunek f925199781
Migrate setup.py to pyproject.toml 2024-04-13 08:49:25 +02:00
Daniel Schwarz 0fc2ec12f5
Display images 2024-04-13 08:28:28 +02:00
Daniel Schwarz 31bbb20324 Make this fix compatible with latest master 2024-03-05 20:08:54 -05:00
Daniel Schwarz 9d59df6c7e Merge branch 'master' into asyncfix 2024-03-05 20:03:05 -05:00
Daniel Schwarz d21b2920cb Fix for compatibility with more recent versions of toot 2024-03-05 19:58:54 -05:00
Daniel Schwarz a5cd9d343c Merge branch 'asyncfix' of https://github.com/danschwarz/toot into asyncfix 2024-03-05 19:58:18 -05:00
Daniel Schwarz c30657dc24
Merge branch 'ihabunek:master' into asyncfix 2023-01-30 13:42:43 -05:00
Daniel Schwarz cb7cbd872a
Merge branch 'ihabunek:master' into asyncfix 2023-01-30 09:16:19 -05:00
Daniel Schwarz ecb9c75f2e React properly to 422: Validation Failed. Status has already been taken errors 2022-12-31 18:16:51 -05:00
Daniel Schwarz fe5b9d1a46 React properly to 422: Validation Failed. Status has already been taken errors 2022-12-13 12:45:07 -05:00
23 changed files with 661 additions and 273 deletions

View File

@ -7,12 +7,13 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v3
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies

View File

@ -1,4 +1,4 @@
[vermin]
only_show_violations = yes
show_tips = no
targets = 3.7
targets = 3.8

View File

@ -3,6 +3,16 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.43.0 (2024-04-13)**
* TUI: Support displaying images (thanks Dan Schwarz)
* Improve GoToSocial compatibility (thanks Luca Matei Pintilie)
* Show visibility in timeline (thanks Sandra Snan)
* Flag `notifications --clear` no longer requires an argument (thanks Sandra
Snan)
* TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)
* Migrated to pyproject.toml finally
**0.42.0 (2024-03-09)**
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)

View File

@ -1,8 +1,7 @@
.PHONY: clean publish test docs
dist :
python setup.py sdist --formats=gztar,zip
python setup.py bdist_wheel --python-tag=py3
dist:
python -m build
publish :
twine upload dist/*.tar.gz dist/*.whl

View File

@ -1,3 +1,18 @@
0.44.0:
date: TBA
changes:
- "**BREAKING:** Require Python 3.8+"
0.43.0:
date: 2024-04-13
changes:
- "TUI: Support displaying images (thanks Dan Schwarz)"
- "Improve GoToSocial compatibility (thanks Luca Matei Pintilie)"
- "Show visibility in timeline (thanks Sandra Snan)"
- "Flag `notifications --clear` no longer requires an argument (thanks Sandra Snan)"
- "TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)"
- "Migrated to pyproject.toml finally"
0.42.0:
date: 2024-03-09
changes:

View File

@ -3,6 +3,16 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.43.0 (2024-04-13)**
* TUI: Support displaying images (thanks Dan Schwarz)
* Improve GoToSocial compatibility (thanks Luca Matei Pintilie)
* Show visibility in timeline (thanks Sandra Snan)
* Flag `notifications --clear` no longer requires an argument (thanks Sandra
Snan)
* TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)
* Migrated to pyproject.toml finally
**0.42.0 (2024-03-09)**
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)

View File

@ -5,13 +5,11 @@ This document is a checklist for creating a toot release.
Currently the process is pretty manual and would benefit from automatization.
Bump & tag version
------------------
Make docs and tag version
-------------------------
* Update the version number in `setup.py`
* Update the version number in `toot/__init__.py`
* Update `changelog.yaml` with the release notes & date
* Run `make changelog` to generate a human readable changelog
* Run `make docs` to generate changelog and update docs
* Commit the changes
* Run `./scripts/tag_version <version>` to tag a release in git
* Run `git push --follow-tags` to upload changes and tag to GitHub

85
pyproject.toml Normal file
View File

@ -0,0 +1,85 @@
[build-system]
requires = ["setuptools>=64", "setuptools_scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "toot"
authors = [{ name="Ivan Habunek", email="ivan@habunek.com" }]
description = "Mastodon CLI client"
readme = "README.rst"
license = { file="LICENSE" }
requires-python = ">=3.8"
dynamic = ["version"]
classifiers = [
"Environment :: Console :: Curses",
"Environment :: Console",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
]
dependencies = [
"beautifulsoup4>=4.5.0,<5.0",
"click~=8.1",
"requests>=2.13,<3.0",
"tomlkit>=0.10.0,<1.0",
"urwid>=2.0.0,<3.0",
"wcwidth>=0.1.7",
]
[project.optional-dependencies]
# Required to display images in the TUI
images = [
"pillow>=9.5.0",
"term-image==0.7.0",
]
# Required to display rich text in the TUI
richtext = [
"urwidgets>=0.1,<0.2"
]
test = [
"flake8",
"pytest",
"pytest-xdist[psutil]",
"setuptools",
"vermin",
"typing-extensions",
"pillow>=9.5.0",
]
dev = [
"build",
"flake8",
"mypy",
"pyright",
"pyyaml",
"textual-dev",
"twine",
"types-beautifulsoup4",
"vermin",
]
[project.urls]
"Homepage" = "https://toot.bezdomni.net"
"Source" = "https://github.com/ihabunek/toot/"
[project.scripts]
toot = "toot.cli:cli"
[tool.setuptools]
packages=[
"toot",
"toot.cli",
"toot.tui",
"toot.tui.richtext",
"toot.utils"
]
[tool.setuptools_scm]
[tool.pyright]
include = ["toot"]
typeCheckingMode = "strict"

View File

@ -16,7 +16,6 @@ import toot
from datetime import date
from os import path
from pkg_resources import get_distribution
path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "changelog.yaml")
with open(path, "r") as f:
@ -33,15 +32,6 @@ if not changelog_item:
print(f"Version `{version}` not found in changelog.", file=sys.stderr)
sys.exit(1)
if toot.__version__ != version:
print(f"toot.__version__ is `{toot.__version__}`, expected {version}.", file=sys.stderr)
sys.exit(1)
dist_version = get_distribution('toot').version
if dist_version != version:
print(f"Version in setup.py is `{dist_version}`, expected {version}.", file=sys.stderr)
sys.exit(1)
release_date = changelog_item["date"]
description = changelog_item.get("description")
changes = changelog_item["changes"]

View File

@ -1,70 +0,0 @@
#!/usr/bin/env python
from setuptools import setup
long_description = """
Toot is a CLI and TUI tool for interacting with Mastodon instances from the
command line.
Allows posting text and media to the timeline, searching, following, muting
and blocking accounts and other actions.
"""
setup(
name='toot',
version='0.42.0',
description='Mastodon CLI client',
long_description=long_description.strip(),
author='Ivan Habunek',
author_email='ivan@habunek.com',
url='https://github.com/ihabunek/toot/',
project_urls={
'Documentation': 'https://toot.bezdomni.net/',
'Issue tracker': 'https://github.com/ihabunek/toot/issues/',
},
keywords='mastodon toot',
license='GPLv3',
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Console :: Curses',
'Environment :: Console',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Programming Language :: Python :: 3',
],
packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
python_requires=">=3.7",
install_requires=[
"click~=8.1",
"requests>=2.13,<3.0",
"beautifulsoup4>=4.5.0,<5.0",
"wcwidth>=0.1.7",
"urwid>=2.0.0,<3.0",
"tomlkit>=0.10.0,<1.0"
],
extras_require={
# Required to display rich text in the TUI
"richtext": [
"urwidgets>=0.1,<0.2"
],
"dev": [
"coverage",
"pyyaml",
"twine",
"wheel",
],
"test": [
"flake8",
"psycopg2-binary",
"pytest",
"pytest-xdist[psutil]",
"setuptools",
"vermin",
"typing-extensions",
],
},
entry_points={
'console_scripts': [
'toot=toot.cli:cli',
],
}
)

View File

@ -1,8 +1,12 @@
import click
import pytest
import sys
from toot.cli.validators import validate_duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text
from toot.tui.utils import LRUCache
from PIL import Image
from collections import namedtuple
from toot.utils import urlencode_url
@ -207,6 +211,111 @@ def test_duration():
duration("banana")
def test_cache_null():
"""Null dict is null."""
cache = LRUCache(cache_max_bytes=1024)
assert cache.__len__() == 0
Case = namedtuple("Case", ["cache_len", "len", "init"])
img = Image.new('RGB', (100, 100))
img_size = sys.getsizeof(img.tobytes())
@pytest.mark.parametrize(
"case",
[
Case(9, 0, []),
Case(9, 1, [("one", img)]),
Case(9, 2, [("one", img), ("two", img)]),
Case(2, 2, [("one", img), ("two", img)]),
Case(1, 1, [("one", img), ("two", img)]),
],
)
@pytest.mark.parametrize("method", ["assign", "init"])
def test_cache_init(case, method):
"""Check that the # of elements is right, given # given and cache_len."""
if method == "init":
cache = LRUCache(case.init, cache_max_bytes=img_size * case.cache_len)
elif method == "assign":
cache = LRUCache(cache_max_bytes=img_size * case.cache_len)
for (key, val) in case.init:
cache[key] = val
else:
assert False
# length is max(#entries, cache_len)
assert cache.__len__() == case.len
# make sure the first entry is the one ejected
if case.cache_len > 1 and case.init:
assert "one" in cache.keys()
else:
assert "one" not in cache.keys()
@pytest.mark.parametrize("method", ["init", "assign"])
def test_cache_overflow_default(method):
"""Test default overflow logic."""
if method == "init":
cache = LRUCache([("one", img), ("two", img), ("three", img)], cache_max_bytes=img_size * 2)
elif method == "assign":
cache = LRUCache(cache_max_bytes=img_size * 2)
cache["one"] = img
cache["two"] = img
cache["three"] = img
else:
assert False
assert "one" not in cache.keys()
assert "two" in cache.keys()
assert "three" in cache.keys()
@pytest.mark.parametrize("mode", ["get", "set"])
@pytest.mark.parametrize("add_third", [False, True])
def test_cache_lru_overflow(mode, add_third):
img = Image.new('RGB', (100, 100))
img_size = sys.getsizeof(img.tobytes())
"""Test that key access resets LRU logic."""
cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 2)
if mode == "get":
dummy = cache["one"]
elif mode == "set":
cache["one"] = img
else:
assert False
if add_third:
cache["three"] = img
assert "one" in cache.keys()
assert "two" not in cache.keys()
assert "three" in cache.keys()
else:
assert "one" in cache.keys()
assert "two" in cache.keys()
assert "three" not in cache.keys()
def test_cache_keyerror():
cache = LRUCache()
with pytest.raises(KeyError):
cache["foo"]
def test_cache_miss_doesnt_eject():
cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 3)
with pytest.raises(KeyError):
cache["foo"]
assert len(cache) == 2
assert "one" in cache.keys()
assert "two" in cache.keys()
def test_urlencode_url():
assert urlencode_url("https://www.example.com") == "https://www.example.com"
assert urlencode_url("https://www.example.com/url%20with%20spaces") == "https://www.example.com/url%20with%20spaces"

View File

@ -3,8 +3,13 @@ import sys
from os.path import join, expanduser
from typing import NamedTuple
from importlib import metadata
__version__ = '0.42.0'
try:
__version__ = metadata.version("toot")
except metadata.PackageNotFoundError:
__version__ = "0.0.0"
class App(NamedTuple):

View File

@ -8,7 +8,7 @@ from typing import BinaryIO, List, Optional
from urllib.parse import urlparse, urlencode, quote
from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE
from toot.exceptions import ConsoleError
from toot.exceptions import ApiError, ConsoleError
from toot.utils import drop_empty_values, str_bool, str_bool_nullable
@ -53,8 +53,28 @@ def _tag_action(app, user, tag_name, action) -> Response:
return http.post(app, user, url)
def create_app(base_url):
url = f"{base_url}/api/v1/apps"
def _status_toggle_action(app, user, status_id, action, data=None):
url = '/api/v1/statuses/{}/{}'.format(status_id, action)
try:
response = http.post(app, user, url).json()
except ApiError as e:
# For "toggle" operations, Mastodon returns unhelpful
# 422: "Validation failed: Status has already been taken"
# responses when you try to bookmark a status already
# bookmarked, or favourite a status already favourited
# so we just swallow those errors here
if str(e) == "Validation failed: Status has already been taken":
response = None
else:
# not the error we expected; re-raise the exception
raise e
finally:
return response
def create_app(domain, scheme='https'):
url = f"{scheme}://{domain}/api/v1/apps"
json = {
'client_name': CLIENT_NAME,
@ -310,38 +330,40 @@ def delete_status(app, user, status_id):
def favourite(app, user, status_id):
return _status_action(app, user, status_id, 'favourite')
return _status_toggle_action(app, user, status_id, 'favourite')
def unfavourite(app, user, status_id):
return _status_action(app, user, status_id, 'unfavourite')
return _status_toggle_action(app, user, status_id, 'unfavourite')
def reblog(app, user, status_id, visibility="public"):
return _status_action(app, user, status_id, 'reblog', data={"visibility": visibility})
return _status_toggle_action(app, user, status_id, 'reblog', data={"visibility": visibility})
def unreblog(app, user, status_id):
return _status_action(app, user, status_id, 'unreblog')
return _status_toggle_action(app, user, status_id, 'unreblog')
def pin(app, user, status_id):
return _status_action(app, user, status_id, 'pin')
return _status_toggle_action(app, user, status_id, 'pin')
def unpin(app, user, status_id):
return _status_action(app, user, status_id, 'unpin')
return _status_toggle_action(app, user, status_id, 'unpin')
def bookmark(app, user, status_id):
return _status_action(app, user, status_id, 'bookmark')
return _status_toggle_action(app, user, status_id, 'bookmark')
def unbookmark(app, user, status_id):
return _status_action(app, user, status_id, 'unbookmark')
return _status_toggle_action(app, user, status_id, 'unbookmark')
def translate(app, user, status_id):
# don't use status_toggle_action for translate as this is
# not toggling anything server-side; it's a read only operation.
return _status_action(app, user, status_id, 'translate')

View File

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

View File

@ -1,8 +1,8 @@
import click
from typing import Optional
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, Context, cli, pass_context
from toot.cli.validators import validate_tui_colors
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
COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
@ -24,6 +24,12 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
using --color, and 1 if using --no-color."""
)
@click.option(
"-s", "--cache-size",
callback=validate_cache_size,
help="""Specify the image cache maximum size in megabytes. Default: 10MB.
Minimum: 1MB."""
)
@click.option(
"-v", "--default-visibility",
type=click.Choice(VISIBILITY_CHOICES),
@ -34,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,
@ -41,7 +52,9 @@ def tui(
media_viewer: Optional[str],
always_show_sensitive: bool,
relative_datetimes: bool,
default_visibility: Optional[str]
cache_size: Optional[int],
default_visibility: Optional[str],
image_format: Optional[str]
):
"""Launches the toot terminal user interface"""
if colors is None:
@ -51,8 +64,10 @@ def tui(
colors=colors,
media_viewer=media_viewer,
relative_datetimes=relative_datetimes,
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()

View File

@ -73,3 +73,21 @@ def validate_tui_colors(ctx, param, value) -> Optional[int]:
return TUI_COLORS[value]
raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}")
def validate_cache_size(ctx: click.Context, param: str, value: Optional[str]) -> Optional[int]:
"""validates the cache size parameter"""
if value is None:
return 1024 * 1024 * 10 # default 10MB
else:
if value.isdigit():
size = int(value)
else:
raise click.BadParameter("Cache size must be numeric.")
if size > 1024:
raise click.BadParameter("Cache size too large: 1024MB maximum.")
elif size < 1:
raise click.BadParameter("Cache size too small: 1MB minimum.")
return size

View File

@ -15,9 +15,8 @@ from dataclasses import dataclass, is_dataclass
from datetime import date, datetime
from functools import lru_cache
from typing import Any, Dict, Optional, Tuple, Type, TypeVar, Union
from typing import get_type_hints
from typing import get_args, get_origin, get_type_hints
from toot.typing_compat import get_args, get_origin
from toot.utils import get_text
from toot.utils.datetime import parse_datetime

View File

@ -2,6 +2,7 @@ import logging
import subprocess
import urwid
from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional
from datetime import datetime, timezone
@ -15,11 +16,12 @@ from toot.utils.datetime import parse_datetime
from .compose import StatusComposer
from .constants import PALETTE
from .entities import Status
from .images import TuiScreen, load_image
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
from .overlays import StatusDeleteConfirmation, Account
from .poll import Poll
from .timeline import Timeline
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, LRUCache
from .widgets import ModalBox, RoundedLineBox
logger = logging.getLogger(__name__)
@ -35,7 +37,9 @@ class TuiOptions(NamedTuple):
media_viewer: Optional[str]
always_show_sensitive: bool
relative_datetimes: bool
cache_size: int
default_visibility: Optional[str]
image_format: Optional[str]
class Header(urwid.WidgetWrap):
@ -95,7 +99,7 @@ class TUI(urwid.Frame):
@staticmethod
def create(app: App, user: User, args: TuiOptions):
"""Factory method, sets up TUI and an event loop."""
screen = urwid.raw_display.Screen()
screen = TuiScreen()
screen.set_terminal_properties(args.colors)
tui = TUI(app, user, screen, args)
@ -144,6 +148,11 @@ class TUI(urwid.Frame):
self.followed_accounts = []
self.preferences = {}
if self.options.cache_size:
self.cache_max = 1024 * 1024 * self.options.cache_size
else:
self.cache_max = 1024 * 1024 * 10 # default 10MB
super().__init__(self.body, header=self.header, footer=self.footer)
def run(self):
@ -648,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",
)
@ -757,6 +766,27 @@ class TUI(urwid.Frame):
return self.run_in_thread(_delete, done_callback=_done)
def async_load_image(self, timeline, status, path, placeholder_index):
def _load():
# don't bother loading images for statuses we are not viewing now
if timeline.get_focused_status().id != status.id:
return
if not hasattr(timeline, "images"):
timeline.images = LRUCache(cache_max_bytes=self.cache_max)
img = load_image(path)
if img:
timeline.images[str(hash(path))] = img
def _done(loop):
# don't bother loading images for statuses we are not viewing now
if timeline.get_focused_status().id != status.id:
return
timeline.update_status_image(status, path, placeholder_index)
return self.run_in_thread(_load, done_callback=_done)
def copy_status(self, status):
# TODO: copy a better version of status content
# including URLs

104
toot/tui/images.py Normal file
View File

@ -0,0 +1,104 @@
import urwid
import math
import requests
import warnings
# If term_image is loaded use their screen implementation which handles images
try:
from term_image.widget import UrwidImageScreen, UrwidImage
from term_image.image import BaseImage, KittyImage, ITerm2Image, BlockImage
from term_image import disable_queries # prevent phantom keystrokes
from PIL import Image, ImageDraw
TuiScreen = UrwidImageScreen
disable_queries()
def image_support_enabled():
return True
def can_render_pixels(image_format):
return image_format in ['kitty', 'iterm']
def get_base_image(image, image_format) -> BaseImage:
# we don't autodetect kitty, iterm; we choose 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 resize_image(basewidth: int, baseheight: int, img: Image.Image) -> Image.Image:
if baseheight and not basewidth:
hpercent = baseheight / float(img.size[1])
width = math.ceil(img.size[0] * hpercent)
img = img.resize((width, baseheight), Image.Resampling.LANCZOS)
elif basewidth and not baseheight:
wpercent = (basewidth / float(img.size[0]))
hsize = int((float(img.size[1]) * float(wpercent)))
img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS)
else:
img = img.resize((basewidth, baseheight), Image.Resampling.LANCZOS)
if img.mode != 'P':
img = img.convert('RGB')
return img
def add_corners(img, rad):
circle = Image.new('L', (rad * 2, rad * 2), 0)
draw = ImageDraw.Draw(circle)
draw.ellipse((0, 0, rad * 2, rad * 2), fill=255)
alpha = Image.new('L', img.size, "white")
w, h = img.size
alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0))
alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad))
alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0))
alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad))
img.putalpha(alpha)
return img
def load_image(url):
with warnings.catch_warnings():
warnings.simplefilter("ignore") # suppress "corrupt exif" output from PIL
try:
img = Image.open(requests.get(url, stream=True).raw)
if img.format == 'PNG' and img.mode != 'RGBA':
img = img.convert("RGBA")
return img
except Exception:
return None
def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget:
if not img:
return urwid.SolidFill(fill_char=" ")
if can_render_pixels(image_format) and corner_radius > 0:
render_img = add_corners(img, 10)
else:
render_img = img
return UrwidImage(get_base_image(render_img, image_format), '<', upscale=True)
# "<" means left-justify the image
except ImportError:
from urwid.raw_display import Screen
TuiScreen = Screen
def image_support_enabled():
return False
def can_render_pixels(image_format: str):
return False
def get_base_image(image, image_format: str):
return None
def add_corners(img, rad):
return None
def load_image(url):
return None
def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget:
return urwid.SolidFill(fill_char=" ")

View File

@ -5,7 +5,9 @@ import webbrowser
from toot import __version__
from toot import api
from toot.tui.utils import highlight_keys
from toot.tui.images import image_support_enabled, load_image, graphics_widget
from toot.tui.widgets import Button, EditBox, SelectableText
from toot.tui.richtext import html_to_widgets
@ -242,11 +244,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()
@ -255,6 +258,30 @@ class Account(urwid.ListBox):
walker = urwid.SimpleListWalker(actions)
super().__init__(walker)
def account_header(self, account):
if image_support_enabled() and account['avatar'] and not account["avatar"].endswith("missing.png"):
img = load_image(account['avatar'])
aimg = urwid.BoxAdapter(
graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10)
else:
aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
if image_support_enabled() and account['header'] and not account["header"].endswith("missing.png"):
img = load_image(account['header'])
himg = (urwid.BoxAdapter(
graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10))
else:
himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
atxt = urwid.Pile([urwid.Divider(),
(urwid.Text(("account", account["display_name"]))),
(urwid.Text(("highlight", "@" + self.account['acct'])))])
columns = urwid.Columns([aimg, ("weight", 9999, himg)], dividechars=2, min_width=20)
header = urwid.Pile([columns, urwid.Divider(), atxt])
return header
def generate_contents(self, account, relationship=None, last_action=None):
if self.last_action and not self.last_action.startswith("Confirm"):
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
@ -276,11 +303,11 @@ class Account(urwid.ListBox):
yield urwid.Divider("")
yield urwid.Divider()
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
yield self.account_header(account)
if account["note"]:
yield urwid.Divider()
widgetlist = html_to_widgets(account["note"])
for line in widgetlist:
yield (line)

View File

@ -1,26 +1,33 @@
import logging
import math
import urwid
import webbrowser
from typing import List, Optional
from toot.tui import app
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.images import image_support_enabled, graphics_widget, can_render_pixels
from toot.tui.widgets import SelectableText, SelectableColumns, RoundedLineBox
logger = logging.getLogger("toot")
screen = urwid.raw_display.Screen()
class Timeline(urwid.Columns):
"""
Displays a list of statuses to the left, and status details on the right.
"""
signals = [
"close", # Close thread
"focus", # Focus changed
@ -41,6 +48,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.tui.options.image_format)
try:
focused_status = statuses[focus]
@ -141,6 +149,16 @@ class Timeline(urwid.Columns):
def modified(self):
"""Called when the list focus switches to a new status"""
status, index, count = self.get_focused_status_with_counts()
if image_support_enabled:
clear_op = getattr(self.tui.screen, "clear_images", None)
# term-image's screen implementation has clear_images(),
# urwid's implementation does not.
# TODO: it would be nice not to check this each time thru
if callable(clear_op):
self.tui.screen.clear_images()
self.draw_status_details(status)
self._emit("focus")
@ -282,7 +300,7 @@ class Timeline(urwid.Columns):
def get_status_index(self, id):
# TODO: This is suboptimal, consider a better way
for n, status in enumerate(self.statuses):
for n, status in enumerate(self.statuses.copy()):
if status.id == id:
return n
raise ValueError("Status with ID {} not found".format(id))
@ -306,6 +324,27 @@ class Timeline(urwid.Columns):
if index == self.status_list.body.focus:
self.draw_status_details(status)
def update_status_image(self, status, path, placeholder_index):
"""Replace image placeholder with image widget and redraw"""
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
# get the image and replace the placeholder with a graphics widget
img = None
if hasattr(self, "images"):
try:
img = self.images[(str(hash(path)))]
except KeyError:
pass
if img:
try:
status.placeholders[placeholder_index]._set_original_widget(
graphics_widget(img, image_format=self.tui.options.image_format, corner_radius=10))
except IndexError:
# ignore IndexErrors.
pass
def remove_status(self, status):
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
@ -318,6 +357,9 @@ class Timeline(urwid.Columns):
class StatusDetails(urwid.Pile):
def __init__(self, timeline: Timeline, status: Optional[Status]):
self.status = status
self.timeline = timeline
if self.status:
self.status.placeholders = []
self.followed_accounts = timeline.tui.followed_accounts
self.options = timeline.tui.options
@ -326,17 +368,83 @@ class StatusDetails(urwid.Pile):
if status else ())
return super().__init__(widget_list)
def image_widget(self, path, rows=None, aspect=None) -> urwid.Widget:
"""Returns a widget capable of displaying the image
path is required; URL to image
rows, if specfied, sets a fixed number of rows. Or:
aspect, if specified, calculates rows based on pane width
and the aspect ratio provided"""
if not rows:
if not aspect:
aspect = 3 / 2 # reasonable default
screen_rows = screen.get_cols_rows()[1]
if self.timeline.can_render_pixels:
# for pixel-rendered images,
# image rows should be 33% of the available screen
# but in no case fewer than 10
rows = max(10, math.floor(screen_rows * .33))
else:
# for cell-rendered images,
# use the max available columns
# and calculate rows based on the image
# aspect ratio
cols = math.floor(0.55 * screen.get_cols_rows()[0])
rows = math.ceil((cols / 2) / aspect)
# if the calculated rows are more than will
# fit on one screen, reduce to one screen of rows
rows = min(screen_rows - 6, rows)
# but in no case fewer than 10 rows
rows = max(rows, 10)
img = None
if hasattr(self.timeline, "images"):
try:
img = self.timeline.images[(str(hash(path)))]
except KeyError:
pass
if img:
return (urwid.BoxAdapter(
graphics_widget(img, image_format=self.timeline.tui.options.image_format, corner_radius=10), rows))
else:
placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows)
self.status.placeholders.append(placeholder)
if image_support_enabled():
self.timeline.tui.async_load_image(self.timeline, self.status, path, len(self.status.placeholders) - 1)
return placeholder
def author_header(self, reblogged_by):
avatar_url = self.status.original.data["account"]["avatar"]
if avatar_url and image_support_enabled():
aimg = self.image_widget(avatar_url, 2)
account_color = ("highlight" if self.status.original.author.account in
self.timeline.tui.followed_accounts else "account")
atxt = urwid.Pile([("pack", urwid.Text(("bold", self.status.original.author.display_name))),
("pack", urwid.Text((account_color, self.status.original.author.account)))])
if image_support_enabled():
columns = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5)
else:
columns = urwid.Columns([("weight", 9999, atxt)], dividechars=1, min_width=5)
return columns
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(("dim", text)))
reblogger_name = (reblogged_by.display_name
if reblogged_by.display_name
else reblogged_by.username)
text = f"{reblogger_name} boosted"
yield urwid.Text(("dim", text))
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
if status.author.display_name:
yield ("pack", urwid.Text(("bold", status.author.display_name)))
account_color = "highlight" if status.author.account in self.followed_accounts else "account"
yield ("pack", urwid.Text((account_color, status.author.account)))
yield self.author_header(reblogged_by)
yield ("pack", urwid.Divider())
if status.data["spoiler_text"]:
@ -363,7 +471,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", url_to_widget(m["url"]))
if m["url"]:
if m["url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("")
try:
aspect = float(m["meta"]["original"]["aspect"])
except Exception:
aspect = None
if image_support_enabled():
yield self.image_widget(m["url"], aspect=aspect)
yield urwid.Divider()
# video media may include a preview URL, show that as a fallback
elif m["preview_url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("")
try:
aspect = float(m["meta"]["small"]["aspect"])
except Exception:
aspect = None
if image_support_enabled():
yield self.image_widget(m["preview_url"], aspect=aspect)
yield urwid.Divider()
yield ("pack", url_to_widget(m["url"]))
poll = status.original.data.get("poll")
if poll:
@ -427,6 +555,15 @@ class StatusDetails(urwid.Pile):
yield urwid.Text("")
yield url_to_widget(card["url"])
if card["image"] and image_support_enabled():
if card["image"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("")
try:
aspect = int(card["width"]) / int(card["height"])
except Exception:
aspect = None
yield self.image_widget(card["image"], aspect=aspect)
def poll_generator(self, poll):
for idx, option in enumerate(poll["options"]):
perc = (round(100 * option["votes_count"] / poll["votes_count"])

View File

@ -1,7 +1,8 @@
import base64
import re
import sys
import urwid
from collections import OrderedDict
from functools import reduce
from html.parser import HTMLParser
from typing import List
@ -109,3 +110,33 @@ def deep_get(adict: dict, path: List[str], default=None):
path,
adict
)
class LRUCache(OrderedDict):
"""Dict with a limited size, ejecting LRUs as needed.
Default max size = 10Mb"""
def __init__(self, *args, cache_max_bytes: int = 1024 * 1024 * 10, **kwargs):
assert cache_max_bytes > 0
self.total_value_size = 0
self.cache_max_bytes = cache_max_bytes
super().__init__(*args, **kwargs)
def __setitem__(self, key: str, value):
if key in self:
self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes())
self.total_value_size += sys.getsizeof(value.tobytes())
super().__setitem__(key, value)
super().move_to_end(key)
while self.total_value_size > self.cache_max_bytes:
old_key, value = next(iter(self.items()))
sz = sys.getsizeof(value.tobytes())
super().__delitem__(old_key)
self.total_value_size -= sz
def __getitem__(self, key: str):
val = super().__getitem__(key)
super().move_to_end(key)
return val

View File

@ -1,147 +0,0 @@
# Taken from https://github.com/rossmacarthur/typing-compat/
# TODO: Remove once the minimum python version is increased to 3.8
#
# Licensed under the MIT license
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# flake8: noqa
import collections
import typing
__all__ = ['get_args', 'get_origin']
__title__ = 'typing-compat'
__version__ = '0.1.0'
__url__ = 'https://github.com/rossmacarthur/typing-compat'
__author__ = 'Ross MacArthur'
__author_email__ = 'ross@macarthur.io'
__description__ = 'Python typing compatibility library'
try:
# Python >=3.8 should have these functions already
from typing import get_args as _get_args # novermin
from typing import get_origin as _get_origin # novermin
except ImportError:
if hasattr(typing, '_GenericAlias'): # Python 3.7
def _get_origin(tp):
"""Copied from the Python 3.8 typing module"""
if isinstance(tp, typing._GenericAlias):
return tp.__origin__
if tp is typing.Generic:
return typing.Generic
return None
def _get_args(tp):
"""Copied from the Python 3.8 typing module"""
if isinstance(tp, typing._GenericAlias):
res = tp.__args__
if (
get_origin(tp) is collections.abc.Callable
and res[0] is not Ellipsis
):
res = (list(res[:-1]), res[-1])
return res
return ()
else: # Python <3.7
def _resolve_via_mro(tp):
if hasattr(tp, '__mro__'):
for t in tp.__mro__:
if t.__module__ in ('builtins', '__builtin__') and t is not object:
return t
return tp
def _get_origin(tp):
"""Emulate the behaviour of Python 3.8 typing module"""
if isinstance(tp, typing._ClassVar):
return typing.ClassVar
elif isinstance(tp, typing._Union):
return typing.Union
elif isinstance(tp, typing.GenericMeta):
if hasattr(tp, '_gorg'):
return _resolve_via_mro(tp._gorg)
else:
while tp.__origin__ is not None:
tp = tp.__origin__
return _resolve_via_mro(tp)
elif hasattr(typing, '_Literal') and isinstance(tp, typing._Literal): # novermin
return typing.Literal # novermin
def _normalize_arg(args):
if isinstance(args, tuple) and len(args) > 1:
base, rest = args[0], tuple(_normalize_arg(arg) for arg in args[1:])
if isinstance(base, typing.CallableMeta):
return typing.Callable[list(rest[:-1]), rest[-1]]
elif isinstance(base, (typing.GenericMeta, typing._Union)):
return base[rest]
return args
def _get_args(tp):
"""Emulate the behaviour of Python 3.8 typing module"""
if isinstance(tp, typing._ClassVar):
return (tp.__type__,)
elif hasattr(tp, '_subs_tree'):
tree = tp._subs_tree()
if isinstance(tree, tuple) and len(tree) > 1:
if isinstance(tree[0], typing.CallableMeta) and len(tree) == 2:
return ([], _normalize_arg(tree[1]))
return tuple(_normalize_arg(arg) for arg in tree[1:])
return ()
def get_origin(tp):
"""
Get the unsubscripted version of a type.
This supports generic types, Callable, Tuple, Union, Literal, Final and
ClassVar. Returns None for unsupported types.
Examples:
get_origin(Literal[42]) is Literal
get_origin(int) is None
get_origin(ClassVar[int]) is ClassVar
get_origin(Generic) is Generic
get_origin(Generic[T]) is Generic
get_origin(Union[T, int]) is Union
get_origin(List[Tuple[T, T]][int]) == list
"""
return _get_origin(tp)
def get_args(tp):
"""
Get type arguments with all substitutions performed.
For unions, basic simplifications used by Union constructor are performed.
Examples:
get_args(Dict[str, int]) == (str, int)
get_args(int) == ()
get_args(Union[int, Union[T, int], str][int]) == (int, str)
get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int])
get_args(Callable[[], T][int]) == ([], int)
"""
return _get_args(tp)