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"""
(? # 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.:
alert! a thing happened
Empty closing tag will reset all styes:
alert!> a thing happened
Styles can be nested:
red red and underline 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. "foobarbaz"
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"{a}" 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"{instance['title']}")
print_out(f"{instance['uri']}")
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"@{account['acct']} {account['display_name']}")
if account["note"]:
print_out("")
print_html(account["note"])
print_out("")
print_out(f"ID: {account['id']}")
print_out(f"Since: {account['created_at'][:10]}")
print_out("")
print_out(f"Followers: {account['followers_count']}")
print_out(f"Following: {account['following_count']}")
print_out(f"Statuses: {account['statuses_count']}")
if account["fields"]:
for field in account["fields"]:
name = field["name"].title()
print_out(f'\n{name}:')
print_html(field["value"])
if field["verified_at"]:
print_out("✓ Verified")
print_out("")
print_out(account["url"])
HASHTAG_PATTERN = re.compile(r'(?\\1', line)
def print_acct_list(accounts):
for account in accounts:
print_out(f"* @{account['acct']} {account['display_name']}")
def print_tag_list(tags):
if tags:
for tag in tags:
print_out(f"* #{tag['name']}\t{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:\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"#{t['name']}" for t in hashtags]))
if not accounts and not hashtags:
print_out("Nothing found")
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"{display_name}" if display_name else "",
f"{username}",
" " * spacing,
f"{time}",
)
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 {status['id']} ",
f"↲ In reply to {in_reply_to} " if in_reply_to else "",
f"↻ Reblogged @{reblog['account']['acct']} " 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 = " ✓"
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)