diff --git a/README.rst b/README.rst index 873bb53..c6e5a5f 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,7 @@ RTV currently supports browsing both subreddits and individual submissions. In e :``▲``/``▼`` or ``j``/``k``: Scroll to the prev/next 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 :``?``: Show the help message :``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 :``/``: Open a prompt to switch subreddits +:``f``: Open a prompt to search the current subreddit The ``/`` prompt accepts subreddits in the following formats diff --git a/rtv/__init__.py b/rtv/__init__.py index 2004061..a85dee2 100644 --- a/rtv/__init__.py +++ b/rtv/__init__.py @@ -3,4 +3,4 @@ from .__version__ import __version__ __title__ = 'Reddit Terminal Viewer' __author__ = 'Michael Lazar' __license__ = 'The MIT License (MIT)' -__copyright__ = '(c) 2015 Michael Lazar' \ No newline at end of file +__copyright__ = '(c) 2015 Michael Lazar' diff --git a/rtv/__main__.py b/rtv/__main__.py index 5066d8c..bf8779c 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -18,6 +18,7 @@ from .docs import * __all__ = [] + def load_config(): """ Search for a configuration file at the location ~/.rtv and attempt to load @@ -34,11 +35,12 @@ def load_config(): return defaults + def command_line(): parser = argparse.ArgumentParser( prog='rtv', description=SUMMARY, - epilog=CONTROLS+HELP, + epilog=CONTROLS + HELP, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('-s', dest='subreddit', help='subreddit name') @@ -56,15 +58,16 @@ def command_line(): 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(): @@ -84,11 +87,11 @@ def main(): # 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) + 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: diff --git a/rtv/config.py b/rtv/config.py index 20223d2..8bad1ff 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -2,4 +2,4 @@ Global configuration settings """ -unicode = False \ No newline at end of file +unicode = False diff --git a/rtv/content.py b/rtv/content.py index b9657f1..8d3a501 100644 --- a/rtv/content.py +++ b/rtv/content.py @@ -8,6 +8,7 @@ from .helpers import humanize_timestamp, wrap_text, strip_subreddit_url __all__ = ['SubredditContent', 'SubmissionContent'] + class BaseContent(object): def get(self, index, n_cols): @@ -40,7 +41,8 @@ class BaseContent(object): retval = [] while stack: 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 nested = getattr(item, 'replies', None) if nested: @@ -70,9 +72,12 @@ class BaseContent(object): data['body'] = comment.body data['created'] = humanize_timestamp(comment.created_utc) data['score'] = '{} pts'.format(comment.score) - data['author'] = (comment.author.name if getattr(comment, 'author') else '[deleted]') - data['is_author'] = (data['author'] == getattr(comment.submission, 'author')) - data['flair'] = (comment.author_flair_text if comment.author_flair_text else '') + author = getattr(comment, 'author') + data['author'] = (author.name if author else '[deleted]') + sub_author = getattr(comment.submission.author, 'name') + data['is_author'] = (data['author'] == sub_author) + flair = comment.author_flair_text + data['flair'] = (flair if flair else '') data['likes'] = comment.likes return data @@ -94,7 +99,8 @@ class BaseContent(object): data['created'] = humanize_timestamp(sub.created_utc) data['comments'] = '{} comments'.format(sub.num_comments) 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['subreddit'] = strip_subreddit_url(sub.permalink) data['flair'] = (sub.link_flair_text if sub.link_flair_text else '') @@ -104,7 +110,9 @@ class BaseContent(object): return data + class SubmissionContent(BaseContent): + """ Grab a submission from PRAW and lazily store comments to an internal list for repeat access. @@ -155,9 +163,10 @@ class SubmissionContent(BaseContent): elif index == -1: data = self._submission_data - data['split_title'] = textwrap.wrap(data['title'], width=n_cols-2) - data['split_text'] = wrap_text(data['text'], width=n_cols-2) - data['n_rows'] = len(data['split_title'])+len(data['split_text'])+5 + data['split_title'] = textwrap.wrap(data['title'], + width=n_cols -2) + data['split_text'] = wrap_text(data['text'], width=n_cols - 2) + data['n_rows'] = len(data['split_title']) + len(data['split_text']) + 5 data['offset'] = 0 else: @@ -191,7 +200,7 @@ class SubmissionContent(BaseContent): elif data['type'] == 'Comment': cache = [data] 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']: break @@ -204,10 +213,10 @@ class SubmissionContent(BaseContent): comment['count'] = count comment['level'] = data['level'] 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': - self._comment_data[index:index+1] = data['cache'] + self._comment_data[index:index + 1] = data['cache'] elif data['type'] == 'MoreComments': with self._loader(): @@ -215,13 +224,14 @@ class SubmissionContent(BaseContent): comments = self.flatten_comments(comments, root_level=data['level']) comment_data = [self.strip_praw_comment(c) for c in comments] - self._comment_data[index:index+1] = comment_data + self._comment_data[index:index + 1] = comment_data else: raise ValueError('% type not recognized' % data['type']) class SubredditContent(BaseContent): + """ Grabs a subreddit from PRAW and lazily stores submissions to an internal list for repeat access. @@ -235,7 +245,7 @@ class SubredditContent(BaseContent): self._submission_data = [] @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: name = 'front' @@ -252,9 +262,11 @@ class SubredditContent(BaseContent): display_name = '/r/{}'.format(name) else: display_name = '/r/{}/{}'.format(name, order) - + if name == 'front': - if order == 'hot': + if search: + submissions = reddit.search(search, None, order) + elif order == 'hot': submissions = reddit.get_front_page(limit=None) elif order == 'top': submissions = reddit.get_top(limit=None) @@ -266,10 +278,11 @@ class SubredditContent(BaseContent): submissions = reddit.get_controversial(limit=None) else: raise SubredditError(display_name) - else: 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) elif order == 'top': submissions = subreddit.get_top(limit=None) diff --git a/rtv/curses_helpers.py b/rtv/curses_helpers.py index 9e5d811..ee9fdaf 100644 --- a/rtv/curses_helpers.py +++ b/rtv/curses_helpers.py @@ -23,6 +23,7 @@ UARROW = u'\u25b2'.encode('utf-8') DARROW = u'\u25bc'.encode('utf-8') BULLET = u'\u2022'.encode('utf-8') + def show_notification(stdscr, message): """ 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): window.addstr(index, 1, line) window.refresh() - stdscr.getch() + ch = stdscr.getch() window.clear() window = None stdscr.refresh() + return ch + + def show_help(stdscr): """ Overlay a message box with the help screen. """ show_notification(stdscr, HELP.split("\n")) + class LoadScreen(object): + """ 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() 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) + window = self._stdscr.derwin(3, message_len + 2, s_row, s_col) while True: - for i in range(len(trail)+1): + for i in range(len(trail) + 1): if not self._is_running: window.clear() @@ -145,7 +151,9 @@ class LoadScreen(object): window.refresh() time.sleep(interval) + class Color(object): + """ Color attributes for curses. """ @@ -158,7 +166,7 @@ class Color(object): 'MAGENTA': (curses.COLOR_MAGENTA, -1), 'CYAN': (curses.COLOR_CYAN, -1), 'WHITE': (curses.COLOR_WHITE, -1), - } + } @classmethod def init(cls): @@ -182,6 +190,7 @@ class Color(object): levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW] return levels[level % len(levels)] + def text_input(window, allow_resize=True): """ Transform a window into a text box that will accept user input and loop @@ -192,7 +201,7 @@ def text_input(window, allow_resize=True): """ window.clear() - + # Set cursor mode to 1 because 2 doesn't display on some terminals curses.curs_set(1) @@ -223,6 +232,7 @@ def text_input(window, allow_resize=True): curses.curs_set(0) return strip_textpad(out) + @contextmanager def curses_session(): """ diff --git a/rtv/docs.py b/rtv/docs.py index 524ee66..988583a 100644 --- a/rtv/docs.py +++ b/rtv/docs.py @@ -2,9 +2,7 @@ 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__) +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 @@ -32,17 +30,19 @@ Global Commands `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 + `ENTER` or `o` : Open the selected item in the default web browser + `u` : Log in `?` : Show this help message Subreddit Mode `RIGHT` or `l` : View comments for the selected submission `/` : Open a prompt to switch subreddits + `f` : Open a prompt to search the current subreddit Submission Mode `LEFT` or `h` : Return to subreddit mode `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 = """ diff --git a/rtv/exceptions.py b/rtv/exceptions.py index c8b980a..9f2e61c 100644 --- a/rtv/exceptions.py +++ b/rtv/exceptions.py @@ -1,17 +1,23 @@ class SubmissionError(Exception): - "Submission could not be loaded" + """Submission could not be loaded""" + def __init__(self, url): self.url = url + class SubredditError(Exception): - "Subreddit could not be reached" + """Subreddit could not be reached""" + def __init__(self, name): self.name = name + class ProgramError(Exception): - "Problem executing an external program" + """Problem executing an external program""" + def __init__(self, name): self.name = name + class EscapeInterrupt(Exception): - "Signal that the ESC key has been pressed" \ No newline at end of file + """Signal that the ESC key has been pressed""" diff --git a/rtv/helpers.py b/rtv/helpers.py index 5f54068..582901a 100644 --- a/rtv/helpers.py +++ b/rtv/helpers.py @@ -11,6 +11,7 @@ from .exceptions import ProgramError __all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad', 'strip_subreddit_url', 'humanize_timestamp', 'open_editor'] + def open_editor(data=''): """ Open a temporary file using the system's default editor. @@ -39,6 +40,7 @@ def open_editor(data=''): return text + def open_browser(url): """ 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: subprocess.check_call(args, stdout=null, stderr=null) + def clean(string): """ Required reading! @@ -75,6 +78,7 @@ def clean(string): string = string.encode(encoding, 'replace') return string + def wrap_text(text, width): """ Wrap text paragraphs to the given character width while preserving newlines. @@ -87,6 +91,7 @@ def wrap_text(text, width): out.extend(lines) return out + def strip_textpad(text): """ Attempt to intelligently strip excess whitespace from the output of a @@ -121,6 +126,7 @@ def strip_textpad(text): out = '\n'.join(stack) return out + def strip_subreddit_url(permalink): """ Strip a subreddit name from the subreddit's permalink. @@ -131,6 +137,7 @@ def strip_subreddit_url(permalink): 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. @@ -154,4 +161,4 @@ def humanize_timestamp(utc_timestamp, verbose=False): 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) \ No newline at end of file + return ('%d years ago' % years) if verbose else ('%dyr' % years) diff --git a/rtv/page.py b/rtv/page.py index 455d9c4..70d13f6 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -1,13 +1,18 @@ import curses +import six +import sys import praw.errors 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'] + class Navigator(object): + """ Handles math behind cursor movement and screen paging. """ @@ -43,7 +48,7 @@ class Navigator(object): valid, redraw = True, False - forward = ((direction*self.step) > 0) + forward = ((direction * self.step) > 0) if forward: if self.page_index < 0: @@ -71,11 +76,12 @@ class Navigator(object): else: self.page_index -= self.step 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 else: self.page_index += self.step - valid = False # Revert + valid = False # Revert return valid, redraw @@ -96,7 +102,58 @@ class Navigator(object): 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): + """ Base terminal viewer incorperates a cursor to navigate content """ @@ -115,11 +172,23 @@ class BasePage(object): self._content_window = 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): self._move_cursor(-1) + self.clear_input_queue() + @BaseController.register(curses.KEY_DOWN, 'j') def move_cursor_down(self): self._move_cursor(1) + self.clear_input_queue() def clear_input_queue(self): "Clear excessive input caused by the scroll wheel or holding down a key" @@ -128,8 +197,8 @@ class BasePage(object): continue self.stdscr.nodelay(0) + @BaseController.register('a') def upvote(self): - data = self.content.get(self.nav.absolute_index) try: if 'likes' not in data: @@ -143,8 +212,8 @@ class BasePage(object): except praw.errors.LoginOrScopeRequired: show_notification(self.stdscr, ['Login to vote']) + @BaseController.register('z') def downvote(self): - data = self.content.get(self.nav.absolute_index) try: if 'likes' not in data: @@ -158,6 +227,61 @@ class BasePage(object): except praw.errors.LoginOrScopeRequired: 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): 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! 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._draw_header() @@ -186,12 +310,13 @@ class BasePage(object): self._header_window.bkgd(' ', attr) 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: username = self.reddit.user.name 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): n = (n_cols - s_col - 1) self._header_window.addnstr(0, s_col, clean(username), n) @@ -215,7 +340,7 @@ class BasePage(object): # and draw upwards. current_row = (n_rows - 1) if inverted else 0 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_cols = n_cols - data['offset'] start = current_row - window_rows if inverted else current_row @@ -250,7 +375,8 @@ class BasePage(object): self._remove_cursor() 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. # if redraw: self._draw_content() diff --git a/rtv/submission.py b/rtv/submission.py index 5e8198c..43899d6 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -5,20 +5,25 @@ import time import praw.errors from .content import SubmissionContent -from .page import BasePage, Navigator +from .page import BasePage, Navigator, BaseController from .helpers import clean, open_browser, open_editor from .curses_helpers import (BULLET, UARROW, DARROW, Color, LoadScreen, - show_help, show_notification, text_input) + show_notification, text_input) from .docs import COMMENT_FILE -__all__ = ['SubmissionPage'] +__all__ = ['SubmissionController', 'SubmissionPage'] + + +class SubmissionController(BaseController): + character_map = {} + class SubmissionPage(BasePage): def __init__(self, stdscr, reddit, url=None, submission=None): + self.controller = SubmissionController(self) self.loader = LoadScreen(stdscr) - if url is not None: content = SubmissionContent.from_url(reddit, url, self.loader) elif submission is not None: @@ -30,59 +35,14 @@ class SubmissionPage(BasePage): page_index=-1) def loop(self): - - self.draw() - - while True: + self.active = True + while self.active: + self.draw() cmd = self.stdscr.getch() + self.controller.trigger(cmd) - if cmd in (curses.KEY_UP, ord('k')): - 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() - + @SubmissionController.register(curses.KEY_RIGHT, 'l') def toggle_comment(self): - current_index = self.nav.absolute_index self.content.toggle(current_index) if self.nav.inverted: @@ -91,19 +51,27 @@ class SubmissionPage(BasePage): # cursor index to go out of bounds. 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 - 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) + @SubmissionController.register(curses.KEY_ENTER, 10, 'o') def open_link(self): - # Always open the page for the submission # May want to expand at some point to open comment permalinks url = self.content.get(-1)['permalink'] open_browser(url) + @SubmissionController.register('c') def add_comment(self): """ Add a comment on the submission if a header is selected. @@ -174,12 +142,12 @@ class SubmissionPage(BasePage): text = clean('{author} '.format(**data)) attr = curses.A_BOLD 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']: text = clean('{flair} '.format(**data)) 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: text, attr = BULLET, curses.A_BOLD @@ -187,16 +155,16 @@ class SubmissionPage(BasePage): text, attr = UARROW, (curses.A_BOLD | Color.GREEN) else: 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)) - win.addnstr(text, n_cols-win.getyx()[1]) + win.addnstr(text, n_cols - win.getyx()[1]) 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: 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 # build it one segment at a time. @@ -205,8 +173,8 @@ class SubmissionPage(BasePage): x = 0 # http://bugs.python.org/issue21088 if (sys.version_info.major, - sys.version_info.minor, - sys.version_info.micro) == (3, 4, 0): + sys.version_info.minor, + sys.version_info.micro) == (3, 4, 0): x, y = y, x win.addch(y, x, curses.ACS_VLINE, attr) @@ -220,9 +188,9 @@ class SubmissionPage(BasePage): n_cols -= 1 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)) - 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 # build it one segment at a time. @@ -235,7 +203,7 @@ class SubmissionPage(BasePage): def draw_submission(win, data): 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 if data['n_rows'] > n_rows: @@ -252,9 +220,9 @@ class SubmissionPage(BasePage): win.addnstr(row, 1, text, n_cols, attr) attr = curses.A_BOLD | Color.YELLOW 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)) - win.addnstr(text, n_cols-win.getyx()[1]) + win.addnstr(text, n_cols - win.getyx()[1]) row = len(data['split_title']) + 2 attr = curses.A_UNDERLINE | Color.BLUE @@ -270,4 +238,4 @@ class SubmissionPage(BasePage): text = clean('{score} {comments}'.format(**data)) win.addnstr(row, 1, text, n_cols, curses.A_BOLD) - win.border() \ No newline at end of file + win.border() diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 8e6b47a..97d292f 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -1,81 +1,43 @@ import curses -import sys import requests from .exceptions import SubredditError -from .page import BasePage, Navigator +from .page import BasePage, Navigator, BaseController from .submission import SubmissionPage from .content import SubredditContent from .helpers import clean, open_browser -from .curses_helpers import (BULLET, UARROW, DARROW, Color, LoadScreen, - text_input, show_notification, show_help) +from .curses_helpers import (BULLET, UARROW, DARROW, Color, LoadScreen, + show_notification) -__all__ = ['opened_links', 'SubredditPage'] +__all__ = ['opened_links', 'SubredditController', 'SubredditPage'] # Used to keep track of browsing history across the current session opened_links = set() + +class SubredditController(BaseController): + character_map = {} + + class SubredditPage(BasePage): def __init__(self, stdscr, reddit, name): + self.controller = SubredditController(self) self.loader = LoadScreen(stdscr) content = SubredditContent.from_name(reddit, name, self.loader) super(SubredditPage, self).__init__(stdscr, reddit, content) def loop(self): - - self.draw() - while True: + self.draw() cmd = self.stdscr.getch() + self.controller.trigger(cmd) - if cmd in (curses.KEY_UP, ord('k')): - 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() - + @SubredditController.register(curses.KEY_F5, 'r') def refresh_content(self, name=None): - name = name or self.content.name try: @@ -88,21 +50,29 @@ class SubredditPage(BasePage): else: 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): - "Open a prompt to type in a new subreddit" - - attr = curses.A_BOLD | Color.CYAN + """Open a prompt to type in a new subreddit""" prompt = 'Enter Subreddit: /r/' - n_rows, n_cols = self.stdscr.getmaxyx() - 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) - if out is not None: - self.refresh_content(name=out) + name = self.prompt_input(prompt) + if name is not None: + self.refresh_content(name=name) + @SubredditController.register(curses.KEY_RIGHT, 'l') def open_submission(self): "Select the current submission to view posts" @@ -114,6 +84,7 @@ class SubredditPage(BasePage): global opened_links opened_links.add(data['url_full']) + @SubredditController.register(curses.KEY_ENTER, 10, 'o') def open_link(self): "Open a link with the webbrowser" @@ -137,7 +108,7 @@ class SubredditPage(BasePage): for row, text in enumerate(data['split_title'], start=offset): if row in valid_rows: 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 if row in valid_rows: @@ -145,12 +116,12 @@ class SubredditPage(BasePage): link_color = Color.MAGENTA if seen else Color.BLUE attr = curses.A_UNDERLINE | link_color 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 if row in valid_rows: 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: text, attr = BULLET, curses.A_BOLD @@ -158,16 +129,16 @@ class SubredditPage(BasePage): text, attr = UARROW, curses.A_BOLD | Color.GREEN else: 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)) - win.addnstr(text, n_cols-win.getyx()[1]) + win.addnstr(text, n_cols - win.getyx()[1]) row = n_title + offset + 2 if row in valid_rows: 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)) - 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)) - win.addnstr(text, n_cols-win.getyx()[1], Color.RED) + win.addnstr(text, n_cols - win.getyx()[1], Color.RED)