Merge branch 'yskmt-login'

This commit is contained in:
Michael Lazar
2015-04-02 09:59:38 -07:00
12 changed files with 300 additions and 195 deletions

View File

@@ -59,7 +59,7 @@ RTV currently supports browsing both subreddits and individual submissions. In e
:``▲``/``▼`` or ``j``/``k``: Scroll to the prev/next item :``▲``/``▼`` or ``j``/``k``: Scroll to the prev/next item
:``a``/``z``: Upvote/downvote the selected item :``a``/``z``: Upvote/downvote the selected item
:``o``: Open the selected item in the default web browser :``ENTER`` or ``o``: Open the selected item in the default web browser
:``r``: Refresh the current page :``r``: Refresh the current page
:``?``: Show the help message :``?``: Show the help message
:``q``: Quit :``q``: Quit
@@ -70,6 +70,7 @@ In subreddit mode you can browse through the top submissions on either the front
:``►`` or ``l``: View comments for the selected submission :``►`` or ``l``: View comments for the selected submission
:``/``: Open a prompt to switch subreddits :``/``: Open a prompt to switch subreddits
:``f``: Open a prompt to search the current subreddit
The ``/`` prompt accepts subreddits in the following formats The ``/`` prompt accepts subreddits in the following formats

View File

@@ -3,4 +3,4 @@ from .__version__ import __version__
__title__ = 'Reddit Terminal Viewer' __title__ = 'Reddit Terminal Viewer'
__author__ = 'Michael Lazar' __author__ = 'Michael Lazar'
__license__ = 'The MIT License (MIT)' __license__ = 'The MIT License (MIT)'
__copyright__ = '(c) 2015 Michael Lazar' __copyright__ = '(c) 2015 Michael Lazar'

View File

@@ -18,6 +18,7 @@ from .docs import *
__all__ = [] __all__ = []
def load_config(): def load_config():
""" """
Search for a configuration file at the location ~/.rtv and attempt to load Search for a configuration file at the location ~/.rtv and attempt to load
@@ -34,11 +35,12 @@ def load_config():
return defaults return defaults
def command_line(): def command_line():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog='rtv', description=SUMMARY, prog='rtv', description=SUMMARY,
epilog=CONTROLS+HELP, epilog=CONTROLS + HELP,
formatter_class=argparse.RawDescriptionHelpFormatter) formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('-s', dest='subreddit', help='subreddit name') parser.add_argument('-s', dest='subreddit', help='subreddit name')
@@ -56,15 +58,16 @@ def command_line():
return args return args
def main(): def main():
"Main entry point" "Main entry point"
# logging.basicConfig(level=logging.DEBUG, filename='rtv.log') # logging.basicConfig(level=logging.DEBUG, filename='rtv.log')
locale.setlocale(locale.LC_ALL, '') locale.setlocale(locale.LC_ALL, '')
args = command_line() args = command_line()
local_config = load_config() local_config = load_config()
# Fill in empty arguments with config file values. Paramaters explicitly # Fill in empty arguments with config file values. Paramaters explicitly
# typed on the command line will take priority over config file params. # typed on the command line will take priority over config file params.
for key, val in local_config.items(): for key, val in local_config.items():
@@ -84,11 +87,11 @@ def main():
# PRAW will prompt for password if it is None # PRAW will prompt for password if it is None
reddit.login(args.username, args.password) reddit.login(args.username, args.password)
with curses_session() as stdscr: with curses_session() as stdscr:
if args.link: if args.link:
page = SubmissionPage(stdscr, reddit, url=args.link) page = SubmissionPage(stdscr, reddit, url=args.link)
page.loop()
page = SubredditPage(stdscr, reddit, args.subreddit)
page.loop() page.loop()
page = SubredditPage(stdscr, reddit, args.subreddit)
page.loop()
except praw.errors.InvalidUserPass: except praw.errors.InvalidUserPass:
print('Invalid password for username: {}'.format(args.username)) print('Invalid password for username: {}'.format(args.username))
except requests.ConnectionError: except requests.ConnectionError:

View File

@@ -2,4 +2,4 @@
Global configuration settings Global configuration settings
""" """
unicode = False unicode = False

View File

