From 372976b1b2d0994e90ed6d59626a5a0f92c8159a Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 26 Aug 2019 13:28:37 +0200 Subject: [PATCH] Overhaul async actions, implement boost and reblog --- toot/tui/NOTES.md | 5 ++ toot/tui/app.py | 133 +++++++++++++++++++++++++++++++----------- toot/tui/constants.py | 1 + toot/tui/entities.py | 6 +- toot/tui/timeline.py | 47 +++++++++++++-- 5 files changed, 151 insertions(+), 41 deletions(-) diff --git a/toot/tui/NOTES.md b/toot/tui/NOTES.md index eba2eaa..98b654c 100644 --- a/toot/tui/NOTES.md +++ b/toot/tui/NOTES.md @@ -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 diff --git a/toot/tui/app.py b/toot/tui/app.py index 9b3b9d7..8b61314 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -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=""): diff --git a/toot/tui/constants.py b/toot/tui/constants.py index b915e83..c6c0b89 100644 --- a/toot/tui/constants.py +++ b/toot/tui/constants.py @@ -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'), diff --git a/toot/tui/entities.py b/toot/tui/entities.py index 6b4c0ca..d916eed 100644 --- a/toot/tui/entities.py +++ b/toot/tui/entities.py @@ -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 "".format(self.id) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index e610af0..2fdc920 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -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):