Introduction
Introduction Statistics Contact Development Disclaimer Help
Experimental curses app for viewing the timeline - toot - Unnamed repository; e…
Log
Files
Refs
LICENSE
---
commit a3b207647b36b877399a0151f4faeb4de2503945
parent 1c22eaa44fe49379caaf8b8dac1cfe6c66b3d255
Author: Ivan Habunek <[email protected]>
Date: Fri, 21 Apr 2017 20:23:48 +0200
Experimental curses app for viewing the timeline
Diffstat:
toot/commands.py | 6 ++++++
toot/console.py | 6 ++++++
toot/curses.py | 251 +++++++++++++++++++++++++++++++
3 files changed, 263 insertions(+), 0 deletions(-)
---
diff --git 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
@@ -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
@@ -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…
+ 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", 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['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['con…
+ account = parse_account(status['reblog']['account'] if status['reblog'] el…
+ 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('&apos;', "'")
+ for l in re.split("</?p[^>]*>", html)
+ if l
+ ]
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.