@@ -0,0 +1,17 @@ | |||
# Maintainer: John Jenkins twodopeshaggy@gmail.com | |||
pkgname=rtv | |||
pkgver=1.2 | |||
pkgrel=1 | |||
pkgdesc="Browse Reddit from your terminal" | |||
arch=('any') | |||
url="https://github.com/michael-lazar/rtv" | |||
license=('MIT') | |||
depends=('ncurses' 'python' 'python-six' 'python-requests' 'python-praw' 'python-setuptools') | |||
source=(https://github.com/michael-lazar/rtv/archive/v$pkgver.tar.gz) | |||
md5sums=('b67388a428ae06e45e8522b0f56037b1') | |||
package() { | |||
cd "$srcdir/$pkgname-$pkgver" | |||
python setup.py install --root="$pkgdir/" --optimize=1 | |||
} |
@@ -0,0 +1,27 @@ | |||
# Generated by makepkg 4.2.1 | |||
# using fakeroot version 1.20.2 | |||
# Mon Apr 6 02:40:49 UTC 2015 | |||
pkgname = rtv | |||
pkgver = 1.2-1 | |||
pkgdesc = Browse Reddit from your terminal | |||
url = https://github.com/michael-lazar/rtv | |||
builddate = 1428288049 | |||
packager = Unknown Packager | |||
size = 217088 | |||
arch = any | |||
license = MIT | |||
depend = ncurses | |||
depend = python | |||
depend = python-six | |||
depend = python-requests | |||
depend = python-praw | |||
depend = python-setuptools | |||
makepkgopt = strip | |||
makepkgopt = docs | |||
makepkgopt = !libtool | |||
makepkgopt = !staticlibs | |||
makepkgopt = emptydirs | |||
makepkgopt = zipman | |||
makepkgopt = purge | |||
makepkgopt = !upx | |||
makepkgopt = !debug |
@@ -0,0 +1,10 @@ | |||
#!/bin/python | |||
# EASY-INSTALL-ENTRY-SCRIPT: 'rtv==1.2','console_scripts','rtv' | |||
__requires__ = 'rtv==1.2' | |||
import sys | |||
from pkg_resources import load_entry_point | |||
if __name__ == '__main__': | |||
sys.exit( | |||
load_entry_point('rtv==1.2', 'console_scripts', 'rtv')() | |||
) |
@@ -0,0 +1,149 @@ | |||
Metadata-Version: 1.1 | |||
Name: rtv | |||
Version: 1.2 | |||
Summary: A simple terminal viewer for Reddit (Reddit Terminal Viewer) | |||
Home-page: https://github.com/michael-lazar/rtv | |||
Author: Michael Lazar | |||
Author-email: lazar.michael22@gmail.com | |||
License: MIT | |||
Description: .. image:: https://pypip.in/version/rtv/badge.svg?text=version&style=flat | |||
:target: https://pypi.python.org/pypi/rtv/ | |||
:alt: Latest Version | |||
.. image:: https://pypip.in/py_versions/rtv/badge.svg?style=flat | |||
:target: https://pypi.python.org/pypi/rtv/ | |||
:alt: Supported Python versions | |||
====================== | |||
Reddit Terminal Viewer | |||
====================== | |||
Browse Reddit from your terminal | |||
.. image:: http://i.imgur.com/W1hxqCt.png | |||
RTV is built in **python** using the **curses** library, and is compatible with *most* terminal emulators on Linux and OS X. | |||
------------- | |||
Update (v1.1) | |||
------------- | |||
Users can now post comments! | |||
.. image:: http://i.imgur.com/twls7iM.png | |||
------------ | |||
Installation | |||
------------ | |||
Install using pip | |||
.. code-block:: bash | |||
$ sudo pip install rtv | |||
Or clone the repository | |||
.. code-block:: bash | |||
$ git clone https://github.com/michael-lazar/rtv.git | |||
$ cd rtv | |||
$ sudo python setup.py install | |||
The installation will place a script in the system path | |||
.. code-block:: bash | |||
$ rtv | |||
$ rtv --help | |||
----- | |||
Usage | |||
----- | |||
RTV currently supports browsing both subreddits and individual submissions. In each mode the controls are slightly different. | |||
**Global Commands** | |||
:``โฒ``/``โผ`` or ``j``/``k``: Scroll to the prev/next item | |||
:``a``/``z``: Upvote/downvote the selected item | |||
:``ENTER`` or ``o``: Open the selected item in the default web browser | |||
:``r``: Refresh the current page | |||
:``u``: Login and logout of your user account | |||
:``?``: Show the help screen | |||
:``q``: Quit | |||
**Subreddit Mode** | |||
In subreddit mode you can browse through the top submissions on either the front page or a specific subreddit. | |||
:``โบ`` or ``l``: View comments for the selected submission | |||
:``/``: Open a prompt to switch subreddits | |||
:``f``: Open a prompt to search the current subreddit | |||
:``p``: Post a new submission to the current subreddit | |||
The ``/`` prompt accepts subreddits in the following formats | |||
* ``/r/python`` | |||
* ``/r/python/new`` | |||
* ``/r/python+linux`` supports multireddits | |||
* ``/r/front`` will redirect to the front page | |||
* ``/r/me`` will display your submissions | |||
**Submission Mode** | |||
In submission mode you can view the self text for a submission and browse comments. | |||
:``โ`` or ``h``: Return to subreddit mode | |||
:``โบ`` or ``l``: Fold the selected comment, or load additional comments | |||
:``c``: Post a new comment on the selected item | |||
------------- | |||
Configuration | |||
------------- | |||
RTV will read a configuration file located at ``$XDG_CONFIG_HOME/rtv/rtv.cfg`` or ``~/.config/rtv/rtv.cfg`` if ``$XDG_CONFIG_HOME`` is not set. | |||
This can be used to avoid having to re-enter login credentials every time the program is launched. | |||
Each line in the file will replace the corresponding default argument in the launch script. | |||
Example config: | |||
.. code-block:: ini | |||
[rtv] | |||
username=MyUsername | |||
password=MySecretPassword | |||
# Log file location | |||
log=/tmp/rtv.log | |||
# Default subreddit | |||
subreddit=CollegeBasketball | |||
# Default submission link - will be opened every time the program starts | |||
# link=http://www.reddit.com/r/CollegeBasketball/comments/31irjq | |||
# Enable unicode characters (experimental) | |||
# This is known to be unstable with east asian wide character sets | |||
# unicode=true | |||
RTV allows users to compose comments and replys using their preferred text editor (**vi**, **nano**, **gedit**, etc). | |||
Set the environment variable ``RTV_EDITOR`` to specify which editor the program should use. | |||
.. code-block:: bash | |||
$ export RTV_EDITOR=gedit | |||
Keywords: reddit terminal praw curses | |||
Platform: UNKNOWN | |||
Classifier: Intended Audience :: End Users/Desktop | |||
Classifier: Environment :: Console :: Curses | |||
Classifier: Operating System :: MacOS :: MacOS X | |||
Classifier: Operating System :: POSIX | |||
Classifier: Natural Language :: English | |||
Classifier: Programming Language :: Python :: 2.7 | |||
Classifier: Programming Language :: Python :: 3.4 | |||
Classifier: Programming Language :: Python :: 3 | |||
Classifier: Topic :: Terminals | |||
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards | |||
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary |
@@ -0,0 +1,23 @@ | |||
MANIFEST.in | |||
README.rst | |||
setup.cfg | |||
setup.py | |||
version.py | |||
rtv/__init__.py | |||
rtv/__main__.py | |||
rtv/__version__.py | |||
rtv/config.py | |||
rtv/content.py | |||
rtv/curses_helpers.py | |||
rtv/docs.py | |||
rtv/exceptions.py | |||
rtv/helpers.py | |||
rtv/page.py | |||
rtv/submission.py | |||
rtv/subreddit.py | |||
rtv.egg-info/PKG-INFO | |||
rtv.egg-info/SOURCES.txt | |||
rtv.egg-info/dependency_links.txt | |||
rtv.egg-info/entry_points.txt | |||
rtv.egg-info/requires.txt | |||
rtv.egg-info/top_level.txt |
@@ -0,0 +1 @@ | |||
@@ -0,0 +1,3 @@ | |||
[console_scripts] | |||
rtv = rtv.__main__:main | |||
@@ -0,0 +1,3 @@ | |||
praw>=2.1.6 | |||
six | |||
requests |
@@ -0,0 +1 @@ | |||
rtv |
@@ -0,0 +1,6 @@ | |||
from .__version__ import __version__ | |||
__title__ = 'Reddit Terminal Viewer' | |||
__author__ = 'Michael Lazar' | |||
__license__ = 'The MIT License (MIT)' | |||
__copyright__ = '(c) 2015 Michael Lazar' |
@@ -0,0 +1,133 @@ | |||
import os | |||
import sys | |||
import argparse | |||
import locale | |||
import logging | |||
import requests | |||
import praw | |||
import praw.errors | |||
from six.moves import configparser | |||
from . import config | |||
from .exceptions import SubmissionError, SubredditError, ProgramError | |||
from .curses_helpers import curses_session | |||
from .submission import SubmissionPage | |||
from .subreddit import SubredditPage | |||
from .docs import * | |||
from .__version__ import __version__ | |||
__all__ = [] | |||
def load_config(): | |||
""" | |||
Search for a configuration file at the location ~/.rtv and attempt to load | |||
saved settings for things like the username and password. | |||
""" | |||
config = configparser.ConfigParser() | |||
HOME = os.path.expanduser('~') | |||
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config')) | |||
config_paths = [ | |||
os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'), | |||
os.path.join(HOME, '.rtv') | |||
] | |||
# read only the first existing config file | |||
for config_path in config_paths: | |||
if os.path.exists(config_path): | |||
config.read(config_path) | |||
break | |||
defaults = {} | |||
if config.has_section('rtv'): | |||
defaults = dict(config.items('rtv')) | |||
if 'unicode' in defaults: | |||
defaults['unicode'] = config.getboolean('rtv', 'unicode') | |||
return defaults | |||
def command_line(): | |||
parser = argparse.ArgumentParser( | |||
prog='rtv', description=SUMMARY, | |||
epilog=CONTROLS + HELP, | |||
formatter_class=argparse.RawDescriptionHelpFormatter) | |||
parser.add_argument('-s', dest='subreddit', help='subreddit name') | |||
parser.add_argument('-l', dest='link', help='full link to a submission') | |||
parser.add_argument('--unicode', action='store_true', | |||
help='enable unicode (experimental)') | |||
parser.add_argument('--log', metavar='FILE', action='store', | |||
help='Log HTTP requests') | |||
group = parser.add_argument_group('authentication (optional)', AUTH) | |||
group.add_argument('-u', dest='username', help='reddit username') | |||
group.add_argument('-p', dest='password', help='reddit password') | |||
args = parser.parse_args() | |||
return args | |||
def main(): | |||
"Main entry point" | |||
# logging.basicConfig(level=logging.DEBUG, filename='rtv.log') | |||
locale.setlocale(locale.LC_ALL, '') | |||
args = command_line() | |||
local_config = load_config() | |||
# set the terminal title | |||
title = 'rtv {0}'.format(__version__) | |||
if os.name == 'nt': | |||
os.system('title {0}'.format(title)) | |||
else: | |||
sys.stdout.write("\x1b]2;{0}\x07".format(title)) | |||
# Fill in empty arguments with config file values. Paramaters explicitly | |||
# typed on the command line will take priority over config file params. | |||
for key, val in local_config.items(): | |||
if getattr(args, key, None) is None: | |||
setattr(args, key, val) | |||
config.unicode = args.unicode | |||
if args.log: | |||
logging.basicConfig(level=logging.DEBUG, filename=args.log) | |||
try: | |||
print('Connecting...') | |||
reddit = praw.Reddit(user_agent=AGENT) | |||
reddit.config.decode_html_entities = True | |||
if args.username: | |||
# PRAW will prompt for password if it is None | |||
reddit.login(args.username, args.password) | |||
with curses_session() as stdscr: | |||
if args.link: | |||
page = SubmissionPage(stdscr, reddit, url=args.link) | |||
page.loop() | |||
page = SubredditPage(stdscr, reddit, args.subreddit) | |||
page.loop() | |||
except praw.errors.InvalidUserPass: | |||
print('Invalid password for username: {}'.format(args.username)) | |||
except requests.ConnectionError: | |||
print('Connection timeout') | |||
except requests.HTTPError: | |||
print('HTTP Error: 404 Not Found') | |||
except SubmissionError as e: | |||
print('Could not reach submission URL: {}'.format(e.url)) | |||
except SubredditError as e: | |||
print('Could not reach subreddit: {}'.format(e.name)) | |||
except ProgramError as e: | |||
print('Error: could not open file with program "{}", ' | |||
'try setting RTV_EDITOR'.format(e.name)) | |||
except KeyboardInterrupt: | |||
return | |||
sys.exit(main()) |
@@ -0,0 +1 @@ | |||
__version__ = '1.2' |
@@ -0,0 +1,5 @@ | |||
""" | |||
Global configuration settings | |||
""" | |||
unicode = False |
@@ -0,0 +1,322 @@ | |||
import textwrap | |||
import praw | |||
import requests | |||
from .exceptions import SubmissionError, SubredditError, AccountError | |||
from .helpers import humanize_timestamp, wrap_text, strip_subreddit_url | |||
__all__ = ['SubredditContent', 'SubmissionContent'] | |||
class BaseContent(object): | |||
def get(self, index, n_cols): | |||
raise NotImplementedError | |||
def iterate(self, index, step, n_cols): | |||
while True: | |||
if step < 0 and index < 0: | |||
# Hack to prevent displaying negative indicies if iterating in | |||
# the negative direction. | |||
break | |||
try: | |||
yield self.get(index, n_cols=n_cols) | |||
except IndexError: | |||
break | |||
index += step | |||
@staticmethod | |||
def flatten_comments(comments, root_level=0): | |||
""" | |||
Flatten a PRAW comment tree while preserving the nested level of each | |||
comment via the `nested_level` attribute. | |||
""" | |||
stack = comments[:] | |||
for item in stack: | |||
item.nested_level = root_level | |||
retval = [] | |||
while stack: | |||
item = stack.pop(0) | |||
if isinstance(item, praw.objects.MoreComments) and ( | |||
item.count == 0): | |||
continue | |||
nested = getattr(item, 'replies', None) | |||
if nested: | |||
for n in nested: | |||
n.nested_level = item.nested_level + 1 | |||
stack[0:0] = nested | |||
retval.append(item) | |||
return retval | |||
@staticmethod | |||
def strip_praw_comment(comment): | |||
""" | |||
Parse through a submission comment and return a dict with data ready to | |||
be displayed through the terminal. | |||
""" | |||
data = {} | |||
data['object'] = comment | |||
data['level'] = comment.nested_level | |||
if isinstance(comment, praw.objects.MoreComments): | |||
data['type'] = 'MoreComments' | |||
data['count'] = comment.count | |||
data['body'] = 'More comments'.format(comment.count) | |||
else: | |||
data['type'] = 'Comment' | |||
data['body'] = comment.body | |||
data['created'] = humanize_timestamp(comment.created_utc) | |||
data['score'] = '{} pts'.format(comment.score) | |||
author = getattr(comment, 'author') | |||
data['author'] = (author.name if author else '[deleted]') | |||
sub_author = getattr(comment.submission.author, 'name') | |||
data['is_author'] = (data['author'] == sub_author) | |||
flair = comment.author_flair_text | |||
data['flair'] = (flair if flair else '') | |||
data['likes'] = comment.likes | |||
data['gold'] = comment.gilded > 0 | |||
return data | |||
@staticmethod | |||
def strip_praw_submission(sub): | |||
""" | |||
Parse through a submission and return a dict with data ready to be | |||
displayed through the terminal. | |||
""" | |||
is_selfpost = lambda s: s.startswith('http://www.reddit.com/r/') | |||
data = {} | |||
data['object'] = sub | |||
data['type'] = 'Submission' | |||
data['title'] = sub.title | |||
data['text'] = sub.selftext | |||
data['created'] = humanize_timestamp(sub.created_utc) | |||
data['comments'] = '{} comments'.format(sub.num_comments) | |||
data['score'] = '{} pts'.format(sub.score) | |||
author = getattr(sub, 'author') | |||
data['author'] = (author.name if author else '[deleted]') | |||
data['permalink'] = sub.permalink | |||
data['subreddit'] = strip_subreddit_url(sub.permalink) | |||
data['flair'] = (sub.link_flair_text if sub.link_flair_text else '') | |||
data['url_full'] = sub.url | |||
data['url'] = ('selfpost' if is_selfpost(sub.url) else sub.url) | |||
data['likes'] = sub.likes | |||
data['gold'] = sub.gilded > 0 | |||
return data | |||
class SubmissionContent(BaseContent): | |||
""" | |||
Grab a submission from PRAW and lazily store comments to an internal | |||
list for repeat access. | |||
""" | |||
def __init__(self, submission, loader, indent_size=2, max_indent_level=4): | |||
self.indent_size = indent_size | |||
self.max_indent_level = max_indent_level | |||
self._loader = loader | |||
self._submission = submission | |||
self._submission_data = self.strip_praw_submission(self._submission) | |||
self.name = self._submission_data['permalink'] | |||
comments = self.flatten_comments(self._submission.comments) | |||
self._comment_data = [self.strip_praw_comment(c) for c in comments] | |||
@classmethod | |||
def from_url(cls, reddit, url, loader, indent_size=2, max_indent_level=4): | |||
try: | |||
with loader(): | |||
submission = reddit.get_submission(url, comment_sort='hot') | |||
except praw.errors.APIException: | |||
raise SubmissionError(url) | |||
return cls(submission, loader, indent_size, max_indent_level) | |||
def get(self, index, n_cols=70): | |||
""" | |||
Grab the `i`th submission, with the title field formatted to fit inside | |||
of a window of width `n` | |||
""" | |||
if index < -1: | |||
raise IndexError | |||
elif index == -1: | |||
data = self._submission_data | |||
data['split_title'] = textwrap.wrap(data['title'], width=n_cols -2) | |||
data['split_text'] = wrap_text(data['text'], width=n_cols - 2) | |||
data['n_rows'] = len(data['split_title'] + data['split_text']) + 5 | |||
data['offset'] = 0 | |||
else: | |||
data = self._comment_data[index] | |||
indent_level = min(data['level'], self.max_indent_level) | |||
data['offset'] = indent_level * self.indent_size | |||
if data['type'] == 'Comment': | |||
width = n_cols - data['offset'] | |||
data['split_body'] = wrap_text(data['body'], width=width) | |||
data['n_rows'] = len(data['split_body']) + 1 | |||
else: | |||
data['n_rows'] = 1 | |||
return data | |||
def toggle(self, index, n_cols=70): | |||
""" | |||
Toggle the state of the object at the given index. | |||
If it is a comment, pack it into a hidden comment. | |||
If it is a hidden comment, unpack it. | |||
If it is more comments, load the comments. | |||
""" | |||
data = self.get(index) | |||
if data['type'] == 'Submission': | |||
# Can't hide the submission! | |||
pass | |||
elif data['type'] == 'Comment': | |||
cache = [data] | |||
count = 1 | |||
for d in self.iterate(index + 1, 1, n_cols): | |||
if d['level'] <= data['level']: | |||
break | |||
count += d.get('count', 1) | |||
cache.append(d) | |||
comment = {} | |||
comment['type'] = 'HiddenComment' | |||
comment['cache'] = cache | |||
comment['count'] = count | |||
comment['level'] = data['level'] | |||
comment['body'] = 'Hidden'.format(count) | |||
self._comment_data[index:index + len(cache)] = [comment] | |||
elif data['type'] == 'HiddenComment': | |||
self._comment_data[index:index + 1] = data['cache'] | |||
elif data['type'] == 'MoreComments': | |||
with self._loader(): | |||
comments = data['object'].comments(update=False) | |||
comments = self.flatten_comments(comments, | |||
root_level=data['level']) | |||
comment_data = [self.strip_praw_comment(c) for c in comments] | |||
self._comment_data[index:index + 1] = comment_data | |||
else: | |||
raise ValueError('% type not recognized' % data['type']) | |||
class SubredditContent(BaseContent): | |||
""" | |||
Grab a subreddit from PRAW and lazily stores submissions to an internal | |||
list for repeat access. | |||
""" | |||
def __init__(self, name, submissions, loader): | |||
self.name = name | |||
self._loader = loader | |||
self._submissions = submissions | |||
self._submission_data = [] | |||
# Verify that content exists for the given submission generator. | |||
# This is necessary because PRAW loads submissions lazily, and | |||
# there is is no other way to check things like multireddits that | |||
# don't have a real corresponding subreddit object. | |||
try: | |||
self.get(0) | |||
except (praw.errors.APIException, requests.HTTPError, | |||
praw.errors.RedirectException): | |||
raise SubredditError(display_name) | |||
@classmethod | |||
def from_name(cls, reddit, name, loader, order='hot', query=None): | |||
if order not in ['hot', 'top', 'rising', 'new', 'controversial']: | |||
raise SubredditError(display_name) | |||
name = name.strip(' /') # Strip leading and trailing backslashes | |||
if name.startswith('r/'): | |||
name = name[2:] | |||
# Grab the display order e.g. "python/new" | |||
if '/' in name: | |||
name, order = name.split('/') | |||
display_name = display_name = '/r/{}'.format(name) | |||
if order != 'hot': | |||
display_name += '/{}'.format(order) | |||
if name == 'me': | |||
if not reddit.is_logged_in(): | |||
raise AccountError | |||
else: | |||
submissions = reddit.user.get_submitted(sort=order) | |||
elif query: | |||
if name == 'front': | |||
submissions = reddit.search(query, subreddit=None, sort=order) | |||
else: | |||
submissions = reddit.search(query, subreddit=name, sort=order) | |||
else: | |||
if name == 'front': | |||
dispatch = { | |||
'hot': reddit.get_front_page, | |||
'top': reddit.get_top, | |||
'rising': reddit.get_rising, | |||
'new': reddit.get_new, | |||
'controversial': reddit.get_controversial, | |||
} | |||
else: | |||
subreddit = reddit.get_subreddit(name) | |||
dispatch = { | |||
'hot': subreddit.get_hot, | |||
'top': subreddit.get_top, | |||
'rising': subreddit.get_rising, | |||
'new': subreddit.get_new, | |||
'controversial': subreddit.get_controversial, | |||
} | |||
submissions = dispatch[order](limit=None) | |||
return cls(display_name, submissions, loader) | |||
def get(self, index, n_cols=70): | |||
""" | |||
Grab the `i`th submission, with the title field formatted to fit inside | |||
of a window of width `n_cols` | |||
""" | |||
if index < 0: | |||
raise IndexError | |||
while index >= len(self._submission_data): | |||
try: | |||
with self._loader(): | |||
submission = next(self._submissions) | |||
except StopIteration: | |||
raise IndexError | |||
else: | |||
data = self.strip_praw_submission(submission) | |||
self._submission_data.append(data) | |||
# Modifies the original dict, faster than copying | |||
data = self._submission_data[index] | |||
data['split_title'] = textwrap.wrap(data['title'], width=n_cols) | |||
data['n_rows'] = len(data['split_title']) + 3 | |||
data['offset'] = 0 | |||
return data |
@@ -0,0 +1,289 @@ | |||
import os | |||
import time | |||
import threading | |||
import curses | |||
from curses import textpad, ascii | |||
from contextlib import contextmanager | |||
from .docs import HELP | |||
from .helpers import strip_textpad | |||
from .exceptions import EscapeInterrupt | |||
__all__ = ['ESCAPE', 'UARROW', 'DARROW', 'BULLET', 'show_notification', | |||
'show_help', 'LoadScreen', 'Color', 'text_input', 'curses_session'] | |||
ESCAPE = 27 | |||
# Curses does define constants for these (e.g. curses.ACS_BULLET) | |||
# However, they rely on using the curses.addch() function, which has been | |||
# found to be buggy and a PITA to work with. By defining them as unicode | |||
# points they can be added via the more reliable curses.addstr(). | |||
# http://bugs.python.org/issue21088 | |||
UARROW = u'\u25b2'.encode('utf-8') | |||
DARROW = u'\u25bc'.encode('utf-8') | |||
BULLET = u'\u2022'.encode('utf-8') | |||
GOLD = u'\u272A'.encode('utf-8') | |||
def show_notification(stdscr, message): | |||
""" | |||
Overlay a message box on the center of the screen and wait for user input. | |||
Params: | |||
message (list): List of strings, one per line. | |||
""" | |||
n_rows, n_cols = stdscr.getmaxyx() | |||
box_width = max(map(len, message)) + 2 | |||
box_height = len(message) + 2 | |||
# Cut off the lines of the message that don't fit on the screen | |||
box_width = min(box_width, n_cols) | |||
box_height = min(box_height, n_rows) | |||
message = message[:box_height-2] | |||
s_row = (n_rows - box_height) // 2 | |||
s_col = (n_cols - box_width) // 2 | |||
window = stdscr.derwin(box_height, box_width, s_row, s_col) | |||
window.erase() | |||
window.border() | |||
for index, line in enumerate(message, start=1): | |||
window.addnstr(index, 1, line, box_width - 2) | |||
window.refresh() | |||
ch = stdscr.getch() | |||
window.clear() | |||
window = None | |||
stdscr.refresh() | |||
return ch | |||
def show_help(stdscr): | |||
""" | |||
Overlay a message box with the help screen. | |||
""" | |||
show_notification(stdscr, HELP.splitlines()) | |||
class LoadScreen(object): | |||
""" | |||
Display a loading dialog while waiting for a blocking action to complete. | |||
This class spins off a seperate thread to animate the loading screen in the | |||
background. | |||
Usage: | |||
#>>> loader = LoadScreen(stdscr) | |||
#>>> with loader(...): | |||
#>>> blocking_request(...) | |||
""" | |||
def __init__(self, stdscr): | |||
self._stdscr = stdscr | |||
self._args = None | |||
self._animator = None | |||
self._is_running = None | |||
def __call__( | |||
self, | |||
delay=0.5, | |||
interval=0.4, | |||
message='Downloading', | |||
trail='...'): | |||
""" | |||
Params: | |||
delay (float): Length of time that the loader will wait before | |||
printing on the screen. Used to prevent flicker on pages that | |||
load very fast. | |||
interval (float): Length of time between each animation frame. | |||
message (str): Message to display | |||
trail (str): Trail of characters that will be animated by the | |||
loading screen. | |||
""" | |||
self._args = (delay, interval, message, trail) | |||
return self | |||
def __enter__(self): | |||
self._animator = threading.Thread(target=self.animate, args=self._args) | |||
self._animator.daemon = True | |||
self._is_running = True | |||
self._animator.start() | |||
def __exit__(self, exc_type, exc_val, exc_tb): | |||
self._is_running = False | |||
self._animator.join() | |||
def animate(self, delay, interval, message, trail): | |||
start = time.time() | |||
while (time.time() - start) < delay: | |||
if not self._is_running: | |||
return | |||
message_len = len(message) + len(trail) | |||
n_rows, n_cols = self._stdscr.getmaxyx() | |||
s_row = (n_rows - 3) // 2 | |||
s_col = (n_cols - message_len - 1) // 2 | |||
window = self._stdscr.derwin(3, message_len + 2, s_row, s_col) | |||
while True: | |||
for i in range(len(trail) + 1): | |||
if not self._is_running: | |||
window.clear() | |||
window = None | |||
self._stdscr.refresh() | |||
return | |||
window.erase() | |||
window.border() | |||
window.addstr(1, 1, message + trail[:i]) | |||
window.refresh() | |||
time.sleep(interval) | |||
class Color(object): | |||
""" | |||
Color attributes for curses. | |||
""" | |||
_colors = { | |||
'RED': (curses.COLOR_RED, -1), | |||
'GREEN': (curses.COLOR_GREEN, -1), | |||
'YELLOW': (curses.COLOR_YELLOW, -1), | |||
'BLUE': (curses.COLOR_BLUE, -1), | |||
'MAGENTA': (curses.COLOR_MAGENTA, -1), | |||
'CYAN': (curses.COLOR_CYAN, -1), | |||
'WHITE': (curses.COLOR_WHITE, -1), | |||
} | |||
@classmethod | |||
def init(cls): | |||
""" | |||
Initialize color pairs inside of curses using the default background. | |||
This should be called once during the curses initial setup. Afterwards, | |||
curses color pairs can be accessed directly through class attributes. | |||
""" | |||
# Assign the terminal's default (background) color to code -1 | |||
curses.use_default_colors() | |||
for index, (attr, code) in enumerate(cls._colors.items(), start=1): | |||
curses.init_pair(index, code[0], code[1]) | |||
setattr(cls, attr, curses.color_pair(index)) | |||
@classmethod | |||
def get_level(cls, level): | |||
levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW] | |||
return levels[level % len(levels)] | |||
def text_input(window, allow_resize=True): | |||
""" | |||
Transform a window into a text box that will accept user input and loop | |||
until an escape sequence is entered. | |||
If enter is pressed, return the input text as a string. | |||
If escape is pressed, return None. | |||
""" | |||
window.clear() | |||
# Set cursor mode to 1 because 2 doesn't display on some terminals | |||
curses.curs_set(1) | |||
# Turn insert_mode off to avoid the recursion error described here | |||
# http://bugs.python.org/issue13051 | |||
textbox = textpad.Textbox(window, insert_mode=False) | |||
textbox.stripspaces = 0 | |||
def validate(ch): | |||
"Filters characters for special key sequences" | |||
if ch == ESCAPE: | |||
raise EscapeInterrupt | |||
if (not allow_resize) and (ch == curses.KEY_RESIZE): | |||
raise EscapeInterrupt | |||
# Fix backspace for iterm | |||
if ch == ascii.DEL: | |||
ch = curses.KEY_BACKSPACE | |||
return ch | |||
# Wrapping in an exception block so that we can distinguish when the user | |||
# hits the return character from when the user tries to back out of the | |||
# input. | |||
try: | |||
out = textbox.edit(validate=validate) | |||
except EscapeInterrupt: | |||
out = None | |||
curses.curs_set(0) | |||
return strip_textpad(out) | |||
@contextmanager | |||
def curses_session(): | |||
""" | |||
Setup terminal and initialize curses. | |||
""" | |||
try: | |||
# Curses must wait for some time after the Escape key is pressed to | |||
# check if it is the beginning of an escape sequence indicating a | |||
# special key. The default wait time is 1 second, which means that | |||
# getch() will not return the escape key (27) until a full second | |||
# after it has been pressed. | |||
# Turn this down to 25 ms, which is close to what VIM uses. | |||
# http://stackoverflow.com/questions/27372068 | |||
os.environ['ESCDELAY'] = '25' | |||
# Initialize curses | |||
stdscr = curses.initscr() | |||
# Turn off echoing of keys, and enter cbreak mode, | |||
# where no buffering is performed on keyboard input | |||
curses.noecho() | |||
curses.cbreak() | |||
# In keypad mode, escape sequences for special keys | |||
# (like the cursor keys) will be interpreted and | |||
# a special value like curses.KEY_LEFT will be returned | |||
stdscr.keypad(1) | |||
# Start color, too. Harmless if the terminal doesn't have | |||
# color; user can test with has_color() later on. The try/catch | |||
# works around a minor bit of over-conscientiousness in the curses | |||
# module -- the error return from C start_color() is ignorable. | |||
try: | |||
curses.start_color() | |||
except: | |||
pass | |||
Color.init() | |||
# Hide blinking cursor | |||
curses.curs_set(0) | |||
yield stdscr | |||
finally: | |||
if stdscr is not None: | |||
stdscr.keypad(0) | |||
curses.echo() | |||
curses.nocbreak() | |||
curses.endwin() |
@@ -0,0 +1,68 @@ | |||
from .__version__ import __version__ | |||
__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'CONTROLS', 'HELP', 'COMMENT_FILE', | |||
'SUBMISSION_FILE'] | |||
AGENT = """\ | |||
desktop:https://github.com/michael-lazar/rtv:{} (by /u/civilization_phaze_3)\ | |||
""".format(__version__) | |||
SUMMARY = """ | |||
Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a | |||
terminal window. | |||
""" | |||
AUTH = """\ | |||
Authenticating is required to vote and leave comments. If only a username is | |||
given, the program will display a secure prompt to enter a password. | |||
""" | |||
CONTROLS = """ | |||
Controls | |||
-------- | |||
RTV currently supports browsing both subreddits and individual submissions. | |||
In each mode the controls are slightly different. In subreddit mode you can | |||
browse through the top submissions on either the front page or a specific | |||
subreddit. In submission mode you can view the self text for a submission and | |||
browse comments. | |||
""" | |||
HELP = """ | |||
Global Commands | |||
`UP/DOWN` or `j/k` : Scroll to the prev/next item | |||
`a/z` : Upvote/downvote the selected item | |||
`ENTER` or `o` : Open the selected item in the default web browser | |||
`r` : Refresh the current page | |||
`u` : Login/logout of your user account | |||
`?` : Show this help message | |||
`q` : Quit the program | |||
Subreddit Mode | |||
`RIGHT` or `l` : View comments for the selected submission | |||
`/` : Open a prompt to switch subreddits | |||
`f` : Open a prompt to search the current subreddit | |||
`p` : Post a new submission to the current subreddit | |||
Submission Mode | |||
`LEFT` or `h` : Return to subreddit mode | |||
`RIGHT` or `l` : Fold the selected comment, or load additional comments | |||
`c` : Post a new comment on the selected item | |||
""" | |||
COMMENT_FILE = """ | |||
# Please enter a comment. Lines starting with '#' will be ignored, | |||
# and an empty message aborts the comment. | |||
# | |||
# Replying to {author}'s {type} | |||
{content} | |||
""" | |||
SUBMISSION_FILE = """ | |||
# Please enter your submission. Lines starting with '#' will be ignored, | |||
# and an empty field aborts the submission. | |||
# | |||
# The first line will be interpreted as the title | |||
# The following lines will be interpreted as the content | |||
# | |||
# Posting to /r/{name} | |||
""" |
@@ -0,0 +1,31 @@ | |||
class EscapeInterrupt(Exception): | |||
"Signal that the ESC key has been pressed" | |||
class RTVError(Exception): | |||
"Base RTV error class" | |||
class AccountError(RTVError): | |||
"Could not access user account" | |||
class SubmissionError(RTVError): | |||
"Submission could not be loaded" | |||
def __init__(self, url): | |||
self.url = url | |||
class SubredditError(RTVError): | |||
"Subreddit could not be reached" | |||
def __init__(self, name): | |||
self.name = name | |||
class ProgramError(RTVError): | |||
"Problem executing an external program" | |||
def __init__(self, name): | |||
self.name = name |
@@ -0,0 +1,164 @@ | |||
import sys | |||
import os | |||
import textwrap | |||
import subprocess | |||
from datetime import datetime | |||
from tempfile import NamedTemporaryFile | |||
from . import config | |||
from .exceptions import ProgramError | |||
__all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad', | |||
'strip_subreddit_url', 'humanize_timestamp', 'open_editor'] | |||
def open_editor(data=''): | |||
""" | |||
Open a temporary file using the system's default editor. | |||
The data string will be written to the file before opening. This function | |||
will block until the editor has closed. At that point the file will be | |||
read and and lines starting with '#' will be stripped. | |||
""" | |||
with NamedTemporaryFile(prefix='rtv-', suffix='.txt', mode='w') as fp: | |||
fp.write(data) | |||
fp.flush() | |||
editor = os.getenv('RTV_EDITOR') or os.getenv('EDITOR') or 'nano' | |||
try: | |||
subprocess.Popen([editor, fp.name]).wait() | |||
except OSError as e: | |||
raise ProgramError(editor) | |||
# Open a second file object to read. This appears to be necessary in | |||
# order to read the changes made by some editors (gedit). w+ mode does | |||
# not work! | |||
with open(fp.name) as fp2: | |||
text = ''.join(line for line in fp2 if not line.startswith('#')) | |||
text = text.rstrip() | |||
return text | |||
def open_browser(url): | |||
""" | |||
Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull. | |||
This is a workaround to stop firefox from spewing warning messages to the | |||
console. See http://bugs.python.org/issue22277 for a better description | |||
of the problem. | |||
""" | |||
command = "import webbrowser; webbrowser.open_new_tab('%s')" % url | |||
args = [sys.executable, '-c', command] | |||
with open(os.devnull, 'ab+', 0) as null: | |||
subprocess.check_call(args, stdout=null, stderr=null) | |||
def clean(string): | |||
""" | |||
Required reading! | |||
http://nedbatchelder.com/text/unipain.html | |||
Python 2 input string will be a unicode type (unicode code points). Curses | |||
will accept unicode if all of the points are in the ascii range. However, if | |||
any of the code points are not valid ascii curses will throw a | |||
UnicodeEncodeError: 'ascii' codec can't encode character, ordinal not in | |||
range(128). If we encode the unicode to a utf-8 byte string and pass that to | |||
curses, it will render correctly. | |||
Python 3 input string will be a string type (unicode code points). Curses | |||
will accept that in all cases. However, the n character count in addnstr | |||
will not be correct. If code points are passed to addnstr, curses will treat | |||
each code point as one character and will not account for wide characters. | |||
If utf-8 is passed in, addnstr will treat each 'byte' as a single character. | |||
""" | |||
encoding = 'utf-8' if config.unicode else 'ascii' | |||
string = string.encode(encoding, 'replace') | |||
return string | |||
def wrap_text(text, width): | |||
""" | |||
Wrap text paragraphs to the given character width while preserving newlines. | |||
""" | |||
out = [] | |||
for paragraph in text.splitlines(): | |||
# Wrap returns an empty list when paragraph is a newline. In order to | |||
# preserve newlines we substitute a list containing an empty string. | |||
lines = textwrap.wrap(paragraph, width=width) or [''] | |||
out.extend(lines) | |||
return out | |||
def strip_textpad(text): | |||
""" | |||
Attempt to intelligently strip excess whitespace from the output of a | |||
curses textpad. | |||
""" | |||
if text is None: | |||
return text | |||
# Trivial case where the textbox is only one line long. | |||
if '\n' not in text: | |||
return text.rstrip() | |||
# Allow one space at the end of the line. If there is more than one space, | |||
# assume that a newline operation was intended by the user | |||
stack, current_line = [], '' | |||
for line in text.split('\n'): | |||
if line.endswith(' '): | |||
stack.append(current_line + line.rstrip()) | |||
current_line = '' | |||
else: | |||
current_line += line | |||
stack.append(current_line) | |||
# Prune empty lines at the bottom of the textbox. | |||
for item in stack[::-1]: | |||
if len(item) == 0: | |||
stack.pop() | |||
else: | |||
break | |||
out = '\n'.join(stack) | |||
return out | |||
def strip_subreddit_url(permalink): | |||
""" | |||
Strip a subreddit name from the subreddit's permalink. | |||
This is used to avoid submission.subreddit.url making a seperate API call. | |||
""" | |||
subreddit = permalink.split('/')[4] | |||
return '/r/{}'.format(subreddit) | |||
def humanize_timestamp(utc_timestamp, verbose=False): | |||
""" | |||
Convert a utc timestamp into a human readable relative-time. | |||
""" | |||
timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp) | |||
seconds = int(timedelta.total_seconds()) | |||
if seconds < 60: | |||
return 'moments ago' if verbose else '0min' | |||
minutes = seconds // 60 | |||
if minutes < 60: | |||
return ('%d minutes ago' % minutes) if verbose else ('%dmin' % minutes) | |||
hours = minutes // 60 | |||
if hours < 24: | |||
return ('%d hours ago' % hours) if verbose else ('%dhr' % hours) | |||
days = hours // 24 | |||
if days < 30: | |||
return ('%d days ago' % days) if verbose else ('%dday' % days) | |||
months = days // 30.4 | |||
if months < 12: | |||
return ('%d months ago' % months) if verbose else ('%dmonth' % months) | |||
years = months // 12 | |||
return ('%d years ago' % years) if verbose else ('%dyr' % years) |
@@ -0,0 +1,399 @@ | |||
import curses | |||
import six | |||
import sys | |||
import praw.errors | |||
from .helpers import clean | |||
from .curses_helpers import Color, show_notification, show_help, text_input | |||
from .docs import AGENT | |||
__all__ = ['Navigator'] | |||
class Navigator(object): | |||
""" | |||
Handles math behind cursor movement and screen paging. | |||
""" | |||
def __init__( | |||
self, | |||
valid_page_cb, | |||
page_index=0, | |||
cursor_index=0, | |||
inverted=False): | |||
self.page_index = page_index | |||
self.cursor_index = cursor_index | |||
self.inverted = inverted | |||
self._page_cb = valid_page_cb | |||
self._header_window = None | |||
self._content_window = None | |||
@property | |||
def step(self): | |||
return 1 if not self.inverted else -1 | |||
@property | |||
def position(self): | |||
return (self.page_index, self.cursor_index, self.inverted) | |||
@property | |||
def absolute_index(self): | |||
return self.page_index + (self.step * self.cursor_index) | |||
def move(self, direction, n_windows): | |||
"Move the cursor down (positive direction) or up (negative direction)" | |||
valid, redraw = True, False | |||
forward = ((direction * self.step) > 0) | |||
if forward: | |||
if self.page_index < 0: | |||
if self._is_valid(0): | |||
# Special case - advance the page index if less than zero | |||
self.page_index = 0 | |||
self.cursor_index = 0 | |||
redraw = True | |||
else: | |||
valid = False | |||
else: | |||
self.cursor_index += 1 | |||
if not self._is_valid(self.absolute_index): | |||
# Move would take us out of bounds | |||
self.cursor_index -= 1 | |||
valid = False | |||
elif self.cursor_index >= (n_windows - 1): | |||
# Flip the orientation and reset the cursor | |||
self.flip(self.cursor_index) | |||
self.cursor_index = 0 | |||
redraw = True | |||
else: | |||
if self.cursor_index > 0: | |||
self.cursor_index -= 1 | |||
else: | |||
self.page_index -= self.step | |||
if self._is_valid(self.absolute_index): | |||
# We have reached the beginning of the page - move the | |||
# index | |||
redraw = True | |||
else: | |||
self.page_index += self.step | |||
valid = False # Revert | |||
return valid, redraw | |||
def flip(self, n_windows): | |||
"Flip the orientation of the page" | |||
self.page_index += (self.step * n_windows) | |||
self.cursor_index = n_windows | |||
self.inverted = not self.inverted | |||
def _is_valid(self, page_index): | |||
"Check if a page index will cause entries to fall outside valid range" | |||
try: | |||
self._page_cb(page_index) | |||
except IndexError: | |||
return False | |||
else: | |||
return True | |||
class BaseController(object): | |||
""" | |||
Event handler for triggering functions with curses keypresses. | |||
Register a keystroke to a class method using the @egister decorator. | |||
#>>> @Controller.register('a', 'A') | |||
#>>> def func(self, *args) | |||
Register a default behavior by using `None`. | |||
#>>> @Controller.register(None) | |||
#>>> def default_func(self, *args) | |||
Bind the controller to a class instance and trigger a key. Additional | |||
arguments will be passed to the function. | |||
#>>> controller = Controller(self) | |||
#>>> controller.trigger('a', *args) | |||
""" | |||
character_map = {None: (lambda *args, **kwargs: None)} | |||
def __init__(self, instance): | |||
self.instance = instance | |||
def trigger(self, char, *args, **kwargs): | |||
if isinstance(char, six.string_types) and len(char) == 1: | |||
char = ord(char) | |||
func = self.character_map.get(char) | |||
if func is None: | |||
func = BaseController.character_map.get(char) | |||
if func is None: | |||
func = self.character_map.get(None) | |||
if func is None: | |||
func = BaseController.character_map.get(None) | |||
return func(self.instance, *args, **kwargs) | |||
@classmethod | |||
def register(cls, *chars): | |||
def wrap(f): | |||
for char in chars: | |||
if isinstance(char, six.string_types) and len(char) == 1: | |||
cls.character_map[ord(char)] = f | |||
else: | |||
cls.character_map[char] = f | |||
return f | |||
return wrap | |||
class BasePage(object): | |||
""" | |||
Base terminal viewer incorperates a cursor to navigate content | |||
""" | |||
MIN_HEIGHT = 10 | |||
MIN_WIDTH = 20 | |||
def __init__(self, stdscr, reddit, content, **kwargs): | |||
self.stdscr = stdscr | |||
self.reddit = reddit | |||
self.content = content | |||
self.nav = Navigator(self.content.get, **kwargs) | |||
self._header_window = None | |||
self._content_window = None | |||
self._subwindows = None | |||
@BaseController.register('q') | |||
def exit(self): | |||
sys.exit() | |||
@BaseController.register('?') | |||
def help(self): | |||
show_help(self._content_window) | |||
@BaseController.register(curses.KEY_UP, 'k') | |||
def move_cursor_up(self): | |||
self._move_cursor(-1) | |||
self.clear_input_queue() | |||
@BaseController.register(curses.KEY_DOWN, 'j') | |||
def move_cursor_down(self): | |||
self._move_cursor(1) | |||
self.clear_input_queue() | |||
def clear_input_queue(self): | |||
"Clear excessive input caused by the scroll wheel or holding down a key" | |||
self.stdscr.nodelay(1) | |||
while self.stdscr.getch() != -1: | |||
continue | |||
self.stdscr.nodelay(0) | |||
@BaseController.register('a') | |||
def upvote(self): | |||
data = self.content.get(self.nav.absolute_index) | |||
try: | |||
if 'likes' not in data: | |||
pass | |||
elif data['likes']: | |||
data['object'].clear_vote() | |||
data['likes'] = None | |||
else: | |||
data['object'].upvote() | |||
data['likes'] = True | |||
except praw.errors.LoginOrScopeRequired: | |||
show_notification(self.stdscr, ['Not logged in']) | |||
@BaseController.register('z') | |||
def downvote(self): | |||
data = self.content.get(self.nav.absolute_index) | |||
try: | |||
if 'likes' not in data: | |||
pass | |||
if data['likes'] is False: | |||
data['object'].clear_vote() | |||
data['likes'] = None | |||
else: | |||
data['object'].downvote() | |||
data['likes'] = False | |||
except praw.errors.LoginOrScopeRequired: | |||
show_notification(self.stdscr, ['Not logged in']) | |||
@BaseController.register('u') | |||
def login(self): | |||
""" | |||
Prompt to log into the user's account, or log out of the current | |||
account. | |||
""" | |||
if self.reddit.is_logged_in(): | |||
self.logout() | |||
return | |||
username = self.prompt_input('Enter username:') | |||
password = self.prompt_input('Enter password:', hide=True) | |||
if not username or not password: | |||
curses.flash() | |||
return | |||
try: | |||
with self.loader(): | |||
self.reddit.login(username, password) | |||
except praw.errors.InvalidUserPass: | |||
show_notification(self.stdscr, ['Invalid user/pass']) | |||
else: | |||
show_notification(self.stdscr, ['Welcome {}'.format(username)]) | |||
def logout(self): | |||
"Prompt to log out of the user's account." | |||
ch = self.prompt_input("Log out? (y/n):") | |||
if ch == 'y': | |||
self.reddit.clear_authentication() | |||
show_notification(self.stdscr, ['Logged out']) | |||
elif ch != 'n': | |||
curses.flash() | |||
def prompt_input(self, prompt, hide=False): | |||
"Prompt the user for input" | |||
attr = curses.A_BOLD | Color.CYAN | |||
n_rows, n_cols = self.stdscr.getmaxyx() | |||
if hide: | |||
prompt += ' ' * (n_cols - len(prompt) - 1) | |||
self.stdscr.addstr(n_rows-1, 0, prompt, attr) | |||
out = self.stdscr.getstr(n_rows-1, 1) | |||
else: | |||
self.stdscr.addstr(n_rows - 1, 0, prompt, attr) | |||
self.stdscr.refresh() | |||
window = self.stdscr.derwin(1, n_cols - len(prompt), | |||
n_rows - 1, len(prompt)) | |||
window.attrset(attr) | |||
out = text_input(window) | |||
return out | |||
def draw(self): | |||
n_rows, n_cols = self.stdscr.getmaxyx() | |||
if n_rows < self.MIN_HEIGHT or n_cols < self.MIN_WIDTH: | |||
return | |||
# Note: 2 argument form of derwin breaks PDcurses on Windows 7! | |||
self._header_window = self.stdscr.derwin(1, n_cols, 0, 0) | |||
self._content_window = self.stdscr.derwin(n_rows - 1, n_cols, 1, 0) | |||
self.stdscr.erase() | |||
self._draw_header() | |||
self._draw_content() | |||
self._add_cursor() | |||
@staticmethod | |||
def draw_item(window, data, inverted): | |||
raise NotImplementedError | |||
def _draw_header(self): | |||
n_rows, n_cols = self._header_window.getmaxyx() | |||
self._header_window.erase() | |||
attr = curses.A_REVERSE | curses.A_BOLD | Color.CYAN | |||
self._header_window.bkgd(' ', attr) | |||
sub_name = self.content.name.replace('/r/front', 'Front Page ') | |||
self._header_window.addnstr(0, 0, clean(sub_name), n_cols - 1) | |||
if self.reddit.user is not None: | |||
username = self.reddit.user.name | |||
s_col = (n_cols - len(username) - 1) | |||
# Only print the username if it fits in the empty space on the | |||
# right | |||
if (s_col - 1) >= len(sub_name): | |||
n = (n_cols - s_col - 1) | |||
self._header_window.addnstr(0, s_col, clean(username), n) | |||
self._header_window.refresh() | |||
def _draw_content(self): | |||
""" | |||
Loop through submissions and fill up the content page. | |||
""" | |||
n_rows, n_cols = self._content_window.getmaxyx() | |||
self._content_window.erase() | |||
self._subwindows = [] | |||
page_index, cursor_index, inverted = self.nav.position | |||
step = self.nav.step | |||
# If not inverted, align the first submission with the top and draw | |||
# downwards. If inverted, align the first submission with the bottom | |||
# and draw upwards. | |||
current_row = (n_rows - 1) if inverted else 0 | |||
available_rows = (n_rows - 1) if inverted else n_rows | |||
for data in self.content.iterate(page_index, step, n_cols - 2): | |||
window_rows = min(available_rows, data['n_rows']) | |||
window_cols = n_cols - data['offset'] | |||
start = current_row - window_rows if inverted else current_row | |||
subwindow = self._content_window.derwin( | |||
window_rows, window_cols, start, data['offset']) | |||
attr = self.draw_item(subwindow, data, inverted) | |||
self._subwindows.append((subwindow, attr)) | |||
available_rows -= (window_rows + 1) # Add one for the blank line | |||
current_row += step * (window_rows + 1) | |||
if available_rows <= 0: | |||
break | |||
else: | |||
# If the page is not full we need to make sure that it is NOT | |||
# inverted. Unfortunately, this currently means drawing the whole | |||
# page over again. Could not think of a better way to pre-determine | |||
# if the content will fill up the page, given that it is dependent | |||
# on the size of the terminal. | |||
if self.nav.inverted: | |||
self.nav.flip((len(self._subwindows) - 1)) | |||
self._draw_content() | |||
self._content_window.refresh() | |||
def _add_cursor(self): | |||
self._edit_cursor(curses.A_REVERSE) | |||
def _remove_cursor(self): | |||
self._edit_cursor(curses.A_NORMAL) | |||
def _move_cursor(self, direction): | |||
self._remove_cursor() | |||
valid, redraw = self.nav.move(direction, len(self._subwindows)) | |||
if not valid: | |||
curses.flash() | |||
# Note: ACS_VLINE doesn't like changing the attribute, so always redraw. | |||
# if redraw: self._draw_content() | |||
self._draw_content() | |||
self._add_cursor() | |||
def _edit_cursor(self, attribute=None): | |||
# Don't allow the cursor to go below page index 0 | |||
if self.nav.absolute_index < 0: | |||
return | |||
window, attr = self._subwindows[self.nav.cursor_index] | |||
if attr is not None: | |||
attribute |= attr | |||
n_rows, _ = window.getmaxyx() | |||
for row in range(n_rows): | |||
window.chgat(row, 0, 1, attribute) | |||
window.refresh() |
@@ -0,0 +1,268 @@ | |||
import curses | |||
import sys | |||
import time | |||
import logging | |||
import praw.errors | |||
from .content import SubmissionContent | |||
from .page import BasePage, Navigator, BaseController | |||
from .helpers import clean, open_browser, open_editor | |||
from .curses_helpers import (BULLET, UARROW, DARROW, GOLD, Color, LoadScreen, | |||
show_notification, text_input) | |||
from .docs import COMMENT_FILE | |||
__all__ = ['SubmissionController', 'SubmissionPage'] | |||
_logger = logging.getLogger(__name__) | |||
class SubmissionController(BaseController): | |||
character_map = {} | |||
class SubmissionPage(BasePage): | |||
def __init__(self, stdscr, reddit, url=None, submission=None): | |||
self.controller = SubmissionController(self) | |||
self.loader = LoadScreen(stdscr) | |||
if url: | |||
content = SubmissionContent.from_url(reddit, url, self.loader) | |||
elif submission: | |||
content = SubmissionContent(submission, self.loader) | |||
else: | |||
raise ValueError('Must specify url or submission') | |||
super(SubmissionPage, self).__init__(stdscr, reddit, | |||
content, page_index=-1) | |||
def loop(self): | |||
"Main control loop" | |||
self.active = True | |||
while self.active: | |||
self.draw() | |||
cmd = self.stdscr.getch() | |||
self.controller.trigger(cmd) | |||
@SubmissionController.register(curses.KEY_RIGHT, 'l') | |||
def toggle_comment(self): | |||
"Toggle the selected comment tree between visible and hidden" | |||
current_index = self.nav.absolute_index | |||
self.content.toggle(current_index) | |||
if self.nav.inverted: | |||
# Reset the page so that the bottom is at the cursor position. | |||
# This is a workaround to handle if folding the causes the | |||
# cursor index to go out of bounds. | |||
self.nav.page_index, self.nav.cursor_index = current_index, 0 | |||
@SubmissionController.register(curses.KEY_LEFT, 'h') | |||
def exit_submission(self): | |||
"Close the submission and return to the subreddit page" | |||
self.active = False | |||
@SubmissionController.register(curses.KEY_F5, 'r') | |||
def refresh_content(self): | |||
"Re-download comments reset the page index" | |||
self.content = SubmissionContent.from_url( | |||
self.reddit, | |||
self.content.name, | |||
self.loader) | |||
self.nav = Navigator(self.content.get, page_index=-1) | |||
@SubmissionController.register(curses.KEY_ENTER, 10, 'o') | |||
def open_link(self): | |||
"Open the current submission page with the webbrowser" | |||
# May want to expand at some point to open comment permalinks | |||
url = self.content.get(-1)['permalink'] | |||
open_browser(url) | |||
@SubmissionController.register('c') | |||
def add_comment(self): | |||
""" | |||
Add a top-level comment if the submission is selected, or reply to the | |||
selected comment. | |||
""" | |||
if not self.reddit.is_logged_in(): | |||
show_notification(self.stdscr, ['Login to post']) | |||
return | |||
data = self.content.get(self.nav.absolute_index) | |||
if data['type'] == 'Submission': | |||
content = data['text'] | |||
elif data['type'] == 'Comment': | |||
content = data['body'] | |||
else: | |||
curses.flash() | |||
return | |||
# Comment out every line of the content | |||
content = '\n'.join(['# |' + line for line in content.split('\n')]) | |||
comment_info = COMMENT_FILE.format( | |||
author=data['author'], | |||
type=data['type'].lower(), | |||
content=content) | |||
curses.endwin() | |||
comment_text = open_editor(comment_info) | |||
curses.doupdate() | |||
if not comment_text: | |||
show_notification(self.stdscr, ['Comment canceled']) | |||
return | |||
try: | |||
if data['type'] == 'Submission': | |||
data['object'].add_comment(comment_text) | |||
else: | |||
data['object'].reply(comment_text) | |||
except praw.errors.APIException: | |||
message = ['Error: {}'.format(e.error_type), e.message] | |||
show_notification(self.stdscr, message) | |||
_logger.exception(e) | |||
except requests.HTTPError as e: | |||
show_notification(self.stdscr, ['Unexpected Error']) | |||
_logger.exception(e) | |||
else: | |||
with self.loader(delay=0, message='Posting'): | |||
time.sleep(2.0) | |||
self.refresh_content() | |||
def draw_item(self, win, data, inverted=False): | |||
if data['type'] == 'MoreComments': | |||
return self.draw_more_comments(win, data) | |||
elif data['type'] == 'HiddenComment': | |||
return self.draw_more_comments(win, data) | |||
elif data['type'] == 'Comment': | |||
return self.draw_comment(win, data, inverted=inverted) | |||
else: | |||
return self.draw_submission(win, data) | |||
@staticmethod | |||
def draw_comment(win, data, inverted=False): | |||
n_rows, n_cols = win.getmaxyx() | |||
n_cols -= 1 | |||
# Handle the case where the window is not large enough to fit the text. | |||
valid_rows = range(0, n_rows) | |||
offset = 0 if not inverted else -(data['n_rows'] - n_rows) | |||
row = offset | |||
if row in valid_rows: | |||
text = clean(u'{author} '.format(**data)) | |||
attr = curses.A_BOLD | |||
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN) | |||
win.addnstr(row, 1, text, n_cols - 1, attr) | |||
if data['flair']: | |||
text = clean(u'{flair} '.format(**data)) | |||
attr = curses.A_BOLD | Color.YELLOW | |||
win.addnstr(text, n_cols - win.getyx()[1], attr) | |||
if data['likes'] is None: | |||
text, attr = BULLET, curses.A_BOLD | |||
elif data['likes']: | |||
text, attr = UARROW, (curses.A_BOLD | Color.GREEN) | |||
else: | |||
text, attr = DARROW, (curses.A_BOLD | Color.RED) | |||
win.addnstr(text, n_cols - win.getyx()[1], attr) | |||
text = clean(u' {score} {created} '.format(**data)) | |||
win.addnstr(text, n_cols - win.getyx()[1]) | |||
if data['gold']: | |||
text, attr = GOLD, (curses.A_BOLD | Color.YELLOW) | |||
win.addnstr(text, n_cols - win.getyx()[1], attr) | |||
n_body = len(data['split_body']) | |||
for row, text in enumerate(data['split_body'], start=offset + 1): | |||
if row in valid_rows: | |||
text = clean(text) | |||
win.addnstr(row, 1, text, n_cols - 1) | |||
# Unfortunately vline() doesn't support custom color so we have to | |||
# build it one segment at a time. | |||
attr = Color.get_level(data['level']) | |||
for y in range(n_rows): | |||
x = 0 | |||
# http://bugs.python.org/issue21088 | |||
if (sys.version_info.major, | |||
sys.version_info.minor, | |||
sys.version_info.micro) == (3, 4, 0): | |||
x, y = y, x | |||
win.addch(y, x, curses.ACS_VLINE, attr) | |||
return (attr | curses.ACS_VLINE) | |||
@staticmethod | |||
def draw_more_comments(win, data): | |||
n_rows, n_cols = win.getmaxyx() | |||
n_cols -= 1 | |||
text = clean(u'{body}'.format(**data)) | |||
win.addnstr(0, 1, text, n_cols - 1) | |||
text = clean(u' [{count}]'.format(**data)) | |||
win.addnstr(text, n_cols - win.getyx()[1], curses.A_BOLD) | |||
# Unfortunately vline() doesn't support custom color so we have to | |||
# build it one segment at a time. | |||
attr = Color.get_level(data['level']) | |||
win.addch(0, 0, curses.ACS_VLINE, attr) | |||
return (attr | curses.ACS_VLINE) | |||
@staticmethod | |||
def draw_submission(win, data): | |||
n_rows, n_cols = win.getmaxyx() | |||
n_cols -= 3 # one for each side of the border + one for offset | |||
for row, text in enumerate(data['split_title'], start=1): | |||
text = clean(text) | |||
win.addnstr(row, 1, text, n_cols, curses.A_BOLD) | |||
row = len(data['split_title']) + 1 | |||
attr = curses.A_BOLD | Color.GREEN | |||
text = clean(u'{author}'.format(**data)) | |||
win.addnstr(row, 1, text, n_cols, attr) | |||
attr = curses.A_BOLD | Color.YELLOW | |||
text = clean(u' {flair}'.format(**data)) | |||
win.addnstr(text, n_cols - win.getyx()[1], attr) | |||
text = clean(u' {created} {subreddit}'.format(**data)) | |||
win.addnstr(text, n_cols - win.getyx()[1]) | |||
row = len(data['split_title']) + 2 | |||
attr = curses.A_UNDERLINE | Color.BLUE | |||
text = clean(u'{url}'.format(**data)) | |||
win.addnstr(row, 1, text, n_cols, attr) | |||
offset = len(data['split_title']) + 3 | |||
# Cut off text if there is not enough room to display the whole post | |||
split_text = data['split_text'] | |||
if data['n_rows'] > n_rows: | |||
cutoff = data['n_rows'] - n_rows + 1 | |||
split_text = split_text[:-cutoff] | |||
split_text.append('(Not enough space to display)') | |||
for row, text in enumerate(split_text, start=offset): | |||
text = clean(text) | |||
win.addnstr(row, 1, text, n_cols) | |||
row = len(data['split_title']) + len(split_text) + 3 | |||
text = clean(u'{score} {comments} '.format(**data)) | |||
win.addnstr(row, 1, text, n_cols, curses.A_BOLD) | |||
if data['gold']: | |||
text, attr = GOLD, (curses.A_BOLD | Color.YELLOW) | |||
win.addnstr(text, n_cols - win.getyx()[1], attr) | |||
win.border() |
@@ -0,0 +1,209 @@ | |||
import curses | |||
import time | |||
import logging | |||
import requests | |||
import praw | |||
from .exceptions import SubredditError, AccountError | |||
from .page import BasePage, Navigator, BaseController | |||
from .submission import SubmissionPage | |||
from .content import SubredditContent | |||
from .helpers import clean, open_browser, open_editor | |||
from .docs import SUBMISSION_FILE | |||
from .curses_helpers import (BULLET, UARROW, DARROW, GOLD, Color, | |||
LoadScreen, show_notification) | |||
__all__ = ['opened_links', 'SubredditController', 'SubredditPage'] | |||
_logger = logging.getLogger(__name__) | |||
# Used to keep track of browsing history across the current session | |||
opened_links = set() | |||
class SubredditController(BaseController): | |||
character_map = {} | |||
class SubredditPage(BasePage): | |||
def __init__(self, stdscr, reddit, name): | |||
self.controller = SubredditController(self) | |||
self.loader = LoadScreen(stdscr) | |||
content = SubredditContent.from_name(reddit, name, self.loader) | |||
super(SubredditPage, self).__init__(stdscr, reddit, content) | |||
def loop(self): | |||
"Main control loop" | |||
while True: | |||
self.draw() | |||
cmd = self.stdscr.getch() | |||
self.controller.trigger(cmd) | |||
@SubredditController.register(curses.KEY_F5, 'r') | |||
def refresh_content(self, name=None): | |||
"Re-download all submissions and reset the page index" | |||
name = name or self.content.name | |||
try: | |||
self.content = SubredditContent.from_name( | |||
self.reddit, name, self.loader) | |||
except AccountError: | |||
show_notification(self.stdscr, ['Not logged in']) | |||
except SubredditError: | |||
show_notification(self.stdscr, ['Invalid subreddit']) | |||
except requests.HTTPError: | |||
show_notification(self.stdscr, ['Could not reach subreddit']) | |||
else: | |||
self.nav = Navigator(self.content.get) | |||
@SubredditController.register('f') | |||
def search_subreddit(self, name=None): | |||
"Open a prompt to search the given subreddit" | |||
name = name or self.content.name | |||
prompt = 'Search {}:'.format(name) | |||
query = self.prompt_input(prompt) | |||
if query is None: | |||
return | |||
try: | |||
self.content = SubredditContent.from_name( | |||
self.reddit, name, self.loader, query=query) | |||
except IndexError: # if there are no submissions | |||
show_notification(self.stdscr, ['No results found']) | |||
else: | |||
self.nav = Navigator(self.content.get) | |||
@SubredditController.register('/') | |||
def prompt_subreddit(self): | |||
"Open a prompt to navigate to a different subreddit" | |||
prompt = 'Enter Subreddit: /r/' | |||
name = self.prompt_input(prompt) | |||
if name is not None: | |||
self.refresh_content(name=name) | |||
@SubredditController.register(curses.KEY_RIGHT, 'l') | |||
def open_submission(self): | |||
"Select the current submission to view posts" | |||
data = self.content.get(self.nav.absolute_index) | |||
page = SubmissionPage(self.stdscr, self.reddit, url=data['permalink']) | |||
page.loop() | |||
if data['url'] == 'selfpost': | |||
global opened_links | |||
opened_links.add(data['url_full']) | |||
@SubredditController.register(curses.KEY_ENTER, 10, 'o') | |||
def open_link(self): | |||
"Open a link with the webbrowser" | |||
url = self.content.get(self.nav.absolute_index)['url_full'] | |||
open_browser(url) | |||
global opened_links | |||
opened_links.add(url) | |||
@SubredditController.register('p') | |||
def post_submission(self): | |||
"Post a new submission to the given subreddit" | |||
if not self.reddit.is_logged_in(): | |||
show_notification(self.stdscr, ['Login to post']) | |||
return | |||
# Strips the subreddit to just the name | |||
# Make sure it is a valid subreddit for submission | |||
subreddit = self.reddit.get_subreddit(self.content.name) | |||
sub = str(subreddit).split('/')[2] | |||
if '+' in sub or sub in ('all', 'front', 'me'): | |||
show_notification(self.stdscr, ['Invalid subreddit']) | |||