@@ -8,6 +8,7 @@ from .helpers import humanize_timestamp, wrap_text, strip_subreddit_url
__all__ = ['SubredditContent', 'SubmissionContent'] __all__ = ['SubredditContent', 'SubmissionContent']
class BaseContent(object): class BaseContent(object):
def get(self, index, n_cols): def get(self, index, n_cols):
@@ -40,7 +41,8 @@ class BaseContent(object):
retval = [] retval = []
while stack: while stack:
item = stack.pop(0) item = stack.pop(0)
if isinstance(item, praw.objects.MoreComments) and (item.count==0): if isinstance(item, praw.objects.MoreComments) and (
item.count == 0):
continue continue
nested = getattr(item, 'replies', None) nested = getattr(item, 'replies', None)
if nested: if nested:
@@ -70,9 +72,12 @@ class BaseContent(object):
data['body'] = comment.body data['body'] = comment.body
data['created'] = humanize_timestamp(comment.created_utc) data['created'] = humanize_timestamp(comment.created_utc)
data['score'] = '{} pts'.format(comment.score) data['score'] = '{} pts'.format(comment.score)
data['author'] = (comment.author.name if getattr(comment, 'author') else '[deleted]') author = getattr(comment, 'author')
data['is_author'] = (data['author'] == getattr(comment.submission, 'author')) data['author'] = (author.name if author else '[deleted]')
data['flair'] = (comment.author_flair_text if comment.author_flair_text else '') sub_author = getattr(comment.submission.author, 'name')
data['is_author'] = (data['author'] == sub_author)
flair = comment.author_flair_text
data['flair'] = (flair if flair else '')
data['likes'] = comment.likes data['likes'] = comment.likes
return data return data
@@ -94,7 +99,8 @@ class BaseContent(object):
data['created'] = humanize_timestamp(sub.created_utc) data['created'] = humanize_timestamp(sub.created_utc)
data['comments'] = '{} comments'.format(sub.num_comments) data['comments'] = '{} comments'.format(sub.num_comments)
data['score'] = '{} pts'.format(sub.score) data['score'] = '{} pts'.format(sub.score)
data['author'] = (sub.author.name if getattr(sub, 'author') else '[deleted]') author = getattr(sub, 'author')
data['author'] = (author.name if author else '[deleted]')
data['permalink'] = sub.permalink data['permalink'] = sub.permalink
data['subreddit'] = strip_subreddit_url(sub.permalink) data['subreddit'] = strip_subreddit_url(sub.permalink)
data['flair'] = (sub.link_flair_text if sub.link_flair_text else '') data['flair'] = (sub.link_flair_text if sub.link_flair_text else '')
@@ -104,7 +110,9 @@ 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
list for repeat access. list for repeat access.
@@ -155,9 +163,10 @@ 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'],
data['split_text'] = wrap_text(data['text'], width=n_cols-2) width=n_cols -2)
data['n_rows'] = len(data['split_title'])+len(data['split_text'])+5 data['split_text'] = wrap_text(data['text'], width=n_cols - 2)
data['n_rows'] = len(data['split_title']) + len(data['split_text']) + 5
data['offset'] = 0 data['offset'] = 0
else: else:
@@ -191,7 +200,7 @@ class SubmissionContent(BaseContent):
elif data['type'] == 'Comment': elif data['type'] == 'Comment':
cache = [data] cache = [data]
count = 1 count = 1
for d in self.iterate(index+1, 1, n_cols): for d in self.iterate(index + 1, 1, n_cols):
if d['level'] <= data['level']: if d['level'] <= data['level']:
break break
@@ -204,10 +213,10 @@ class SubmissionContent(BaseContent):
comment['count'] = count comment['count'] = count
comment['level'] = data['level'] comment['level'] = data['level']
comment['body'] = 'Hidden'.format(count) comment['body'] = 'Hidden'.format(count)
self._comment_data[index:index+len(cache)] = [comment] self._comment_data[index:index + len(cache)] = [comment]
elif data['type'] == 'HiddenComment': elif data['type'] == 'HiddenComment':
self._comment_data[index:index+1] = data['cache'] self._comment_data[index:index + 1] = data['cache']
elif data['type'] == 'MoreComments': elif data['type'] == 'MoreComments':
with self._loader(): with self._loader():
@@ -215,13 +224,14 @@ class SubmissionContent(BaseContent):
comments = self.flatten_comments(comments, comments = self.flatten_comments(comments,
root_level=data['level']) 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
else: else:
raise ValueError('% type not recognized' % data['type']) raise ValueError('% type not recognized' % data['type'])
class SubredditContent(BaseContent): class SubredditContent(BaseContent):
""" """
Grabs a subreddit from PRAW and lazily stores submissions to an internal Grabs a subreddit from PRAW and lazily stores submissions to an internal
list for repeat access. list for repeat access.
@@ -235,7 +245,7 @@ class SubredditContent(BaseContent):
self._submission_data = [] self._submission_data = []
@classmethod @classmethod
def from_name(cls, reddit, name, loader, order='hot'): def from_name(cls, reddit, name, loader, order='hot', search=None):
if name is None: if name is None:
name = 'front' name = 'front'
@@ -252,9 +262,11 @@ class SubredditContent(BaseContent):
display_name = '/r/{}'.format(name) display_name = '/r/{}'.format(name)
else: else:
display_name = '/r/{}/{}'.format(name, order) display_name = '/r/{}/{}'.format(name, order)
if name == 'front': if name == 'front':
if order == 'hot': if search:
submissions = reddit.search(search, None, order)
elif order == 'hot':
submissions = reddit.get_front_page(limit=None) submissions = reddit.get_front_page(limit=None)
elif order == 'top': elif order == 'top':
submissions = reddit.get_top(limit=None) submissions = reddit.get_top(limit=None)
@@ -266,10 +278,11 @@ class SubredditContent(BaseContent):
submissions = reddit.get_controversial(limit=None) submissions = reddit.get_controversial(limit=None)
else: else:
raise SubredditError(display_name) raise SubredditError(display_name)
else: else:
subreddit = reddit.get_subreddit(name) subreddit = reddit.get_subreddit(name)
if order == 'hot': if search:
submissions = reddit.search(search, name, order)
elif order == 'hot':
submissions = subreddit.get_hot(limit=None) submissions = subreddit.get_hot(limit=None)
elif order == 'top': elif order == 'top':
submissions = subreddit.get_top(limit=None) submissions = subreddit.get_top(limit=None)

View File

@@ -23,6 +23,7 @@ UARROW = u'\u25b2'.encode('utf-8')
DARROW = u'\u25bc'.encode('utf-8') DARROW = u'\u25bc'.encode('utf-8')
BULLET = u'\u2022'.encode('utf-8') BULLET = u'\u2022'.encode('utf-8')
def show_notification(stdscr, message): def show_notification(stdscr, message):
""" """
Overlay a message box on the center of the screen and wait for user input. Overlay a message box on the center of the screen and wait for user input.
@@ -51,19 +52,24 @@ def show_notification(stdscr, message):
for index, line in enumerate(message, start=1): for index, line in enumerate(message, start=1):
window.addstr(index, 1, line) window.addstr(index, 1, line)
window.refresh() window.refresh()
stdscr.getch() ch = stdscr.getch()
window.clear() window.clear()
window = None window = None
stdscr.refresh() stdscr.refresh()
return ch
def show_help(stdscr): def show_help(stdscr):
""" """
Overlay a message box with the help screen. Overlay a message box with the help screen.
""" """
show_notification(stdscr, HELP.split("\n")) show_notification(stdscr, HELP.split("\n"))
class LoadScreen(object): class LoadScreen(object):
""" """
Display a loading dialog while waiting for a blocking action to complete. Display a loading dialog while waiting for a blocking action to complete.
@@ -128,10 +134,10 @@ class LoadScreen(object):
n_rows, n_cols = self._stdscr.getmaxyx() n_rows, n_cols = self._stdscr.getmaxyx()
s_row = (n_rows - 3) // 2 s_row = (n_rows - 3) // 2
s_col = (n_cols - message_len - 1) // 2 s_col = (n_cols - message_len - 1) // 2
window = self._stdscr.derwin(3, message_len+2, s_row, s_col) window = self._stdscr.derwin(3, message_len + 2, s_row, s_col)
while True: while True:
for i in range(len(trail)+1): for i in range(len(trail) + 1):
if not self._is_running: if not self._is_running:
window.clear() window.clear()
@@ -145,7 +151,9 @@ class LoadScreen(object):
window.refresh() window.refresh()
time.sleep(interval) time.sleep(interval)
class Color(object): class Color(object):
""" """
Color attributes for curses. Color attributes for curses.
""" """
@@ -158,7 +166,7 @@ class Color(object):
'MAGENTA': (curses.COLOR_MAGENTA, -1), 'MAGENTA': (curses.COLOR_MAGENTA, -1),
'CYAN': (curses.COLOR_CYAN, -1), 'CYAN': (curses.COLOR_CYAN, -1),
'WHITE': (curses.COLOR_WHITE, -1), 'WHITE': (curses.COLOR_WHITE, -1),
} }
@classmethod @classmethod
def init(cls): def init(cls):
@@ -182,6 +190,7 @@ class Color(object):
levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW] levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW]
return levels[level % len(levels)] return levels[level % len(levels)]
def text_input(window, allow_resize=True): def text_input(window, allow_resize=True):
""" """
Transform a window into a text box that will accept user input and loop Transform a window into a text box that will accept user input and loop
@@ -192,7 +201,7 @@ def text_input(window, allow_resize=True):
""" """
window.clear() window.clear()
# Set cursor mode to 1 because 2 doesn't display on some terminals # Set cursor mode to 1 because 2 doesn't display on some terminals
curses.curs_set(1) curses.curs_set(1)
@@ -223,6 +232,7 @@ def text_input(window, allow_resize=True):
curses.curs_set(0) curses.curs_set(0)
return strip_textpad(out) return strip_textpad(out)
@contextmanager @contextmanager
def curses_session(): def curses_session():
""" """

