mirror of
https://github.com/ihabunek/toot
synced 2024-12-25 00:20:55 +01:00
386 lines
11 KiB
Python
386 lines
11 KiB
Python
import os
|
|
import re
|
|
import sys
|
|
import textwrap
|
|
|
|
from typing import List
|
|
from wcwidth import wcswidth
|
|
|
|
from toot.tui.utils import parse_datetime
|
|
from toot.utils import get_text, parse_html
|
|
from toot.wcstring import wc_wrap
|
|
|
|
|
|
STYLES = {
|
|
'reset': '\033[0m',
|
|
'bold': '\033[1m',
|
|
'dim': '\033[2m',
|
|
'italic': '\033[3m',
|
|
'underline': '\033[4m',
|
|
'red': '\033[91m',
|
|
'green': '\033[92m',
|
|
'yellow': '\033[93m',
|
|
'blue': '\033[94m',
|
|
'magenta': '\033[95m',
|
|
'cyan': '\033[96m',
|
|
}
|
|
|
|
STYLE_TAG_PATTERN = re.compile(r"""
|
|
(?<!\\) # not preceeded by a backslash - allows escaping
|
|
< # literal
|
|
(/)? # optional closing - first group
|
|
(.*?) # style names - ungreedy - second group
|
|
> # literal
|
|
""", re.X)
|
|
|
|
|
|
def colorize(message):
|
|
"""
|
|
Replaces style tags in `message` with ANSI escape codes.
|
|
|
|
Markup is inspired by HTML, but you can use multiple words pre tag, e.g.:
|
|
|
|
<red bold>alert!</red bold> a thing happened
|
|
|
|
Empty closing tag will reset all styes:
|
|
|
|
<red bold>alert!</> a thing happened
|
|
|
|
Styles can be nested:
|
|
|
|
<red>red <underline>red and underline</underline> red</red>
|
|
"""
|
|
|
|
def _codes(styles):
|
|
for style in styles:
|
|
yield STYLES.get(style, "")
|
|
|
|
def _generator(message):
|
|
# A list is used instead of a set because we want to keep style order
|
|
# This allows nesting colors, e.g. "<blue>foo<red>bar</red>baz</blue>"
|
|
position = 0
|
|
active_styles = []
|
|
|
|
for match in re.finditer(STYLE_TAG_PATTERN, message):
|
|
is_closing = bool(match.group(1))
|
|
styles = match.group(2).strip().split()
|
|
|
|
start, end = match.span()
|
|
# Replace backslash for escaped <
|
|
yield message[position:start].replace("\\<", "<")
|
|
|
|
if is_closing:
|
|
yield STYLES["reset"]
|
|
|
|
# Empty closing tag resets all styles
|
|
if styles == []:
|
|
active_styles = []
|
|
else:
|
|
active_styles = [s for s in active_styles if s not in styles]
|
|
yield from _codes(active_styles)
|
|
else:
|
|
active_styles = active_styles + styles
|
|
yield from _codes(styles)
|
|
|
|
position = end
|
|
|
|
if position == 0:
|
|
# Nothing matched, yield the original string
|
|
yield message
|
|
else:
|
|
# Yield the remaining fragment
|
|
yield message[position:]
|
|
# Reset styles at the end to prevent leaking
|
|
yield STYLES["reset"]
|
|
|
|
return "".join(_generator(message))
|
|
|
|
|
|
def strip_tags(message):
|
|
return re.sub(STYLE_TAG_PATTERN, "", message)
|
|
|
|
|
|
def use_ansi_color():
|
|
"""Returns True if ANSI color codes should be used."""
|
|
|
|
# Windows doesn't support color unless ansicon is installed
|
|
# See: http://adoxa.altervista.org/ansicon/
|
|
if sys.platform == 'win32' and 'ANSICON' not in os.environ:
|
|
return False
|
|
|
|
# Don't show color if stdout is not a tty, e.g. if output is piped on
|
|
if not sys.stdout.isatty():
|
|
return False
|
|
|
|
# Don't show color if explicitly specified in options
|
|
if "--no-color" in sys.argv:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
USE_ANSI_COLOR = use_ansi_color()
|
|
|
|
QUIET = "--quiet" in sys.argv
|
|
|
|
|
|
def print_out(*args, **kwargs):
|
|
if not QUIET:
|
|
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
|
|
print(*args, **kwargs)
|
|
|
|
|
|
def print_err(*args, **kwargs):
|
|
args = [f"<red>{a}</red>" for a in args]
|
|
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
|
|
print(*args, file=sys.stderr, **kwargs)
|
|
|
|
|
|
def print_instance(instance):
|
|
print_out(f"<green>{instance['title']}</green>")
|
|
print_out(f"<blue>{instance['uri']}</blue>")
|
|
print_out(f"running Mastodon {instance['version']}")
|
|
print_out()
|
|
|
|
description = instance.get("description")
|
|
if description:
|
|
for paragraph in re.split(r"[\r\n]+", description.strip()):
|
|
paragraph = get_text(paragraph)
|
|
print_out(textwrap.fill(paragraph, width=80))
|
|
print_out()
|
|
|
|
rules = instance.get("rules")
|
|
if rules:
|
|
print_out("Rules:")
|
|
for ordinal, rule in enumerate(rules):
|
|
ordinal = f"{ordinal + 1}."
|
|
lines = textwrap.wrap(rule["text"], 80 - len(ordinal))
|
|
first = True
|
|
for line in lines:
|
|
if first:
|
|
print_out(f"{ordinal} {line}")
|
|
first = False
|
|
else:
|
|
print_out(f"{' ' * len(ordinal)} {line}")
|
|
|
|
|
|
def print_account(account):
|
|
print_out(f"<green>@{account['acct']}</green> {account['display_name']}")
|
|
|
|
if account["note"]:
|
|
print_out("")
|
|
print_html(account["note"])
|
|
|
|
print_out("")
|
|
print_out(f"ID: <green>{account['id']}</green>")
|
|
print_out(f"Since: <green>{account['created_at'][:10]}</green>")
|
|
print_out("")
|
|
print_out(f"Followers: <yellow>{account['followers_count']}</yellow>")
|
|
print_out(f"Following: <yellow>{account['following_count']}</yellow>")
|
|
print_out(f"Statuses: <yellow>{account['statuses_count']}</yellow>")
|
|
|
|
if account["fields"]:
|
|
for field in account["fields"]:
|
|
name = field["name"].title()
|
|
print_out(f'\n<yellow>{name}</yellow>:')
|
|
print_html(field["value"])
|
|
if field["verified_at"]:
|
|
print_out("<green>✓ Verified</green>")
|
|
|
|
print_out("")
|
|
print_out(account["url"])
|
|
|
|
|
|
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
|
|
|
|
|
|
def highlight_hashtags(line):
|
|
return re.sub(HASHTAG_PATTERN, '<cyan>\\1</cyan>', line)
|
|
|
|
|
|
def print_acct_list(accounts):
|
|
for account in accounts:
|
|
print_out(f"* <green>@{account['acct']}</green> {account['display_name']}")
|
|
|
|
|
|
def print_user_list(users):
|
|
for user in users:
|
|
print_out(f"* {user}")
|
|
|
|
|
|
def print_tag_list(tags):
|
|
if tags:
|
|
for tag in tags:
|
|
print_out(f"* <green>#{tag['name']}\t</green>{tag['url']}")
|
|
else:
|
|
print_out("You're not following any hashtags.")
|
|
|
|
|
|
def print_lists(lists):
|
|
headers = ["ID", "Title", "Replies"]
|
|
data = [[lst["id"], lst["title"], lst["replies_policy"]] for lst in lists]
|
|
print_table(headers, data)
|
|
|
|
|
|
def print_table(headers: List[str], data: List[List[str]]):
|
|
widths = [[len(cell) for cell in row] for row in data + [headers]]
|
|
widths = [max(width) for width in zip(*widths)]
|
|
|
|
def style(string, tag):
|
|
return f"<{tag}>{string}</{tag}>" if tag else string
|
|
|
|
def print_row(row, tag=None):
|
|
for idx, cell in enumerate(row):
|
|
width = widths[idx]
|
|
print_out(style(cell.ljust(width), tag), end="")
|
|
print_out(" ", end="")
|
|
print_out()
|
|
|
|
underlines = ["-" * width for width in widths]
|
|
|
|
print_row(headers, "bold")
|
|
print_row(underlines, "dim")
|
|
|
|
for row in data:
|
|
print_row(row)
|
|
|
|
|
|
def print_list_accounts(accounts):
|
|
if accounts:
|
|
print_out("Accounts in list</green>:\n")
|
|
print_acct_list(accounts)
|
|
else:
|
|
print_out("This list has no accounts.")
|
|
|
|
|
|
def print_search_results(results):
|
|
accounts = results['accounts']
|
|
hashtags = results['hashtags']
|
|
|
|
if accounts:
|
|
print_out("\nAccounts:")
|
|
print_acct_list(accounts)
|
|
|
|
if hashtags:
|
|
print_out("\nHashtags:")
|
|
print_out(", ".join([f"<green>#{t['name']}</green>" for t in hashtags]))
|
|
|
|
if not accounts and not hashtags:
|
|
print_out("<yellow>Nothing found</yellow>")
|
|
|
|
|
|
def print_status(status, width):
|
|
reblog = status['reblog']
|
|
content = reblog['content'] if reblog else status['content']
|
|
media_attachments = reblog['media_attachments'] if reblog else status['media_attachments']
|
|
in_reply_to = status['in_reply_to_id']
|
|
poll = reblog.get('poll') if reblog else status.get('poll')
|
|
|
|
time = parse_datetime(status['created_at'])
|
|
time = time.strftime('%Y-%m-%d %H:%M %Z')
|
|
|
|
username = "@" + status['account']['acct']
|
|
spacing = width - wcswidth(username) - wcswidth(time) - 2
|
|
|
|
display_name = status['account']['display_name']
|
|
if display_name:
|
|
spacing -= wcswidth(display_name) + 1
|
|
|
|
print_out(
|
|
f"<green>{display_name}</green>" if display_name else "",
|
|
f"<blue>{username}</blue>",
|
|
" " * spacing,
|
|
f"<yellow>{time}</yellow>",
|
|
)
|
|
|
|
print_out("")
|
|
print_html(content, width)
|
|
|
|
if media_attachments:
|
|
print_out("\nMedia:")
|
|
for attachment in media_attachments:
|
|
url = attachment["url"]
|
|
for line in wc_wrap(url, width):
|
|
print_out(line)
|
|
|
|
if poll:
|
|
print_poll(poll)
|
|
|
|
print_out()
|
|
print_out(
|
|
f"ID <yellow>{status['id']}</yellow> ",
|
|
f"↲ In reply to <yellow>{in_reply_to}</yellow> " if in_reply_to else "",
|
|
f"↻ Reblogged <blue>@{reblog['account']['acct']}</blue> " if reblog else "",
|
|
)
|
|
|
|
|
|
def print_html(text, width=80):
|
|
first = True
|
|
for paragraph in parse_html(text):
|
|
if not first:
|
|
print_out("")
|
|
for line in paragraph:
|
|
for subline in wc_wrap(line, width):
|
|
print_out(highlight_hashtags(subline))
|
|
first = False
|
|
|
|
|
|
def print_poll(poll):
|
|
print_out()
|
|
for idx, option in enumerate(poll["options"]):
|
|
perc = (round(100 * option["votes_count"] / poll["votes_count"])
|
|
if poll["votes_count"] else 0)
|
|
|
|
if poll["voted"] and poll["own_votes"] and idx in poll["own_votes"]:
|
|
voted_for = " <yellow>✓</yellow>"
|
|
else:
|
|
voted_for = ""
|
|
|
|
print_out(f'{option["title"]} - {perc}% {voted_for}')
|
|
|
|
poll_footer = f'Poll · {poll["votes_count"]} votes'
|
|
|
|
if poll["expired"]:
|
|
poll_footer += " · Closed"
|
|
|
|
if poll["expires_at"]:
|
|
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
|
|
poll_footer += f" · Closes on {expires_at}"
|
|
|
|
print_out()
|
|
print_out(poll_footer)
|
|
|
|
|
|
def print_timeline(items, width=100):
|
|
print_out("─" * width)
|
|
for item in items:
|
|
print_status(item, width)
|
|
print_out("─" * width)
|
|
|
|
|
|
notification_msgs = {
|
|
"follow": "{account} now follows you",
|
|
"mention": "{account} mentioned you in",
|
|
"reblog": "{account} reblogged your status",
|
|
"favourite": "{account} favourited your status",
|
|
}
|
|
|
|
|
|
def print_notification(notification, width=100):
|
|
account = "{display_name} @{acct}".format(**notification["account"])
|
|
msg = notification_msgs.get(notification["type"])
|
|
if msg is None:
|
|
return
|
|
|
|
print_out("─" * width)
|
|
print_out(msg.format(account=account))
|
|
status = notification.get("status")
|
|
if status is not None:
|
|
print_status(status, width)
|
|
|
|
|
|
def print_notifications(notifications, width=100):
|
|
for notification in notifications:
|
|
print_notification(notification)
|
|
print_out("─" * width)
|