1
0
mirror of https://github.com/ihabunek/toot synced 2025-02-10 17:10:51 +01:00

Overhaul async actions, implement boost and reblog

This commit is contained in:
Ivan Habunek 2019-08-26 13:28:37 +02:00
parent 2349173a45
commit 372976b1b2
No known key found for this signature in database
GPG Key ID: CDBD63C43A30BB95
5 changed files with 151 additions and 41 deletions

View File

@ -7,5 +7,10 @@ https://github.com/TomasTomecek/sen/blob/master/sen/tui/ui.py
check out:
https://github.com/rndusr/stig/tree/master/stig/tui
TODO/Ideas:
* pack left column in timeline view
* when an error happens, show it in the status bar and have "press E to view exception" to show it in an overlay.
* maybe even have error reporting? e.g. button to open an issue on github?
Questions:
* is it possible to make a span a urwid.Text selectable? e.g. for urls and hashtags

View File

@ -55,6 +55,9 @@ class Footer(urwid.Pile):
def set_message(self, text):
self.message.set_text(text)
def set_error_message(self, text):
self.message.set_text(("footer_message_error", text))
def clear_message(self):
self.message.set_text("")
@ -97,51 +100,73 @@ class TUI(urwid.Frame):
super().__init__(self.body, header=self.header, footer=self.footer)
def run(self):
self.loop.set_alarm_in(0, self.schedule_load_statuses)
self.loop.set_alarm_in(0, lambda *args: self.async_load_statuses(is_initial=True))
self.loop.run()
self.executor.shutdown(wait=False)
def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None):
future = self.executor.submit(fn)
if done_callback:
future.add_done_callback(done_callback)
def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None, error_callback=None):
"""Runs `fn(*args, **kwargs)` asynchronously in a separate thread.
def schedule_load_statuses(self, *args):
self.run_in_thread(self.load_statuses, done_callback=self.statuses_loaded_initial)
On completion calls `done_callback` if `fn` exited cleanly, or
`error_callback` if an exception was caught. Callback methods are
invoked in the main thread, not the thread in which `fn` is executed.
"""
def load_statuses(self):
self.footer.set_message("Loading statuses...")
try:
data = next(self.timeline_generator)
except StopIteration:
return []
finally:
self.footer.clear_message()
def _default_error_callback(ex):
self.exception = ex
self.footer.set_error_message("An exeption occured, press E to view")
# # FIXME: REMOVE DEBUGGING
# with open("tmp/statuses2.json", "w") as f:
# import json
# json.dump(data, f, indent=4)
_error_callback = error_callback or _default_error_callback
return [Status(s, self.app.instance) for s in data]
def _done(future):
try:
result = future.result()
if done_callback:
# Use alarm to invoke callback in main thread
self.loop.set_alarm_in(0, lambda *args: done_callback(result))
except Exception as ex:
exception = ex
logger.exception(exception)
self.loop.set_alarm_in(0, lambda *args: _error_callback(exception))
def schedule_load_next(self):
self.run_in_thread(self.load_statuses, done_callback=self.statuses_loaded_next)
future = self.executor.submit(fn, *args, **kwargs)
future.add_done_callback(_done)
def statuses_loaded_initial(self, future):
# TODO: handle errors in future
self.timeline = Timeline(self, future.result())
urwid.connect_signal(self.timeline, "status_focused",
def build_timeline(self, statuses):
timeline = Timeline(self, statuses)
urwid.connect_signal(timeline, "status_focused",
lambda _, args: self.status_focused(*args))
urwid.connect_signal(self.timeline, "next",
lambda *args: self.schedule_load_next())
self.timeline.status_focused() # Draw first status
self.body = self.timeline
urwid.connect_signal(timeline, "next",
lambda *args: self.async_load_statuses(is_initial=False))
return timeline
def statuses_loaded_next(self, future):
# TODO: handle errors in future
self.timeline.add_statuses(future.result())
def async_load_statuses(self, is_initial):
"""Asynchronously load a list of statuses."""
def _load_statuses():
self.footer.set_message("Loading statuses...")
try:
data = next(self.timeline_generator)
except StopIteration:
return []
finally:
self.footer.clear_message()
return [Status(s, self.app.instance) for s in data]
def _done_initial(statuses):
"""Process initial batch of statuses, construct a Timeline."""
self.timeline = self.build_timeline(statuses)
self.timeline.status_focused() # Draw first status
self.body = self.timeline
def _done_next(statuses):
"""Process sequential batch of statuses, adds statuses to the
existing timeline."""
self.timeline.add_statuses(statuses)
self.run_in_thread(_load_statuses,
done_callback=_done_initial if is_initial else _done_next)
def status_focused(self, status, index, count):
self.footer.set_status([
@ -161,6 +186,46 @@ class TUI(urwid.Frame):
},
)
def async_toggle_favourite(self, status):
def _favourite():
logger.info("Favouriting {}".format(status))
api.favourite(self.app, self.user, status.id)
def _unfavourite():
logger.info("Unfavouriting {}".format(status))
api.unfavourite(self.app, self.user, status.id)
def _done(loop):
# Create a new Status with flipped favourited flag
new_data = status.data
new_data["favourited"] = not status.favourited
self.timeline.update_status(Status(new_data, status.instance))
self.run_in_thread(
_unfavourite if status.favourited else _favourite,
done_callback=_done
)
def async_toggle_reblog(self, status):
def _reblog():
logger.info("Reblogging {}".format(status))
api.reblog(self.app, self.user, status.id)
def _unreblog():
logger.info("Unreblogging {}".format(status))
api.unreblog(self.app, self.user, status.id)
def _done(loop):
# Create a new Status with flipped reblogged flag
new_data = status.data
new_data["reblogged"] = not status.reblogged
self.timeline.update_status(Status(new_data, status.instance))
self.run_in_thread(
_unreblog if status.reblogged else _reblog,
done_callback=_done
)
# --- Overlay handling -----------------------------------------------------
def open_overlay(self, widget, options={}, title=""):

View File

@ -2,6 +2,7 @@
PALETTE = [
# Components
('footer_message', 'dark green', ''),
('footer_message_error', 'white', 'dark red'),
('footer_status', 'white', 'dark blue'),
('footer_status_bold', 'white, bold', 'dark blue'),
('header', 'white', 'dark blue'),

View File

@ -28,15 +28,19 @@ class Status:
self.data = data
self.instance = instance
# TODO: make Status immutable?
self.id = self.data["id"]
self.display_name = self.data["account"]["display_name"]
self.account = self.get_account()
self.created_at = parse_datetime(data["created_at"])
self.author = get_author(data, instance)
self.favourited = data.get("favourited", False)
self.reblogged = data.get("reblogged", False)
def get_account(self):
acct = self.data['account']['acct']
return acct if "@" in acct else "{}@{}".format(acct, self.instance)
def __repr__(self):
return "<Status id={}>".format(self.id)

View File

@ -2,6 +2,7 @@ import logging
import urwid
import webbrowser
from toot import api
from toot.utils import format_content
from .utils import highlight_hashtags
@ -30,9 +31,14 @@ class Timeline(urwid.Columns):
self.status_list = self.build_status_list(statuses)
self.status_details = StatusDetails(statuses[0])
# Maps status ID to its index in the list
self.status_index_map = {
status.id: n for n, status in enumerate(statuses)
}
super().__init__([
("weight", 50, self.status_list),
("weight", 50, self.status_details),
("weight", 40, self.status_list),
("weight", 60, self.status_details),
], dividechars=1)
def build_status_list(self, statuses):
@ -79,6 +85,16 @@ class Timeline(urwid.Columns):
if index >= count:
self._emit("next")
if key in ("b", "B"):
status = self.get_focused_status()
self.tui.async_toggle_reblog(status)
return
if key in ("f", "F"):
status = self.get_focused_status()
self.tui.async_toggle_favourite(status)
return
if key in ("v", "V"):
status = self.get_focused_status()
webbrowser.open(status.data["url"])
@ -91,11 +107,30 @@ class Timeline(urwid.Columns):
return super().keypress(size, key)
def add_statuses(self, statuses):
self.statuses += statuses
new_items = [self.build_list_item(status) for status in statuses]
self.status_list.body.extend(new_items)
def add_status(self, status):
self.statuses.append(status)
self.status_index_map[status.id] = len(self.statuses) - 1
self.status_list.body.append(self.build_list_item(status))
def add_statuses(self, statuses):
for status in statuses:
self.add_status(status)
def update_status(self, status):
"""Overwrite status in list with the new instance and redraw."""
index = self.status_index_map[status.id]
assert self.statuses[index].id == status.id
# 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.status_details = StatusDetails(status)
self.contents[1] = self.status_details, ("weight", 50, False)
class StatusDetails(urwid.Pile):
def __init__(self, status):