View File

@@ -2,9 +2,7 @@ from .__version__ import __version__
__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'CONTROLS', 'HELP'] __all__ = ['AGENT', 'SUMMARY', 'AUTH', 'CONTROLS', 'HELP']
AGENT = """ AGENT = "desktop:https://github.com/michael-lazar/rtv:{} (by /u/civilization_phaze_3)".format(__version__)
desktop:https://github.com/michael-lazar/rtv:{} (by /u/civilization_phaze_3)
""".format(__version__)
SUMMARY = """ SUMMARY = """
Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a
@@ -32,17 +30,19 @@ Global Commands
`a/z` : Upvote/downvote the selected item `a/z` : Upvote/downvote the selected item
`r` : Refresh the current page `r` : Refresh the current page
`q` : Quit the program `q` : Quit the program
`o` : Open the selected item in the default web browser `ENTER` or `o` : Open the selected item in the default web browser
`u` : Log in
`?` : Show this help message `?` : Show this help message
Subreddit Mode Subreddit Mode
`RIGHT` or `l` : View comments for the selected submission `RIGHT` or `l` : View comments for the selected submission
`/` : Open a prompt to switch subreddits `/` : Open a prompt to switch subreddits
`f` : Open a prompt to search the current subreddit
Submission Mode Submission Mode
`LEFT` or `h` : Return to subreddit mode `LEFT` or `h` : Return to subreddit mode
`RIGHT` or `l` : Fold the selected comment, or load additional comments `RIGHT` or `l` : Fold the selected comment, or load additional comments
`c` : Comment/reply on the selected item `c` : Comment/reply on the selected item
""" """
COMMENT_FILE = """ COMMENT_FILE = """

