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:
parent
2349173a45
commit
372976b1b2
@ -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
|
||||
|
133
toot/tui/app.py
133
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=""):
|
||||
|
@ -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'),
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
Loading…
x
Reference in New Issue
Block a user