Squashed commit of the following:

Updated the supported python versions list.
    Fixed regression in displaying xposts. #173.
    Fixing a few style things.
    Added a more robust test for the tornado handler.
    Trying without pytest-cov
    Updated travis for coverage.
    Remove python 3.2 support because no unicode literals, following what praw supports.
    "Side effect is not iterable."
    Added requirements for travis.
    Renamed travis file correctly.
    Adding test configurations, got tox working.
    Adding vcr cassettes to the repo.
    Renamed requirements files.
    Split up tests and cleaned up test names.
    Tests done, still one failure.
    Treat cassettes as binary to prevent bad merging.
    Fixed a few broken tests.
    Added a timeout to notifications.
    Prepping subreddit page.
    Finished submission page tests.
    Working on submission tests.
    Fixed vcr matching on urls with params, started submission tests.
    Log cleanup.
    Still trying to fix a broken test.
    -Fixed a few pytest bugs and tweaked logging.
    Still working on subscription tests.
    Finished page tests, on to subscription page.
    Finished content tests and starting page tests.
    Added the test refresh-token file to gitignore.
    Moved functional test file out of the repository.
    Continuing work on subreddit content tests.
    Tests now match module names, cassettes are split into individual tests for faster loading.
    Linter fixes.
    Cleanup.
    Added support for nested loaders.
    Added pytest options, starting subreddit content tests.
    Back on track with loader, continuing content tests.
    Finishing submission content tests and discovered snag with loader exception handling.
    VCR up and running, continuing to implement content tests.
    Playing around with vcr.py
    Moved helper functions into terminal and new objects.py
    Fixed a few broken tests.
    Working on navigator tests.
    Reorganizing some things.
    Mocked webbrowser._tryorder for terminal test.
    Completed oauth tests.
    Progress on the oauth tests.
    Working on adding fake tornado request.
    Starting on OAuth tool tests.
    Finished curses helpers tests.
    Still working on curses helpers tests.
    Almost finished with tests on curses helpers.
    Adding tests and working on mocking stdscr.
    Starting to add tests for curses functions.
    Merge branch 'future_work' of https://github.com/michael-lazar/rtv into future_work
    Refactoring controller, still in progress.
    Renamed auth handler.
    Rename CursesHelper to CursesBase.
    Added temporary file with a possible template for func testing.
    Mixup between basename and dirname.
    Merge branch 'future_work' of https://github.com/michael-lazar/rtv into future_work
    py3 compatability for mock.
    Beginning to refactor the curses session.
    Started adding tests, improved unicode handling in the config.
    Cleanup, fixed a few typos.
    Major refactor, almost done!.
    Started a config class.
    Merge branch 'master' into future_work
    The editor now handles unicode characters in all situations.
    Fixed a few typos from previous commits.
    __main__.py formatting.
    Cleaned up history logic and moved to the config file.
This commit is contained in:
Michael Lazar
2015-12-02 22:37:50 -08:00
parent b91bb86e36
commit a7b789bfd9
70 changed files with 42141 additions and 1560 deletions

View File

@@ -1,3 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from .__version__ import __version__
__title__ = 'Reddit Terminal Viewer'

View File

@@ -1,22 +1,21 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
import locale
import logging
import praw
import praw.errors
import tornado
from requests import exceptions
from . import config
from .exceptions import RTVError
from .curses_helpers import curses_session, LoadScreen
from .submission import SubmissionPage
from . import docs
from .config import Config
from .oauth import OAuthHelper
from .terminal import Terminal
from .objects import curses_session
from .subreddit import SubredditPage
from .docs import AGENT
from .oauth import OAuthTool
from .__version__ import __version__
__all__ = []
_logger = logging.getLogger(__name__)
# Pycharm debugging note:
@@ -35,57 +34,66 @@ def main():
locale.setlocale(locale.LC_ALL, '')
# Set the terminal title
# TODO: Need to clear the title when the program exits
title = 'rtv {0}'.format(__version__)
sys.stdout.write("\x1b]2;{0}\x07".format(title))
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
# Fill in empty arguments with config file values. Parameters explicitly
# typed on the command line will take priority over config file params.
parser = config.build_parser()
args = parser.parse_args()
# Attempt to load from the config file first, and then overwrite with any
# provided command line arguments.
config = Config()
config.from_file()
config.from_args()
local_config = config.load_config()
for key, val in local_config.items():
if getattr(args, key, None) is None:
setattr(args, key, val)
# Load the browsing history from previous sessions
config.load_history()
if args.ascii:
config.unicode = False
if not args.persistent:
config.persistent = False
if args.clear_auth:
config.clear_refresh_token()
# Load any previously saved auth session token
config.load_refresh_token()
if config['clear_auth']:
config.delete_refresh_token()
if args.log:
logging.basicConfig(level=logging.DEBUG, filename=args.log)
if config['log']:
logging.basicConfig(level=logging.DEBUG, filename=config['log'])
else:
# Add an empty handler so the logger doesn't complain
logging.root.addHandler(logging.NullHandler())
# Construct the reddit user agent
user_agent = docs.AGENT.format(version=__version__)
try:
print('Connecting...')
reddit = praw.Reddit(user_agent=AGENT.format(version=__version__))
reddit.config.decode_html_entities = False
with curses_session() as stdscr:
oauth = OAuthTool(reddit, stdscr, LoadScreen(stdscr))
if oauth.refresh_token:
term = Terminal(stdscr, config['ascii'])
with term.loader(catch_exception=False):
reddit = praw.Reddit(
user_agent=user_agent,
decode_html_entities=False,
disable_update_check=True)
# Authorize on launch if the refresh token is present
oauth = OAuthHelper(reddit, term, config)
if config.refresh_token:
oauth.authorize()
if args.link:
page = SubmissionPage(stdscr, reddit, oauth, url=args.link)
page.loop()
subreddit = args.subreddit or 'front'
page = SubredditPage(stdscr, reddit, oauth, subreddit)
with term.loader():
page = SubredditPage(
reddit, term, config, oauth,
name=config['subreddit'], url=config['link'])
if term.loader.exception:
return
page.loop()
except (exceptions.RequestException, praw.errors.PRAWException,
RTVError) as e:
except Exception as e:
_logger.exception(e)
print('{}: {}'.format(type(e).__name__, e))
raise
except KeyboardInterrupt:
pass
finally:
# Try to save the browsing history
config.save_history()
# Ensure sockets are closed to prevent a ResourceWarning
reddit.handler.http.close()
if 'reddit' in locals():
reddit.handler.http.close()
# Explicitly close file descriptors opened by Tornado's IOLoop
tornado.ioloop.IOLoop.current().close(all_fds=True)
sys.exit(main())
sys.exit(main())

View File

@@ -1 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
__version__ = '1.6.1'

View File