View File

@@ -1,17 +1,23 @@
class SubmissionError(Exception): class SubmissionError(Exception):
"Submission could not be loaded" """Submission could not be loaded"""
def __init__(self, url): def __init__(self, url):
self.url = url self.url = url
class SubredditError(Exception): class SubredditError(Exception):
"Subreddit could not be reached" """Subreddit could not be reached"""
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
class ProgramError(Exception): class ProgramError(Exception):
"Problem executing an external program" """Problem executing an external program"""
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
class EscapeInterrupt(Exception): class EscapeInterrupt(Exception):
"Signal that the ESC key has been pressed" """Signal that the ESC key has been pressed"""

View File

@@ -11,6 +11,7 @@ from .exceptions import ProgramError
__all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad', __all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad',
'strip_subreddit_url', 'humanize_timestamp', 'open_editor'] 'strip_subreddit_url', 'humanize_timestamp', 'open_editor']
def open_editor(data=''): def open_editor(data=''):
""" """
Open a temporary file using the system's default editor. Open a temporary file using the system's default editor.
@@ -39,6 +40,7 @@ def open_editor(data=''):
return text return text
def open_browser(url): def open_browser(url):
""" """
Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull. Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull.
@@ -52,6 +54,7 @@ def open_browser(url):
with open(os.devnull, 'ab+', 0) as null: with open(os.devnull, 'ab+', 0) as null:
subprocess.check_call(args, stdout=null, stderr=null) subprocess.check_call(args, stdout=null, stderr=null)
def clean(string): def clean(string):
""" """
Required reading! Required reading!
@@ -75,6 +78,7 @@ def clean(string):
string = string.encode(encoding, 'replace') string = string.encode(encoding, 'replace')
return string return string
def wrap_text(text, width): def wrap_text(text, width):
""" """
Wrap text paragraphs to the given character width while preserving newlines. Wrap text paragraphs to the given character width while preserving newlines.
@@ -87,6 +91,7 @@ def wrap_text(text, width):
out.extend(lines) out.extend(lines)
return out return out
def strip_textpad(text): def strip_textpad(text):
""" """
Attempt to intelligently strip excess whitespace from the output of a Attempt to intelligently strip excess whitespace from the output of a
@@ -121,6 +126,7 @@ def strip_textpad(text):
out = '\n'.join(stack) out = '\n'.join(stack)
return out return out
def strip_subreddit_url(permalink): def strip_subreddit_url(permalink):
""" """
Strip a subreddit name from the subreddit's permalink. Strip a subreddit name from the subreddit's permalink.
@@ -131,6 +137,7 @@ def strip_subreddit_url(permalink):
subreddit = permalink.split('/')[4] subreddit = permalink.split('/')[4]
return '/r/{}'.format(subreddit) return '/r/{}'.format(subreddit)
def humanize_timestamp(utc_timestamp, verbose=False): def humanize_timestamp(utc_timestamp, verbose=False):
""" """
Convert a utc timestamp into a human readable relative-time. Convert a utc timestamp into a human readable relative-time.
@@ -154,4 +161,4 @@ def humanize_timestamp(utc_timestamp, verbose=False):
if months < 12: if months < 12:
return ('%d months ago' % months) if verbose else ('%dmonth' % months) return ('%d months ago' % months) if verbose else ('%dmonth' % months)
years = months // 12 years = months // 12
return ('%d years ago' % years) if verbose else ('%dyr' % years) return ('%d years ago' % years) if verbose else ('%dyr' % years)

View File

