diff --git a/toot/commands.py b/toot/commands.py index 243d101..f0824d1 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -15,6 +15,7 @@ from textwrap import TextWrapper, wrap from toot import api, config, DEFAULT_INSTANCE, User, App, ConsoleError from toot.output import green, yellow, print_error +from toot.curses import TimelineApp def register_app(instance): @@ -166,6 +167,11 @@ def timeline(app, user, args): print("─" * 31 + "┼" + "─" * 88) +def curses(app, user, args): + generator = api.timeline_generator(app, user) + TimelineApp(generator).run() + + def post(app, user, args): if args.media: media = _do_upload(app, user, args.media) diff --git a/toot/console.py b/toot/console.py index 378eec5..c6bfa69 100644 --- a/toot/console.py +++ b/toot/console.py @@ -138,6 +138,12 @@ COMMANDS = [ arguments=[], require_auth=True, ), + Command( + name="curses", + description="An experimental timeline app.", + arguments=[], + require_auth=True, + ), ] diff --git a/toot/curses.py b/toot/curses.py new file mode 100644 index 0000000..ef348e1 --- /dev/null +++ b/toot/curses.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from __future__ import print_function + +import curses +import re +import webbrowser + +from bs4 import BeautifulSoup +from textwrap import wrap + + +class Color: + @staticmethod + def setup_palette(): + curses.init_pair(1, curses.COLOR_BLUE, curses.COLOR_BLACK) + curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) + curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) + + @staticmethod + def blue(): + return curses.color_pair(1) + + @staticmethod + def green(): + return curses.color_pair(2) + + @staticmethod + def yellow(): + return curses.color_pair(3) + + +class TimelineApp: + def __init__(self, status_generator): + self.status_generator = status_generator + self.statuses = [] + self.selected = None + + def run(self): + curses.wrapper(self._wrapped_run) + + def _wrapped_run(self, stdscr): + self.left_width = 60 + self.right_width = curses.COLS - self.left_width + + # Setup windows + self.top = curses.newwin(2, curses.COLS, 0, 0) + self.left = curses.newpad(curses.LINES * 2, self.left_width) + self.right = curses.newwin(curses.LINES - 4, self.right_width, 2, self.left_width) + self.bottom = curses.newwin(2, curses.COLS, curses.LINES - 2, 0) + + Color.setup_palette() + + # Load some data and redraw + self.fetch_next() + self.selected = 0 + self.full_redraw() + + self.loop() + + def loop(self): + while True: + key = self.left.getkey() + + if key.lower() == 'q': + return + + elif key.lower() == 'v': + status = self.get_selected_status() + if status: + webbrowser.open(status['url']) + + elif key.lower() == 'j' or key == curses.KEY_DOWN: + self.select_next() + + elif key.lower() == 'k' or key == curses.KEY_UP: + self.select_previous() + + def select_previous(self): + """Move to the previous status in the timeline.""" + if self.selected == 0: + return + + old_index = self.selected + new_index = self.selected - 1 + + self.selected = new_index + self.redraw_after_selection_change(old_index, new_index) + + def select_next(self): + """Move to the next status in the timeline.""" + if self.selected + 1 >= len(self.statuses): + return + + old_index = self.selected + new_index = self.selected + 1 + + self.selected = new_index + self.redraw_after_selection_change(old_index, new_index) + + def redraw_after_selection_change(self, old_index, new_index): + old_status = self.statuses[old_index] + new_status = self.statuses[new_index] + + # Perform a partial redraw + self.draw_status_row(self.left, old_status, 3 * old_index - 1, False) + self.draw_status_row(self.left, new_status, 3 * new_index - 1, True) + self.draw_status_details(self.right, new_status) + + def fetch_next(self): + try: + statuses = self.status_generator.__next__() + except StopIteration: + return None + + for status in statuses: + self.statuses.append(parse_status(status)) + + return len(statuses) + + def full_redraw(self): + """Perform a full redraw of the UI.""" + self.left.clear() + self.right.clear() + self.top.clear() + self.bottom.clear() + + self.left.box() + self.right.box() + + self.top.addstr(" toot - your Mastodon command line interface\n", Color.yellow()) + self.top.addstr(" https://github.com/ihabunek/toot") + + self.draw_statuses(self.left) + self.draw_status_details(self.right, self.get_selected_status()) + self.draw_usage(self.bottom) + + self.left.refresh(0, 0, 2, 0, curses.LINES - 4, self.left_width) + + self.right.refresh() + self.top.refresh() + self.bottom.refresh() + + def draw_usage(self, window): + # Show usage on the bottom + window.addstr("Usage: | ") + window.addch("j", Color.green()) + window.addstr(" next | ") + window.addch("k", Color.green()) + window.addstr(" previous | ") + window.addch("v", Color.green()) + window.addstr(" open in browser | ") + window.addch("q", Color.green()) + window.addstr(" quit") + + window.refresh() + + def get_selected_status(self): + if len(self.statuses) > self.selected: + return self.statuses[self.selected] + + def draw_status_row(self, window, status, offset, highlight=False): + width = window.getmaxyx()[1] + color = Color.blue() if highlight else 0 + + date, time = status['created_at'] + window.addstr(offset + 2, 2, date, color) + window.addstr(offset + 3, 2, time, color) + + window.addstr(offset + 2, 15, status['author']['acct'], color) + window.addstr(offset + 3, 15, status['author']['display_name'], color) + + window.addstr(offset + 4, 1, '─' * (width - 2)) + + window.refresh(0, 0, 2, 0, curses.LINES - 4, self.left_width) + + + def draw_statuses(self, window): + for index, status in enumerate(self.statuses): + offset = 3 * index - 1 + highlight = self.selected == index + self.draw_status_row(window, status, offset, highlight) + + def draw_status_details(self, window, status): + window.erase() + window.box() + + acct = status['author']['acct'] + name = status['author']['display_name'] + + window.addstr(1, 2, "@" + acct, Color.green()) + window.addstr(2, 2, name, Color.yellow()) + + text_width = self.right_width - 4 + + y = 4 + for line in status['lines']: + for wrapped in wrap(line, text_width): + window.addstr(y, 2, wrapped.ljust(text_width)) + y += 1 + y += 1 + + window.addstr(y, 2, '─' * text_width) + y += 1 + + window.addstr(y, 2, status['url']) + y += 1 + + if status['boosted_by']: + acct = status['boosted_by']['acct'] + window.addstr(y, 2, "Boosted by ") + window.addstr("@", Color.green()) + window.addstr(acct, Color.green()) + y += 1 + + window.refresh() + + +def parse_status(status): + content = status['reblog']['content'] if status['reblog'] else status['content'] + account = parse_account(status['reblog']['account'] if status['reblog'] else status['account']) + boosted_by = parse_account(status['account']) if status['reblog'] else None + + lines = parse_html(content) + + created_at = status['created_at'][:19].split('T') + + return { + 'author': account, + 'boosted_by': boosted_by, + 'lines': lines, + 'url': status['url'], + 'created_at': created_at, + } + + +def parse_account(account): + return { + 'id': account['id'], + 'acct': account['acct'], + 'display_name': account['display_name'], + } + + +def parse_html(html): + """Attempt to convert html to plain text while keeping line breaks""" + return [ + BeautifulSoup(l, "html.parser").get_text().replace(''', "'") + for l in re.split("]*>", html) + if l + ]