Major refactor, package cleanup. Untested.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
from .__version__ import __version__
|
||||||
|
|
||||||
|
__title__ = 'Reddit Terminal Viewer'
|
||||||
|
__author__ = 'Michael Lazar'
|
||||||
|
__license__ = 'The MIT License (MIT)'
|
||||||
|
__copyright__ = '(c) 2015 Michael Lazar'
|
||||||
108
rtv/__main__.py
108
rtv/__main__.py
@@ -1,7 +1,105 @@
|
|||||||
# Entry point for rtv module
|
import os
|
||||||
# Run by moving into the top level directory (the one with setup.py)
|
import sys
|
||||||
# and typing "python -m rtv"
|
import argparse
|
||||||
|
import locale
|
||||||
|
import logging
|
||||||
|
|
||||||
from rtv.main import main
|
import requests
|
||||||
|
import praw
|
||||||
|
import praw.errors
|
||||||
|
from six.moves import configparser
|
||||||
|
|
||||||
main()
|
from . import config
|
||||||
|
from .exceptions import SubmissionError, SubredditError
|
||||||
|
from .curses_helpers import curses_session
|
||||||
|
from .submission import SubmissionPage
|
||||||
|
from .subreddit import SubredditPage
|
||||||
|
from .docs import *
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""
|
||||||
|
Search for a configuration file at the location ~/.rtv and attempt to load
|
||||||
|
saved settings for things like the username and password.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config_path = os.path.join(os.path.expanduser('~'), '.rtv')
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(config_path)
|
||||||
|
|
||||||
|
defaults = {}
|
||||||
|
if config.has_section('rtv'):
|
||||||
|
defaults = dict(config.items('rtv'))
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
def command_line():
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog='rtv', description=SUMMARY,
|
||||||
|
epilog=CONTROLS+HELP,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
|
||||||
|
parser.add_argument('-s', dest='subreddit', help='subreddit name')
|
||||||
|
parser.add_argument('-l', dest='link', help='full link to a submission')
|
||||||
|
parser.add_argument('--unicode', action='store_true',
|
||||||
|
help='enable unicode (experimental)')
|
||||||
|
parser.add_argument('--log', action='store',
|
||||||
|
help='Log all HTTP requests to the given file')
|
||||||
|
|
||||||
|
group = parser.add_argument_group('authentication (optional)', AUTH)
|
||||||
|
group.add_argument('-u', dest='username', help='reddit username')
|
||||||
|
group.add_argument('-p', dest='password', help='reddit password')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"Main entry point"
|
||||||
|
|
||||||
|
# logging.basicConfig(level=logging.DEBUG, filename='rtv.log')
|
||||||
|
locale.setlocale(locale.LC_ALL, '')
|
||||||
|
|
||||||
|
args = command_line()
|
||||||
|
local_config = load_config()
|
||||||
|
|
||||||
|
# Fill in empty arguments with config file values. Paramaters explicitly
|
||||||
|
# typed on the command line will take priority over config file params.
|
||||||
|
for key, val in local_config.items():
|
||||||
|
if getattr(args, key) is None:
|
||||||
|
setattr(args, key, val)
|
||||||
|
|
||||||
|
config.unicode = args.unicode
|
||||||
|
|
||||||
|
if args.log:
|
||||||
|
logging.basicConfig(level=logging.DEBUG, filename=args.log)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print('Connecting...')
|
||||||
|
reddit = praw.Reddit(user_agent=AGENT)
|
||||||
|
reddit.config.decode_html_entities = True
|
||||||
|
if args.username:
|
||||||
|
# PRAW will prompt for password if it is None
|
||||||
|
reddit.login(args.username, args.password)
|
||||||
|
with curses_session() as stdscr:
|
||||||
|
if args.link:
|
||||||
|
page = SubmissionPage(stdscr, reddit, url=args.link)
|
||||||
|
page.loop()
|
||||||
|
page = SubredditPage(stdscr, reddit, args.subreddit)
|
||||||
|
page.loop()
|
||||||
|
except praw.errors.InvalidUserPass:
|
||||||
|
print('Invalid password for username: {}'.format(args.username))
|
||||||
|
except requests.ConnectionError:
|
||||||
|
print('Connection timeout')
|
||||||
|
except requests.HTTPError:
|
||||||
|
print('HTTP Error: 404 Not Found')
|
||||||
|
except SubmissionError as e:
|
||||||
|
print('Could not reach submission URL: {}'.format(e.url))
|
||||||
|
except SubredditError as e:
|
||||||
|
print('Could not reach subreddit: {}'.format(e.name))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
1
rtv/__version__.py
Normal file
1
rtv/__version__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = '1.0.post1'
|
||||||
5
rtv/config.py
Normal file
5
rtv/config.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Global configuration settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
unicode = False
|
||||||
@@ -1,57 +1,12 @@
|
|||||||
import textwrap
|
import textwrap
|
||||||
from datetime import datetime
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
import praw
|
import praw
|
||||||
import six
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .errors import SubmissionURLError, SubredditNameError
|
from .exceptions import SubmissionError, SubredditError
|
||||||
|
from .helpers import humanize_timestamp, wrap_text, strip_subreddit_url
|
||||||
|
|
||||||
def split_text(big_text, width):
|
__all__ = ['SubredditContent', 'SubmissionContent']
|
||||||
return [
|
|
||||||
text for line in big_text.splitlines()
|
|
||||||
# wrap returns an empty list when "line" is a newline. In order to
|
|
||||||
# consider newlines we need a list containing an empty string.
|
|
||||||
for text in (textwrap.wrap(line, width=width) or [''])]
|
|
||||||
|
|
||||||
def strip_subreddit_url(permalink):
|
|
||||||
"""
|
|
||||||
Grab the subreddit from the permalink because submission.subreddit.url
|
|
||||||
makes a seperate call to the API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def default_loader():
|
|
||||||
yield
|
|
||||||
|
|
||||||
class BaseContent(object):
|
class BaseContent(object):
|
||||||
|
|
||||||
@@ -149,7 +104,6 @@ class BaseContent(object):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class SubmissionContent(BaseContent):
|
class SubmissionContent(BaseContent):
|
||||||
"""
|
"""
|
||||||
Grab a submission from PRAW and lazily store comments to an internal
|
Grab a submission from PRAW and lazily store comments to an internal
|
||||||
@@ -159,7 +113,7 @@ class SubmissionContent(BaseContent):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
submission,
|
submission,
|
||||||
loader=default_loader,
|
loader,
|
||||||
indent_size=2,
|
indent_size=2,
|
||||||
max_indent_level=4):
|
max_indent_level=4):
|
||||||
|
|
||||||
@@ -178,7 +132,7 @@ class SubmissionContent(BaseContent):
|
|||||||
cls,
|
cls,
|
||||||
reddit,
|
reddit,
|
||||||
url,
|
url,
|
||||||
loader=default_loader,
|
loader,
|
||||||
indent_size=2,
|
indent_size=2,
|
||||||
max_indent_level=4):
|
max_indent_level=4):
|
||||||
|
|
||||||
@@ -186,7 +140,7 @@ class SubmissionContent(BaseContent):
|
|||||||
with loader():
|
with loader():
|
||||||
submission = reddit.get_submission(url, comment_sort='hot')
|
submission = reddit.get_submission(url, comment_sort='hot')
|
||||||
except praw.errors.APIException:
|
except praw.errors.APIException:
|
||||||
raise SubmissionURLError(url)
|
raise SubmissionError(url)
|
||||||
|
|
||||||
return cls(submission, loader, indent_size, max_indent_level)
|
return cls(submission, loader, indent_size, max_indent_level)
|
||||||
|
|
||||||
@@ -202,8 +156,8 @@ class SubmissionContent(BaseContent):
|
|||||||
elif index == -1:
|
elif index == -1:
|
||||||
data = self._submission_data
|
data = self._submission_data
|
||||||
data['split_title'] = textwrap.wrap(data['title'], width=n_cols-2)
|
data['split_title'] = textwrap.wrap(data['title'], width=n_cols-2)
|
||||||
data['split_text'] = split_text(data['text'], width=n_cols-2)
|
data['split_text'] = wrap_text(data['text'], width=n_cols-2)
|
||||||
data['n_rows'] = (len(data['split_title']) + len(data['split_text']) + 5)
|
data['n_rows'] = len(data['split_title'])+len(data['split_text'])+5
|
||||||
data['offset'] = 0
|
data['offset'] = 0
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -212,7 +166,8 @@ class SubmissionContent(BaseContent):
|
|||||||
data['offset'] = indent_level * self.indent_size
|
data['offset'] = indent_level * self.indent_size
|
||||||
|
|
||||||
if data['type'] == 'Comment':
|
if data['type'] == 'Comment':
|
||||||
data['split_body'] = split_text(data['body'], width=n_cols-data['offset'])
|
width = n_cols - data['offset']
|
||||||
|
data['split_body'] = wrap_text(data['body'], width=width)
|
||||||
data['n_rows'] = len(data['split_body']) + 1
|
data['n_rows'] = len(data['split_body']) + 1
|
||||||
else:
|
else:
|
||||||
data['n_rows'] = 1
|
data['n_rows'] = 1
|
||||||
@@ -257,7 +212,8 @@ class SubmissionContent(BaseContent):
|
|||||||
elif data['type'] == 'MoreComments':
|
elif data['type'] == 'MoreComments':
|
||||||
with self._loader():
|
with self._loader():
|
||||||
comments = data['object'].comments(update=False)
|
comments = data['object'].comments(update=False)
|
||||||
comments = self.flatten_comments(comments, root_level=data['level'])
|
comments = self.flatten_comments(comments,
|
||||||
|
root_level=data['level'])
|
||||||
comment_data = [self.strip_praw_comment(c) for c in comments]
|
comment_data = [self.strip_praw_comment(c) for c in comments]
|
||||||
self._comment_data[index:index+1] = comment_data
|
self._comment_data[index:index+1] = comment_data
|
||||||
|
|
||||||
@@ -281,6 +237,9 @@ class SubredditContent(BaseContent):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_name(cls, reddit, name, loader, order='hot'):
|
def from_name(cls, reddit, name, loader, order='hot'):
|
||||||
|
|
||||||
|
if name is None:
|
||||||
|
name = 'front'
|
||||||
|
|
||||||
name = name.strip(' /') # Strip leading and trailing backslashes
|
name = name.strip(' /') # Strip leading and trailing backslashes
|
||||||
if name.startswith('r/'):
|
if name.startswith('r/'):
|
||||||
name = name[2:]
|
name = name[2:]
|
||||||
@@ -306,7 +265,7 @@ class SubredditContent(BaseContent):
|
|||||||
elif order == 'controversial':
|
elif order == 'controversial':
|
||||||
submissions = reddit.get_controversial(limit=None)
|
submissions = reddit.get_controversial(limit=None)
|
||||||
else:
|
else:
|
||||||
raise SubredditNameError(display_name)
|
raise SubredditError(display_name)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
subreddit = reddit.get_subreddit(name)
|
subreddit = reddit.get_subreddit(name)
|
||||||
@@ -321,7 +280,7 @@ class SubredditContent(BaseContent):
|
|||||||
elif order == 'controversial':
|
elif order == 'controversial':
|
||||||
submissions = subreddit.get_controversial(limit=None)
|
submissions = subreddit.get_controversial(limit=None)
|
||||||
else:
|
else:
|
||||||
raise SubredditNameError(display_name)
|
raise SubredditError(display_name)
|
||||||
|
|
||||||
# Verify that content exists for the given submission generator.
|
# Verify that content exists for the given submission generator.
|
||||||
# This is necessary because PRAW loads submissions lazily, and
|
# This is necessary because PRAW loads submissions lazily, and
|
||||||
@@ -331,7 +290,7 @@ class SubredditContent(BaseContent):
|
|||||||
try:
|
try:
|
||||||
content.get(0)
|
content.get(0)
|
||||||
except (praw.errors.APIException, requests.HTTPError):
|
except (praw.errors.APIException, requests.HTTPError):
|
||||||
raise SubredditNameError(display_name)
|
raise SubredditError(display_name)
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,154 @@
|
|||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
import curses
|
import curses
|
||||||
from curses import textpad, ascii
|
from curses import textpad, ascii
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
import six
|
from .docs import HELP
|
||||||
from six.moves import configparser
|
from .helpers import strip_textpad
|
||||||
|
|
||||||
from .errors import EscapePressed
|
__all__ = ['ESCAPE', 'UARROW', 'DARROW', 'BULLET', 'show_notification',
|
||||||
|
'show_help', 'LoadScreen', 'Color', 'text_input', 'curses_session']
|
||||||
|
|
||||||
HELP = """
|
ESCAPE = 27
|
||||||
Global Commands
|
|
||||||
`UP/DOWN` or `j/k` : Scroll to the prev/next item
|
|
||||||
`a/z` : Upvote/downvote the selected item
|
|
||||||
`r` : Refresh the current page
|
|
||||||
`q` : Quit the program
|
|
||||||
`o` : Open the selected item in the default web browser
|
|
||||||
`?` : Show this help message
|
|
||||||
|
|
||||||
Subreddit Mode
|
# Curses does define constants for these (e.g. curses.ACS_BULLET)
|
||||||
`RIGHT` or `l` : View comments for the selected submission
|
# However, they rely on using the curses.addch() function, which has been
|
||||||
`/` : Open a prompt to switch subreddits
|
# found to be buggy and a PITA to work with. By defining them as unicode
|
||||||
|
# points they can be added via the more reliable curses.addstr().
|
||||||
|
# http://bugs.python.org/issue21088
|
||||||
|
UARROW = u'\u25b2'.encode('utf-8')
|
||||||
|
DARROW = u'\u25bc'.encode('utf-8')
|
||||||
|
BULLET = u'\u2022'.encode('utf-8')
|
||||||
|
|
||||||
Submission Mode
|
def show_notification(stdscr, message):
|
||||||
`LEFT` or `h` : Return to subreddit mode
|
"""
|
||||||
`RIGHT` or `l` : Fold the selected comment, or load additional comments
|
Overlay a message box on the center of the screen and wait for user input.
|
||||||
"""
|
|
||||||
|
|
||||||
class Symbol(object):
|
Params:
|
||||||
|
message (list): List of strings, one per line.
|
||||||
|
"""
|
||||||
|
|
||||||
UNICODE = False
|
n_rows, n_cols = stdscr.getmaxyx()
|
||||||
|
|
||||||
ESCAPE = 27
|
box_width = max(map(len, message)) + 2
|
||||||
|
box_height = len(message) + 2
|
||||||
|
|
||||||
# Curses does define constants for these (e.g. curses.ACS_BULLET)
|
# Make sure the window is large enough to fit the message
|
||||||
# However, they rely on using the curses.addch() function, which has been
|
if (box_width > n_cols) or (box_height > n_rows):
|
||||||
# found to be buggy and a PITA to work with. By defining them as unicode
|
curses.flash()
|
||||||
# points they can be added via the more reliable curses.addstr().
|
return
|
||||||
# http://bugs.python.org/issue21088
|
|
||||||
UARROW = u'\u25b2'.encode('utf-8')
|
|
||||||
DARROW = u'\u25bc'.encode('utf-8')
|
|
||||||
BULLET = u'\u2022'.encode('utf-8')
|
|
||||||
|
|
||||||
@classmethod
|
s_row = (n_rows - box_height) // 2
|
||||||
def clean(cls, string):
|
s_col = (n_cols - box_width) // 2
|
||||||
|
|
||||||
|
window = stdscr.derwin(box_height, box_width, s_row, s_col)
|
||||||
|
window.erase()
|
||||||
|
window.border()
|
||||||
|
|
||||||
|
for index, line in enumerate(message, start=1):
|
||||||
|
window.addstr(index, 1, line)
|
||||||
|
window.refresh()
|
||||||
|
stdscr.getch()
|
||||||
|
|
||||||
|
window.clear()
|
||||||
|
window = None
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
def show_help(stdscr):
|
||||||
|
"""
|
||||||
|
Overlay a message box with the help screen.
|
||||||
|
"""
|
||||||
|
show_notification(stdscr, docs.HELP.split("\n"))
|
||||||
|
|
||||||
|
class LoadScreen(object):
|
||||||
|
"""
|
||||||
|
Display a loading dialog while waiting for a blocking action to complete.
|
||||||
|
|
||||||
|
This class spins off a seperate thread to animate the loading screen in the
|
||||||
|
background.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
#>>> loader = LoadScreen(stdscr)
|
||||||
|
#>>> with loader(...):
|
||||||
|
#>>> blocking_request(...)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, stdscr):
|
||||||
|
|
||||||
|
self._stdscr = stdscr
|
||||||
|
|
||||||
|
self._args = None
|
||||||
|
self._animator = None
|
||||||
|
self._is_running = None
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
delay=0.5,
|
||||||
|
interval=0.4,
|
||||||
|
message='Downloading',
|
||||||
|
trail='...'):
|
||||||
"""
|
"""
|
||||||
Required reading!
|
Params:
|
||||||
http://nedbatchelder.com/text/unipain.html
|
delay (float): Length of time that the loader will wait before
|
||||||
|
printing on the screen. Used to prevent flicker on pages that
|
||||||
Python 2 input string will be a unicode type (unicode code points). Curses
|
load very fast.
|
||||||
will accept that if all of the points are in the ascii range. However, if
|
interval (float): Length of time between each animation frame.
|
||||||
any of the code points are not valid ascii curses will throw a
|
message (str): Message to display
|
||||||
UnicodeEncodeError: 'ascii' codec can't encode character, ordinal not in
|
trail (str): Trail of characters that will be animated by the
|
||||||
range(128). However, if we encode the unicode to a utf-8 byte string and
|
loading screen.
|
||||||
pass that to curses, curses 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 get screwed up.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
encoding = 'utf-8' if cls.UNICODE else 'ascii'
|
self._args = (delay, interval, message, trail)
|
||||||
string = string.encode(encoding, 'replace')
|
return self
|
||||||
return string
|
|
||||||
|
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):
|
class Color(object):
|
||||||
|
"""
|
||||||
|
Color attributes for curses.
|
||||||
|
"""
|
||||||
|
|
||||||
COLORS = {
|
_colors = {
|
||||||
'RED': (curses.COLOR_RED, -1),
|
'RED': (curses.COLOR_RED, -1),
|
||||||
'GREEN': (curses.COLOR_GREEN, -1),
|
'GREEN': (curses.COLOR_GREEN, -1),
|
||||||
'YELLOW': (curses.COLOR_YELLOW, -1),
|
'YELLOW': (curses.COLOR_YELLOW, -1),
|
||||||
@@ -76,12 +158,6 @@ class Color(object):
|
|||||||
'WHITE': (curses.COLOR_WHITE, -1),
|
'WHITE': (curses.COLOR_WHITE, -1),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_level(cls, level):
|
|
||||||
|
|
||||||
levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW]
|
|
||||||
return levels[level % len(levels)]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def init(cls):
|
def init(cls):
|
||||||
"""
|
"""
|
||||||
@@ -94,25 +170,15 @@ class Color(object):
|
|||||||
# Assign the terminal's default (background) color to code -1
|
# Assign the terminal's default (background) color to code -1
|
||||||
curses.use_default_colors()
|
curses.use_default_colors()
|
||||||
|
|
||||||
for index, (attr, code) in enumerate(cls.COLORS.items(), start=1):
|
for index, (attr, code) in enumerate(cls._colors.items(), start=1):
|
||||||
curses.init_pair(index, code[0], code[1])
|
curses.init_pair(index, code[0], code[1])
|
||||||
setattr(cls, attr, curses.color_pair(index))
|
setattr(cls, attr, curses.color_pair(index))
|
||||||
|
|
||||||
def load_config():
|
@classmethod
|
||||||
"""
|
def get_level(cls, level):
|
||||||
Search for a configuration file at the location ~/.rtv and attempt to load
|
|
||||||
saved settings for things like the username and password.
|
|
||||||
"""
|
|
||||||
|
|
||||||
config_path = os.path.join(os.path.expanduser('~'), '.rtv')
|
levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW]
|
||||||
config = configparser.ConfigParser()
|
return levels[level % len(levels)]
|
||||||
config.read(config_path)
|
|
||||||
|
|
||||||
defaults = {}
|
|
||||||
if config.has_section('rtv'):
|
|
||||||
defaults = dict(config.items('rtv'))
|
|
||||||
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
def text_input(window, allow_resize=True):
|
def text_input(window, allow_resize=True):
|
||||||
"""
|
"""
|
||||||
@@ -131,23 +197,17 @@ def text_input(window, allow_resize=True):
|
|||||||
# Turn insert_mode off to avoid the recursion error described here
|
# Turn insert_mode off to avoid the recursion error described here
|
||||||
# http://bugs.python.org/issue13051
|
# http://bugs.python.org/issue13051
|
||||||
textbox = textpad.Textbox(window, insert_mode=False)
|
textbox = textpad.Textbox(window, insert_mode=False)
|
||||||
|
|
||||||
# Strip whitespace from the textbox 'smarter' than textpad.Textbox() does.
|
|
||||||
textbox.stripspaces = 0
|
textbox.stripspaces = 0
|
||||||
|
|
||||||
def validate(ch):
|
def validate(ch):
|
||||||
"Filters characters for special key sequences"
|
"Filters characters for special key sequences"
|
||||||
|
if ch == ESCAPE:
|
||||||
if ch == Symbol.ESCAPE:
|
raise EscapeInterrupt
|
||||||
raise EscapePressed
|
|
||||||
|
|
||||||
if (not allow_resize) and (ch == curses.KEY_RESIZE):
|
if (not allow_resize) and (ch == curses.KEY_RESIZE):
|
||||||
raise EscapePressed
|
raise EscapeInterrupt
|
||||||
|
|
||||||
# Fix backspace for iterm
|
# Fix backspace for iterm
|
||||||
if ch == ascii.DEL:
|
if ch == ascii.DEL:
|
||||||
ch = curses.KEY_BACKSPACE
|
ch = curses.KEY_BACKSPACE
|
||||||
|
|
||||||
return ch
|
return ch
|
||||||
|
|
||||||
# Wrapping in an exception block so that we can distinguish when the user
|
# Wrapping in an exception block so that we can distinguish when the user
|
||||||
@@ -155,84 +215,17 @@ def text_input(window, allow_resize=True):
|
|||||||
# input.
|
# input.
|
||||||
try:
|
try:
|
||||||
out = textbox.edit(validate=validate)
|
out = textbox.edit(validate=validate)
|
||||||
except EscapePressed:
|
except EscapeInterrupt:
|
||||||
out = None
|
out = None
|
||||||
|
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
|
return strip_textpad(out)
|
||||||
if out is None:
|
|
||||||
return out
|
|
||||||
else:
|
|
||||||
return strip_text(out)
|
|
||||||
|
|
||||||
def strip_text(text):
|
|
||||||
"Intelligently strip whitespace from the text output of a curses textpad."
|
|
||||||
|
|
||||||
# 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 display_message(stdscr, message):
|
|
||||||
"Display a message box at the center of the screen and wait for a keypress"
|
|
||||||
|
|
||||||
n_rows, n_cols = stdscr.getmaxyx()
|
|
||||||
|
|
||||||
box_width = max(map(len, message)) + 2
|
|
||||||
box_height = len(message) + 2
|
|
||||||
|
|
||||||
# Make sure the window is large enough to fit the message
|
|
||||||
# TODO: Should find a better way to display the message in this situation
|
|
||||||
if (box_width > n_cols) or (box_height > n_rows):
|
|
||||||
curses.flash()
|
|
||||||
return
|
|
||||||
|
|
||||||
s_row = (n_rows - box_height) // 2
|
|
||||||
s_col = (n_cols - box_width) // 2
|
|
||||||
window = stdscr.derwin(box_height, box_width, s_row, s_col)
|
|
||||||
|
|
||||||
window.erase()
|
|
||||||
window.border()
|
|
||||||
|
|
||||||
for index, line in enumerate(message, start=1):
|
|
||||||
window.addstr(index, 1, line)
|
|
||||||
|
|
||||||
window.refresh()
|
|
||||||
stdscr.getch()
|
|
||||||
|
|
||||||
window.clear()
|
|
||||||
window = None
|
|
||||||
stdscr.refresh()
|
|
||||||
|
|
||||||
def display_help(stdscr):
|
|
||||||
"""Display a help message box at the center of the screen and wait for a
|
|
||||||
keypress"""
|
|
||||||
|
|
||||||
help_msgs = HELP.split("\n")
|
|
||||||
display_message(stdscr, help_msgs)
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def curses_session():
|
def curses_session():
|
||||||
|
"""
|
||||||
|
Setup terminal and initialize curses.
|
||||||
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Curses must wait for some time after the Escape key is pressed to
|
# Curses must wait for some time after the Escape key is pressed to
|
||||||
@@ -274,8 +267,9 @@ def curses_session():
|
|||||||
yield stdscr
|
yield stdscr
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|
||||||
if stdscr is not None:
|
if stdscr is not None:
|
||||||
stdscr.keypad(0)
|
stdscr.keypad(0)
|
||||||
curses.echo()
|
curses.echo()
|
||||||
curses.nocbreak()
|
curses.nocbreak()
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
46
rtv/docs.py
Normal file
46
rtv/docs.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from .__version__ import __version__
|
||||||
|
|
||||||
|
__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'CONTROLS', 'HELP']
|
||||||
|
|
||||||
|
AGENT = """
|
||||||
|
desktop:https://github.com/michael-lazar/rtv:{} (by /u/civilization_phaze_3)
|
||||||
|
""".format(__version__)
|
||||||
|
|
||||||
|
SUMMARY = """
|
||||||
|
Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a
|
||||||
|
terminal window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
AUTH = """
|
||||||
|
Authenticating is required to vote and leave comments. For added security, it
|
||||||
|
is recommended that only you only provide a username. There will be an
|
||||||
|
additional prompt for the password if it is not provided.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONTROLS = """
|
||||||
|
Controls
|
||||||
|
--------
|
||||||
|
RTV currently supports browsing both subreddits and individual submissions.
|
||||||
|
In each mode the controls are slightly different. In subreddit mode you can
|
||||||
|
browse through the top submissions on either the front page or a specific
|
||||||
|
subreddit. In submission mode you can view the self text for a submission and
|
||||||
|
browse comments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
HELP = """
|
||||||
|
Global Commands
|
||||||
|
`UP/DOWN` or `j/k` : Scroll to the prev/next item
|
||||||
|
`a/z` : Upvote/downvote the selected item
|
||||||
|
`r` : Refresh the current page
|
||||||
|
`q` : Quit the program
|
||||||
|
`o` : Open the selected item in the default web browser
|
||||||
|
`?` : Show this help message
|
||||||
|
|
||||||
|
Subreddit Mode
|
||||||
|
`RIGHT` or `l` : View comments for the selected submission
|
||||||
|
`/` : Open a prompt to switch subreddits
|
||||||
|
|
||||||
|
Submission Mode
|
||||||
|
`LEFT` or `h` : Return to subreddit mode
|
||||||
|
`RIGHT` or `l` : Fold the selected comment, or load additional comments
|
||||||
|
"""
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
class EscapePressed(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class SubmissionURLError(Exception):
|
|
||||||
|
|
||||||
def __init__(self, url):
|
|
||||||
self.url = url
|
|
||||||
|
|
||||||
class SubredditNameError(Exception):
|
|
||||||
|
|
||||||
def __init__(self, name):
|
|
||||||
self.name = name
|
|
||||||
12
rtv/exceptions.py
Normal file
12
rtv/exceptions.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class SubmissionError(Exception):
|
||||||
|
"Submission could not be loaded"
|
||||||
|
def __init__(self, url):
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
class SubredditError(Exception):
|
||||||
|
"Subreddit could not be reached"
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
class EscapeInterrupt(Exception):
|
||||||
|
"Signal that the ESC key has been pressed"
|
||||||
127
rtv/helpers.py
Normal file
127
rtv/helpers.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
__all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad',
|
||||||
|
'strip_subreddit_url', 'humanize_timestamp']
|
||||||
|
|
||||||
|
def open_browser(url):
|
||||||
|
"""
|
||||||
|
Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull.
|
||||||
|
|
||||||
|
This is a workaround to stop firefox from spewing warning messages to the
|
||||||
|
console. See http://bugs.python.org/issue22277 for a better description
|
||||||
|
of the problem.
|
||||||
|
"""
|
||||||
|
command = "import webbrowser; webbrowser.open_new_tab('%s')" % url
|
||||||
|
args = [sys.executable, '-c', command]
|
||||||
|
with open(os.devnull, 'ab+', 0) as null:
|
||||||
|
subprocess.check_call(args, stdout=null, stderr=null)
|
||||||
|
|
||||||
|
def clean(string):
|
||||||
|
"""
|
||||||
|
Required reading!
|
||||||
|
http://nedbatchelder.com/text/unipain.html
|
||||||
|
|
||||||
|
Python 2 input string will be a unicode type (unicode code points). Curses
|
||||||
|
will accept unicode if all of the points are in the ascii range. However, if
|
||||||
|
any of the code points are not valid ascii curses will throw a
|
||||||
|
UnicodeEncodeError: 'ascii' codec can't encode character, ordinal not in
|
||||||
|
range(128). If we encode the unicode to a utf-8 byte string and pass that to
|
||||||
|
curses, it will render correctly.
|
||||||
|
|
||||||
|
Python 3 input string will be a string type (unicode code points). Curses
|
||||||
|
will accept that in all cases. However, the n character count in addnstr
|
||||||
|
will not be correct. If code points are passed to addnstr, curses will treat
|
||||||
|
each code point as one character and will not account for wide characters.
|
||||||
|
If utf-8 is passed in, addnstr will treat each 'byte' as a single character.
|
||||||
|
"""
|
||||||
|
|
||||||
|
encoding = 'utf-8' if config.unicode else 'ascii'
|
||||||
|
string = string.encode(encoding, 'replace')
|
||||||
|
return string
|
||||||
|
|
||||||
|
def wrap_text(text, width):
|
||||||
|
"""
|
||||||
|
Wrap text paragraphs to the given character width while preserving newlines.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for paragraph in text.splitlines():
|
||||||
|
# Wrap returns an empty list when paragraph is a newline. In order to
|
||||||
|
# preserve newlines we substitute a list containing an empty string.
|
||||||
|
lines = textwrap.wrap(paragraph, width=width) or ['']
|
||||||
|
out.extend(lines)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def strip_textpad(text):
|
||||||
|
"""
|
||||||
|
Attempt to intelligently strip excess whitespace from the output of a
|
||||||
|
curses textpad.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if text is None:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Trivial case where the textbox is only one line long.
|
||||||
|
if '\n' not in text:
|
||||||
|
return text.rstrip()
|
||||||
|
|
||||||
|
# Allow one space at the end of the line. If there is more than one space,
|
||||||
|
# assume that a newline operation was intended by the user
|
||||||
|
stack, current_line = [], ''
|
||||||
|
for line in text.split('\n'):
|
||||||
|
if line.endswith(' '):
|
||||||
|
stack.append(current_line + line.rstrip())
|
||||||
|
current_line = ''
|
||||||
|
else:
|
||||||
|
current_line += line
|
||||||
|
stack.append(current_line)
|
||||||
|
|
||||||
|
# Prune empty lines at the bottom of the textbox.
|
||||||
|
for item in stack[::-1]:
|
||||||
|
if len(item) == 0:
|
||||||
|
stack.pop()
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
out = '\n'.join(stack)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def strip_subreddit_url(permalink):
|
||||||
|
"""
|
||||||
|
Strip a subreddit name from the subreddit's permalink.
|
||||||
|
|
||||||
|
This is used to avoid submission.subreddit.url making a seperate API call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
subreddit = permalink.split('/')[4]
|
||||||
|
return '/r/{}'.format(subreddit)
|
||||||
|
|
||||||
|
def humanize_timestamp(utc_timestamp, verbose=False):
|
||||||
|
"""
|
||||||
|
Convert a utc timestamp into a human readable relative-time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp)
|
||||||
|
|
||||||
|
seconds = int(timedelta.total_seconds())
|
||||||
|
if seconds < 60:
|
||||||
|
return 'moments ago' if verbose else '0min'
|
||||||
|
minutes = seconds // 60
|
||||||
|
if minutes < 60:
|
||||||
|
return ('%d minutes ago' % minutes) if verbose else ('%dmin' % minutes)
|
||||||
|
hours = minutes // 60
|
||||||
|
if hours < 24:
|
||||||
|
return ('%d hours ago' % hours) if verbose else ('%dhr' % hours)
|
||||||
|
days = hours // 24
|
||||||
|
if days < 30:
|
||||||
|
return ('%d days ago' % days) if verbose else ('%dday' % days)
|
||||||
|
months = days // 30.4
|
||||||
|
if months < 12:
|
||||||
|
return ('%d months ago' % months) if verbose else ('%dmonth' % months)
|
||||||
|
years = months // 12
|
||||||
|
return ('%d years ago' % years) if verbose else ('%dyr' % years)
|
||||||
106
rtv/main.py
106
rtv/main.py
@@ -1,106 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import locale
|
|
||||||
|
|
||||||
import praw
|
|
||||||
from requests.exceptions import ConnectionError, HTTPError
|
|
||||||
from praw.errors import InvalidUserPass
|
|
||||||
|
|
||||||
from .errors import SubmissionURLError, SubredditNameError
|
|
||||||
from .utils import Symbol, curses_session, load_config, HELP
|
|
||||||
from .subreddit import SubredditPage
|
|
||||||
from .submission import SubmissionPage
|
|
||||||
|
|
||||||
# Debugging
|
|
||||||
# import logging
|
|
||||||
# logging.basicConfig(level=logging.DEBUG, filename='rtv.log')
|
|
||||||
|
|
||||||
locale.setlocale(locale.LC_ALL, '')
|
|
||||||
|
|
||||||
AGENT = """
|
|
||||||
desktop:https://github.com/michael-lazar/rtv:(by /u/civilization_phaze_3)
|
|
||||||
"""
|
|
||||||
|
|
||||||
DESCRIPTION = """
|
|
||||||
Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a
|
|
||||||
terminal window.
|
|
||||||
"""
|
|
||||||
|
|
||||||
EPILOG = """
|
|
||||||
Controls
|
|
||||||
--------
|
|
||||||
RTV currently supports browsing both subreddits and individual submissions.
|
|
||||||
In each mode the controls are slightly different. In subreddit mode you can
|
|
||||||
browse through the top submissions on either the front page or a specific
|
|
||||||
subreddit. In submission mode you can view the self text for a submission and
|
|
||||||
browse comments.
|
|
||||||
"""
|
|
||||||
|
|
||||||
EPILOG += HELP
|
|
||||||
|
|
||||||
def main():
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog='rtv', description=DESCRIPTION, epilog=EPILOG,
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
parser.add_argument('-s', dest='subreddit', help='subreddit name')
|
|
||||||
parser.add_argument('-l', dest='link', help='full link to a submission')
|
|
||||||
parser.add_argument('--unicode', help='enable unicode (experimental)',
|
|
||||||
action='store_true')
|
|
||||||
|
|
||||||
group = parser.add_argument_group(
|
|
||||||
'authentication (optional)',
|
|
||||||
'Authenticating allows you to view your customized front page. '
|
|
||||||
'If only the username is given, the password will be prompted '
|
|
||||||
'securely.')
|
|
||||||
group.add_argument('-u', dest='username', help='reddit username')
|
|
||||||
group.add_argument('-p', dest='password', help='reddit password')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Try to fill in empty arguments with values from the config.
|
|
||||||
# Command line flags should always take priority!
|
|
||||||
for key, val in load_config().items():
|
|
||||||
if getattr(args, key) is None:
|
|
||||||
setattr(args, key, val)
|
|
||||||
|
|
||||||
Symbol.UNICODE = args.unicode
|
|
||||||
|
|
||||||
if args.subreddit is None:
|
|
||||||
args.subreddit = 'front'
|
|
||||||
|
|
||||||
try:
|
|
||||||
print('Connecting...')
|
|
||||||
reddit = praw.Reddit(user_agent=AGENT)
|
|
||||||
reddit.config.decode_html_entities = True
|
|
||||||
|
|
||||||
if args.username:
|
|
||||||
# PRAW will prompt for password if it is None
|
|
||||||
reddit.login(args.username, args.password)
|
|
||||||
|
|
||||||
with curses_session() as stdscr:
|
|
||||||
|
|
||||||
if args.link:
|
|
||||||
page = SubmissionPage(stdscr, reddit, url=args.link)
|
|
||||||
page.loop()
|
|
||||||
|
|
||||||
page = SubredditPage(stdscr, reddit, args.subreddit)
|
|
||||||
page.loop()
|
|
||||||
|
|
||||||
except InvalidUserPass:
|
|
||||||
print('Invalid password for username: {}'.format(args.username))
|
|
||||||
|
|
||||||
except ConnectionError:
|
|
||||||
print('Connection timeout: Could not connect to http://www.reddit.com')
|
|
||||||
|
|
||||||
except HTTPError:
|
|
||||||
print('HTTP Error: 404 Not Found')
|
|
||||||
|
|
||||||
except SubmissionURLError as e:
|
|
||||||
print('Could not reach submission URL: {}'.format(e.url))
|
|
||||||
|
|
||||||
except SubredditNameError as e:
|
|
||||||
print('Could not reach subreddit: {}'.format(e.name))
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return
|
|
||||||
|
|
||||||
15
rtv/page.py
15
rtv/page.py
@@ -1,8 +1,11 @@
|
|||||||
import curses
|
import curses
|
||||||
|
|
||||||
import praw
|
import praw.errors
|
||||||
|
|
||||||
from .utils import Color, Symbol, display_message
|
from .helpers import clean
|
||||||
|
from .curses_helpers import Color, show_notification
|
||||||
|
|
||||||
|
__all__ = ['Navigator']
|
||||||
|
|
||||||
class Navigator(object):
|
class Navigator(object):
|
||||||
"""
|
"""
|
||||||
@@ -138,7 +141,7 @@ class BasePage(object):
|
|||||||
data['object'].upvote()
|
data['object'].upvote()
|
||||||
data['likes'] = True
|
data['likes'] = True
|
||||||
except praw.errors.LoginOrScopeRequired:
|
except praw.errors.LoginOrScopeRequired:
|
||||||
display_message(self.stdscr, ['Login to vote'])
|
show_notification(self.stdscr, ['Login to vote'])
|
||||||
|
|
||||||
def downvote(self):
|
def downvote(self):
|
||||||
|
|
||||||
@@ -153,7 +156,7 @@ class BasePage(object):
|
|||||||
data['object'].downvote()
|
data['object'].downvote()
|
||||||
data['likes'] = False
|
data['likes'] = False
|
||||||
except praw.errors.LoginOrScopeRequired:
|
except praw.errors.LoginOrScopeRequired:
|
||||||
display_message(self.stdscr, ['Login to vote'])
|
show_notification(self.stdscr, ['Login to vote'])
|
||||||
|
|
||||||
def draw(self):
|
def draw(self):
|
||||||
|
|
||||||
@@ -183,7 +186,7 @@ class BasePage(object):
|
|||||||
self._header_window.bkgd(' ', attr)
|
self._header_window.bkgd(' ', attr)
|
||||||
|
|
||||||
sub_name = self.content.name.replace('/r/front', 'Front Page ')
|
sub_name = self.content.name.replace('/r/front', 'Front Page ')
|
||||||
self._header_window.addnstr(0, 0, Symbol.clean(sub_name), n_cols-1)
|
self._header_window.addnstr(0, 0, clean(sub_name), n_cols-1)
|
||||||
|
|
||||||
if self.reddit.user is not None:
|
if self.reddit.user is not None:
|
||||||
username = self.reddit.user.name
|
username = self.reddit.user.name
|
||||||
@@ -191,7 +194,7 @@ class BasePage(object):
|
|||||||
# Only print the username if it fits in the empty space on the right
|
# Only print the username if it fits in the empty space on the right
|
||||||
if (s_col - 1) >= len(sub_name):
|
if (s_col - 1) >= len(sub_name):
|
||||||
n = (n_cols - s_col - 1)
|
n = (n_cols - s_col - 1)
|
||||||
self._header_window.addnstr(0, s_col, Symbol.clean(username), n)
|
self._header_window.addnstr(0, s_col, clean(username), n)
|
||||||
|
|
||||||
self._header_window.refresh()
|
self._header_window.refresh()
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import curses
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from praw.errors import APIException
|
import praw.errors
|
||||||
|
|
||||||
from .content import SubmissionContent
|
from .content import SubmissionContent
|
||||||
from .page import BasePage
|
from .page import BasePage
|
||||||
from .utils import Color, Symbol, display_help, text_input
|
|
||||||
from .workers import LoadScreen, open_browser
|
from .workers import LoadScreen, open_browser
|
||||||
|
from .curses_helpers import (BULLET, UARROW, DARROW, Color, show_help,
|
||||||
|
text_input)
|
||||||
|
|
||||||
|
__all__ = ['SubmissionPage']
|
||||||
|
|
||||||
class SubmissionPage(BasePage):
|
class SubmissionPage(BasePage):
|
||||||
|
|
||||||
@@ -125,31 +128,31 @@ class SubmissionPage(BasePage):
|
|||||||
row = offset
|
row = offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
|
|
||||||
text = Symbol.clean('{author} '.format(**data))
|
text = clean('{author} '.format(**data))
|
||||||
attr = curses.A_BOLD
|
attr = curses.A_BOLD
|
||||||
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
|
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
|
||||||
win.addnstr(row, 1, text, n_cols-1, attr)
|
win.addnstr(row, 1, text, n_cols-1, attr)
|
||||||
|
|
||||||
if data['flair']:
|
if data['flair']:
|
||||||
text = Symbol.clean('{flair} '.format(**data))
|
text = clean('{flair} '.format(**data))
|
||||||
attr = curses.A_BOLD | Color.YELLOW
|
attr = curses.A_BOLD | Color.YELLOW
|
||||||
win.addnstr(text, n_cols-win.getyx()[1], attr)
|
win.addnstr(text, n_cols-win.getyx()[1], attr)
|
||||||
|
|
||||||
if data['likes'] is None:
|
if data['likes'] is None:
|
||||||
text, attr = Symbol.BULLET, curses.A_BOLD
|
text, attr = BULLET, curses.A_BOLD
|
||||||
elif data['likes']:
|
elif data['likes']:
|
||||||
text, attr = Symbol.UARROW, (curses.A_BOLD | Color.GREEN)
|
text, attr = UARROW, (curses.A_BOLD | Color.GREEN)
|
||||||
else:
|
else:
|
||||||
text, attr = Symbol.DARROW, (curses.A_BOLD | Color.RED)
|
text, attr = DARROW, (curses.A_BOLD | Color.RED)
|
||||||
win.addnstr(text, n_cols-win.getyx()[1], attr)
|
win.addnstr(text, n_cols-win.getyx()[1], attr)
|
||||||
|
|
||||||
text = Symbol.clean(' {score} {created}'.format(**data))
|
text = clean(' {score} {created}'.format(**data))
|
||||||
win.addnstr(text, n_cols-win.getyx()[1])
|
win.addnstr(text, n_cols-win.getyx()[1])
|
||||||
|
|
||||||
n_body = len(data['split_body'])
|
n_body = len(data['split_body'])
|
||||||
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:
|
if row in valid_rows:
|
||||||
text = Symbol.clean(text)
|
text = clean(text)
|
||||||
win.addnstr(row, 1, text, n_cols-1)
|
win.addnstr(row, 1, text, n_cols-1)
|
||||||
|
|
||||||
# Unfortunately vline() doesn't support custom color so we have to
|
# Unfortunately vline() doesn't support custom color so we have to
|
||||||
@@ -173,9 +176,9 @@ class SubmissionPage(BasePage):
|
|||||||
n_rows, n_cols = win.getmaxyx()
|
n_rows, n_cols = win.getmaxyx()
|
||||||
n_cols -= 1
|
n_cols -= 1
|
||||||
|
|
||||||
text = Symbol.clean('{body}'.format(**data))
|
text = clean('{body}'.format(**data))
|
||||||
win.addnstr(0, 1, text, n_cols-1)
|
win.addnstr(0, 1, text, n_cols-1)
|
||||||
text = Symbol.clean(' [{count}]'.format(**data))
|
text = clean(' [{count}]'.format(**data))
|
||||||
win.addnstr(text, n_cols-win.getyx()[1], curses.A_BOLD)
|
win.addnstr(text, n_cols-win.getyx()[1], curses.A_BOLD)
|
||||||
|
|
||||||
# Unfortunately vline() doesn't support custom color so we have to
|
# Unfortunately vline() doesn't support custom color so we have to
|
||||||
@@ -197,31 +200,31 @@ class SubmissionPage(BasePage):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for row, text in enumerate(data['split_title'], start=1):
|
for row, text in enumerate(data['split_title'], start=1):
|
||||||
text = Symbol.clean(text)
|
text = clean(text)
|
||||||
win.addnstr(row, 1, text, n_cols, curses.A_BOLD)
|
win.addnstr(row, 1, text, n_cols, curses.A_BOLD)
|
||||||
|
|
||||||
row = len(data['split_title']) + 1
|
row = len(data['split_title']) + 1
|
||||||
attr = curses.A_BOLD | Color.GREEN
|
attr = curses.A_BOLD | Color.GREEN
|
||||||
text = Symbol.clean('{author}'.format(**data))
|
text = clean('{author}'.format(**data))
|
||||||
win.addnstr(row, 1, text, n_cols, attr)
|
win.addnstr(row, 1, text, n_cols, attr)
|
||||||
attr = curses.A_BOLD | Color.YELLOW
|
attr = curses.A_BOLD | Color.YELLOW
|
||||||
text = Symbol.clean(' {flair}'.format(**data))
|
text = clean(' {flair}'.format(**data))
|
||||||
win.addnstr(text, n_cols-win.getyx()[1], attr)
|
win.addnstr(text, n_cols-win.getyx()[1], attr)
|
||||||
text = Symbol.clean(' {created} {subreddit}'.format(**data))
|
text = clean(' {created} {subreddit}'.format(**data))
|
||||||
win.addnstr(text, n_cols-win.getyx()[1])
|
win.addnstr(text, n_cols-win.getyx()[1])
|
||||||
|
|
||||||
row = len(data['split_title']) + 2
|
row = len(data['split_title']) + 2
|
||||||
attr = curses.A_UNDERLINE | Color.BLUE
|
attr = curses.A_UNDERLINE | Color.BLUE
|
||||||
text = Symbol.clean('{url}'.format(**data))
|
text = clean('{url}'.format(**data))
|
||||||
win.addnstr(row, 1, text, n_cols, attr)
|
win.addnstr(row, 1, text, n_cols, attr)
|
||||||
|
|
||||||
offset = len(data['split_title']) + 3
|
offset = len(data['split_title']) + 3
|
||||||
for row, text in enumerate(data['split_text'], start=offset):
|
for row, text in enumerate(data['split_text'], start=offset):
|
||||||
text = Symbol.clean(text)
|
text = clean(text)
|
||||||
win.addnstr(row, 1, text, n_cols)
|
win.addnstr(row, 1, text, n_cols)
|
||||||
|
|
||||||
row = len(data['split_title']) + len(data['split_text']) + 3
|
row = len(data['split_title']) + len(data['split_text']) + 3
|
||||||
text = Symbol.clean('{score} {comments}'.format(**data))
|
text = clean('{score} {comments}'.format(**data))
|
||||||
win.addnstr(row, 1, text, n_cols, curses.A_BOLD)
|
win.addnstr(row, 1, text, n_cols, curses.A_BOLD)
|
||||||
|
|
||||||
win.border()
|
win.border()
|
||||||
@@ -273,7 +276,7 @@ class SubmissionPage(BasePage):
|
|||||||
data['object'].add_comment(comment_text)
|
data['object'].add_comment(comment_text)
|
||||||
else:
|
else:
|
||||||
data['object'].reply(comment_text)
|
data['object'].reply(comment_text)
|
||||||
except APIException as e:
|
except praw.errors.APIException as e:
|
||||||
display_message(self.stdscr, [e.message])
|
display_message(self.stdscr, [e.message])
|
||||||
else:
|
else:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import curses
|
import curses
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from requests.exceptions import HTTPError
|
import requests
|
||||||
|
|
||||||
from .errors import SubredditNameError
|
from .exceptions import SubredditError
|
||||||
from .page import BasePage
|
from .page import BasePage
|
||||||
from .submission import SubmissionPage
|
from .submission import SubmissionPage
|
||||||
from .content import SubredditContent
|
from .content import SubredditContent
|
||||||
from .utils import Symbol, Color, text_input, display_message, display_help
|
|
||||||
from .workers import LoadScreen, open_browser
|
from .workers import LoadScreen, open_browser
|
||||||
|
from .curses_helpers import (BULLET, UARROW, DARROW, Color, text_input,
|
||||||
|
show_notification, show_help)
|
||||||
|
|
||||||
|
__all__ = ['opened_links', 'SubredditPage']
|
||||||
|
|
||||||
# Used to keep track of browsing history across the current session
|
# Used to keep track of browsing history across the current session
|
||||||
_opened_links = set()
|
opened_links = set()
|
||||||
|
|
||||||
class SubredditPage(BasePage):
|
class SubredditPage(BasePage):
|
||||||
|
|
||||||
@@ -79,11 +82,11 @@ class SubredditPage(BasePage):
|
|||||||
self.content = SubredditContent.from_name(
|
self.content = SubredditContent.from_name(
|
||||||
self.reddit, name, self.loader)
|
self.reddit, name, self.loader)
|
||||||
|
|
||||||
except SubredditNameError:
|
except SubredditError:
|
||||||
display_message(self.stdscr, ['Invalid subreddit'])
|
show_notification(self.stdscr, ['Invalid subreddit'])
|
||||||
|
|
||||||
except HTTPError:
|
except requests.HTTPError:
|
||||||
display_message(self.stdscr, ['Could not reach subreddit'])
|
show_notification(self.stdscr, ['Could not reach subreddit'])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.nav.page_index, self.nav.cursor_index = 0, 0
|
self.nav.page_index, self.nav.cursor_index = 0, 0
|
||||||
@@ -112,8 +115,8 @@ class SubredditPage(BasePage):
|
|||||||
page.loop()
|
page.loop()
|
||||||
|
|
||||||
if data['url'] == 'selfpost':
|
if data['url'] == 'selfpost':
|
||||||
global _opened_links
|
global opened_links
|
||||||
_opened_links.add(data['url_full'])
|
opened_links.add(data['url_full'])
|
||||||
|
|
||||||
def open_link(self):
|
def open_link(self):
|
||||||
"Open a link with the webbrowser"
|
"Open a link with the webbrowser"
|
||||||
@@ -121,8 +124,8 @@ class SubredditPage(BasePage):
|
|||||||
url = self.content.get(self.nav.absolute_index)['url_full']
|
url = self.content.get(self.nav.absolute_index)['url_full']
|
||||||
open_browser(url)
|
open_browser(url)
|
||||||
|
|
||||||
global _opened_links
|
global opened_links
|
||||||
_opened_links.add(url)
|
opened_links.add(url)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def draw_item(win, data, inverted=False):
|
def draw_item(win, data, inverted=False):
|
||||||
@@ -137,38 +140,38 @@ class SubredditPage(BasePage):
|
|||||||
n_title = len(data['split_title'])
|
n_title = len(data['split_title'])
|
||||||
for row, text in enumerate(data['split_title'], start=offset):
|
for row, text in enumerate(data['split_title'], start=offset):
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
text = Symbol.clean(text)
|
text = clean(text)
|
||||||
win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD)
|
win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD)
|
||||||
|
|
||||||
row = n_title + offset
|
row = n_title + offset
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
seen = (data['url_full'] in _opened_links)
|
seen = (data['url_full'] in opened_links)
|
||||||
link_color = Color.MAGENTA if seen else Color.BLUE
|
link_color = Color.MAGENTA if seen else Color.BLUE
|
||||||
attr = curses.A_UNDERLINE | link_color
|
attr = curses.A_UNDERLINE | link_color
|
||||||
text = Symbol.clean('{url}'.format(**data))
|
text = clean('{url}'.format(**data))
|
||||||
win.addnstr(row, 1, text, n_cols-1, attr)
|
win.addnstr(row, 1, text, n_cols-1, attr)
|
||||||
|
|
||||||
row = n_title + offset + 1
|
row = n_title + offset + 1
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
text = Symbol.clean('{score} '.format(**data))
|
text = clean('{score} '.format(**data))
|
||||||
win.addnstr(row, 1, text, n_cols-1)
|
win.addnstr(row, 1, text, n_cols-1)
|
||||||
|
|
||||||
if data['likes'] is None:
|
if data['likes'] is None:
|
||||||
text, attr = Symbol.BULLET, curses.A_BOLD
|
text, attr = BULLET, curses.A_BOLD
|
||||||
elif data['likes']:
|
elif data['likes']:
|
||||||
text, attr = Symbol.UARROW, curses.A_BOLD | Color.GREEN
|
text, attr = UARROW, curses.A_BOLD | Color.GREEN
|
||||||
else:
|
else:
|
||||||
text, attr = Symbol.DARROW, curses.A_BOLD | Color.RED
|
text, attr = DARROW, curses.A_BOLD | Color.RED
|
||||||
win.addnstr(text, n_cols-win.getyx()[1], attr)
|
win.addnstr(text, n_cols-win.getyx()[1], attr)
|
||||||
|
|
||||||
text = Symbol.clean(' {created} {comments}'.format(**data))
|
text = clean(' {created} {comments}'.format(**data))
|
||||||
win.addnstr(text, n_cols-win.getyx()[1])
|
win.addnstr(text, n_cols-win.getyx()[1])
|
||||||
|
|
||||||
row = n_title + offset + 2
|
row = n_title + offset + 2
|
||||||
if row in valid_rows:
|
if row in valid_rows:
|
||||||
text = Symbol.clean('{author}'.format(**data))
|
text = clean('{author}'.format(**data))
|
||||||
win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD)
|
win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD)
|
||||||
text = Symbol.clean(' {subreddit}'.format(**data))
|
text = clean(' {subreddit}'.format(**data))
|
||||||
win.addnstr(text, n_cols-win.getyx()[1], Color.YELLOW)
|
win.addnstr(text, n_cols-win.getyx()[1], Color.YELLOW)
|
||||||
text = Symbol.clean(' {flair}'.format(**data))
|
text = clean(' {flair}'.format(**data))
|
||||||
win.addnstr(text, n_cols-win.getyx()[1], Color.RED)
|
win.addnstr(text, n_cols-win.getyx()[1], Color.RED)
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import time
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
def open_browser(url):
|
|
||||||
"""
|
|
||||||
Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull.
|
|
||||||
|
|
||||||
This is a workaround to stop firefox from spewing warning messages to the
|
|
||||||
console. See http://bugs.python.org/issue22277 for a better description
|
|
||||||
of the problem.
|
|
||||||
"""
|
|
||||||
command = "import webbrowser; webbrowser.open_new_tab('%s')" % url
|
|
||||||
args = [sys.executable, '-c', command]
|
|
||||||
with open(os.devnull, 'ab+', 0) as null:
|
|
||||||
subprocess.check_call(args, stdout=null, stderr=null)
|
|
||||||
|
|
||||||
|
|
||||||
class LoadScreen(object):
|
|
||||||
"""
|
|
||||||
Display a loading dialog while waiting for a blocking action to complete.
|
|
||||||
|
|
||||||
This class spins off a seperate thread to animate the loading screen in the
|
|
||||||
background.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
#>>> loader = LoadScreen(stdscr)
|
|
||||||
#>>> with loader(...):
|
|
||||||
#>>> blocking_request(...)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, stdscr):
|
|
||||||
|
|
||||||
self._stdscr = stdscr
|
|
||||||
|
|
||||||
self._args = None
|
|
||||||
self._animator = None
|
|
||||||
self._is_running = None
|
|
||||||
|
|
||||||
def __call__(
|
|
||||||
self,
|
|
||||||
delay=0.5,
|
|
||||||
interval=0.4,
|
|
||||||
message='Downloading',
|
|
||||||
trail='...'):
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Check for timeout error
|
|
||||||
|
|
||||||
def animate(self, delay, interval, message, trail):
|
|
||||||
|
|
||||||
# Delay before starting animation to avoid wasting resources if the
|
|
||||||
# wait time is very short
|
|
||||||
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)
|
|
||||||
6
setup.py
6
setup.py
@@ -1,6 +1,5 @@
|
|||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
from version import __version__ as version
|
||||||
__version__ = '1.0.post1'
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='rtv',
|
name='rtv',
|
||||||
@@ -13,8 +12,9 @@ setup(
|
|||||||
license='MIT',
|
license='MIT',
|
||||||
keywords='reddit terminal praw curses',
|
keywords='reddit terminal praw curses',
|
||||||
packages=['rtv'],
|
packages=['rtv'],
|
||||||
|
include_package_data=True,
|
||||||
install_requires=['praw>=2.1.6', 'six', 'requests'],
|
install_requires=['praw>=2.1.6', 'six', 'requests'],
|
||||||
entry_points={'console_scripts': ['rtv=rtv.main:main']},
|
entry_points={'console_scripts': ['rtv=rtv.__main__:main']},
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Intended Audience :: End Users/Desktop',
|
'Intended Audience :: End Users/Desktop',
|
||||||
'Environment :: Console :: Curses',
|
'Environment :: Console :: Curses',
|
||||||
|
|||||||
1
version.py
Symbolic link
1
version.py
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
rtv/__version__.py
|
||||||
Reference in New Issue
Block a user