@@ -1,13 +1,18 @@
import curses import curses
import six
import sys
import praw.errors import praw.errors
from .helpers import clean from .helpers import clean
from .curses_helpers import Color, show_notification from .curses_helpers import Color, show_notification, show_help, text_input
from .docs import AGENT
__all__ = ['Navigator'] __all__ = ['Navigator']
class Navigator(object): class Navigator(object):
""" """
Handles math behind cursor movement and screen paging. Handles math behind cursor movement and screen paging.
""" """
@@ -43,7 +48,7 @@ class Navigator(object):
valid, redraw = True, False valid, redraw = True, False
forward = ((direction*self.step) > 0) forward = ((direction * self.step) > 0)
if forward: if forward:
if self.page_index < 0: if self.page_index < 0:
@@ -71,11 +76,12 @@ class Navigator(object):
else: else:
self.page_index -= self.step self.page_index -= self.step
if self._is_valid(self.absolute_index): if self._is_valid(self.absolute_index):
# We have reached the beginning of the page - move the index # We have reached the beginning of the page - move the
# index
redraw = True redraw = True
else: else:
self.page_index += self.step self.page_index += self.step
valid = False # Revert valid = False # Revert
return valid, redraw return valid, redraw
@@ -96,7 +102,58 @@ class Navigator(object):
return True return True
class BaseController(object):
"""
Event handler for triggering functions with curses keypresses.
Register a keystroke to a class method using the @egister decorator.
#>>> @Controller.register('a', 'A')
#>>> def func(self, *args)
Register a default behavior by using `None`.
#>>> @Controller.register(None)
#>>> def default_func(self, *args)
Bind the controller to a class instance and trigger a key. Additional
arguments will be passed to the function.
#>>> controller = Controller(self)
#>>> controller.trigger('a', *args)
"""
character_map = {None: (lambda *args, **kwargs: None)}
def __init__(self, instance):
self.instance = instance
def trigger(self, char, *args, **kwargs):
if isinstance(char, six.string_types) and len(char) == 1:
char = ord(char)
func = self.character_map.get(char)
if func is None:
func = BaseController.character_map.get(char)
if func is None:
func = self.character_map.get(None)
if func is None:
func = BaseController.character_map.get(None)
return func(self.instance, *args, **kwargs)
@classmethod
def register(cls, *chars):
def wrap(f):
for char in chars:
if isinstance(char, six.string_types) and len(char) == 1:
cls.character_map[ord(char)] = f
else:
cls.character_map[char] = f
return f
return wrap
class BasePage(object): class BasePage(object):
""" """
Base terminal viewer incorperates a cursor to navigate content Base terminal viewer incorperates a cursor to navigate content
""" """
@@ -115,11 +172,23 @@ class BasePage(object):
self._content_window = None self._content_window = None
self._subwindows = None self._subwindows = None
@BaseController.register('q')
def exit(self):
sys.exit()
@BaseController.register('?')
def help(self):
show_help(self.stdscr)
@BaseController.register(curses.KEY_UP, 'k')
def move_cursor_up(self): def move_cursor_up(self):
self._move_cursor(-1) self._move_cursor(-1)
self.clear_input_queue()
@BaseController.register(curses.KEY_DOWN, 'j')
def move_cursor_down(self): def move_cursor_down(self):
self._move_cursor(1) self._move_cursor(1)
self.clear_input_queue()
def clear_input_queue(self): def clear_input_queue(self):
"Clear excessive input caused by the scroll wheel or holding down a key" "Clear excessive input caused by the scroll wheel or holding down a key"
@@ -128,8 +197,8 @@ class BasePage(object):
continue continue
self.stdscr.nodelay(0) self.stdscr.nodelay(0)
@BaseController.register('a')
def upvote(self): def upvote(self):
data = self.content.get(self.nav.absolute_index) data = self.content.get(self.nav.absolute_index)
try: try:
if 'likes' not in data: if 'likes' not in data:
@@ -143,8 +212,8 @@ class BasePage(object):
except praw.errors.LoginOrScopeRequired: except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Login to vote']) show_notification(self.stdscr, ['Login to vote'])
@BaseController.register('z')
def downvote(self): def downvote(self):
data = self.content.get(self.nav.absolute_index) data = self.content.get(self.nav.absolute_index)
try: try:
if 'likes' not in data: if 'likes' not in data:
@@ -158,6 +227,61 @@ class BasePage(object):
except praw.errors.LoginOrScopeRequired: except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Login to vote']) show_notification(self.stdscr, ['Login to vote'])
@BaseController.register('u')
def login(self):
"""
Prompt to log into the user's account. Log out if the user is already
logged in.
"""
if self.reddit.is_logged_in():
self.logout()
return
username = self.prompt_input('Enter username:')
password = self.prompt_input('Enter password:', hide=True)
if not username or not password:
curses.flash()
return
try:
self.reddit.login(username, password)
except praw.errors.InvalidUserPass:
show_notification(self.stdscr, ['Invalid user/pass'])
else:
show_notification(self.stdscr, ['Logged in'])
def logout(self):
"""
Prompt to log out of the user's account.
"""
ch = self.prompt_input("Log out? (y/n):")
if ch == 'y':
self.reddit.clear_authentication()
show_notification(self.stdscr, ['Logged out'])
elif ch != 'n':
curses.flash()
def prompt_input(self, prompt, hide=False):
"""Prompt the user for input"""
attr = curses.A_BOLD | Color.CYAN
n_rows, n_cols = self.stdscr.getmaxyx()
if hide:
prompt += ' ' * (n_cols - len(prompt) - 1)
self.stdscr.addstr(n_rows-1, 0, prompt, attr)
out = self.stdscr.getstr(n_rows-1, 1)
else:
self.stdscr.addstr(n_rows - 1, 0, prompt, attr)
self.stdscr.refresh()
window = self.stdscr.derwin(1, n_cols - len(prompt),
n_rows - 1, len(prompt))
window.attrset(attr)
out = text_input(window)
return out
def draw(self): def draw(self):
n_rows, n_cols = self.stdscr.getmaxyx() n_rows, n_cols = self.stdscr.getmaxyx()
@@ -166,7 +290,7 @@ class BasePage(object):
# Note: 2 argument form of derwin breaks PDcurses on Windows 7! # Note: 2 argument form of derwin breaks PDcurses on Windows 7!
self._header_window = self.stdscr.derwin(1, n_cols, 0, 0) 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._content_window = self.stdscr.derwin(n_rows - 1, n_cols, 1, 0)
self.stdscr.erase() self.stdscr.erase()
self._draw_header() self._draw_header()
@@ -186,12 +310,13 @@ 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, 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
s_col = (n_cols - len(username) - 1) s_col = (n_cols - len(username) - 1)
# 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, clean(username), n) self._header_window.addnstr(0, s_col, clean(username), n)
@@ -215,7 +340,7 @@ class BasePage(object):
# and draw upwards. # and draw upwards.
current_row = (n_rows - 1) if inverted else 0 current_row = (n_rows - 1) if inverted else 0
available_rows = (n_rows - 1) if inverted else n_rows available_rows = (n_rows - 1) if inverted else n_rows
for data in self.content.iterate(page_index, step, n_cols-2): for data in self.content.iterate(page_index, step, n_cols - 2):
window_rows = min(available_rows, data['n_rows']) window_rows = min(available_rows, data['n_rows'])
window_cols = n_cols - data['offset'] window_cols = n_cols - data['offset']
start = current_row - window_rows if inverted else current_row start = current_row - window_rows if inverted else current_row
@@ -250,7 +375,8 @@ class BasePage(object):
self._remove_cursor() self._remove_cursor()
valid, redraw = self.nav.move(direction, len(self._subwindows)) valid, redraw = self.nav.move(direction, len(self._subwindows))
if not valid: curses.flash() if not valid:
curses.flash()
# Note: ACS_VLINE doesn't like changing the attribute, so always redraw. # Note: ACS_VLINE doesn't like changing the attribute, so always redraw.
# if redraw: self._draw_content() # if redraw: self._draw_content()