@@ -1,38 +1,30 @@
"""
Global configuration settings
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import codecs
import argparse
from six.moves import configparser
from . import docs, __version__
HOME = os.path.expanduser('~')
PACKAGE = os.path.dirname(__file__)
XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg')
TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token')
unicode = True
persistent = True
# https://github.com/reddit/reddit/wiki/OAuth2
# Client ID is of type "installed app" and the secret should be left empty
oauth_client_id = 'E2oEtRQfdfAfNQ'
oauth_client_secret = 'praw_gapfill'
oauth_redirect_uri = 'http://127.0.0.1:65000/'
oauth_redirect_port = 65000
oauth_scope = ['edit', 'history', 'identity', 'mysubreddits',
'privatemessages', 'read', 'report', 'save', 'submit',
'subscribe', 'vote']
HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log')
TEMPLATE = os.path.join(PACKAGE, 'templates')
def build_parser():
parser = argparse.ArgumentParser(
prog='rtv', description=docs.SUMMARY, epilog=docs.CONTROLS+docs.HELP,
prog='rtv', description=docs.SUMMARY,
epilog=docs.CONTROLS+docs.HELP,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
'-V', '--version', action='version', version='rtv '+__version__,
)
'-V', '--version', action='version', version='rtv '+__version__)
parser.add_argument(
'-s', dest='subreddit',
help='name of the subreddit that will be opened on start')
@@ -40,57 +32,163 @@ def build_parser():
'-l', dest='link',
help='full URL of a submission that will be opened on start')
parser.add_argument(
'--ascii', action='store_true',
'--ascii', action='store_const', const=True,
help='enable ascii-only mode')
parser.add_argument(
'--log', metavar='FILE', action='store',
help='log HTTP requests to a file')
parser.add_argument(
'--non-persistent', dest='persistent', action='store_false',
'--non-persistent', dest='persistent', action='store_const',
const=False,
help='Forget all authenticated users when the program exits')
parser.add_argument(
'--clear-auth', dest='clear_auth', action='store_true',
'--clear-auth', dest='clear_auth', action='store_const', const=True,
help='Remove any saved OAuth tokens before starting')
return parser
def load_config():
class OrderedSet(object):
"""
Attempt to load settings from the local config file.
A simple implementation of an ordered set. A set is used to check
for membership, and a list is used to maintain ordering.
"""
config = configparser.ConfigParser()
if os.path.exists(CONFIG):
config.read(CONFIG)
def __init__(self, elements=None):
elements = elements or []
self._set = set(elements)
self._list = elements
config_dict = {}
if config.has_section('rtv'):
config_dict = dict(config.items('rtv'))
def __contains__(self, item):
return item in self._set
# Convert 'true'/'false' to boolean True/False
if 'ascii' in config_dict:
config_dict['ascii'] = config.getboolean('rtv', 'ascii')
if 'clear_auth' in config_dict:
config_dict['clear_auth'] = config.getboolean('rtv', 'clear_auth')
if 'persistent' in config_dict:
config_dict['persistent'] = config.getboolean('rtv', 'persistent')
def __len__(self):
return len(self._list)
return config_dict
def __getitem__(self, item):
return self._list[item]
def add(self, item):
self._set.add(item)
self._list.append(item)
def load_refresh_token(filename=TOKEN):
if os.path.exists(filename):
with open(filename) as fp:
return fp.read().strip()
else:
return None
class Config(object):
DEFAULT = {
'ascii': False,
'persistent': True,
'clear_auth': False,
'log': None,
'link': None,
'subreddit': 'front',
'history_size': 200,
# https://github.com/reddit/reddit/wiki/OAuth2
# Client ID is of type "installed app" and the secret should be empty
'oauth_client_id': 'E2oEtRQfdfAfNQ',
'oauth_client_secret': 'praw_gapfill',
'oauth_redirect_uri': 'http://127.0.0.1:65000/',
'oauth_redirect_port': 65000,
'oauth_scope': [
'edit', 'history', 'identity', 'mysubreddits', 'privatemessages',
'read', 'report', 'save', 'submit', 'subscribe', 'vote'],
'template_path': TEMPLATE,
}
def save_refresh_token(token, filename=TOKEN):
with open(filename, 'w+') as fp:
fp.write(token)
def __init__(self,
config_file=CONFIG,
history_file=HISTORY,
token_file=TOKEN,
**kwargs):
self.config_file = config_file
self.history_file = history_file
self.token_file = token_file
self.config = kwargs
def clear_refresh_token(filename=TOKEN):
if os.path.exists(filename):
os.remove(filename)
# `refresh_token` and `history` are saved/loaded at separate locations,
# so they are treated differently from the rest of the config options.
self.refresh_token = None
self.history = OrderedSet()
def __getitem__(self, item):
return self.config.get(item, self.DEFAULT.get(item))
def __setitem__(self, key, value):
self.config[key] = value
def __delitem__(self, key):
self.config.pop(key, None)
def update(self, **kwargs):
self.config.update(kwargs)
def from_args(self):
parser = build_parser()
args = vars(parser.parse_args())
# Filter out argument values that weren't supplied
args = {key: val for key, val in args.items() if val is not None}
self.update(**args)
def from_file(self):
config = configparser.ConfigParser()
if os.path.exists(self.config_file):
with codecs.open(self.config_file, encoding='utf-8') as fp:
config.readfp(fp)
config_dict = {}
if config.has_section('rtv'):
config_dict = dict(config.items('rtv'))
# Convert 'true'/'false' to boolean True/False
if 'ascii' in config_dict:
config_dict['ascii'] = config.getboolean('rtv', 'ascii')
if 'clear_auth' in config_dict:
config_dict['clear_auth'] = config.getboolean('rtv', 'clear_auth')
if 'persistent' in config_dict:
config_dict['persistent'] = config.getboolean('rtv', 'persistent')
self.update(**config_dict)
def load_refresh_token(self):
if os.path.exists(self.token_file):
with open(self.token_file) as fp:
self.refresh_token = fp.read().strip()
else:
self.refresh_token = None
def save_refresh_token(self):
self._ensure_filepath(self.token_file)
with open(self.token_file, 'w+') as fp:
fp.write(self.refresh_token)
def delete_refresh_token(self):
if os.path.exists(self.token_file):
os.remove(self.token_file)
self.refresh_token = None
def load_history(self):
if os.path.exists(self.history_file):
with codecs.open(self.history_file, encoding='utf-8') as fp:
self.history = OrderedSet([line.strip() for line in fp])
else:
self.history = OrderedSet()
def save_history(self):
self._ensure_filepath(self.history_file)
with codecs.open(self.history_file, 'w+', encoding='utf-8') as fp:
fp.writelines('\n'.join(self.history[-self['history_size']:]))
def delete_history(self):
if os.path.exists(self.history_file):
os.remove(self.history_file)
self.history = OrderedSet()
@staticmethod
def _ensure_filepath(filename):
"""
Ensure that the directory exists before trying to write to the file.
"""
filepath = os.path.dirname(filename)
if not os.path.exists(filepath):
os.makedirs(filepath)

View File

@@ -1,28 +1,27 @@
import logging
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import praw
import requests
import re
from datetime import datetime
from .exceptions import (SubmissionError, SubredditError, SubscriptionError,
AccountError)
from .helpers import humanize_timestamp, wrap_text, strip_subreddit_url
import six
import praw
from kitchen.text.display import wrap
__all__ = ['SubredditContent', 'SubmissionContent', 'SubscriptionContent']
_logger = logging.getLogger(__name__)
from . import exceptions
class BaseContent(object):
class Content(object):
def get(self, index, n_cols):
raise NotImplementedError
def iterate(self, index, step, n_cols):
def iterate(self, index, step, n_cols=70):
while True:
if step < 0 and index < 0:
# Hack to prevent displaying negative indices if iterating in
# the negative direction.
# Hack to prevent displaying a submission's post if iterating
# comments in the negative direction
break
try:
yield self.get(index, n_cols=n_cols)
@@ -63,8 +62,8 @@ class BaseContent(object):
retval.append(item)
return retval
@staticmethod
def strip_praw_comment(comment):
@classmethod
def strip_praw_comment(cls, comment):
"""
Parse through a submission comment and return a dict with data ready to
be displayed through the terminal.
@@ -89,7 +88,7 @@ class BaseContent(object):
data['type'] = 'Comment'
data['body'] = comment.body
data['created'] = humanize_timestamp(comment.created_utc)
data['created'] = cls.humanize_timestamp(comment.created_utc)
data['score'] = '{} pts'.format(comment.score)
data['author'] = name
data['is_author'] = (name == sub_name)
@@ -100,8 +99,8 @@ class BaseContent(object):
return data
@staticmethod
def strip_praw_submission(sub):
@classmethod
def strip_praw_submission(cls, sub):
"""
Parse through a submission and return a dict with data ready to be
displayed through the terminal.
@@ -114,7 +113,7 @@ class BaseContent(object):
"""
reddit_link = re.compile(
"https?://(www\.)?(np\.)?redd(it\.com|\.it)/r/.*")
'https?://(www\.)?(np\.)?redd(it\.com|\.it)/r/.*')
author = getattr(sub, 'author', '[deleted]')
name = getattr(author, 'name', '[deleted]')
flair = getattr(sub, 'link_flair_text', '')
@@ -124,12 +123,12 @@ class BaseContent(object):
data['type'] = 'Submission'
data['title'] = sub.title
data['text'] = sub.selftext
data['created'] = humanize_timestamp(sub.created_utc)
data['created'] = cls.humanize_timestamp(sub.created_utc)
data['comments'] = '{} comments'.format(sub.num_comments)
data['score'] = '{} pts'.format(sub.score)
data['author'] = name
data['permalink'] = sub.permalink
data['subreddit'] = str(sub.subreddit)
data['subreddit'] = six.text_type(sub.subreddit)
data['flair'] = flair
data['url_full'] = sub.url
data['likes'] = sub.likes
@@ -146,7 +145,9 @@ class BaseContent(object):
data['url'] = 'self.{}'.format(data['subreddit'])
elif reddit_link.match(url_full):
data['url_type'] = 'x-post'
data['url'] = 'self.{}'.format(strip_subreddit_url(url_full)[3:])
# Strip the subreddit name from the permalink to avoid having
# submission.subreddit.url make a separate API call
data['url'] = 'self.{}'.format(url_full.split('/')[4])
else:
data['url_type'] = 'external'
data['url'] = url_full
@@ -165,11 +166,51 @@ class BaseContent(object):
data['type'] = 'Subscription'
data['name'] = "/r/" + subscription.display_name
data['title'] = subscription.title
return data
@staticmethod
def humanize_timestamp(utc_timestamp, verbose=False):
"""
Convert a utc timestamp into a human readable relative-time.
"""
class SubmissionContent(BaseContent):
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
@staticmethod
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 = wrap(paragraph, width=width) or ['']
out.extend(lines)
return out
class SubmissionContent(Content):
"""
Grab a submission from PRAW and lazily store comments to an internal
list for repeat access.
@@ -194,13 +235,8 @@ class SubmissionContent(BaseContent):
def from_url(cls, reddit, url, loader, indent_size=2, max_indent_level=8,
order=None):
try:
with loader():
url = url.replace('http:', 'https:')
submission = reddit.get_submission(url, comment_sort=order)
except (praw.errors.APIException, praw.errors.NotFound):
raise SubmissionError('Could not load %s' % url)
url = url.replace('http:', 'https:')
submission = reddit.get_submission(url, comment_sort=order)
return cls(submission, loader, indent_size, max_indent_level, order)
def get(self, index, n_cols=70):
@@ -214,8 +250,8 @@ class SubmissionContent(BaseContent):
elif index == -1:
data = self._submission_data
data['split_title'] = wrap_text(data['title'], width=n_cols-2)
data['split_text'] = wrap_text(data['text'], width=n_cols-2)
data['split_title'] = self.wrap_text(data['title'], width=n_cols-2)
data['split_text'] = self.wrap_text(data['text'], width=n_cols-2)
data['n_rows'] = len(data['split_title'] + data['split_text']) + 5
data['offset'] = 0
@@ -226,7 +262,7 @@ class SubmissionContent(BaseContent):
if data['type'] == 'Comment':
width = n_cols - data['offset']
data['split_body'] = wrap_text(data['body'], width=width)
data['split_body'] = self.wrap_text(data['body'], width=width)
data['n_rows'] = len(data['split_body']) + 1
else:
data['n_rows'] = 1
@@ -270,17 +306,19 @@ class SubmissionContent(BaseContent):
elif data['type'] == 'MoreComments':
with self._loader():
# Undefined behavior if using a nested loader here
assert self._loader.depth == 1
comments = data['object'].comments(update=True)
comments = self.flatten_comments(comments,
root_level=data['level'])
if not self._loader.exception:
comments = self.flatten_comments(comments, 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'])
raise ValueError('%s type not recognized' % data['type'])
class SubredditContent(BaseContent):
class SubredditContent(Content):
"""
Grab a subreddit from PRAW and lazily stores submissions to an internal
list for repeat access.
@@ -300,11 +338,8 @@ class SubredditContent(BaseContent):
# don't have a real corresponding subreddit object.
try:
self.get(0)
except (praw.errors.APIException, requests.HTTPError,
praw.errors.RedirectException, praw.errors.Forbidden,
praw.errors.InvalidSubreddit, praw.errors.NotFound,
IndexError):
raise SubredditError('Could not reach subreddit %s' % name)
except IndexError:
raise exceptions.SubredditError('Unable to retrieve subreddit')
@classmethod
def from_name(cls, reddit, name, loader, order=None, query=None):
@@ -322,11 +357,11 @@ class SubredditContent(BaseContent):
display_name = '/r/{}'.format(name)
if order not in ['hot', 'top', 'rising', 'new', 'controversial', None]:
raise SubredditError('Unrecognized order "%s"' % order)
raise exceptions.SubredditError('Unrecognized order "%s"' % order)
if name == 'me':
if not reddit.is_oauth_session():
raise AccountError('Could not access user account')
raise exceptions.AccountError('Could not access user account')
elif order:
submissions = reddit.user.get_submitted(sort=order)
else:
@@ -375,25 +410,27 @@ class SubredditContent(BaseContent):
try:
with self._loader():
submission = next(self._submissions)
if self._loader.exception:
raise IndexError
except StopIteration:
raise IndexError
else:
data = self.strip_praw_submission(submission)
data['index'] = index
# Add the post number to the beginning of the title
data['title'] = u'{}. {}'.format(index+1, data['title'])
data['title'] = '{0}. {1}'.format(index+1, data['title'])
self._submission_data.append(data)
# Modifies the original dict, faster than copying
data = self._submission_data[index]
data['split_title'] = wrap_text(data['title'], width=n_cols)
data['split_title'] = self.wrap_text(data['title'], width=n_cols)
data['n_rows'] = len(data['split_title']) + 3
data['offset'] = 0
return data
class SubscriptionContent(BaseContent):
class SubscriptionContent(Content):
def __init__(self, subscriptions, loader):
@@ -403,14 +440,14 @@ class SubscriptionContent(BaseContent):
self._subscriptions = subscriptions
self._subscription_data = []
try:
self.get(0)
except IndexError:
raise exceptions.SubscriptionError('Unable to load subscriptions')
@classmethod
def from_user(cls, reddit, loader):
try:
with loader():
subscriptions = reddit.get_my_subreddits(limit=None)
except praw.errors.APIException:
raise SubscriptionError('Unable to load subscriptions')
subscriptions = reddit.get_my_subreddits(limit=None)
return cls(subscriptions, loader)
def get(self, index, n_cols=70):
@@ -426,6 +463,8 @@ class SubscriptionContent(BaseContent):
try:
with self._loader():
subscription = next(self._subscriptions)
if self._loader.exception:
raise IndexError
except StopIteration:
raise IndexError
else:
@@ -433,7 +472,7 @@ class SubscriptionContent(BaseContent):
self._subscription_data.append(data)
data = self._subscription_data[index]
data['split_title'] = wrap_text(data['title'], width=n_cols)
data['split_title'] = self.wrap_text(data['title'], width=n_cols)
data['n_rows'] = len(data['split_title']) + 1
data['offset'] = 0

View File

