Merge pull request #39 from ihabunek/curses-resize - toot - Unnamed repository; edit this file 'description' to name the repository.
(DIR) Log
(DIR) Files
(DIR) Refs
(DIR) LICENSE
---
(DIR) commit d3d69509cb6b2c22bd202bd66a20bc3e9a555cc2
(DIR) parent b444b06116eda5dc55856e77c9ea593102911a1f
(HTM) Author: Ivan Habunek <ivan@habunek.com>
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(-)
---
(DIR) 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):
(DIR) 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.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'],
- }
(DIR) 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()
(DIR) 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,
),
]
(DIR) diff --git a/toot/ui/__init__.py b/toot/ui/__init__.py
(DIR) 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", 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'],
+ }
(DIR) 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
(DIR) 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] + '…'