View File

@@ -5,20 +5,25 @@ import time
import praw.errors import praw.errors
from .content import SubmissionContent from .content import SubmissionContent
from .page import BasePage, Navigator from .page import BasePage, Navigator, BaseController
from .helpers import clean, open_browser, open_editor from .helpers import clean, open_browser, open_editor
from .curses_helpers import (BULLET, UARROW, DARROW, Color, LoadScreen, from .curses_helpers import (BULLET, UARROW, DARROW, Color, LoadScreen,
show_help, show_notification, text_input) show_notification, text_input)
from .docs import COMMENT_FILE from .docs import COMMENT_FILE
__all__ = ['SubmissionPage'] __all__ = ['SubmissionController', 'SubmissionPage']
class SubmissionController(BaseController):
character_map = {}
class SubmissionPage(BasePage): class SubmissionPage(BasePage):
def __init__(self, stdscr, reddit, url=None, submission=None): def __init__(self, stdscr, reddit, url=None, submission=None):
self.controller = SubmissionController(self)
self.loader = LoadScreen(stdscr) self.loader = LoadScreen(stdscr)
if url is not None: if url is not None:
content = SubmissionContent.from_url(reddit, url, self.loader) content = SubmissionContent.from_url(reddit, url, self.loader)
elif submission is not None: elif submission is not None:
@@ -30,59 +35,14 @@ class SubmissionPage(BasePage):
page_index=-1) page_index=-1)
def loop(self): def loop(self):
self.active = True
self.draw() while self.active:
self.draw()
while True:
cmd = self.stdscr.getch() cmd = self.stdscr.getch()
self.controller.trigger(cmd)
if cmd in (curses.KEY_UP, ord('k')): @SubmissionController.register(curses.KEY_RIGHT, 'l')
self.move_cursor_up()
self.clear_input_queue()
elif cmd in (curses.KEY_DOWN, ord('j')):
self.move_cursor_down()
self.clear_input_queue()
elif cmd in (curses.KEY_RIGHT, curses.KEY_ENTER, ord('l')):
self.toggle_comment()
self.draw()
elif cmd in (curses.KEY_LEFT, ord('h')):
break
elif cmd == ord('o'):
self.open_link()
self.draw()
elif cmd in (curses.KEY_F5, ord('r')):
self.refresh_content()
self.draw()
elif cmd == ord('c'):
self.add_comment()
self.draw()
elif cmd == ord('?'):
show_help(self.stdscr)
self.draw()
elif cmd == ord('a'):
self.upvote()
self.draw()
elif cmd == ord('z'):
self.downvote()
self.draw()
elif cmd == ord('q'):
sys.exit()
elif cmd == curses.KEY_RESIZE:
self.draw()
def toggle_comment(self): def toggle_comment(self):
current_index = self.nav.absolute_index current_index = self.nav.absolute_index
self.content.toggle(current_index) self.content.toggle(current_index)
if self.nav.inverted: if self.nav.inverted:
@@ -91,19 +51,27 @@ class SubmissionPage(BasePage):
# cursor index to go out of bounds. # cursor index to go out of bounds.
self.nav.page_index, self.nav.cursor_index = current_index, 0 self.nav.page_index, self.nav.cursor_index = current_index, 0
def refresh_content(self): @SubmissionController.register(curses.KEY_LEFT, 'h')
def exit_submission(self):
self.active = False
@SubmissionController.register(curses.KEY_F5, 'r')
def refresh_content(self):
url = self.content.name url = self.content.name
self.content = SubmissionContent.from_url(self.reddit, url, self.loader) self.content = SubmissionContent.from_url(
self.reddit,
url,
self.loader)
self.nav = Navigator(self.content.get, page_index=-1) self.nav = Navigator(self.content.get, page_index=-1)
@SubmissionController.register(curses.KEY_ENTER, 10, 'o')
def open_link(self): def open_link(self):
# Always open the page for the submission # Always open the page for the submission
# May want to expand at some point to open comment permalinks # May want to expand at some point to open comment permalinks
url = self.content.get(-1)['permalink'] url = self.content.get(-1)['permalink']
open_browser(url) open_browser(url)
@SubmissionController.register('c')
def add_comment(self): def add_comment(self):
""" """
Add a comment on the submission if a header is selected. Add a comment on the submission if a header is selected.
@@ -174,12 +142,12 @@ class SubmissionPage(BasePage):
text = 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 = 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 = BULLET, curses.A_BOLD text, attr = BULLET, curses.A_BOLD
@@ -187,16 +155,16 @@ class SubmissionPage(BasePage):
text, attr = UARROW, (curses.A_BOLD | Color.GREEN) text, attr = UARROW, (curses.A_BOLD | Color.GREEN)
else: else:
text, attr = 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 = 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 = 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
# build it one segment at a time. # build it one segment at a time.
@@ -205,8 +173,8 @@ class SubmissionPage(BasePage):
x = 0 x = 0
# http://bugs.python.org/issue21088 # http://bugs.python.org/issue21088
if (sys.version_info.major, if (sys.version_info.major,
sys.version_info.minor, sys.version_info.minor,
sys.version_info.micro) == (3, 4, 0): sys.version_info.micro) == (3, 4, 0):
x, y = y, x x, y = y, x
win.addch(y, x, curses.ACS_VLINE, attr) win.addch(y, x, curses.ACS_VLINE, attr)
@@ -220,9 +188,9 @@ class SubmissionPage(BasePage):
n_cols -= 1 n_cols -= 1
text = 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 = 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
# build it one segment at a time. # build it one segment at a time.
@@ -235,7 +203,7 @@ class SubmissionPage(BasePage):
def draw_submission(win, data): def draw_submission(win, data):
n_rows, n_cols = win.getmaxyx() n_rows, n_cols = win.getmaxyx()
n_cols -= 3 # one for each side of the border + one for offset n_cols -= 3 # one for each side of the border + one for offset
# Don't print at all if there is not enough room to fit the whole sub # Don't print at all if there is not enough room to fit the whole sub
if data['n_rows'] > n_rows: if data['n_rows'] > n_rows:
@@ -252,9 +220,9 @@ class SubmissionPage(BasePage):
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 = 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 = 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
@@ -270,4 +238,4 @@ class SubmissionPage(BasePage):
text = 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()