@@ -1,368 +0,0 @@
import os
import time
import threading
import curses
from curses import textpad, ascii
from contextlib import contextmanager
from . import config
from .docs import HELP
from .helpers import strip_textpad, clean
from .exceptions import EscapeInterrupt
__all__ = ['ESCAPE', 'get_gold', 'show_notification', 'show_help',
'LoadScreen', 'Color', 'text_input', 'curses_session',
'prompt_input', 'add_line', 'get_arrow']
# Curses does define constants for symbols (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
ESCAPE = 27
def get_gold():
"""
Return the gilded symbol.
"""
symbol = u'\u272A' if config.unicode else '*'
attr = curses.A_BOLD | Color.YELLOW
return symbol, attr
def get_arrow(likes):
"""
Return the vote symbol to display, based on the `likes` paramater.
"""
if likes is None:
symbol = u'\u2022' if config.unicode else 'o'
attr = curses.A_BOLD
elif likes:
symbol = u'\u25b2' if config.unicode else '^'
attr = curses.A_BOLD | Color.GREEN
else:
symbol = u'\u25bc' if config.unicode else 'v'
attr = curses.A_BOLD | Color.RED
return symbol, attr
def add_line(window, text, row=None, col=None, attr=None):
"""
Unicode aware version of curses's built-in addnstr method.
Safely draws a line of text on the window starting at position (row, col).
Checks the boundaries of the window and cuts off the text if it exceeds
the length of the window.
"""
# The following arg combinations must be supported to conform with addnstr
# (window, text)
# (window, text, attr)
# (window, text, row, col)
# (window, text, row, col, attr)
cursor_row, cursor_col = window.getyx()
row = row if row is not None else cursor_row
col = col if col is not None else cursor_col
max_rows, max_cols = window.getmaxyx()
n_cols = max_cols - col - 1
if n_cols <= 0:
# Trying to draw outside of the screen bounds
return
text = clean(text, n_cols)
params = [] if attr is None else [attr]
window.addstr(row, col, text, *params)
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):
add_line(window, line, index, 1)
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 separate 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)
def prompt_input(window, prompt, hide=False):
"""
Display a prompt where the user can enter text at the bottom of the screen
Set hide to True to make the input text invisible.
"""
attr = curses.A_BOLD | Color.CYAN
n_rows, n_cols = window.getmaxyx()
if hide:
prompt += ' ' * (n_cols - len(prompt) - 1)
window.addstr(n_rows-1, 0, prompt, attr)
out = window.getstr(n_rows-1, 1)
else:
window.addstr(n_rows - 1, 0, prompt, attr)
window.refresh()
subwin = window.derwin(1, n_cols - len(prompt),
n_rows - 1, len(prompt))
subwin.attrset(attr)
out = text_input(subwin)
return 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()

View File

@@ -1,5 +1,5 @@
__all__ = ['AGENT', 'SUMMARY', 'CONTROLS', 'HELP', 'COMMENT_FILE',
'SUBMISSION_FILE', 'COMMENT_EDIT_FILE']
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
AGENT = """\
desktop:https://github.com/michael-lazar/rtv:{version}\
@@ -49,7 +49,7 @@ Submission Mode
`SPACE` : Fold the selected comment, or load additional comments
"""
COMMENT_FILE = u"""
COMMENT_FILE = """
# Please enter a comment. Lines starting with '#' will be ignored,
# and an empty message aborts the comment.
#
@@ -57,19 +57,26 @@ COMMENT_FILE = u"""
{content}
"""
COMMENT_EDIT_FILE = u"""{content}
COMMENT_EDIT_FILE = """{content}
# Please enter a comment. Lines starting with '#' will be ignored,
# and an empty message aborts the comment.
#
# Editing your comment
"""
SUBMISSION_FILE = u"""{content}
SUBMISSION_FILE = """
# Please enter your submission. Lines starting with '#' will be ignored,
# and an empty field aborts the submission.
# and an empty message aborts the submission.
#
# The first line will be interpreted as the title
# The following lines will be interpreted as the content
#
# Posting to {name}
"""
SUBMISSION_EDIT_FILE = """{content}
# Please enter your submission. Lines starting with '#' will be ignored,
# and an empty message aborts the submission.
#
# Editing {name}
"""

View File

@@ -1,3 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
class EscapeInterrupt(Exception):
"Signal that the ESC key has been pressed"

View File

@@ -1,224 +0,0 @@
import sys
import os
import curses
import webbrowser
import subprocess
from datetime import datetime
from tempfile import NamedTemporaryFile
# kitchen solves deficiencies in textwrap's handling of unicode characters
from kitchen.text.display import wrap, textual_width_chop
import six
from . import config
from .exceptions import ProgramError
__all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad',
'strip_subreddit_url', 'humanize_timestamp', 'open_editor',
'check_browser_display']
def clean(string, n_cols=None):
"""
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.
"""
if n_cols is not None and n_cols <= 0:
return ''
if not config.unicode:
if six.PY3 or isinstance(string, unicode):
string = string.encode('ascii', 'replace')
return string[:n_cols] if n_cols else string
else:
if n_cols:
string = textual_width_chop(string, n_cols)
if six.PY3 or isinstance(string, unicode):
string = string.encode('utf-8')
return string
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='wb') as fp:
fp.write(clean(data))
fp.flush()
editor = os.getenv('RTV_EDITOR') or os.getenv('EDITOR') or 'nano'
curses.endwin()
try:
subprocess.Popen([editor, fp.name]).wait()
except OSError:
raise ProgramError('Could not open file with %s' % editor)
curses.doupdate()
# 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):
"""
Open the given url using the default webbrowser. The preferred browser can
specified with the $BROWSER environment variable. If not specified, python
webbrowser will try to determine the default to use based on your system.
For browsers requiring an X display, we 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.
For console browsers (e.g. w3m), RTV will suspend and display the browser
window within the same terminal. This mode is triggered either when
1. $BROWSER is set to a known console browser, or
2. $DISPLAY is undefined, indicating that the terminal is running headless
There may be other cases where console browsers are opened (xdg-open?) but
are not detected here.
"""
if check_browser_display():
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)
else:
curses.endwin()
webbrowser.open_new_tab(url)
curses.doupdate()
def check_browser_display():
"""
Use a number of methods to guess if the default webbrowser will open in
the background as opposed to opening directly in the terminal.
"""
display = bool(os.environ.get("DISPLAY"))
# Use the convention defined here to parse $BROWSER
# https://docs.python.org/2/library/webbrowser.html
console_browsers = ['www-browser', 'links', 'links2', 'elinks', 'lynx',
'w3m']
if "BROWSER" in os.environ:
user_browser = os.environ["BROWSER"].split(os.pathsep)[0]
if user_browser in console_browsers:
display = False
if webbrowser._tryorder and webbrowser._tryorder[0] in console_browsers:
display = False
return display
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 = 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 separate 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)

View File

@@ -1,70 +0,0 @@
import os
__all__ = ['load_history', 'save_history']
def history_path():
"""
Create the path to the history log
"""
HOME = os.path.expanduser('~')
XDG_CONFIG_HOME = os.getenv('XDG_CACHE_HOME',
os.path.join(HOME, '.config'))
path = os.path.join(XDG_CONFIG_HOME, 'rtv')
if not os.path.exists(path):
os.makedirs(path)
return os.path.join(path, 'history.log')
def load_history():
"""
Load the history file into memory if it exists
"""
path = history_path()
if os.path.exists(path):
with open(path) as history_file:
# reverse the list so the newest ones are first
history = [line.strip() for line in history_file][::-1]
return OrderedSet(history)
return OrderedSet()
def save_history(history):
"""
Save the visited links to the history log
"""
path = history_path()
with open(path, 'w+') as history_file:
for i in range(200):
if not history:
break
try:
history_file.write(history.pop() + '\n')
except UnicodeEncodeError:
# Ignore unicode URLS, may want to handle this at some point
continue
class OrderedSet(object):
"""
A simple implementation of an ordered set. A set is used to check
for membership, and a list is used to maintain ordering.
"""
def __init__(self, elements=[]):
self._set = set(elements)
self._list = elements
def __contains__(self, item):
return item in self._set
def __len__(self):
return len(self._list)
def add(self, item):
self._set.add(item)
self._list.append(item)
def pop(self):
return self._list.pop()

View File

@@ -1,127 +1,143 @@
import os
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import time
import uuid
import praw
from tornado import gen, ioloop, web, httpserver
from concurrent.futures import ThreadPoolExecutor
from . import config
from .curses_helpers import show_notification
from .helpers import check_browser_display, open_browser
__all__ = ['OAuthTool']
class OAuthHandler(web.RequestHandler):
"""
Intercepts the redirect that Reddit sends the user to after they verify or
deny the application access.
oauth_state = None
oauth_code = None
oauth_error = None
The GET should supply 3 request params:
state: Unique id that was supplied by us at the beginning of the
process to verify that the session matches.
code: Code that we can use to generate the refresh token.
error: If an error occurred, it will be placed here.
"""
template_path = os.path.join(os.path.dirname(__file__), 'templates')
class AuthHandler(web.RequestHandler):
def initialize(self, display=None, params=None):
self.display = display
self.params = params
def get(self):
global oauth_state, oauth_code, oauth_error
self.params['state'] = self.get_argument('state', default=None)
self.params['code'] = self.get_argument('code', default=None)
self.params['error'] = self.get_argument('error', default=None)
oauth_state = self.get_argument('state', default='placeholder')
oauth_code = self.get_argument('code', default='placeholder')
oauth_error = self.get_argument('error', default='placeholder')
self.render('index.html', **self.params)
self.render('index.html', state=oauth_state, code=oauth_code,
error=oauth_error)
# Stop IOLoop if using a background browser such as firefox
if check_browser_display():
ioloop.IOLoop.current().stop()
complete = self.params['state'] and self.params['code']
if complete or self.params['error']:
# Stop IOLoop if using a background browser such as firefox
if self.display:
ioloop.IOLoop.current().stop()
class OAuthTool(object):
class OAuthHelper(object):
def __init__(self, reddit, stdscr=None, loader=None):
def __init__(self, reddit, term, config):
self.term = term
self.reddit = reddit
self.stdscr = stdscr
self.loader = loader
self.http_server = None
self.config = config
self.refresh_token = config.load_refresh_token()
self.http_server = None
self.params = {'state': None, 'code': None, 'error': None}
# Initialize Tornado webapp
routes = [('/', AuthHandler)]
self.callback_app = web.Application(routes,
template_path=template_path)
# Pass a mutable params object so the request handler can modify it
kwargs = {'display': self.term.display, 'params': self.params}
routes = [('/', OAuthHandler, kwargs)]
self.callback_app = web.Application(
routes, template_path=self.config['template_path'])
self.reddit.set_oauth_app_info(config.oauth_client_id,
config.oauth_client_secret,
config.oauth_redirect_uri)
self.reddit.set_oauth_app_info(
self.config['oauth_client_id'],
self.config['oauth_client_secret'],
self.config['oauth_redirect_uri'])
# Reddit's mobile website works better on terminal browsers
if not check_browser_display():
if not self.term.display:
if '.compact' not in self.reddit.config.API_PATHS['authorize']:
self.reddit.config.API_PATHS['authorize'] += '.compact'
def authorize(self):
self.params.update(state=None, code=None, error=None)
# If we already have a token, request new access credentials
if self.refresh_token:
with self.loader(message='Logging in'):
self.reddit.refresh_access_information(self.refresh_token)
return
if self.config.refresh_token:
with self.term.loader(message='Logging in'):
self.reddit.refresh_access_information(
self.config.refresh_token)
return
# https://github.com/tornadoweb/tornado/issues/1420
io = ioloop.IOLoop.current()
# Start the authorization callback server
if self.http_server is None:
self.http_server = httpserver.HTTPServer(self.callback_app)
self.http_server.listen(config.oauth_redirect_port)
self.http_server.listen(self.config['oauth_redirect_port'])
hex_uuid = uuid.uuid4().hex
state = uuid.uuid4().hex
authorize_url = self.reddit.get_authorize_url(
hex_uuid, scope=config.oauth_scope, refreshable=True)
state, scope=self.config['oauth_scope'], refreshable=True)
# Open the browser and wait for the user to authorize the app
if check_browser_display():
with self.loader(message='Waiting for authorization'):
open_browser(authorize_url)
ioloop.IOLoop.current().start()
if self.term.display:
# Open a background browser (e.g. firefox) which is non-blocking.
# Stop the iloop when the user hits the auth callback, at which
# point we continue and check the callback params.
with self.term.loader(message='Opening browser for authorization'):
self.term.open_browser(authorize_url)
io.start()
if self.term.loader.exception:
io.clear_instance()
return
else:
with self.loader(delay=0, message='Redirecting to reddit'):
# Provide user feedback
# Open the terminal webbrowser in a background thread and wait
# while for the user to close the process. Once the process is
# closed, the iloop is stopped and we can check if the user has
# hit the callback URL.
with self.term.loader(delay=0, message='Redirecting to reddit'):
# This load message exists to provide user feedback
time.sleep(1)
ioloop.IOLoop.current().add_callback(self._open_authorize_url,
authorize_url)
ioloop.IOLoop.current().start()
io.add_callback(self._async_open_browser, authorize_url)
io.start()
if oauth_error == 'access_denied':
show_notification(self.stdscr, ['Declined access'])
if self.params['error'] == 'access_denied':
self.term.show_notification('Declined access')
return
elif oauth_error != 'placeholder':
show_notification(self.stdscr, ['Authentication error'])
elif self.params['error']:
self.term.show_notification('Authentication error')
return
elif hex_uuid != oauth_state:
# Check if UUID matches obtained state.
# If not, authorization process is compromised.
show_notification(self.stdscr, ['UUID mismatch'])
elif self.params['state'] != state:
self.term.show_notification('UUID mismatch')
return
try:
with self.loader(message='Logging in'):
access_info = self.reddit.get_access_information(oauth_code)
self.refresh_token = access_info['refresh_token']
if config.persistent:
config.save_refresh_token(access_info['refresh_token'])
except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken):
show_notification(self.stdscr, ['Invalid OAuth data'])
else:
message = ['Welcome {}!'.format(self.reddit.user.name)]
show_notification(self.stdscr, message)
with self.term.loader(message='Logging in'):
info = self.reddit.get_access_information(self.params['code'])
if self.term.loader.exception:
return
message = 'Welcome {}!'.format(self.reddit.user.name)
self.term.show_notification(message)
self.config.refresh_token = info['refresh_token']
if self.config['persistent']:
self.config.save_refresh_token()
def clear_oauth_data(self):
self.reddit.clear_authentication()
config.clear_refresh_token()
self.refresh_token = None
self.config.delete_refresh_token()
@gen.coroutine
def _open_authorize_url(self, url):
def _async_open_browser(self, url):
with ThreadPoolExecutor(max_workers=1) as executor:
yield executor.submit(open_browser, url)
ioloop.IOLoop.current().stop()
yield executor.submit(self.term.open_browser, url)
ioloop.IOLoop.current().stop()

