diff --git a/setup.py b/setup.py index d9e09b6..3c03ece 100644 --- a/setup.py +++ b/setup.py @@ -34,12 +34,13 @@ setup( 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', ], - packages=['toot', 'toot.ui'], + packages=['toot', 'toot.ui', 'toot.tui'], python_requires=">=3.4", install_requires=[ "requests>=2.13,<3.0", "beautifulsoup4>=4.5.0,<5.0", "wcwidth>=0.1.7,<2.0", + "urwid>=2.0.0,<3.0", ], entry_points={ 'console_scripts': [ diff --git a/toot/commands.py b/toot/commands.py index 202a3d5..b680e1b 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -324,3 +324,8 @@ def notifications(app, user, args): return print_notifications(notifications) + + +def tui(app, user, args): + from .tui.app import TUI + TUI.create(app, user).run() diff --git a/toot/console.py b/toot/console.py index e46f148..f0721e4 100644 --- a/toot/console.py +++ b/toot/console.py @@ -275,6 +275,12 @@ READ_COMMANDS = [ arguments=curses_args, require_auth=False, ), + Command( + name="tui", + description="Launches the TUI (terminal user interface).", + arguments=curses_args, + require_auth=False, + ), ] POST_COMMANDS = [ diff --git a/toot/tui/NOTES.md b/toot/tui/NOTES.md new file mode 100644 index 0000000..2155bfe --- /dev/null +++ b/toot/tui/NOTES.md @@ -0,0 +1,5 @@ +maybe ??? +https://github.com/CanonicalLtd/subiquity/blob/master/subiquitycore/core.py#L280 + +educational: +https://github.com/TomasTomecek/sen/blob/master/sen/tui/ui.py diff --git a/toot/tui/__init__.py b/toot/tui/__init__.py new file mode 100644 index 0000000..4da1a49 --- /dev/null +++ b/toot/tui/__init__.py @@ -0,0 +1,10 @@ +from urwid.command_map import command_map +from urwid.command_map import CURSOR_UP, CURSOR_DOWN, CURSOR_LEFT, CURSOR_RIGHT + +# Add movement using h/j/k/l to default command map +command_map._command.update({ + 'k': CURSOR_UP, + 'j': CURSOR_DOWN, + 'h': CURSOR_LEFT, + 'l': CURSOR_RIGHT, +}) diff --git a/toot/tui/app.py b/toot/tui/app.py new file mode 100644 index 0000000..2a60cb0 --- /dev/null +++ b/toot/tui/app.py @@ -0,0 +1,114 @@ +import logging +import urwid + +from concurrent.futures import ThreadPoolExecutor + +from toot.api import home_timeline_generator + +from .constants import PALETTE +from .entities import Status +from .timeline import Timeline + +logger = logging.getLogger(__name__) + + +class Header(urwid.WidgetWrap): + def __init__(self, app, user): + self.app = app + self.user = user + + self.text = urwid.Text("") + self.cols = urwid.Columns([ + ("pack", urwid.Text(('header_bold', 'toot'))), + ("pack", urwid.Text(('header', f' | {user.username}@{app.instance}'))), + ("pack", self.text), + ]) + + widget = urwid.AttrMap(self.cols, 'header') + widget = urwid.Padding(widget) + self._wrapped_widget = widget + + def clear_text(self, text): + self.text.set_text("") + + def set_text(self, text): + self.text.set_text(" | " + text) + + +class Footer(urwid.Pile): + def __init__(self): + self.status = urwid.Text("") + self.message = urwid.Text("") + + return super().__init__([ + urwid.AttrMap(self.status, "footer_status"), + urwid.AttrMap(self.message, "footer_message"), + ]) + + def set_status(self, text): + self.status.set_text(text) + + def set_message(self, text): + self.message.set_text(text) + + def set_error(self, text): + # TODO: change to red + self.message.set_text(text) + + +class TUI(urwid.Frame): + """Main TUI frame.""" + + @classmethod + def create(cls, app, user): + """Factory method, sets up TUI and an event loop.""" + + tui = cls(app, user) + loop = urwid.MainLoop( + tui, + palette=PALETTE, + event_loop=urwid.AsyncioEventLoop(), + unhandled_input=tui.unhandled_input, + ) + tui.loop = loop + + return tui + + def __init__(self, app, user): + self.app = app + self.user = user + + self.loop = None # set in `create` + self.executor = ThreadPoolExecutor(max_workers=1) + self.timeline_generator = home_timeline_generator(app, user, limit=40) + + self.body = urwid.Filler(urwid.Text("Loading toots...", align="center")) + self.header = Header(app, user) + self.footer = Footer() + self.footer.set_status("Loading...") + + super().__init__(self.body, header=self.header, footer=self.footer) + + def run(self): + self.loop.set_alarm_in(0, self.schedule_loading_toots) + self.loop.run() + self.executor.shutdown(wait=False) + + def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None): + future = self.executor.submit(self.load_toots) + if done_callback: + future.add_done_callback(done_callback) + + def schedule_loading_toots(self, *args): + self.run_in_thread(self.load_toots, done_callback=self.toots_loaded) + + def load_toots(self): + data = next(self.timeline_generator) + return [Status(s, self.app.instance) for s in data] + + def toots_loaded(self, future): + self.body = Timeline(self, future.result()) + + def unhandled_input(self, key): + if key in ('q', 'Q'): + raise urwid.ExitMainLoop() diff --git a/toot/tui/constants.py b/toot/tui/constants.py new file mode 100644 index 0000000..b9a7603 --- /dev/null +++ b/toot/tui/constants.py @@ -0,0 +1,22 @@ +# name, fg, bg, mono, fg_h, bg_h +PALETTE = [ + # Header + ('header', 'white', 'dark blue'), + ('header_bold', 'white,bold', 'dark blue'), + + # Footer + ('footer_status', 'white', 'dark blue'), + ('footer_message', 'dark green', ''), + + # by color name + ('blue', 'light blue', ''), + ('blue_bold', 'light blue, bold', ''), + ('blue_selected', 'white,bold', 'dark blue'), + ('cyan', 'dark cyan', ''), + ('cyan_bold', 'dark cyan,bold', ''), + ('green', 'dark green', ''), + ('green_selected', 'white,bold', 'dark green'), + ('italic', 'white', ''), + ('yellow', 'yellow', ''), + ('yellow_selected', 'yellow', 'dark blue'), +] diff --git a/toot/tui/entities.py b/toot/tui/entities.py new file mode 100644 index 0000000..a7def81 --- /dev/null +++ b/toot/tui/entities.py @@ -0,0 +1,25 @@ +from datetime import datetime + + +def parse_datetime(value): + """Returns an aware datetime in local timezone""" + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z").astimezone() + + +class Status: + """ + A wrapper around the Status entity data fetched from Mastodon. + + https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status + """ + def __init__(self, data, instance): + self.data = data + self.instance = instance + + self.id = self.data["id"] + self.account = self.get_account() + self.created_at = parse_datetime(data["created_at"]) + + def get_account(self): + acct = self.data['account']['acct'] + return acct if "@" in acct else "{}@{}".format(acct, self.instance) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py new file mode 100644 index 0000000..f0eeba8 --- /dev/null +++ b/toot/tui/timeline.py @@ -0,0 +1,90 @@ +import logging +import urwid + +from .widgets import SelectableText, SelectableColumns + +logger = logging.getLogger("toot") + + +class Timeline(urwid.Columns): + """ + Displays a list of statuses to the left, and status details on the right. + + TODO: Switch to top/bottom for narrow views. + """ + + signals = ["status_focused"] + + def __init__(self, tui, statuses): + self.tui = tui + self.statuses = statuses + self.instance = tui.app.instance + + self.status_list = self.build_status_list(statuses) + self.status_details = self.build_status_details(statuses[0], self.instance) + + # TODO: + # self.status_cache = {} + + super().__init__([ + ("weight", 50, self.status_list), + ("weight", 50, self.status_details), + ], dividechars=1) + + def build_status_list(self, statuses): + items = [self.list_item(status) for status in statuses] + walker = urwid.SimpleFocusListWalker(items) + urwid.connect_signal(walker, "modified", self.status_focused) + return urwid.ListBox(walker) + + def build_status_details(self, status, instance): + details = StatusDetails(status, instance) + return urwid.Filler(details, valign="top") + + def get_focused_status(self): + return self.statuses[self.status_list.body.focus] + + def status_activated(self, *args): + """Called when a status is clicked, or Enter is pressed.""" + # logger.info("status_activated " + str(args)) + + def status_focused(self): + """Called when the list focus switches to a new status""" + status = self.get_focused_status() + details = StatusDetails(status, self.instance) + self.status_details.set_body(details) + self._emit("status_focused", [status]) + + def list_item(self, status): + item = StatusListItem(status, self.instance) + urwid.connect_signal(item, "click", self.status_activated) + return urwid.AttrMap(item, None, focus_map={ + "blue": "green_selected", + "green": "green_selected", + "yellow": "green_selected", + None: "green_selected", + }) + + +class StatusDetails(urwid.Pile): + def __init__(self, status, instance): + return super().__init__([ + urwid.Text(status.id) + ]) + + + +class StatusListItem(SelectableColumns): + def __init__(self, status, instance): + created_at = status.created_at.strftime("%Y-%m-%d %H:%M") + favourited = ("yellow", "★") if status.data["favourited"] else " " + reblogged = ("yellow", "⤶") if status.data["reblogged"] else " " + + return super().__init__([ + ("pack", SelectableText(("blue", created_at), wrap="clip")), + ("pack", urwid.Text(" ")), + urwid.Text(("green", status.account), wrap="clip"), + ("pack", urwid.Text(" ")), + ("pack", urwid.Text(favourited)), + ("pack", urwid.Text(reblogged)), + ]) diff --git a/toot/tui/widgets.py b/toot/tui/widgets.py new file mode 100644 index 0000000..1db986b --- /dev/null +++ b/toot/tui/widgets.py @@ -0,0 +1,31 @@ +import urwid + + +class Clickable: + """ + Add a `click` signal which is sent when the item is activated or clicked. + + TODO: make it work on widgets which have other signals. + """ + signals = ["click"] + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit('click') + return + + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1: + self._emit('click') + + return super().mouse_event(size, event, button, x, y, focus) + + +class SelectableText(Clickable, urwid.Text): + _selectable = True + + +class SelectableColumns(Clickable, urwid.Columns): + _selectable = True