View File

@@ -1,81 +1,43 @@
import curses import curses
import sys
import requests import requests
from .exceptions import SubredditError from .exceptions import SubredditError
from .page import BasePage, Navigator from .page import BasePage, Navigator, BaseController
from .submission import SubmissionPage from .submission import SubmissionPage
from .content import SubredditContent from .content import SubredditContent
from .helpers import clean, open_browser from .helpers import clean, open_browser
from .curses_helpers import (BULLET, UARROW, DARROW, Color, LoadScreen, from .curses_helpers import (BULLET, UARROW, DARROW, Color, LoadScreen,
text_input, show_notification, show_help) show_notification)
__all__ = ['opened_links', 'SubredditPage'] __all__ = ['opened_links', 'SubredditController', '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 SubredditController(BaseController):
character_map = {}
class SubredditPage(BasePage): class SubredditPage(BasePage):
def __init__(self, stdscr, reddit, name): def __init__(self, stdscr, reddit, name):
self.controller = SubredditController(self)
self.loader = LoadScreen(stdscr) self.loader = LoadScreen(stdscr)
content = SubredditContent.from_name(reddit, name, self.loader) content = SubredditContent.from_name(reddit, name, self.loader)
super(SubredditPage, self).__init__(stdscr, reddit, content) super(SubredditPage, self).__init__(stdscr, reddit, content)
def loop(self): def loop(self):
self.draw()
while True: while True:
self.draw()
cmd = self.stdscr.getch() cmd = self.stdscr.getch()
self.controller.trigger(cmd)
if cmd in (curses.KEY_UP, ord('k')): @SubredditController.register(curses.KEY_F5, 'r')
self.move_cursor_up()
self.clear_input_queue()
elif cmd in (curses.KEY_DOWN, ord('j')):
self.move_cursor_down()
self.clear_input_queue()
elif cmd in (curses.KEY_RIGHT, curses.KEY_ENTER, ord('l')):
self.open_submission()
self.draw()
elif cmd == ord('o'):
self.open_link()
self.draw()
elif cmd in (curses.KEY_F5, ord('r')):
self.refresh_content()
self.draw()
elif cmd == ord('?'):
show_help(self.stdscr)
self.draw()
elif cmd == ord('a'):
self.upvote()
self.draw()
elif cmd == ord('z'):
self.downvote()
self.draw()
elif cmd == ord('q'):
sys.exit()
elif cmd == curses.KEY_RESIZE:
self.draw()
elif cmd == ord('/'):
self.prompt_subreddit()
self.draw()
def refresh_content(self, name=None): def refresh_content(self, name=None):
name = name or self.content.name name = name or self.content.name
try: try:
@@ -88,21 +50,29 @@ class SubredditPage(BasePage):
else: else:
self.nav = Navigator(self.content.get) self.nav = Navigator(self.content.get)
@SubredditController.register('f')
def search_subreddit(self, name=None):
"""Open a prompt to search the subreddit"""
name = name or self.content.name
prompt = 'Search this Subreddit: '
search = self.prompt_input(prompt)
if search is not None:
try:
self.nav.cursor_index = 0
self.content = SubredditContent.from_name(self.reddit, name,
self.loader, search=search)
except IndexError: # if there are no submissions
show_notification(self.stdscr, ['No results found'])
@SubredditController.register('/')
def prompt_subreddit(self): def prompt_subreddit(self):
"Open a prompt to type in a new subreddit" """Open a prompt to type in a new subreddit"""
attr = curses.A_BOLD | Color.CYAN
prompt = 'Enter Subreddit: /r/' prompt = 'Enter Subreddit: /r/'
n_rows, n_cols = self.stdscr.getmaxyx() name = self.prompt_input(prompt)
self.stdscr.addstr(n_rows-1, 0, prompt, attr) if name is not None:
self.stdscr.refresh() self.refresh_content(name=name)
window = self.stdscr.derwin(1, n_cols-len(prompt),n_rows-1, len(prompt))
window.attrset(attr)
out = text_input(window)
if out is not None:
self.refresh_content(name=out)
@SubredditController.register(curses.KEY_RIGHT, 'l')
def open_submission(self): def open_submission(self):
"Select the current submission to view posts" "Select the current submission to view posts"
@@ -114,6 +84,7 @@ class SubredditPage(BasePage):
global opened_links global opened_links
opened_links.add(data['url_full']) opened_links.add(data['url_full'])
@SubredditController.register(curses.KEY_ENTER, 10, 'o')
def open_link(self): def open_link(self):
"Open a link with the webbrowser" "Open a link with the webbrowser"
@@ -137,7 +108,7 @@ class SubredditPage(BasePage):
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 = 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:
@@ -145,12 +116,12 @@ class SubredditPage(BasePage):
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 = 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 = 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 = BULLET, curses.A_BOLD text, attr = BULLET, curses.A_BOLD
@@ -158,16 +129,16 @@ class SubredditPage(BasePage):
text, attr = UARROW, curses.A_BOLD | Color.GREEN text, attr = UARROW, curses.A_BOLD | Color.GREEN
else: else:
text, attr = 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 = 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 = 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 = 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 = 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)