Merge pull request #39 from ihabunek/curses-resize - toot - Unnamed repository;… | |
Log | |
Files | |
Refs | |
LICENSE | |
--- | |
commit d3d69509cb6b2c22bd202bd66a20bc3e9a555cc2 | |
parent b444b06116eda5dc55856e77c9ea593102911a1f | |
Author: Ivan Habunek <[email protected]> | |
Date: Sun, 14 Jan 2018 15:49:41 +0100 | |
Merge pull request #39 from ihabunek/curses-resize | |
Improvements to the curses app | |
Diffstat: | |
toot/api.py | 33 +++++++++++++++++++++++-------- | |
toot/app.py | 254 ------------------------------- | |
toot/commands.py | 13 +++++++++++-- | |
toot/console.py | 14 ++++++++++++-- | |
toot/ui/__init__.py | 0 | |
toot/ui/app.py | 375 +++++++++++++++++++++++++++++++ | |
toot/ui/utils.py | 28 ++++++++++++++++++++++++++++ | |
toot/utils.py | 8 ++++++++ | |
8 files changed, 459 insertions(+), 266 deletions(-) | |
--- | |
diff --git 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' | |
+def _timeline_generator(app, user, path, limit=20): | |
+ while path: | |
+ response = http.get(app, user, path) | |
+ yield response.json() | |
+ path = get_next_path(response.headers) | |
+ | |
- while next_path: | |
- response = http.get(app, user, next_path) | |
+def _anon_timeline_generator(instance, path, limit=20): | |
+ while path: | |
+ url = "https://{}{}".format(instance, path) | |
+ response = http.anon_get(url, path) | |
yield response.json() | |
- next_path = _get_next_path(response.headers) | |
+ 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 | |
@@ -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… | |
- 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", Colo… | |
- 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 | |
@@ -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 | |
@@ -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 | |
diff --git a/toot/ui/app.py 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"… | |
+ 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 p… | |
+ 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 … | |
+ 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=Tru… | |
+ 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… | |
+ display_name = trunc(status['account']['display_name'], trunc_width).l… | |
+ | |
+ 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… | |
+ 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 req… | |
+ | |
+ 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, lef… | |
+ | |
+ 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.G… | |
+ 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 - … | |
+ 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)), Colo… | |
+ | |
+ 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… | |
+ self.left.draw_status_row(new_status, new_index, highlight=True, draw_… | |
+ 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 | |
@@ -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 | |
@@ -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] + '…' |