49 changed files with 2129 additions and 0 deletions
@ -0,0 +1,17 @@
@@ -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 |
||||
} |
Binary file not shown.
@ -0,0 +1,27 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
|
||||
|
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
[console_scripts] |
||||
rtv = rtv.__main__:main |
||||
|
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
praw>=2.1.6 |
||||
six |
||||
requests |
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
rtv |
@ -0,0 +1,6 @@
@@ -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 @@
@@ -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()) |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
__version__ = '1.2' |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
""" |
||||
Global configuration settings |
||||
""" |
||||
|
||||
unicode = False |
@ -0,0 +1,322 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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): |
||||
|
||||