diff --git a/toot/api.py b/toot/api.py index eee0473..f0c8f77 100644 --- a/toot/api.py +++ b/toot/api.py @@ -88,21 +88,38 @@ def timeline_home(app, user): return http.get(app, user, '/api/v1/timelines/home').json() -def _get_next_path(headers): +def get_next_path(headers): + """Given timeline response headers, returns the path to the next batch""" links = headers.get('Link', '') matches = re.match('<([^>]+)>; rel="next"', links) if matches: - url = matches.group(1) - return urlparse(url).path + parsed = urlparse(matches.group(1)) + return "?".join([parsed.path, parsed.query]) -def timeline_generator(app, user): - next_path = '/api/v1/timelines/home' - - while next_path: - response = http.get(app, user, next_path) +def _timeline_generator(app, user, path, limit=20): + while path: + response = http.get(app, user, path) yield response.json() - next_path = _get_next_path(response.headers) + path = get_next_path(response.headers) + + +def _anon_timeline_generator(instance, path, limit=20): + while path: + url = "https://{}{}".format(instance, path) + response = http.anon_get(url, path) + yield response.json() + path = get_next_path(response.headers) + + +def home_timeline_generator(app, user, limit=20): + path = '/api/v1/timelines/home?limit={}'.format(limit) + return _timeline_generator(app, user, path) + + +def public_timeline_generator(instance, limit=20): + path = '/api/v1/timelines/public?limit={}'.format(limit) + return _anon_timeline_generator(instance, path) def upload_media(app, user, file): diff --git a/toot/app.py b/toot/app.py deleted file mode 100644 index 0bed2a1..0000000 --- a/toot/app.py +++ /dev/null @@ -1,254 +0,0 @@ -# -*- coding: utf-8 -*- - -import webbrowser - -from textwrap import wrap - -from toot.exceptions import ConsoleError -from toot.utils import format_content - -# Attempt to load curses, which is not available on windows -try: - import curses -except ImportError as e: - raise ConsoleError("Curses is not available on this platform") - - -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 = next(self.status_generator) - 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['account']['acct'], color) - window.addstr(offset + 3, 15, status['account']['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['account']['acct'] - name = status['account']['display_name'] - - window.addstr(1, 2, "@" + acct, Color.green()) - window.addstr(2, 2, name, Color.yellow()) - - y = 4 - text_width = self.right_width - 4 - - for line in status['lines']: - wrapped_lines = wrap(line, text_width) if line else [''] - for wrapped_line in wrapped_lines: - window.addstr(y, 2, wrapped_line.ljust(text_width)) - y = y + 1 - - if status['media_attachments']: - y += 1 - for attachment in status['media_attachments']: - url = attachment['text_url'] or attachment['url'] - for line in wrap(url, text_width): - window.addstr(y, 2, line) - y += 1 - - window.addstr(y, 1, '-' * (text_width + 2)) - y += 1 - - if status['url'] is not None: - 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): - _status = status.get('reblog') or status - account = parse_account(_status['account']) - lines = list(format_content(_status['content'])) - - created_at = status['created_at'][:19].split('T') - boosted_by = parse_account(status['account']) if status['reblog'] else None - - return { - 'account': account, - 'boosted_by': boosted_by, - 'created_at': created_at, - 'lines': lines, - 'media_attachments': _status['media_attachments'], - 'url': status['url'], - } - - -def parse_account(account): - return { - 'id': account['id'], - 'acct': account['acct'], - 'display_name': account['display_name'], - } diff --git a/toot/commands.py b/toot/commands.py index 084cfed..d80135e 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -64,8 +64,17 @@ def timeline(app, user, args): def curses(app, user, args): - from toot.app import TimelineApp - generator = api.timeline_generator(app, user) + from toot.ui.app import TimelineApp + + if not args.public and (not app or not user): + raise ConsoleError("You must be logged in to view the home timeline.") + + if args.public: + instance = args.instance or app.instance + generator = api.public_timeline_generator(instance) + else: + generator = api.home_timeline_generator(app, user) + TimelineApp(generator).run() diff --git a/toot/console.py b/toot/console.py index 0db79cd..20edce1 100644 --- a/toot/console.py +++ b/toot/console.py @@ -138,8 +138,18 @@ READ_COMMANDS = [ Command( name="curses", description="An experimental timeline app (doesn't work on Windows)", - arguments=[], - require_auth=True, + arguments=[ + (["-p", "--public"], { + "action": 'store_true', + "default": False, + "help": "Resolve non-local accounts", + }), + (["-i", "--instance"], { + "type": str, + "help": 'instance from which to read (for public timeline only)', + }) + ], + require_auth=False, ), ] diff --git a/toot/ui/__init__.py b/toot/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toot/ui/app.py b/toot/ui/app.py new file mode 100644 index 0000000..bb69dde --- /dev/null +++ b/toot/ui/app.py @@ -0,0 +1,375 @@ +# -*- coding: utf-8 -*- + +import webbrowser + +from textwrap import wrap + +from toot.exceptions import ConsoleError +from toot.ui.utils import draw_horizontal_divider, draw_lines +from toot.utils import format_content, trunc + +# Attempt to load curses, which is not available on windows +try: + import curses +except ImportError as e: + raise ConsoleError("Curses is not available on this platform") + + +class Color: + @classmethod + def setup_palette(class_): + curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK) + curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_BLACK) + curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK) + curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_BLACK) + curses.init_pair(5, curses.COLOR_RED, curses.COLOR_BLACK) + curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLUE) + + class_.WHITE = curses.color_pair(1) + class_.BLUE = curses.color_pair(2) + class_.GREEN = curses.color_pair(3) + class_.YELLOW = curses.color_pair(4) + class_.RED = curses.color_pair(5) + class_.WHITE_ON_BLUE = curses.color_pair(6) + + +class HeaderWindow: + def __init__(self, height, width, y, x): + self.window = curses.newwin(height, width, y, x) + self.height = height + self.width = width + + def draw(self): + self.window.erase() + self.window.addstr(0, 1, "toot - your Mastodon command line interface", Color.YELLOW) + self.window.addstr(1, 1, "https://github.com/ihabunek/toot") + self.window.refresh() + + +class FooterWindow: + def __init__(self, height, width, y, x): + self.window = curses.newwin(height, width, y, x) + self.height = height + self.width = width + + def draw_status(self, selected, count): + text = "Showing toot {} of {}".format(selected + 1, count) + text = trunc(text, self.width - 1).ljust(self.width - 1) + self.window.addstr(0, 0, text, Color.WHITE_ON_BLUE | curses.A_BOLD) + self.window.refresh() + + def draw_message(self, text, color): + text = trunc(text, self.width - 1).ljust(self.width - 1) + self.window.addstr(1, 0, text, color) + self.window.refresh() + + def clear_message(self): + self.window.addstr(1, 0, "".ljust(self.width - 1)) + self.window.refresh() + + +class StatusListWindow: + """Window which shows the scrollable list of statuses (left side).""" + def __init__(self, height, width, top, left): + # Dimensions and position of region in stdscr which will contain the pad + self.region_height = height + self.region_width = width + self.region_top = top + self.region_left = left + + # How many statuses fit on one page (excluding border, at 3 lines per status) + self.page_size = (height - 2) // 3 + + # Initially, size the pad to the dimensions of the region, will be + # increased later to accomodate statuses + self.pad = curses.newpad(10, width) + self.pad.box() + + self.scroll_pos = 0 + + def draw_statuses(self, statuses, selected, starting=0): + # Resize window to accomodate statuses if required + height, width = self.pad.getmaxyx() + + new_height = len(statuses) * 3 + 1 + if new_height > height: + self.pad.resize(new_height, width) + self.pad.box() + + last_idx = len(statuses) - 1 + + for index, status in enumerate(statuses): + if index >= starting: + highlight = selected == index + draw_divider = index < last_idx + self.draw_status_row(status, index, highlight, draw_divider) + + def draw_status_row(self, status, index, highlight=False, draw_divider=True): + offset = 3 * index + + height, width = self.pad.getmaxyx() + color = Color.GREEN if highlight else Color.WHITE + + date, time = status['created_at'] + self.pad.addstr(offset + 1, 1, " " + date.ljust(14), color) + self.pad.addstr(offset + 2, 1, " " + time.ljust(14), color) + + trunc_width = width - 15 + acct = trunc("@" + status['account']['acct'], trunc_width).ljust(trunc_width) + display_name = trunc(status['account']['display_name'], trunc_width).ljust(trunc_width) + + if status['account']['display_name']: + self.pad.addstr(offset + 1, 14, display_name, color) + self.pad.addstr(offset + 2, 14, acct, color) + else: + self.pad.addstr(offset + 1, 14, acct, color) + + if draw_divider: + draw_horizontal_divider(self.pad, offset + 3) + + self.refresh() + + def refresh(self): + self.pad.refresh( + self.scroll_pos * 3, # top + 0, # left + self.region_top, + self.region_left, + self.region_height + 1, # +1 required to refresh full height, not sure why + self.region_width, + ) + + def scroll_to(self, index): + self.scroll_pos = index + self.refresh() + + def scroll_up(self): + if self.scroll_pos > 0: + self.scroll_to(self.scroll_pos - 1) + + def scroll_down(self): + self.scroll_to(self.scroll_pos + 1) + + def scroll_if_required(self, new_index): + if new_index < self.scroll_pos: + self.scroll_up() + elif new_index >= self.scroll_pos + self.page_size: + self.scroll_down() + else: + self.refresh() + + +class StatusDetailWindow: + """Window which shows details of a status (right side)""" + def __init__(self, height, width, y, x): + self.window = curses.newwin(height, width, y, x) + self.height = height + self.width = width + + def content_lines(self, status): + acct = status['account']['acct'] + name = status['account']['display_name'] + + if name: + yield name, Color.YELLOW + yield "@" + acct, Color.GREEN + yield + + text_width = self.width - 4 + + for line in status['lines']: + wrapped_lines = wrap(line, text_width) if line else [''] + for wrapped_line in wrapped_lines: + yield wrapped_line.ljust(text_width) + + if status['media_attachments']: + yield + yield "Media:" + for attachment in status['media_attachments']: + url = attachment['text_url'] or attachment['url'] + for line in wrap(url, text_width): + yield line + + def footer_lines(self, status): + text_width = self.width - 4 + + if status['url'] is not None: + for line in wrap(status['url'], text_width): + yield line + + if status['boosted_by']: + acct = status['boosted_by']['acct'] + yield "Boosted by @{}".format(acct), Color.BLUE + + def draw(self, status): + self.window.erase() + self.window.box() + + if not status: + return + + content = self.content_lines(status) + footer = self.footer_lines(status) + + y = draw_lines(self.window, content, 2, 1, Color.WHITE) + draw_horizontal_divider(self.window, y) + draw_lines(self.window, footer, 2, y + 1, Color.WHITE) + + self.window.refresh() + + +class TimelineApp: + def __init__(self, status_generator): + self.status_generator = status_generator + self.statuses = [] + self.stdscr = None + + def run(self): + curses.wrapper(self._wrapped_run) + + def _wrapped_run(self, stdscr): + self.stdscr = stdscr + + Color.setup_palette() + self.setup_windows() + + # Load some data and redraw + self.fetch_next() + self.selected = 0 + self.full_redraw() + + self.loop() + + def setup_windows(self): + screen_height, screen_width = self.stdscr.getmaxyx() + + if screen_width < 60: + raise ConsoleError("Terminal screen is too narrow, toot curses requires at least 60 columns to display properly.") + + left_width = max(min(screen_width // 3, 60), 30) + right_width = screen_width - left_width + + self.header = HeaderWindow(2, screen_width, 0, 0) + self.footer = FooterWindow(2, screen_width, screen_height - 2, 0) + self.left = StatusListWindow(screen_height - 4, left_width, 2, 0) + self.right = StatusDetailWindow(screen_height - 4, right_width, 2, left_width) + + def loop(self): + while True: + key = self.left.pad.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 == 'B': + self.select_next() + + elif key.lower() == 'k' or key == 'A': + self.select_previous() + + elif key == 'KEY_RESIZE': + self.setup_windows() + self.full_redraw() + + def select_previous(self): + """Move to the previous status in the timeline.""" + self.footer.clear_message() + + if self.selected == 0: + self.footer.draw_message("Cannot move beyond first toot.", Color.GREEN) + 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.""" + self.footer.clear_message() + + old_index = self.selected + new_index = self.selected + 1 + + # Load more statuses if no more are available + if self.selected + 1 >= len(self.statuses): + self.fetch_next() + self.left.draw_statuses(self.statuses, self.selected, new_index - 1) + self.draw_footer_status() + + self.selected = new_index + self.redraw_after_selection_change(old_index, new_index) + + def fetch_next(self): + try: + self.footer.draw_message("Loading toots...", Color.BLUE) + statuses = next(self.status_generator) + except StopIteration: + return None + + for status in statuses: + self.statuses.append(parse_status(status)) + + self.footer.draw_message("Loaded {} toots".format(len(statuses)), Color.GREEN) + + return len(statuses) + + def full_redraw(self): + """Perform a full redraw of the UI.""" + self.header.draw() + self.draw_footer_status() + + self.left.draw_statuses(self.statuses, self.selected) + self.right.draw(self.get_selected_status()) + self.header.draw() + + 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.left.draw_status_row(old_status, old_index, highlight=False, draw_divider=False) + self.left.draw_status_row(new_status, new_index, highlight=True, draw_divider=False) + self.left.scroll_if_required(new_index) + + self.right.draw(new_status) + self.draw_footer_status() + + def get_selected_status(self): + if len(self.statuses) > self.selected: + return self.statuses[self.selected] + + def draw_footer_status(self): + self.footer.draw_status(self.selected, len(self.statuses)) + + +def parse_status(status): + _status = status.get('reblog') or status + account = parse_account(_status['account']) + lines = list(format_content(_status['content'])) + + created_at = status['created_at'][:19].split('T') + boosted_by = parse_account(status['account']) if status['reblog'] else None + + return { + 'account': account, + 'boosted_by': boosted_by, + 'created_at': created_at, + 'lines': lines, + 'media_attachments': _status['media_attachments'], + 'url': _status['url'], + } + + +def parse_account(account): + return { + 'id': account['id'], + 'acct': account['acct'], + 'display_name': account['display_name'], + } diff --git a/toot/ui/utils.py b/toot/ui/utils.py new file mode 100644 index 0000000..4e7cae5 --- /dev/null +++ b/toot/ui/utils.py @@ -0,0 +1,28 @@ +def draw_horizontal_divider(window, y): + height, width = window.getmaxyx() + + # Don't draw out of bounds + if y < height - 1: + line = '├' + '─' * (width - 2) + '┤' + window.addstr(y, 0, line) + + +def enumerate_lines(generator, default_color): + for y, item in enumerate(generator): + if isinstance(item, tuple) and len(item) == 2: + yield y, item[0], item[1] + elif isinstance(item, str): + yield y, item, default_color + elif item is None: + yield y, "", default_color + else: + raise ValueError("Wrong yield in generator") + + +def draw_lines(window, lines, x, y, default_color): + height, _ = window.getmaxyx() + for dy, line, color in enumerate_lines(lines, default_color): + if y + dy < height - 1: + window.addstr(y + dy, x, line, color) + + return y + dy + 1 diff --git a/toot/utils.py b/toot/utils.py index 55f9724..b7ae649 100644 --- a/toot/utils.py +++ b/toot/utils.py @@ -57,3 +57,11 @@ def domain_exists(name): def assert_domain_exists(domain): if not domain_exists(domain): raise ConsoleError("Domain {} not found".format(domain)) + + +def trunc(text, length): + """Trims text to given length, if trimmed appends ellipsis.""" + if len(text) <= length: + return text + + return text[:length - 1] + '…'