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:
@@ -1,3 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from .__version__ import __version__
|
||||
|
||||
__title__ = 'Reddit Terminal Viewer'
|
||||
|
||||
@@ -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())
|
||||
@@ -1 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
__version__ = '1.6.1'
|
||||
|
||||
198
rtv/config.py
198
rtv/config.py
@@ -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)
|
||||
147
rtv/content.py
147
rtv/content.py
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
19
rtv/docs.py
19
rtv/docs.py
@@ -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}
|
||||
"""
|
||||
@@ -1,3 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
class EscapeInterrupt(Exception):
|
||||
"Signal that the ESC key has been pressed"
|
||||
|
||||
|
||||
224
rtv/helpers.py
224
rtv/helpers.py
@@ -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)
|
||||
@@ -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()
|
||||
170
rtv/oauth.py
170
rtv/oauth.py
@@ -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
554
rtv/objects.py
Normal 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
|
||||
516
rtv/page.py
516
rtv/page.py
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
236
rtv/subreddit.py
236
rtv/subreddit.py
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
446
rtv/terminal.py
Normal 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
|
||||
Reference in New Issue
Block a user