554
rtv/objects.py Normal file
View File

@@ -0,0 +1,554 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import time
import curses
import signal
import inspect
import weakref
import logging
import threading
from contextlib import contextmanager
import six
import praw
import requests
from . import exceptions
_logger = logging.getLogger(__name__)
@contextmanager
def curses_session():
"""
Setup terminal and initialize curses. Most of this copied from
curses.wrapper in order to convert the wrapper into a context manager.
"""
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
# 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
# Hide the blinking cursor
curses.curs_set(0)
Color.init()
yield stdscr
finally:
if 'stdscr' in locals():
stdscr.keypad(0)
curses.echo()
curses.nocbreak()
curses.endwin()
class LoadScreen(object):
"""
Display a loading dialog while waiting for a blocking action to complete.
This class spins off a separate thread to animate the loading screen in the
background. The loading thread also takes control of stdscr.getch(). If
an exception occurs in the main thread while the loader is active, the
exception will be caught, attached to the loader object, and displayed as
a notification. The attached exception can be used to trigger context
sensitive actions. For example, if the connection hangs while opening a
submission, the user may press ctrl-c to raise a KeyboardInterrupt. In this
case we would *not* want to refresh the current page.
>>> with self.terminal.loader(...) as loader:
>>> # Perform a blocking request to load content
>>> blocking_request(...)
>>>
>>> if loader.exception is None:
>>> # Only run this if the load was successful
>>> self.refresh_content()
When a loader is nested inside of itself, the outermost loader takes
priority and all of the nested loaders become no-ops. Call arguments given
to nested loaders will be ignored, and errors will propagate to the parent.
>>> with self.terminal.loader(...) as loader:
>>>
>>> # Additional loaders will be ignored
>>> with self.terminal.loader(...):
>>> raise KeyboardInterrupt()
>>>
>>> # This code will not be executed because the inner loader doesn't
>>> # catch the exception
>>> assert False
>>>
>>> # The exception is finally caught by the outer loader
>>> assert isinstance(terminal.loader.exception, KeyboardInterrupt)
"""
HANDLED_EXCEPTIONS = [
(exceptions.SubscriptionError, 'No Subscriptions'),
(exceptions.AccountError, 'Unable to Access Account'),
(exceptions.SubredditError, 'Invalid Subreddit'),
(praw.errors.InvalidSubreddit, 'Invalid Subreddit'),
(praw.errors.InvalidComment, 'Invalid Comment'),
(praw.errors.InvalidSubmission, 'Invalid Submission'),
(praw.errors.OAuthAppRequired, 'Invalid OAuth data'),
(praw.errors.OAuthException, 'Invalid OAuth data'),
(praw.errors.LoginOrScopeRequired, 'Not Logged In'),
(praw.errors.ClientException, 'Reddit Client Error'),
(praw.errors.NotFound, 'Not Found'),
(praw.errors.APIException, 'Reddit API Error'),
(praw.errors.HTTPException, 'Reddit HTTP Error'),
(requests.HTTPError, 'Unexpected HTTP Error'),
(requests.ConnectionError, 'Connection Error'),
(KeyboardInterrupt, None),
]
def __init__(self, terminal):
self.exception = None
self.catch_exception = None
self.depth = 0
self._terminal = weakref.proxy(terminal)
self._args = None
self._animator = None
self._is_running = None
def __call__(self, delay=0.5, interval=0.4, message='Downloading',
trail='...', catch_exception=True):
"""
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.
catch_exception (bool): If an exception occurs while the loader is
active, this flag determines whether it is caught or allowed to
bubble up.
"""
if self.depth > 0:
return self
self.exception = None
self.catch_exception = catch_exception
self._args = (delay, interval, message, trail)
return self
def __enter__(self):
self.depth += 1
if self.depth > 1:
return self
self._animator = threading.Thread(target=self.animate, args=self._args)
self._animator.daemon = True
self._is_running = True
self._animator.start()
return self
def __exit__(self, exc_type, e, exc_tb):
self.depth -= 1
if self.depth > 0:
return
self._is_running = False
self._animator.join()
self._terminal.stdscr.refresh()
if self.catch_exception and e is not None:
# Log the exception and attach it so the caller can inspect it
self.exception = e
_logger.info('Loader caught: {0} - {1}'.format(type(e).__name__, e))
# If an error occurred, display a notification on the screen
for base, message in self.HANDLED_EXCEPTIONS:
if isinstance(e, base):
if message:
self._terminal.show_notification(message)
break
else:
return # Re-raise unhandled exceptions
return True # Otherwise swallow the exception and continue
def animate(self, delay, interval, message, trail):
# The animation starts with a configurable delay before drawing on the
# screen. This is to prevent very short loading sections from
# flickering on the screen before immediately disappearing.
with self._terminal.no_delay():
start = time.time()
while (time.time() - start) < delay:
# Pressing escape triggers a keyboard interrupt
if self._terminal.getch() == self._terminal.ESCAPE:
os.kill(os.getpid(), signal.SIGINT)
self._is_running = False
if not self._is_running:
return
time.sleep(0.01)
# Build the notification window
message_len = len(message) + len(trail)
n_rows, n_cols = self._terminal.stdscr.getmaxyx()
s_row = (n_rows - 3) // 2
s_col = (n_cols - message_len - 1) // 2
window = curses.newwin(3, message_len + 2, s_row, s_col)
# Animate the loading prompt until the stopping condition is triggered
# when the context manager exits.
with self._terminal.no_delay():
while True:
for i in range(len(trail) + 1):
if not self._is_running:
window.erase()
del window
self._terminal.stdscr.touchwin()
self._terminal.stdscr.refresh()
return
window.erase()
window.border()
self._terminal.add_line(window, message + trail[:i], 1, 1)
window.refresh()
# Break up the designated sleep interval into smaller
# chunks so we can more responsively check for interrupts.
for _ in range(int(interval/0.01)):
# Pressing escape triggers a keyboard interrupt
if self._terminal.getch() == self._terminal.ESCAPE:
os.kill(os.getpid(), signal.SIGINT)
self._is_running = False
break
time.sleep(0.01)
class Color(object):
"""
Color attributes for curses.
"""
RED = curses.A_NORMAL
GREEN = curses.A_NORMAL
YELLOW = curses.A_NORMAL
BLUE = curses.A_NORMAL
MAGENTA = curses.A_NORMAL
CYAN = curses.A_NORMAL
WHITE = curses.A_NORMAL
_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)]
class Navigator(object):
"""
Handles the math behind cursor movement and screen paging.
This class determines how cursor movements effect the currently displayed
page. For example, if scrolling down the page, items are drawn from the
bottom up. This ensures that the item at the very bottom of the screen
(the one selected by cursor) will be fully drawn and not cut off. Likewise,
when scrolling up the page, items are drawn from the top down. If the
cursor is moved around without hitting the top or bottom of the screen, the
current mode is preserved.
"""
def __init__(
self,
valid_page_cb,
page_index=0,
cursor_index=0,
inverted=False):
"""
Params:
valid_page_callback (func): This function, usually `Content.get`,
takes a page index and raises an IndexError if that index falls
out of bounds. This is used to determine the upper and lower
bounds of the page, i.e. when to stop scrolling.
page_index (int): Initial page index.
cursor_index (int): Initial cursor index, relative to the page.
inverted (bool): Whether the page scrolling is reversed of not.
normal - The page is drawn from the top of the screen,
starting with the page index, down to the bottom of
the screen.
inverted - The page is drawn from the bottom of the screen,
starting with the page index, up to the top of the
screen.
"""
self.page_index = page_index
self.cursor_index = cursor_index
self.inverted = inverted
self._page_cb = valid_page_cb
@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 the index of the currently selected item.
"""
return self.page_index + (self.step * self.cursor_index)
def move(self, direction, n_windows):
"""
Move the cursor up or down by the given increment.
Params:
direction (int): `1` will move the cursor down one item and `-1`
will move the cursor up one item.
n_windows (int): The number of items that are currently being drawn
on the screen.
Returns:
valid (bool): Indicates whether or not the attempted cursor move is
allowed. E.g. When the cursor is on the last comment,
attempting to scroll down any further would not be valid.
redraw (bool): Indicates whether or not the screen needs to be
redrawn.
"""
assert direction in (-1, 1)
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 move_page(self, direction, n_windows):
"""
Move the page down (positive direction) or up (negative direction).
Paging down:
The post on the bottom of the page becomes the post at the top of
the page and the cursor is moved to the top.
Paging up:
The post at the top of the page becomes the post at the bottom of
the page and the cursor is moved to the bottom.
"""
assert direction in (-1, 1)
assert n_windows >= 0
# top of subreddit/submission page or only one
# submission/reply on the screen: act as normal move
if (self.absolute_index < 0) | (n_windows == 0):
valid, redraw = self.move(direction, n_windows)
else:
# first page
if self.absolute_index < n_windows and direction < 0:
self.page_index = -1
self.cursor_index = 0
self.inverted = False
# not submission mode: starting index is 0
if not self._is_valid(self.absolute_index):
self.page_index = 0
valid = True
else:
# flip to the direction of movement
if ((direction > 0) & (self.inverted is True))\
| ((direction < 0) & (self.inverted is False)):
self.page_index += (self.step * (n_windows-1))
self.inverted = not self.inverted
self.cursor_index \
= (n_windows-(direction < 0)) - self.cursor_index
valid = False
adj = 0
# check if reached the bottom
while not valid:
n_move = n_windows - adj
if n_move == 0:
break
self.page_index += n_move * direction
valid = self._is_valid(self.absolute_index)
if not valid:
self.page_index -= n_move * direction
adj += 1
redraw = True
return valid, redraw
def flip(self, n_windows):
"""
Flip the orientation of the page.
"""
assert n_windows >= 0
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 Controller(object):
"""
Event handler for triggering functions with curses keypresses.
Register a keystroke to a class method using the @register 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 = {}
def __init__(self, instance):
self.instance = instance
# Build a list of parent controllers that follow the object's MRO to
# check if any parent controllers have registered the keypress
self.parents = inspect.getmro(type(self))[:-1]
def trigger(self, char, *args, **kwargs):
if isinstance(char, six.string_types) and len(char) == 1:
char = ord(char)
func = None
# Check if the controller (or any of the controller's parents) have
# registered a function to the given key
for controller in self.parents:
if func:
break
func = controller.character_map.get(char)
# If the controller has not registered the key, check if there is a
# default function registered
for controller in self.parents:
if func:
break
func = controller.character_map.get(None)
return func(self.instance, *args, **kwargs) if func else None
@classmethod
def register(cls, *chars):
def inner(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 inner

View File

@@ -1,257 +1,47 @@
import curses
import time
import six
import sys
import logging
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
import time
import curses
from functools import wraps
import praw.errors
import requests
from kitchen.text.display import textual_width
from .helpers import open_editor
from .curses_helpers import (Color, show_notification, show_help, prompt_input,
add_line)
from .docs import COMMENT_EDIT_FILE, SUBMISSION_FILE
__all__ = ['Navigator', 'BaseController', 'BasePage']
_logger = logging.getLogger(__name__)
from . import docs
from .objects import Controller, Color
class Navigator(object):
def logged_in(f):
"""
Handles math behind cursor movement and screen paging.
Decorator for Page methods that require the user to be authenticated.
"""
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 move_page(self, direction, n_windows):
"""
Move page down (positive direction) or up (negative direction).
"""
# top of subreddit/submission page or only one
# submission/reply on the screen: act as normal move
if (self.absolute_index < 0) | (n_windows == 0):
valid, redraw = self.move(direction, n_windows)
else:
# first page
if self.absolute_index < n_windows and direction < 0:
self.page_index = -1
self.cursor_index = 0
self.inverted = False
# not submission mode: starting index is 0
if not self._is_valid(self.absolute_index):
self.page_index = 0
valid = True
else:
# flip to the direction of movement
if ((direction > 0) & (self.inverted is True))\
| ((direction < 0) & (self.inverted is False)):
self.page_index += (self.step * (n_windows-1))
self.inverted = not self.inverted
self.cursor_index \
= (n_windows-(direction < 0)) - self.cursor_index
valid = False
adj = 0
# check if reached the bottom
while not valid:
n_move = n_windows - adj
if n_move == 0:
break
self.page_index += n_move * direction
valid = self._is_valid(self.absolute_index)
if not valid:
self.page_index -= n_move * direction
adj += 1
redraw = True
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
@wraps(f)
def wrapped_method(self, *args, **kwargs):
if not self.reddit.is_oauth_session():
self.term.show_notification('Not logged in')
return
return f(self, *args, **kwargs)
return wrapped_method
class SafeCaller(object):
def __init__(self, window):
self.window = window
self.catch = True
def __enter__(self):
return self
def __exit__(self, exc_type, e, exc_tb):
if self.catch:
if isinstance(e, praw.errors.APIException):
message = ['Error: {}'.format(e.error_type), e.message]
show_notification(self.window, message)
_logger.exception(e)
return True
elif isinstance(e, praw.errors.ClientException):
message = ['Error: Client Exception', e.message]
show_notification(self.window, message)
_logger.exception(e)
return True
elif isinstance(e, requests.HTTPError):
show_notification(self.window, ['Unexpected Error'])
_logger.exception(e)
return True
elif isinstance(e, requests.ConnectionError):
show_notification(self.window, ['Unexpected Error'])
_logger.exception(e)
return True
class PageController(Controller):
character_map = {}
class BaseController(object):
"""
Event handler for triggering functions with curses keypresses.
class Page(object):
Register a keystroke to a class method using the @egister decorator.
#>>> @Controller.register('a', 'A')
#>>> def func(self, *args)
def __init__(self, reddit, term, config, oauth):
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 incorporates a cursor to navigate content
"""
MIN_HEIGHT = 10
MIN_WIDTH = 20
def __init__(self, stdscr, reddit, content, oauth, **kwargs):
self.stdscr = stdscr
self.reddit = reddit
self.content = content
self.term = term
self.config = config
self.oauth = oauth
self.nav = Navigator(self.content.get, **kwargs)
self.content = None
self.nav = None
self.controller = None
self.active = True
self._header_window = None
self._content_window = None
self._subwindows = None
@@ -260,101 +50,114 @@ class BasePage(object):
raise NotImplementedError
@staticmethod
def draw_item(window, data, inverted):
def _draw_item(window, data, inverted):
raise NotImplementedError
@BaseController.register('q')
def loop(self):
"""
Main control loop runs the following steps:
1. Re-draw the screen
2. Wait for user to press a key (includes terminal resizing)
3. Trigger the method registered to the input key
The loop will run until self.active is set to False from within one of
the methods.
"""
self.active = True
while self.active:
self.draw()
ch = self.term.stdscr.getch()
self.controller.trigger(ch)
@PageController.register('q')
def exit(self):
"""
Prompt to exit the application.
"""
ch = prompt_input(self.stdscr, "Do you really want to quit? (y/n): ")
if ch == 'y':
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
sys.exit()
elif ch != 'n':
curses.flash()
@BaseController.register('Q')
@PageController.register('Q')
def force_exit(self):
sys.exit()
@BaseController.register('?')
def help(self):
show_help(self._content_window)
@PageController.register('?')
def show_help(self):
self.term.show_notification(docs.HELP.strip().splitlines())
@BaseController.register('1')
@PageController.register('1')
def sort_content_hot(self):
self.refresh_content(order='hot')
@BaseController.register('2')
@PageController.register('2')
def sort_content_top(self):
self.refresh_content(order='top')
@BaseController.register('3')
@PageController.register('3')
def sort_content_rising(self):
self.refresh_content(order='rising')
@BaseController.register('4')
@PageController.register('4')
def sort_content_new(self):
self.refresh_content(order='new')
@BaseController.register('5')
@PageController.register('5')
def sort_content_controversial(self):
self.refresh_content(order='controversial')
@BaseController.register(curses.KEY_UP, 'k')
@PageController.register(curses.KEY_UP, 'k')
def move_cursor_up(self):
self._move_cursor(-1)
self.clear_input_queue()
@BaseController.register(curses.KEY_DOWN, 'j')
@PageController.register(curses.KEY_DOWN, 'j')
def move_cursor_down(self):
self._move_cursor(1)
self.clear_input_queue()
@BaseController.register('n', curses.KEY_NPAGE)
def move_page_down(self):
self._move_page(1)
self.clear_input_queue()
@BaseController.register('m', curses.KEY_PPAGE)
@PageController.register('m', curses.KEY_PPAGE)
def move_page_up(self):
self._move_page(-1)
self.clear_input_queue()
@BaseController.register('a')
@PageController.register('n', curses.KEY_NPAGE)
def move_page_down(self):
self._move_page(1)
self.clear_input_queue()
@PageController.register('a')
@logged_in
def upvote(self):
data = self.content.get(self.nav.absolute_index)
try:
if 'likes' not in data:
pass
elif data['likes']:
if 'likes' not in data:
self.term.flash()
elif data['likes']:
with self.term.loader():
data['object'].clear_vote()
if not self.term.loader.exception:
data['likes'] = None
else:
else:
with self.term.loader():
data['object'].upvote()
if not self.term.loader.exception:
data['likes'] = True
except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Not logged in'])
@BaseController.register('z')
@PageController.register('z')
@logged_in
def downvote(self):
data = self.content.get(self.nav.absolute_index)
try:
if 'likes' not in data:
pass
elif data['likes'] or data['likes'] is None:
if 'likes' not in data:
self.term.flash()
elif data['likes'] or data['likes'] is None:
with self.term.loader():
data['object'].downvote()
if not self.term.loader.exception:
data['likes'] = False
else:
else:
with self.term.loader():
data['object'].clear_vote()
if not self.term.loader.exception:
data['likes'] = None
except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Not logged in'])
@BaseController.register('u')
@PageController.register('u')
def login(self):
"""
Prompt to log into the user's account, or log out of the current
@@ -362,138 +165,105 @@ class BasePage(object):
"""
if self.reddit.is_oauth_session():
ch = prompt_input(self.stdscr, "Log out? (y/n): ")
if ch == 'y':
if self.term.prompt_y_or_n('Log out? (y/n): '):
self.oauth.clear_oauth_data()
show_notification(self.stdscr, ['Logged out'])
elif ch != 'n':
curses.flash()
self.term.show_notification('Logged out')
else:
self.oauth.authorize()
@BaseController.register('d')
def delete(self):
@PageController.register('d')
@logged_in
def delete_item(self):
"""
Delete a submission or comment.
"""
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
return
data = self.content.get(self.nav.absolute_index)
if data.get('author') != self.reddit.user.name:
curses.flash()
self.term.flash()
return
prompt = 'Are you sure you want to delete this? (y/n): '
char = prompt_input(self.stdscr, prompt)
if char != 'y':
show_notification(self.stdscr, ['Aborted'])
if not self.term.prompt_y_or_n(prompt):
self.term.show_notification('Aborted')
return
with self.safe_call as s:
with self.loader(message='Deleting', delay=0):
data['object'].delete()
time.sleep(2.0)
s.catch = False
with self.term.loader(message='Deleting', delay=0):
data['object'].delete()
# Give reddit time to process the request
time.sleep(2.0)
if self.term.loader.exception is None:
self.refresh_content()
@BaseController.register('e')
@PageController.register('e')
@logged_in
def edit(self):
"""
Edit a submission or comment.
"""
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
return
data = self.content.get(self.nav.absolute_index)
if data.get('author') != self.reddit.user.name:
curses.flash()
self.term.flash()
return
if data['type'] == 'Submission':
subreddit = self.reddit.get_subreddit(self.content.name)
content = data['text']
info = SUBMISSION_FILE.format(content=content, name=subreddit)
info = docs.SUBMISSION_EDIT_FILE.format(
content=content, name=subreddit)
elif data['type'] == 'Comment':
content = data['body']
info = COMMENT_EDIT_FILE.format(content=content)
info = docs.COMMENT_EDIT_FILE.format(content=content)
else:
curses.flash()
self.term.flash()
return
text = open_editor(info)
text = self.term.open_editor(info)
if text == content:
show_notification(self.stdscr, ['Aborted'])
self.term.show_notification('Aborted')
return
with self.safe_call as s:
with self.loader(message='Editing', delay=0):
data['object'].edit(text)
time.sleep(2.0)
s.catch = False
with self.term.loader(message='Editing', delay=0):
data['object'].edit(text)
time.sleep(2.0)
if self.term.loader.exception is None:
self.refresh_content()
@BaseController.register('i')
@PageController.register('i')
@logged_in
def get_inbox(self):
"""
Checks the inbox for unread messages and displays a notification.
"""
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
return
inbox = len(list(self.reddit.get_unread(limit=1)))
try:
if inbox > 0:
show_notification(self.stdscr, ['New Messages'])
elif inbox == 0:
show_notification(self.stdscr, ['No New Messages'])
except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Not Logged In'])
message = 'New Messages' if inbox > 0 else 'No New Messages'
self.term.show_notification(message)
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)
@property
def safe_call(self):
"""
Wrap praw calls with extended error handling.
If a PRAW related error occurs inside of this context manager, a
notification will be displayed on the screen instead of the entire
application shutting down. This function will return a callback that
can be used to check the status of the call.
Usage:
#>>> with self.safe_call as s:
#>>> self.reddit.submit(...)
#>>> s.catch = False
#>>> on_success()
"""
return SafeCaller(self.stdscr)
with self.term.no_delay():
while self.term.getch() != -1:
continue
def draw(self):
n_rows, n_cols = self.stdscr.getmaxyx()
if n_rows < self.MIN_HEIGHT or n_cols < self.MIN_WIDTH:
window = self.term.stdscr
n_rows, n_cols = window.getmaxyx()
if n_rows < self.term.MIN_HEIGHT or n_cols < self.term.MIN_WIDTH:
# TODO: Will crash when you try to navigate if the terminal is too
# small at startup because self._subwindows will never be populated
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._header_window = window.derwin(1, n_cols, 0, 0)
self._content_window = window.derwin(n_rows - 1, n_cols, 1, 0)
self.stdscr.erase()
window.erase()
self._draw_header()
self._draw_content()
self._add_cursor()
@@ -503,20 +273,26 @@ class BasePage(object):
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)
# curses.bkgd expects bytes in py2 and unicode in py3
ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN
self._header_window.bkgd(ch, attr)
sub_name = self.content.name.replace('/r/front', 'Front Page')
add_line(self._header_window, sub_name, 0, 0)
self.term.add_line(self._header_window, sub_name, 0, 0)
if self.content.order is not None:
add_line(self._header_window, ' [{}]'.format(self.content.order))
order = ' [{}]'.format(self.content.order)
self.term.add_line(self._header_window, order)
if self.reddit.user is not None:
# The starting position of the name depends on if we're converting
# to ascii or not
width = len if self.config['ascii'] else textual_width
username = self.reddit.user.name
s_col = (n_cols - textual_width(username) - 1)
s_col = (n_cols - width(username) - 1)
# Only print username if it fits in the empty space on the right
if (s_col - 1) >= textual_width(sub_name):
add_line(self._header_window, username, 0, s_col)
if (s_col - 1) >= width(sub_name):
self.term.add_line(self._header_window, username, 0, s_col)
self._header_window.refresh()
@@ -543,7 +319,7 @@ class BasePage(object):
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)
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)
@@ -571,7 +347,7 @@ class BasePage(object):
self._remove_cursor()
valid, redraw = self.nav.move(direction, len(self._subwindows))
if not valid:
curses.flash()
self.term.flash()
# Note: ACS_VLINE doesn't like changing the attribute,
# so always redraw.
@@ -582,14 +358,14 @@ class BasePage(object):
self._remove_cursor()
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
if not valid:
curses.flash()
self.term.flash()
# Note: ACS_VLINE doesn't like changing the attribute,
# so always redraw.
self._draw_content()
self._add_cursor()
def _edit_cursor(self, attribute=None):
def _edit_cursor(self, attribute):
# Don't allow the cursor to go below page index 0
if self.nav.absolute_index < 0:
@@ -599,7 +375,7 @@ class BasePage(object):
# This could happen if the window is resized and the cursor index is
# pushed out of bounds
if self.nav.cursor_index >= len(self._subwindows):
self.nav.cursor_index = len(self._subwindows)-1
self.nav.cursor_index = len(self._subwindows) - 1
window, attr = self._subwindows[self.nav.cursor_index]
if attr is not None:
@@ -609,4 +385,4 @@ class BasePage(object):
for row in range(n_rows):
window.chgat(row, 0, 1, attribute)
window.refresh()
window.refresh()

