1
0
mirror of https://github.com/ihabunek/toot.git synced 2024-06-23 06:25:26 +00:00
toot/toot/output.py
Sandra Snan 7244b2718f
Print visibility in CLI
I went with two spaces before and after but feel free to change that
to whatever! Having the visibility printed this way is pretty useful
for us who mostly read posts through the CLI.
2024-04-08 08:31:19 +02:00

341 lines
9.3 KiB
Python

import click
import re
import shutil
import textwrap
import typing as t
from toot.entities import Account, Instance, Notification, Poll, Status, List
from toot.utils import get_text, html_to_paragraphs
from toot.wcstring import wc_wrap
from wcwidth import wcswidth
DEFAULT_WIDTH = 80
def get_max_width() -> int:
return click.get_current_context().max_content_width or DEFAULT_WIDTH
def get_terminal_width() -> int:
return shutil.get_terminal_size().columns
def get_width() -> int:
return min(get_terminal_width(), get_max_width())
def print_warning(text: str):
click.secho(f"Warning: {text}", fg="yellow", err=True)
def print_instance(instance: Instance):
width = get_width()
click.echo(instance_to_text(instance, width))
def instance_to_text(instance: Instance, width: int) -> str:
return "\n".join(instance_lines(instance, width))
def instance_lines(instance: Instance, width: int) -> t.Generator[str, None, None]:
yield f"{green(instance.title)}"
yield f"{blue(instance.uri)}"
yield f"running Mastodon {instance.version}"
yield ""
if instance.description:
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
paragraph = get_text(paragraph)
yield textwrap.fill(paragraph, width=width)
yield ""
if instance.rules:
yield "Rules:"
for ordinal, rule in enumerate(instance.rules):
ordinal = f"{ordinal + 1}."
lines = textwrap.wrap(rule.text, width - len(ordinal))
first = True
for line in lines:
if first:
yield f"{ordinal} {line}"
first = False
else:
yield f"{' ' * len(ordinal)} {line}"
yield ""
contact = instance.contact_account
if contact:
yield f"Contact: {contact.display_name} @{contact.acct}"
def print_account(account: Account) -> None:
width = get_width()
click.echo(account_to_text(account, width))
def account_to_text(account: Account, width: int) -> str:
return "\n".join(account_lines(account, width))
def account_lines(account: Account, width: int) -> t.Generator[str, None, None]:
acct = f"@{account.acct}"
since = account.created_at.strftime("%Y-%m-%d")
yield f"{green(acct)} {account.display_name}"
if account.note:
yield ""
yield from html_lines(account.note, width)
yield ""
yield f"ID: {green(account.id)}"
yield f"Since: {green(since)}"
yield ""
yield f"Followers: {yellow(account.followers_count)}"
yield f"Following: {yellow(account.following_count)}"
yield f"Statuses: {yellow(account.statuses_count)}"
if account.fields:
for field in account.fields:
name = field.name.title()
yield f'\n{yellow(name)}:'
yield from html_lines(field.value, width)
if field.verified_at:
yield green("✓ Verified")
yield ""
yield account.url
def print_acct_list(accounts):
for account in accounts:
acct = green(f"@{account['acct']}")
click.echo(f"* {acct} {account['display_name']}")
def print_tag_list(tags):
for tag in tags:
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
def print_lists(lists: t.List[List]):
headers = ["ID", "Title", "Replies"]
data = [[lst.id, lst.title, lst.replies_policy or ""] for lst in lists]
print_table(headers, data)
def print_table(headers: t.List[str], data: t.List[t.List[str]]):
widths = [[len(cell) for cell in row] for row in data + [headers]]
widths = [max(width) for width in zip(*widths)]
def print_row(row):
for idx, cell in enumerate(row):
width = widths[idx]
click.echo(cell.ljust(width), nl=False)
click.echo(" ", nl=False)
click.echo()
underlines = ["-" * width for width in widths]
print_row(headers)
print_row(underlines)
for row in data:
print_row(row)
def print_list_accounts(accounts):
if accounts:
click.echo("Accounts in list:\n")
print_acct_list(accounts)
else:
click.echo("This list has no accounts.")
def print_search_results(results):
accounts = results["accounts"]
hashtags = results["hashtags"]
if accounts:
click.echo("\nAccounts:")
print_acct_list(accounts)
if hashtags:
click.echo("\nHashtags:")
click.echo(", ".join([format_tag_name(tag) for tag in hashtags]))
if not accounts and not hashtags:
click.echo("Nothing found")
def print_status(status: Status) -> None:
width = get_width()
click.echo(status_to_text(status, width))
def status_to_text(status: Status, width: int) -> str:
return "\n".join(status_lines(status))
def status_lines(status: Status) -> t.Generator[str, None, None]:
width = get_width()
status_id = status.id
in_reply_to_id = status.in_reply_to_id
reblogged_by = status.account if status.reblog else None
status = status.original
time = status.created_at.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:
author = f"{green(display_name)} {blue(username)}"
spacing -= wcswidth(display_name) + 1
else:
author = blue(username)
spaces = " " * spacing
yield f"{author} {spaces} {yellow(time)}"
yield ""
yield from html_lines(status.content, width)
if status.media_attachments:
yield ""
yield "Media:"
for attachment in status.media_attachments:
url = attachment.url
for line in wc_wrap(url, width):
yield line
if status.poll:
yield from poll_lines(status.poll)
reblogged_by_acct = f"@{reblogged_by.acct}" if reblogged_by else None
yield ""
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
boost = f"{blue(reblogged_by_acct)} boosted " if reblogged_by else ""
yield f"ID {yellow(status_id)} visibility: {status.visibility} {reply} {boost}"
def html_lines(html: str, width: int) -> t.Generator[str, None, None]:
first = True
for paragraph in html_to_paragraphs(html):
if not first:
yield ""
for line in paragraph:
for subline in wc_wrap(line, width):
yield subline
first = False
def poll_lines(poll: Poll) -> t.Generator[str, None, None]:
for idx, option in enumerate(poll.options):
perc = (round(100 * option.votes_count / poll.votes_count)
if poll.votes_count and option.votes_count is not None else 0)
if poll.voted and poll.own_votes and idx in poll.own_votes:
voted_for = yellow("")
else:
voted_for = ""
yield 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 = poll.expires_at.strftime("%Y-%m-%d %H:%M")
poll_footer += f" · Closes on {expires_at}"
yield ""
yield poll_footer
def print_timeline(items: t.Iterable[Status]):
print_divider()
for item in items:
print_status(item)
print_divider()
def print_notification(notification: Notification):
print_notification_header(notification)
if notification.status:
print_divider(char="-")
print_status(notification.status)
def print_notifications(notifications: t.List[Notification]):
for notification in notifications:
if notification.type not in ['pleroma:emoji_reaction']:
print_divider()
print_notification(notification)
print_divider()
def print_notification_header(notification: Notification):
account_name = format_account_name(notification.account)
if (notification.type == "follow"):
click.echo(f"{account_name} now follows you")
elif (notification.type == "mention"):
click.echo(f"{account_name} mentioned you")
elif (notification.type == "reblog"):
click.echo(f"{account_name} reblogged your status")
elif (notification.type == "favourite"):
click.echo(f"{account_name} favourited your status")
elif (notification.type == "update"):
click.echo(f"{account_name} edited a post")
else:
click.secho(f"Unknown notification type: '{notification.type}'", err=True, fg="yellow")
click.secho("Please report an issue to toot.", err=True, fg="yellow")
def print_divider(char: str = ""):
click.echo(char * get_width())
def format_tag_name(tag):
return green(f"#{tag['name']}")
def format_account_name(account: Account) -> str:
acct = blue(f"@{account.acct}")
if account.display_name:
return f"{green(account.display_name)} {acct}"
else:
return acct
# Shorthand functions for coloring output
def blue(text: t.Any) -> str:
return click.style(text, fg="blue")
def bold(text: t.Any) -> str:
return click.style(text, bold=True)
def cyan(text: t.Any) -> str:
return click.style(text, fg="cyan")
def dim(text: t.Any) -> str:
return click.style(text, dim=True)
def green(text: t.Any) -> str:
return click.style(text, fg="green")
def yellow(text: t.Any) -> str:
return click.style(text, fg="yellow")