Introduction
Introduction Statistics Contact Development Disclaimer Help
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] + '…'
You are viewing proxied material from vernunftzentrum.de. The copyright of proxied material belongs to its original authors. Any comments or complaints in relation to proxied material should be directed to the original authors of the content concerned. Please see the disclaimer for more details.