View File

@@ -1,47 +1,33 @@
import curses
import sys
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import time
import logging
import curses
from . import docs
from .content import SubmissionContent
from .page import BasePage, Navigator, BaseController
from .helpers import open_browser, open_editor
from .curses_helpers import (Color, LoadScreen, get_arrow, get_gold, add_line,
show_notification)
from .docs import COMMENT_FILE
__all__ = ['SubmissionController', 'SubmissionPage']
_logger = logging.getLogger(__name__)
from .page import Page, PageController, logged_in
from .objects import Navigator, Color
from .terminal import Terminal
class SubmissionController(BaseController):
class SubmissionController(PageController):
character_map = {}
class SubmissionPage(BasePage):
class SubmissionPage(Page):
def __init__(self, stdscr, reddit, oauth, url=None, submission=None):
def __init__(self, reddit, term, config, oauth, url=None, submission=None):
super(SubmissionPage, self).__init__(reddit, term, config, oauth)
if url:
self.content = SubmissionContent.from_url(reddit, url, term.loader)
else:
self.content = SubmissionContent(submission, term.loader)
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, oauth,
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)
# Start at the submission post, which is indexed as -1
self.nav = Navigator(self.content.get, page_index=-1)
@SubmissionController.register(curses.KEY_RIGHT, 'l', ' ')
def toggle_comment(self):
@@ -50,9 +36,9 @@ class SubmissionPage(BasePage):
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.
# Reset the navigator so that the cursor is at the bottom of the
# page. This is a workaround to handle if folding the comment
# 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')
@@ -63,88 +49,93 @@ class SubmissionPage(BasePage):
@SubmissionController.register(curses.KEY_F5, 'r')
def refresh_content(self, order=None):
"Re-download comments reset the page index"
"Re-download comments and reset the page index"
order = order or self.content.order
self.content = SubmissionContent.from_url(
self.reddit, self.content.name, self.loader, order=order)
self.nav = Navigator(self.content.get, page_index=-1)
url = self.content.name
@SubmissionController.register(curses.KEY_ENTER, 10, 'o')
with self.term.loader():
self.content = SubmissionContent.from_url(
self.reddit, url, self.term.loader, order=order)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get, page_index=-1)
@SubmissionController.register(curses.KEY_ENTER, Terminal.RETURN, 'o')
def open_link(self):
"Open the current submission page with the webbrowser"
"Open the selected item with the webbrowser"
data = self.content.get(self.nav.absolute_index)
url = data.get('permalink')
if url:
open_browser(url)
self.term.open_browser(url)
else:
curses.flash()
self.term.flash()
@SubmissionController.register('c')
@logged_in
def add_comment(self):
"""
Add a top-level comment if the submission is selected, or reply to the
selected comment.
"""
Submit a reply to the selected item.
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
return
Selected item:
Submission - add a top level comment
Comment - add a comment reply
"""
data = self.content.get(self.nav.absolute_index)
if data['type'] == 'Submission':
content = data['text']
body = data['text']
reply = data['object'].add_comment
elif data['type'] == 'Comment':
content = data['body']
body = data['body']
reply = data['object'].reply
else:
curses.flash()
self.term.flash()
return
# Comment out every line of the content
content = '\n'.join(['# |' + line for line in content.split('\n')])
comment_info = COMMENT_FILE.format(
# Construct the text that will be displayed in the editor file.
# The post body will be commented out and added for reference
lines = ['# |' + line for line in body.split('\n')]
content = '\n'.join(lines)
comment_info = docs.COMMENT_FILE.format(
author=data['author'],
type=data['type'].lower(),
content=content)
comment_text = open_editor(comment_info)
if not comment_text:
show_notification(self.stdscr, ['Aborted'])
comment = self.term.open_editor(comment_info)
if not comment:
self.term.show_notification('Aborted')
return
with self.safe_call as s:
with self.loader(message='Posting', delay=0):
if data['type'] == 'Submission':
data['object'].add_comment(comment_text)
else:
data['object'].reply(comment_text)
time.sleep(2.0)
s.catch = False
with self.term.loader(message='Posting', delay=0):
reply(comment)
# Give reddit time to process the submission
time.sleep(2.0)
if not self.term.loader.exception:
self.refresh_content()
@SubmissionController.register('d')
@logged_in
def delete_comment(self):
"Delete a comment as long as it is not the current submission"
if self.nav.absolute_index != -1:
self.delete()
self.delete_item()
else:
curses.flash()
self.term.flash()
def draw_item(self, win, data, inverted=False):
def _draw_item(self, win, data, inverted=False):
if data['type'] == 'MoreComments':
return self.draw_more_comments(win, data)
return self._draw_more_comments(win, data)
elif data['type'] == 'HiddenComment':
return self.draw_more_comments(win, data)
return self._draw_more_comments(win, data)
elif data['type'] == 'Comment':
return self.draw_comment(win, data, inverted=inverted)
return self._draw_comment(win, data, inverted=inverted)
else:
return self.draw_submission(win, data)
return self._draw_submission(win, data)
@staticmethod
def draw_comment(win, data, inverted=False):
def _draw_comment(self, win, data, inverted=False):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1
@@ -158,73 +149,65 @@ class SubmissionPage(BasePage):
attr = curses.A_BOLD
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
add_line(win, u'{author} '.format(**data), row, 1, attr)
self.term.add_line(win, '{author} '.format(**data), row, 1, attr)
if data['flair']:
attr = curses.A_BOLD | Color.YELLOW
add_line(win, u'{flair} '.format(**data), attr=attr)
self.term.add_line(win, '{flair} '.format(**data), attr=attr)
text, attr = get_arrow(data['likes'])
add_line(win, text, attr=attr)
add_line(win, u' {score} {created} '.format(**data))
text, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, text, attr=attr)
self.term.add_line(win, ' {score} {created} '.format(**data))
if data['gold']:
text, attr = get_gold()
add_line(win, text, attr=attr)
text, attr = self.term.guilded
self.term.add_line(win, text, attr=attr)
for row, text in enumerate(data['split_body'], start=offset + 1):
for row, text in enumerate(data['split_body'], start=offset+1):
if row in valid_rows:
add_line(win, text, row, 1)
self.term.add_line(win, text, row, 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'])
x = 0
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
self.term.addch(win, y, x, curses.ACS_VLINE, attr)
win.addch(y, x, curses.ACS_VLINE, attr)
return attr | curses.ACS_VLINE
return (attr | curses.ACS_VLINE)
@staticmethod
def draw_more_comments(win, data):
def _draw_more_comments(self, win, data):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1
add_line(win, u'{body}'.format(**data), 0, 1)
add_line(win, u' [{count}]'.format(**data), attr=curses.A_BOLD)
self.term.add_line(win, '{body}'.format(**data), 0, 1)
self.term.add_line(win, ' [{count}]'.format(**data), attr=curses.A_BOLD)
attr = Color.get_level(data['level'])
win.addch(0, 0, curses.ACS_VLINE, attr)
self.term.addch(win, 0, 0, curses.ACS_VLINE, attr)
return (attr | curses.ACS_VLINE)
return attr | curses.ACS_VLINE
@staticmethod
def draw_submission(win, data):
def _draw_submission(self, 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):
add_line(win, text, row, 1, curses.A_BOLD)
self.term.add_line(win, text, row, 1, curses.A_BOLD)
row = len(data['split_title']) + 1
attr = curses.A_BOLD | Color.GREEN
add_line(win, u'{author}'.format(**data), row, 1, attr)
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
attr = curses.A_BOLD | Color.YELLOW
if data['flair']:
add_line(win, u' {flair}'.format(**data), attr=attr)
add_line(win, u' {created} {subreddit}'.format(**data))
self.term.add_line(win, ' {flair}'.format(**data), attr=attr)
self.term.add_line(win, ' {created} {subreddit}'.format(**data))
row = len(data['split_title']) + 2
attr = curses.A_UNDERLINE | Color.BLUE
add_line(win, u'{url}'.format(**data), row, 1, attr)
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
offset = len(data['split_title']) + 3
# Cut off text if there is not enough room to display the whole post
@@ -235,20 +218,20 @@ class SubmissionPage(BasePage):
split_text.append('(Not enough space to display)')
for row, text in enumerate(split_text, start=offset):
add_line(win, text, row, 1)
self.term.add_line(win, text, row, 1)
row = len(data['split_title']) + len(split_text) + 3
add_line(win, u'{score} '.format(**data), row, 1)
text, attr = get_arrow(data['likes'])
add_line(win, text, attr=attr)
add_line(win, u' {comments} '.format(**data))
self.term.add_line(win, '{score} '.format(**data), row, 1)
text, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, text, attr=attr)
self.term.add_line(win, ' {comments} '.format(**data))
if data['gold']:
text, attr = get_gold()
add_line(win, text, attr=attr)
text, attr = self.term.gold
self.term.add_line(win, text, attr=attr)
if data['nsfw']:
text, attr = 'NSFW', (curses.A_BOLD | Color.RED)
add_line(win, text, attr=attr)
self.term.add_line(win, text, attr=attr)
win.border()

View File

@@ -1,54 +1,40 @@
import curses
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import time
import logging
import atexit
import curses
import requests
import six
from .exceptions import SubredditError, AccountError
from .page import BasePage, Navigator, BaseController
from . import docs
from .content import SubredditContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Color
from .submission import SubmissionPage
from .subscription import SubscriptionPage
from .content import SubredditContent
from .helpers import open_browser, open_editor
from .docs import SUBMISSION_FILE
from .history import load_history, save_history
from .curses_helpers import (Color, LoadScreen, add_line, get_arrow, get_gold,
show_notification, prompt_input)
__all__ = ['history', 'SubredditController', 'SubredditPage']
_logger = logging.getLogger(__name__)
history = load_history()
from .terminal import Terminal
@atexit.register
def save_links():
global history
save_history(history)
class SubredditController(BaseController):
class SubredditController(PageController):
character_map = {}
class SubredditPage(BasePage):
class SubredditPage(Page):
def __init__(self, stdscr, reddit, oauth, name):
def __init__(self, reddit, term, config, oauth, name, url=None):
"""
Params:
name (string): Name of subreddit to open
url (string): Optional submission to load upon start
"""
super(SubredditPage, self).__init__(reddit, term, config, oauth)
self.content = SubredditContent.from_name(reddit, name, term.loader)
self.controller = SubredditController(self)
self.loader = LoadScreen(stdscr)
self.oauth = oauth
self.nav = Navigator(self.content.get)
content = SubredditContent.from_name(reddit, name, self.loader)
super(SubredditPage, self).__init__(stdscr, reddit, content, oauth)
def loop(self):
"Main control loop"
while True:
self.draw()
cmd = self.stdscr.getch()
self.controller.trigger(cmd)
if url:
self.open_submission(url=url)
@SubredditController.register(curses.KEY_F5, 'r')
def refresh_content(self, name=None, order=None):
@@ -62,16 +48,10 @@ class SubredditPage(BasePage):
if order == 'ignore':
order = None
try:
with self.term.loader():
self.content = SubredditContent.from_name(
self.reddit, name, self.loader, order=order)
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.reddit, name, self.term.loader, order=order)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubredditController.register('f')
@@ -79,115 +59,114 @@ class SubredditPage(BasePage):
"Open a prompt to search the given subreddit"
name = name or self.content.name
prompt = 'Search {}:'.format(name)
query = prompt_input(self.stdscr, prompt)
if query is None:
query = self.term.prompt_input('Search {0}:'.format(name))
if not query:
return
try:
with self.term.loader():
self.content = SubredditContent.from_name(
self.reddit, name, self.loader, query=query)
except (IndexError, SubredditError): # if there are no submissions
show_notification(self.stdscr, ['No results found'])
else:
self.reddit, name, self.term.loader, query=query)
if not self.term.loader.exception:
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 = prompt_input(self.stdscr, prompt)
name = self.term.prompt_input('Enter Subreddit: /r/')
if name is not None:
self.refresh_content(name=name, order='ignore')
@SubredditController.register(curses.KEY_RIGHT, 'l')
def open_submission(self):
def open_submission(self, url=None):
"Select the current submission to view posts"
data = self.content.get(self.nav.absolute_index)
page = SubmissionPage(self.stdscr, self.reddit, self.oauth,
url=data['permalink'])
page.loop()
if data['url_type'] == 'selfpost':
global history
history.add(data['url_full'])
data = {}
if url is None:
data = self.content.get(self.nav.absolute_index)
url = data['permalink']
@SubredditController.register(curses.KEY_ENTER, 10, 'o')
with self.term.loader():
page = SubmissionPage(
self.reddit, self.term, self.config, self.oauth, url=url)
if self.term.loader.exception:
return
page.loop()
if data.get('url_type') in ('selfpost', 'x-post'):
self.config.history.add(data['url_full'])
@SubredditController.register(curses.KEY_ENTER, Terminal.RETURN, 'o')
def open_link(self):
"Open a link with the webbrowser"
data = self.content.get(self.nav.absolute_index)
url = data['url_full']
global history
history.add(url)
if data['url_type'] in ['x-post', 'selfpost']:
page = SubmissionPage(self.stdscr, self.reddit, self.oauth,
url=url)
page.loop()
data = self.content.get(self.nav.absolute_index)
if data['url_type'] in ('x-post', 'selfpost'):
# Open links to other posts directly in RTV
self.open_submission()
else:
open_browser(url)
self.term.open_browser(data['url_full'])
self.config.history.add(data['url_full'])
@SubredditController.register('c')
@logged_in
def post_submission(self):
"Post a new submission to the given subreddit"
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
# Check that the subreddit can be submitted to
name = self.content.name
if '+' in name or name in ('/r/all', '/r/front', '/r/me'):
self.term.show_notification("Can't post to {0}".format(name))
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'])
submission_info = docs.SUBMISSION_FILE.format(name=name)
text = self.term.open_editor(submission_info)
if not text or '\n' not in text:
self.term.show_notification('Aborted')
return
# Open the submission window
submission_info = SUBMISSION_FILE.format(name=subreddit, content='')
curses.endwin()
submission_text = open_editor(submission_info)
curses.doupdate()
# Validate the submission content
if not submission_text:
show_notification(self.stdscr, ['Aborted'])
return
if '\n' not in submission_text:
show_notification(self.stdscr, ['No content'])
title, content = text.split('\n', 1)
with self.term.loader(message='Posting', delay=0):
submission = self.reddit.submit(name, title, text=content)
# Give reddit time to process the submission
time.sleep(2.0)
if self.term.loader.exception:
return
title, content = submission_text.split('\n', 1)
with self.safe_call as s:
with self.loader(message='Posting', delay=0):
post = self.reddit.submit(sub, title, text=content)
time.sleep(2.0)
# Open the newly created post
s.catch = False
page = SubmissionPage(self.stdscr, self.reddit, self.oauth,
submission=post)
page.loop()
self.refresh_content()
# Open the newly created post
with self.term.loader():
page = SubmissionPage(
self.reddit, self.term, self.config, self.oauth,
submission=submission)
if self.term.loader.exception:
return
page.loop()
self.refresh_content()
@SubredditController.register('s')
@logged_in
def open_subscriptions(self):
"Open user subscriptions page"
if not self.reddit.is_oauth_session():
show_notification(self.stdscr, ['Not logged in'])
with self.term.loader():
page = SubscriptionPage(
self.reddit, self.term, self.config, self.oauth)
if self.term.loader.exception:
return
# Open subscriptions page
page = SubscriptionPage(self.stdscr, self.reddit, self.oauth)
page.loop()
# When user has chosen a subreddit in the subscriptions list,
# When the user has chosen a subreddit in the subscriptions list,
# refresh content with the selected subreddit
if page.selected_subreddit_data is not None:
self.refresh_content(name=page.selected_subreddit_data['name'])
if page.subreddit_data is not None:
self.refresh_content(name=page.subreddit_data['name'],
order='ignore')
@staticmethod
def draw_item(win, data, inverted=False):
def _draw_item(self, win, data, inverted=False):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
@@ -199,33 +178,36 @@ class SubredditPage(BasePage):
n_title = len(data['split_title'])
for row, text in enumerate(data['split_title'], start=offset):
if row in valid_rows:
add_line(win, text, row, 1, curses.A_BOLD)
self.term.add_line(win, text, row, 1, curses.A_BOLD)
row = n_title + offset
if row in valid_rows:
seen = (data['url_full'] in history)
seen = (data['url_full'] in self.config.history)
link_color = Color.MAGENTA if seen else Color.BLUE
attr = curses.A_UNDERLINE | link_color
add_line(win, u'{url}'.format(**data), row, 1, attr)
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
row = n_title + offset + 1
if row in valid_rows:
add_line(win, u'{score} '.format(**data), row, 1)
text, attr = get_arrow(data['likes'])
add_line(win, text, attr=attr)
add_line(win, u' {created} {comments} '.format(**data))
self.term.add_line(win, '{score} '.format(**data), row, 1)
text, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, text, attr=attr)
self.term.add_line(win, ' {created} {comments} '.format(**data))
if data['gold']:
text, attr = get_gold()
add_line(win, text, attr=attr)
text, attr = self.term.guilded
self.term.add_line(win, text, attr=attr)
if data['nsfw']:
text, attr = 'NSFW', (curses.A_BOLD | Color.RED)
add_line(win, text, attr=attr)
self.term.add_line(win, text, attr=attr)
row = n_title + offset + 2
if row in valid_rows:
add_line(win, u'{author}'.format(**data), row, 1, curses.A_BOLD)
add_line(win, u' /r/{subreddit}'.format(**data), attr=Color.YELLOW)
text = '{author}'.format(**data)
self.term.add_line(win, text, row, 1, curses.A_BOLD)
text = ' /r/{subreddit}'.format(**data)
self.term.add_line(win, text, attr=Color.YELLOW)
if data['flair']:
add_line(win, u' {flair}'.format(**data), attr=Color.RED)
text = ' {flair}'.format(**data)
self.term.add_line(win, text, attr=Color.RED)

View File

@@ -1,66 +1,56 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import curses
import logging
from .page import Page, PageController
from .content import SubscriptionContent
from .page import BasePage, Navigator, BaseController
from .curses_helpers import (Color, LoadScreen, add_line)
__all__ = ['SubscriptionController', 'SubscriptionPage']
_logger = logging.getLogger(__name__)
from .objects import Color, Navigator
from .terminal import Terminal
class SubscriptionController(BaseController):
class SubscriptionController(PageController):
character_map = {}
class SubscriptionPage(BasePage):
class SubscriptionPage(Page):
def __init__(self, stdscr, reddit, oauth):
def __init__(self, reddit, term, config, oauth):
super(SubscriptionPage, self).__init__(reddit, term, config, oauth)
self.content = SubscriptionContent.from_user(reddit, term.loader)
self.controller = SubscriptionController(self)
self.loader = LoadScreen(stdscr)
self.selected_subreddit_data = None
content = SubscriptionContent.from_user(reddit, self.loader)
super(SubscriptionPage, self).__init__(stdscr, reddit, content, oauth)
def loop(self):
"Main control loop"
self.active = True
while self.active:
self.draw()
cmd = self.stdscr.getch()
self.controller.trigger(cmd)
self.nav = Navigator(self.content.get)
self.subreddit_data = None
@SubscriptionController.register(curses.KEY_F5, 'r')
def refresh_content(self, order=None):
"Re-download all subscriptions and reset the page index"
# reddit.get_my_subreddits() does not support sorting by order
if order:
# reddit.get_my_subreddits() does not support sorting by order
curses.flash()
else:
self.content = SubscriptionContent.from_user(self.reddit,
self.loader)
self.nav = Navigator(self.content.get)
self.term.flash()
return
@SubscriptionController.register(curses.KEY_ENTER, 10, curses.KEY_RIGHT)
def store_selected_subreddit(self):
self.content = SubscriptionContent.from_user(self.reddit,
self.term.loader)
self.nav = Navigator(self.content.get)
@SubscriptionController.register(curses.KEY_ENTER, Terminal.RETURN,
curses.KEY_RIGHT, 'l')
def select_subreddit(self):
"Store the selected subreddit and return to the subreddit page"
self.selected_subreddit_data = self.content.get(
self.nav.absolute_index)
self.subreddit_data = self.content.get(self.nav.absolute_index)
self.active = False
@SubscriptionController.register(curses.KEY_LEFT, 'h', 's')
@SubscriptionController.register(curses.KEY_LEFT, Terminal.ESCAPE, 'h', 's')
def close_subscriptions(self):
"Close subscriptions and return to the subreddit page"
self.active = False
@staticmethod
def draw_item(win, data, inverted=False):
def _draw_item(self, win, data, inverted=False):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
@@ -71,9 +61,9 @@ class SubscriptionPage(BasePage):
row = offset
if row in valid_rows:
attr = curses.A_BOLD | Color.YELLOW
add_line(win, u'{name}'.format(**data), row, 1, attr)
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
row = offset + 1
for row, text in enumerate(data['split_title'], start=row):
if row in valid_rows:
add_line(win, text, row, 1)
self.term.add_line(win, text, row, 1)

View File

@@ -28,9 +28,10 @@
{% if error == 'access_denied' %}
<h1 style="color: red">Access Denied</h1><hr>
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> was denied access and will continue to operate in unauthenticated mode, you can close this window.
{% elif error != 'placeholder' %}
<h1 style="color: red">Error : {{ error }}</h1>
{% elif (state == 'placeholder' or code == 'placeholder') %}
{% elif error is not None %}
<h1 style="color: red">Error</h1><hr>
<p>{{ error }}</p>
{% elif (state is None or code is None) %}
<h1>Wait...</h1><hr>
<p>This page is supposed to be a Reddit OAuth callback. You can't just come here hands in your pocket!</p>
{% else %}

446
rtv/terminal.py Normal file
View File

@@ -0,0 +1,446 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import sys
import time
import codecs
import curses
import webbrowser
import subprocess
import curses.ascii
from curses import textpad
from contextlib import contextmanager
from tempfile import NamedTemporaryFile
import six
from kitchen.text.display import textual_width_chop
from .objects import LoadScreen, Color
from .exceptions import EscapeInterrupt, ProgramError
class Terminal(object):
MIN_HEIGHT = 10
MIN_WIDTH = 20
# ASCII code
ESCAPE = 27
RETURN = 10
def __init__(self, stdscr, ascii=False):
self.stdscr = stdscr
self.ascii = ascii
self.loader = LoadScreen(self)
self._display = None
@property
def up_arrow(self):
symbol = '^' if self.ascii else ''
attr = curses.A_BOLD | Color.GREEN
return symbol, attr
@property
def down_arrow(self):
symbol = 'v' if self.ascii else ''
attr = curses.A_BOLD | Color.RED
return symbol, attr
@property
def neutral_arrow(self):
symbol = 'o' if self.ascii else ''
attr = curses.A_BOLD
return symbol, attr
@property
def guilded(self):
symbol = '*' if self.ascii else ''
attr = curses.A_BOLD | Color.YELLOW
return symbol, attr
@property
def display(self):
"""
Use a number of methods to guess if the default webbrowser will open in
the background as opposed to opening directly in the terminal.
"""
if self._display is None:
display = bool(os.environ.get("DISPLAY"))
# Use the convention defined here to parse $BROWSER
# https://docs.python.org/2/library/webbrowser.html
console_browsers = ['www-browser', 'links', 'links2', 'elinks',
'lynx', 'w3m']
if "BROWSER" in os.environ:
user_browser = os.environ["BROWSER"].split(os.pathsep)[0]
if user_browser in console_browsers:
display = False
if webbrowser._tryorder:
if webbrowser._tryorder[0] in console_browsers:
display = False
self._display = display
return self._display
@staticmethod
def flash():
return curses.flash()
@staticmethod
def addch(window, y, x, ch, attr):
"""
Curses addch() method that fixes a major bug in python 3.4.
See http://bugs.python.org/issue21088
"""
if sys.version_info[:3] == (3, 4, 0):
y, x = x, y
window.addch(y, x, ch, attr)
def getch(self):
return self.stdscr.getch()
@staticmethod
@contextmanager
def suspend():
"""
Suspend curses in order to open another subprocess in the terminal.
"""
try:
curses.endwin()
yield
finally:
curses.doupdate()
@contextmanager
def no_delay(self):
"""
Temporarily turn off character delay mode. In this mode, getch will not
block while waiting for input and will return -1 if no key has been
pressed.
"""
try:
self.stdscr.nodelay(1)
yield
finally:
self.stdscr.nodelay(0)
def get_arrow(self, likes):
"""
Curses does define constants for symbols (e.g. curses.ACS_BULLET).
However, they rely on using the curses.addch() function, which has been
found to be buggy and a general 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
"""
if likes is None:
return self.neutral_arrow
elif likes:
return self.up_arrow
else:
return self.down_arrow
def clean(self, string, n_cols=None):
"""
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.
"""
if n_cols is not None and n_cols <= 0:
return ''
if self.ascii:
if isinstance(string, six.binary_type):
string = string.decode('utf-8')
string = string.encode('ascii', 'replace')
return string[:n_cols] if n_cols else string
else:
if n_cols:
string = textual_width_chop(string, n_cols)
if isinstance(string, six.text_type):
string = string.encode('utf-8')
return string
def add_line(self, window, text, row=None, col=None, attr=None):
"""
Unicode aware version of curses's built-in addnstr method.
Safely draws a line of text on the window starting at position
(row, col). Checks the boundaries of the window and cuts off the text
if it exceeds the length of the window.
"""
# The following arg combos must be supported to conform with addnstr
# (window, text)
# (window, text, attr)
# (window, text, row, col)
# (window, text, row, col, attr)
cursor_row, cursor_col = window.getyx()
row = row if row is not None else cursor_row
col = col if col is not None else cursor_col
max_rows, max_cols = window.getmaxyx()
n_cols = max_cols - col - 1
if n_cols <= 0:
# Trying to draw outside of the screen bounds
return
text = self.clean(text, n_cols)
params = [] if attr is None else [attr]
window.addstr(row, col, text, *params)
def show_notification(self, message, timeout=None):
"""
Overlay a message box on the center of the screen and wait for input.
Params:
message (list or string): List of strings, one per line.
timeout (float): Optional, maximum length of time that the message
will be shown before disappearing.
"""
if isinstance(message, six.string_types):
message = [message]
n_rows, n_cols = self.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 = curses.newwin(box_height, box_width, s_row, s_col)
window.erase()
window.border()
for index, line in enumerate(message, start=1):
self.add_line(window, line, index, 1)
window.refresh()
ch, start = -1, time.time()
with self.no_delay():
while timeout is None or time.time() - start < timeout:
ch = self.getch()
if ch != -1:
break
time.sleep(0.01)
window.clear()
del window
self.stdscr.touchwin()
self.stdscr.refresh()
return ch
def open_browser(self, url):
"""
Open the given url using the default webbrowser. The preferred browser
can specified with the $BROWSER environment variable. If not specified,
python webbrowser will try to determine the default to use based on
your system.
For browsers requiring an X display, we 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.
For console browsers (e.g. w3m), RTV will suspend and display the
browser window within the same terminal. This mode is triggered either
when
1. $BROWSER is set to a known console browser, or
2. $DISPLAY is undefined, indicating that the terminal is running
headless
There may be other cases where console browsers are opened (xdg-open?)
but are not detected here.
"""
if self.display:
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)
else:
with self.suspend():
webbrowser.open_new_tab(url)
def open_editor(self, 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='wb') as fp:
fp.write(codecs.encode(data, 'utf-8'))
fp.flush()
editor = os.getenv('RTV_EDITOR') or os.getenv('EDITOR') or 'nano'
try:
with self.suspend():
subprocess.Popen([editor, fp.name]).wait()
except OSError:
raise ProgramError('Could not open file with %s' % 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 codecs.open(fp.name, 'r', 'utf-8') as fp2:
text = ''.join(line for line in fp2 if not line.startswith('#'))
text = text.rstrip()
return text
def text_input(self, window, allow_resize=False):
"""
Transform a window into a text box that will accept user input and loop
until an escape sequence is entered.
If the escape key (27) is pressed, cancel the textbox and return None.
Otherwise, the textbox will wait until it is full (^j, or a new line is
entered on the bottom line) or the BEL key (^g) is pressed.
"""
window.clear()
# Set cursor mode to 1 because 2 doesn't display on some terminals
curses.curs_set(1)
# Keep insert_mode off to avoid the recursion error described here
# http://bugs.python.org/issue13051
textbox = textpad.Textbox(window)
textbox.stripspaces = 0
def validate(ch):
"Filters characters for special key sequences"
if ch == self.ESCAPE:
raise EscapeInterrupt()
if (not allow_resize) and (ch == curses.KEY_RESIZE):
raise EscapeInterrupt()
# Fix backspace for iterm
if ch == curses.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)
if isinstance(out, six.binary_type):
out = out.decode('utf-8')
except EscapeInterrupt:
out = None
curses.curs_set(0)
return self.strip_textpad(out)
def prompt_input(self, prompt, key=False):
"""
Display a text prompt at the bottom of the screen.
Params:
prompt (string): Text prompt that will be displayed
key (bool): If true, grab a single keystroke instead of a full
string. This can be faster than pressing enter for
single key prompts (e.g. y/n?)
"""
n_rows, n_cols = self.stdscr.getmaxyx()
attr = curses.A_BOLD | Color.CYAN
prompt = self.clean(prompt, n_cols - 1)
window = self.stdscr.derwin(
1, n_cols - len(prompt), n_rows - 1, len(prompt))
window.attrset(attr)
self.add_line(self.stdscr, prompt, n_rows-1, 0, attr)
self.stdscr.refresh()
if key:
curses.curs_set(1)
ch = self.getch()
# We can't convert the character to unicode, because it may return
# Invalid values for keys that don't map to unicode characters,
# e.g. F1
text = ch if ch != self.ESCAPE else None
curses.curs_set(0)
else:
text = self.text_input(window)
return text
def prompt_y_or_n(self, prompt):
"""
Wrapper around prompt_input for simple yes/no queries.
"""
ch = self.prompt_input(prompt, key=True)
if ch in (ord('Y'), ord('y')):
return True
elif ch in (ord('N'), ord('n'), None):
return False
else:
self.flash()
return False
@staticmethod
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