toot/toot/tui/timeline.py

474 wiersze
16 KiB
Python

import logging
import sys
import urwid
import webbrowser
from typing import List, Optional
from .entities import Account, Poll, PreviewCard, Status
from .scroll import Scrollable, ScrollBar
from .utils import highlight_hashtags, parse_datetime, highlight_keys
from .widgets import SelectableText, SelectableColumns
from toot.utils import format_content
from toot.utils.language import language_name
from toot.tui.utils import time_ago
logger = logging.getLogger("toot")
class Timeline(urwid.Columns):
"""
Displays a list of statuses to the left, and status details on the right.
"""
signals = [
"account", # Display account info and actions
"close", # Close thread
"compose", # Compose a new toot
"delete", # Delete own status
"favourite", # Favourite status
"focus", # Focus changed
"bookmark", # Bookmark status
"media", # Display media attachments
"menu", # Show a context menu
"next", # Fetch more statuses
"reblog", # Reblog status
"reply", # Compose a reply to a status
"source", # Show status source
"links", # Show status links
"thread", # Show thread for status
"translate", # Translate status
"save", # Save current timeline
"zoom", # Open status in scrollable popup window
"clear-screen", # Clear the screen (used internally)
]
def __init__(self, name, statuses, can_translate, followed_tags=[], focus=0, is_thread=False):
self.name = name
self.is_thread = is_thread
self.statuses = statuses
self.can_translate = can_translate
self.status_list = self.build_status_list(statuses, focus=focus)
self.followed_tags = followed_tags
try:
focused_status = statuses[focus]
except IndexError:
focused_status = None
self.status_details = StatusDetails(self, focused_status)
status_widget = self.wrap_status_details(self.status_details)
super().__init__([
("weight", 40, self.status_list),
("weight", 0, urwid.AttrWrap(urwid.SolidFill(""), "blue_selected")),
("weight", 60, status_widget),
])
def wrap_status_details(self, status_details: "StatusDetails") -> urwid.Widget:
"""Wrap StatusDetails widget with a scollbar and footer."""
return urwid.Padding(
urwid.Frame(
body=ScrollBar(
Scrollable(urwid.Padding(status_details, right=1)),
thumb_char="\u2588",
trough_char="\u2591",
),
footer=self.get_option_text(status_details.status),
),
left=1
)
def build_status_list(self, statuses, focus):
items = [self.build_list_item(status) for status in statuses]
walker = urwid.SimpleFocusListWalker(items)
walker.set_focus(focus)
urwid.connect_signal(walker, "modified", self.modified)
return urwid.ListBox(walker)
def build_list_item(self, status):
item = StatusListItem(status)
urwid.connect_signal(item, "click", lambda *args:
self._emit("menu", status))
return urwid.AttrMap(item, None, focus_map={
"blue": "green_selected",
"green": "green_selected",
"yellow": "green_selected",
"cyan": "green_selected",
"red": "green_selected",
None: "green_selected",
})
def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
if not status:
return None
assert status._meta
is_mine = status._meta.is_mine
options = [
"[A]ccount" if not is_mine else "",
"[B]oost",
"[D]elete" if is_mine else "",
"B[o]okmark",
"[F]avourite",
"[V]iew",
"[T]hread" if not self.is_thread else "",
"[L]inks",
"[R]eply",
"So[u]rce",
"[Z]oom",
"Tra[n]slate" if self.can_translate else "",
"[H]elp",
]
options = "\n" + " ".join(o for o in options if o)
options = highlight_keys(options, "white_bold", "cyan")
return urwid.Text(options)
def get_focused_status(self) -> Optional[Status]:
try:
return self.statuses[self.status_list.body.focus]
except TypeError:
return None
def get_focused_status_with_counts(self):
"""Returns a tuple of:
* focused status
* focused status' index in the status list
* length of the status list
"""
return (
self.get_focused_status(),
self.status_list.body.focus,
len(self.statuses),
)
def modified(self):
"""Called when the list focus switches to a new status"""
status, index, count = self.get_focused_status_with_counts()
self.draw_status_details(status)
self._emit("focus")
def refresh_status_details(self):
"""Redraws the details of the focused status."""
status = self.get_focused_status()
self.draw_status_details(status)
def draw_status_details(self, status):
self.status_details = StatusDetails(self, status)
widget = self.wrap_status_details(self.status_details)
self.contents[2] = widget, ("weight", 60, False)
def keypress(self, size, key):
status = self.get_focused_status()
command = self._command_map[key]
if not status:
return super().keypress(size, key)
# If down is pressed on last status in list emit a signal to load more.
# TODO: Consider pre-loading statuses earlier
if command in [urwid.CURSOR_DOWN, urwid.CURSOR_PAGE_DOWN] \
and self.status_list.body.focus:
index = self.status_list.body.focus + 1
count = len(self.statuses)
if index >= count:
self._emit("next")
if key in ("a", "A"):
self._emit("account", status.original.account.id)
return
if key in ("b", "B"):
self._emit("reblog", status)
return
if key in ("c", "C"):
self._emit("compose")
return
if key in ("d", "D"):
self._emit("delete", status)
return
if key in ("f", "F"):
self._emit("favourite", status)
return
if key in ("m", "M"):
self._emit("media", status)
return
if key in ("q", "Q"):
self._emit("close")
return
if key == "esc" and self.is_thread:
self._emit("close")
return
if key in ("r", "R"):
self._emit("reply", status)
return
if key in ("s", "S"):
status._meta.show_sensitive = True
self.refresh_status_details()
return
if key in ("o", "O"):
self._emit("bookmark", status)
return
if key in ("l", "L"):
self._emit("links", status)
return
if key in ("n", "N"):
if self.can_translate:
self._emit("translate", status)
return
if key in ("t", "T"):
self._emit("thread", status)
return
if key in ("u", "U"):
self._emit("source", status)
return
if key in ("v", "V"):
if status.original.url:
webbrowser.open(status.original.url)
# force a screen refresh; necessary with console browsers
self._emit("clear-screen")
return
if key in ("p", "P"):
self._emit("save", status)
return
if key in ("z", "Z"):
self._emit("zoom", self.status_details)
return
return super().keypress(size, key)
def append_status(self, status: Status):
self.statuses.append(status)
self.status_list.body.append(self.build_list_item(status))
def prepend_status(self, status: Status):
self.statuses.insert(0, status)
self.status_list.body.insert(0, self.build_list_item(status))
def append_statuses(self, statuses: List[Status]):
for status in statuses:
self.append_status(status)
def get_status_index(self, id: str) -> int:
# TODO: This is suboptimal, consider a better way
for n, status in enumerate(self.statuses):
if status.id == id:
return n
raise ValueError("Status with ID {} not found".format(id))
def focus_status(self, status: Status):
index = self.get_status_index(status.id)
self.status_list.body.set_focus(index)
def update_status(self, status: Status):
"""Overwrite status in list with the new instance and redraw."""
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
# Update internal status list
self.statuses[index] = status
# Redraw list item
self.status_list.body[index] = self.build_list_item(status)
# Redraw status details if status is focused
if index == self.status_list.body.focus:
self.draw_status_details(status)
# TODO: dedupe code with update_status
def redraw_status(self, status: Status):
index = self.get_status_index(status.id)
# Redraw list item
self.status_list.body[index] = self.build_list_item(status)
# Redraw status details if status is focused
if index == self.status_list.body.focus:
self.draw_status_details(status)
def remove_status(self, status):
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
del self.statuses[index]
del self.status_list.body[index]
self.refresh_status_details()
class StatusDetails(urwid.Pile):
def __init__(self, timeline: Timeline, status: Optional[Status]):
self.status = status
self.followed_tags = timeline.followed_tags
reblogged_by = status.account if status and status.reblog else None
widget_list = list(self.content_generator(status, reblogged_by)
if status else ())
return super().__init__(widget_list)
def content_generator(self, status: Status, reblogged_by: Optional[Account]):
assert status._meta
meta = status._meta
status = status.original
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.account.display_name:
yield ("pack", urwid.Text(("green", status.account.display_name)))
yield ("pack", urwid.Text(("yellow", status.account.acct)))
yield ("pack", urwid.Divider())
if status.spoiler_text:
yield ("pack", urwid.Text(status.spoiler_text))
yield ("pack", urwid.Divider())
# Show content warning
if status.spoiler_text and not meta.show_sensitive:
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
else:
content = meta.translation if meta.show_translation else status.content
for line in format_content(content):
yield ("pack", urwid.Text(highlight_hashtags(line, self.followed_tags)))
for m in status.media_attachments:
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
yield ("pack", urwid.Text([("bold", "Media attachment"), f" ({m.type})"]))
if m.description:
yield ("pack", urwid.Text(m.description))
yield ("pack", urwid.Text(("link", m.url)))
if status.poll:
yield ("pack", urwid.Divider())
yield ("pack", self.build_linebox(self.poll_generator(status.poll)))
if status.card:
yield ("pack", urwid.Divider())
yield ("pack", self.build_linebox(self.card_generator(status.card)))
application_name = status.application.name if status.application else None
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray"))
translated_from = (
language_name(meta.translated_from)
if meta.show_translation and meta.translated_from
else None
)
visibility_colors = {
"public": "gray",
"unlisted": "white",
"private": "cyan",
"direct": "yellow"
}
visibility = status.visibility.title()
visibility_color = visibility_colors.get(status.visibility, "gray")
yield ("pack", urwid.Text([
("blue", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
("red" if status.bookmarked else "gray", "🠷 "),
("gray", f"{status.replies_count} "),
("yellow" if status.reblogged else "gray", f"{status.reblogs_count} "),
("yellow" if status.favourited else "gray", f"{status.favourites_count}"),
(visibility_color, f" · {visibility}"),
("yellow", f" · Translated from {translated_from} " if translated_from else ""),
("gray", f" · {application_name}" if application_name else ""),
]))
# Push things to bottom
yield ("weight", 1, urwid.BoxAdapter(urwid.SolidFill(" "), 1))
def build_linebox(self, contents):
contents = urwid.Pile(list(contents))
contents = urwid.Padding(contents, left=1, right=1)
return urwid.LineBox(contents)
def card_generator(self, card: PreviewCard):
yield urwid.Text(("green", card.title.strip()))
if card.author_name:
yield urwid.Text(["by ", ("yellow", card.author_name.strip())])
yield urwid.Text("")
if card.description:
yield urwid.Text(card.description.strip())
yield urwid.Text("")
yield urwid.Text(("link", card.url))
def poll_generator(self, poll: Poll):
for idx, option in enumerate(poll.options):
option_votes_count = option.votes_count or 0
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 = ""
yield urwid.Text(option.title + voted_for)
yield urwid.ProgressBar("", "poll_bar", perc)
status = "Poll · {} votes".format(poll.votes_count)
if poll.expired:
status += " · Closed"
if poll.expires_at:
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
status += " · Closes on {}".format(expires_at)
yield urwid.Text(("gray", status))
class StatusListItem(SelectableColumns):
def __init__(self, status: Status):
# TODO: hacky implementation to avoid creating conflicts for existing
# pull reuqests, refactor when merged.
created_at = (
time_ago(status.created_at).ljust(3, " ")
if "--relative-datetimes" in sys.argv
else status.created_at.strftime("%Y-%m-%d %H:%M")
)
edited_flag = "*" if status.edited_at else " "
favourited = ("yellow", "") if status.original.favourited else " "
reblogged = ("yellow", "") if status.original.reblogged else " "
is_reblog = ("cyan", "") if status.reblog else " "
is_reply = ("cyan", "") if status.original.in_reply_to_id else " "
# TODO: Add server name for home accounts?
account = status.original.account.acct
return super().__init__([
("pack", SelectableText(("blue", created_at), wrap="clip")),
("pack", urwid.Text(("blue", edited_flag))),
("pack", urwid.Text(" ")),
("pack", urwid.Text(favourited)),
("pack", urwid.Text(" ")),
("pack", urwid.Text(reblogged)),
("pack", urwid.Text(" ")),
urwid.Text(("green", account), wrap="clip"),
("pack", urwid.Text(is_reply)),
("pack", urwid.Text(is_reblog)),
("pack", urwid.Text(" ")),
])