From eba9238adca835d1829ee48354517ef8aec101cb Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sat, 14 Mar 2015 00:19:16 -0700 Subject: [PATCH] Semi-major refactor, added upvotes/downvotes. --- rtv/content.py | 25 +++++----- rtv/main.py | 10 ++-- rtv/page.py | 47 ++++++++++++++----- rtv/submission.py | 113 ++++++++++++++++++++++++++-------------------- rtv/subreddit.py | 64 +++++++++++++++++--------- rtv/utils.py | 60 ++++++++++++++---------- 6 files changed, 194 insertions(+), 125 deletions(-) diff --git a/rtv/content.py b/rtv/content.py index 96995da..4d226ac 100644 --- a/rtv/content.py +++ b/rtv/content.py @@ -10,13 +10,10 @@ from .errors import SubmissionURLError, SubredditNameError def split_text(big_text, width): 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 ['']) - ] + 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): """ @@ -64,12 +61,10 @@ class BaseContent(object): def iterate(self, index, step, n_cols): while True: - - # Hack to prevent displaying negative indicies if iterating in the - # negative direction. if step < 0 and index < 0: + # Hack to prevent displaying negative indicies if iterating in + # the negative direction. break - try: yield self.get(index, n_cols=n_cols) except IndexError: @@ -109,6 +104,11 @@ class BaseContent(object): data['object'] = comment data['level'] = comment.nested_level + if getattr(comment.submission, 'author'): + sub_author = comment.submission.author.name + else: + sub_author = '[deleted]' + if isinstance(comment, praw.objects.MoreComments): data['type'] = 'MoreComments' data['count'] = comment.count @@ -119,9 +119,9 @@ class BaseContent(object): data['created'] = humanize_timestamp(comment.created_utc) data['score'] = '{} pts'.format(comment.score) data['author'] = (comment.author.name if getattr(comment, 'author') else '[deleted]') - sub_author = (comment.submission.author.name if getattr(comment.submission, 'author') else '[deleted]') data['is_author'] = (data['author'] == sub_author) data['flair'] = (comment.author_flair_text if comment.author_flair_text else '') + data['likes'] = comment.likes return data @@ -148,6 +148,7 @@ class BaseContent(object): data['flair'] = (sub.link_flair_text if sub.link_flair_text else '') data['url_full'] = sub.url data['url'] = ('selfpost' if is_selfpost(sub.url) else sub.url) + data['likes'] = sub.likes return data diff --git a/rtv/main.py b/rtv/main.py index aa3899c..d5d9f00 100644 --- a/rtv/main.py +++ b/rtv/main.py @@ -5,9 +5,8 @@ import praw from requests.exceptions import ConnectionError, HTTPError from praw.errors import InvalidUserPass -from . import utils from .errors import SubmissionURLError, SubredditNameError -from .utils import curses_session, load_config, HELP +from .utils import Symbol, curses_session, load_config, HELP from .subreddit import SubredditPage from .submission import SubmissionPage @@ -28,7 +27,7 @@ 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 @@ -45,7 +44,7 @@ def main(): 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 (beta)', + parser.add_argument('--unicode', help='enable unicode (experimental)', action='store_true') group = parser.add_argument_group( @@ -64,7 +63,7 @@ def main(): if getattr(args, key) is None: setattr(args, key, val) - utils.UNICODE = args.unicode + Symbol.UNICODE = args.unicode if args.subreddit is None: args.subreddit = 'front' @@ -75,6 +74,7 @@ def main(): 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: diff --git a/rtv/page.py b/rtv/page.py index 0700396..c6efa38 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -1,6 +1,6 @@ import curses -from .utils import Color, clean +from .utils import Color, Symbol class Navigator(object): """ @@ -129,14 +129,41 @@ class BasePage(object): continue self.stdscr.nodelay(0) + def upvote(self): + + data = self.content.get(self.nav.absolute_index) + if 'likes' not in data: + curses.flash() + + elif data['likes']: + data['object'].clear_vote() + data['likes'] = None + else: + data['object'].upvote() + data['likes'] = True + + def downvote(self): + + data = self.content.get(self.nav.absolute_index) + if 'likes' not in data: + curses.flash() + + if data['likes'] is False: + data['object'].clear_vote() + data['likes'] = None + else: + data['object'].downvote() + data['likes'] = False + def draw(self): n_rows, n_cols = self.stdscr.getmaxyx() if n_rows < self.MIN_HEIGHT or n_cols < self.MIN_WIDTH: return + # Note: 2 argument form of derwin breaks PDcurses on Windows 7! self._header_window = self.stdscr.derwin(1, n_cols, 0, 0) - self._content_window = self.stdscr.derwin(1, 0) + self._content_window = self.stdscr.derwin(n_rows-1, n_cols, 1, 0) self.stdscr.erase() self._draw_header() @@ -157,7 +184,7 @@ 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, Symbol.clean(sub_name), n_cols-1) if self.reddit.user is not None: username = self.reddit.user.name @@ -165,7 +192,7 @@ class BasePage(object): # 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) + self._header_window.addnstr(0, s_col, Symbol.clean(username), n) self._header_window.refresh() @@ -215,14 +242,11 @@ class BasePage(object): self.remove_cursor() valid, redraw = self.nav.move(direction, len(self._subwindows)) - if not valid: - curses.flash() - - # TODO: If we don't redraw, ACS_VLINE gets screwed up when changing the - # attr back to normal. There may be a way around this. - if True: #if redraw - self._draw_content() + if not valid: curses.flash() + # Note: ACS_VLINE doesn't like changing the attribute, so always redraw. + # if redraw: self._draw_content() + self._draw_content() self.add_cursor() def _edit_cursor(self, attribute=None): @@ -231,7 +255,6 @@ class BasePage(object): if self.nav.absolute_index < 0: return - # TODO: attach attr to data[attr] or something window, attr = self._subwindows[self.nav.cursor_index] if attr is not None: attribute |= attr diff --git a/rtv/submission.py b/rtv/submission.py index eef43f1..71f76e1 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -1,12 +1,9 @@ import curses import sys -import webbrowser - -import six from .content import SubmissionContent from .page import BasePage -from .utils import LoadScreen, Color, ESCAPE, display_help, open_new_tab, clean +from .utils import LoadScreen, Color, Symbol, display_help, open_browser class SubmissionPage(BasePage): @@ -42,6 +39,9 @@ class SubmissionPage(BasePage): self.toggle_comment() self.draw() + elif cmd in (curses.KEY_LEFT, ord('h')): + break + elif cmd == ord('o'): self.open_link() self.draw() @@ -54,17 +54,19 @@ class SubmissionPage(BasePage): display_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 in (ESCAPE, curses.KEY_LEFT, ord('h')): - break - - else: - curses.beep() def toggle_comment(self): @@ -81,19 +83,16 @@ class SubmissionPage(BasePage): # 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_new_tab(url) + open_browser(url) def draw_item(self, win, data, inverted=False): if data['type'] == 'MoreComments': return self.draw_more_comments(win, data) - elif data['type'] == 'HiddenComment': return self.draw_more_comments(win, data) - elif data['type'] == 'Comment': return self.draw_comment(win, data, inverted=inverted) - else: return self.draw_submission(win, data) @@ -109,53 +108,66 @@ class SubmissionPage(BasePage): row = offset if row in valid_rows: - text = '{author}'.format(**data) + + text = Symbol.clean('{author} '.format(**data)) attr = curses.A_BOLD attr |= (Color.BLUE if not data['is_author'] else Color.GREEN) - win.addnstr(row, 1, clean(text), n_cols-1, attr) - text = ' {flair}'.format(**data) - win.addnstr(clean(text), n_cols-win.getyx()[1], curses.A_BOLD | Color.YELLOW) - text = ' {score} {created}'.format(**data) - win.addnstr(clean(text), n_cols - win.getyx()[1]) + win.addnstr(row, 1, text, n_cols-1, attr) + + if data['flair']: + text = Symbol.clean('{flair} '.format(**data)) + attr = curses.A_BOLD | Color.YELLOW + win.addnstr(text, n_cols-win.getyx()[1], attr) + + if data['likes'] is None: + text, attr = Symbol.BULLET, curses.A_BOLD + elif data['likes']: + text, attr = Symbol.UARROW, (curses.A_BOLD | Color.GREEN) + else: + text, attr = Symbol.DARROW, (curses.A_BOLD | Color.RED) + win.addnstr(text, n_cols-win.getyx()[1], attr) + + text = Symbol.clean(' {score} {created}'.format(**data)) + 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): if row in valid_rows: - win.addnstr(row, 1, clean(text), n_cols-1) + text = Symbol.clean(text) + win.addnstr(row, 1, text, n_cols-1) - # Vertical line, unfortunately vline() doesn't support custom color so - # we have to build it one chr at a time. + # Unfortunately vline() doesn't support custom color so we have to + # build it one segment at a time. attr = Color.get_level(data['level']) for y in range(n_rows): - x = 0 - - # Nobody pays attention to curses ;( # http://bugs.python.org/issue21088 if (sys.version_info.major, - sys.version_info.minor, + sys.version_info.minor, sys.version_info.micro) == (3, 4, 0): x, y = y, x - + win.addch(y, x, curses.ACS_VLINE, attr) - return attr | curses.ACS_VLINE + return (attr | curses.ACS_VLINE) @staticmethod def draw_more_comments(win, data): n_rows, n_cols = win.getmaxyx() n_cols -= 1 - text = '{body}'.format(**data) - win.addnstr(0, 1, clean(text), n_cols-1) - text = ' [{count}]'.format(**data) - win.addnstr(clean(text), n_cols - win.getyx()[1], curses.A_BOLD) + text = Symbol.clean('{body}'.format(**data)) + win.addnstr(0, 1, text, n_cols-1) + text = Symbol.clean(' [{count}]'.format(**data)) + 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. attr = Color.get_level(data['level']) - for y in range(n_rows): - win.addch(y, 0, curses.ACS_VLINE, attr) + win.addch(0, 0, curses.ACS_VLINE, attr) - return attr | curses.ACS_VLINE + return (attr | curses.ACS_VLINE) @staticmethod def draw_submission(win, data): @@ -169,28 +181,31 @@ class SubmissionPage(BasePage): return for row, text in enumerate(data['split_title'], start=1): - win.addnstr(row, 1, clean(text), n_cols, curses.A_BOLD) + text = Symbol.clean(text) + win.addnstr(row, 1, text, n_cols, curses.A_BOLD) row = len(data['split_title']) + 1 attr = curses.A_BOLD | Color.GREEN - text = '{author}'.format(**data) - win.addnstr(row, 1, clean(text), n_cols, attr) - text = ' {flair}'.format(**data) - win.addnstr(clean(text), n_cols-win.getyx()[1], curses.A_BOLD | Color.YELLOW) - text = ' {created} {subreddit}'.format(**data) - win.addnstr(clean(text), n_cols - win.getyx()[1]) + text = Symbol.clean('{author}'.format(**data)) + win.addnstr(row, 1, text, n_cols, attr) + attr = curses.A_BOLD | Color.YELLOW + text = Symbol.clean(' {flair}'.format(**data)) + win.addnstr(text, n_cols-win.getyx()[1], attr) + text = Symbol.clean(' {created} {subreddit}'.format(**data)) + win.addnstr(text, n_cols-win.getyx()[1]) row = len(data['split_title']) + 2 attr = curses.A_UNDERLINE | Color.BLUE - text = '{url}'.format(**data) - win.addnstr(row, 1, clean(text), n_cols, attr) + text = Symbol.clean('{url}'.format(**data)) + win.addnstr(row, 1, text, n_cols, attr) offset = len(data['split_title']) + 3 for row, text in enumerate(data['split_text'], start=offset): - win.addnstr(row, 1, clean(text), n_cols) + text = Symbol.clean(text) + win.addnstr(row, 1, text, n_cols) row = len(data['split_title']) + len(data['split_text']) + 3 - text = '{score} {comments}'.format(**data) - win.addnstr(row, 1, clean(text), n_cols, curses.A_BOLD) + text = Symbol.clean('{score} {comments}'.format(**data)) + win.addnstr(row, 1, text, n_cols, curses.A_BOLD) - win.border() + win.border() \ No newline at end of file diff --git a/rtv/subreddit.py b/rtv/subreddit.py index f059c91..8fa7c70 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -7,9 +7,8 @@ from .errors import SubredditNameError from .page import BasePage from .submission import SubmissionPage from .content import SubredditContent -from .utils import (LoadScreen, Color, text_input, display_message, - display_help, open_new_tab, clean) - +from .utils import (LoadScreen, Symbol, Color, text_input, display_message, + display_help, open_browser) # Used to keep track of browsing history across the current session _opened_links = set() @@ -54,6 +53,14 @@ class SubredditPage(BasePage): display_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() @@ -64,9 +71,6 @@ class SubredditPage(BasePage): self.prompt_subreddit() self.draw() - else: - curses.beep() - def refresh_content(self, name=None): name = name or self.content.name @@ -75,19 +79,23 @@ class SubredditPage(BasePage): self.content = SubredditContent.from_name( self.reddit, name, self.loader) - except (SubredditNameError, HTTPError): - display_message(self.stdscr, ['Invalid Subreddit']) + except SubredditNameError: + display_message(self.stdscr, ['Invalid subreddit']) + + except HTTPError: + display_message(self.stdscr, ['Could not reach subreddit']) else: self.nav.page_index, self.nav.cursor_index = 0, 0 self.nav.inverted = False def prompt_subreddit(self): + "Open a prompt to type in a new subreddit" attr = curses.A_BOLD | Color.CYAN prompt = 'Enter Subreddit: /r/' n_rows, n_cols = self.stdscr.getmaxyx() - self.stdscr.addstr(n_rows-1, 0, clean(prompt), attr) + 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) @@ -108,9 +116,10 @@ class SubredditPage(BasePage): _opened_links.add(data['url_full']) def open_link(self): + "Open a link with the webbrowser" url = self.content.get(self.nav.absolute_index)['url_full'] - open_new_tab(url) + open_browser(url) global _opened_links _opened_links.add(url) @@ -128,27 +137,38 @@ class SubredditPage(BasePage): n_title = len(data['split_title']) for row, text in enumerate(data['split_title'], start=offset): if row in valid_rows: - attr = curses.A_BOLD - win.addstr(row, 1, clean(text), attr) + text = Symbol.clean(text) + win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD) row = n_title + offset if row in valid_rows: seen = (data['url_full'] in _opened_links) link_color = Color.MAGENTA if seen else Color.BLUE attr = curses.A_UNDERLINE | link_color - text = '{url}'.format(**data) - win.addnstr(row, 1, clean(text), n_cols-1, attr) + text = Symbol.clean('{url}'.format(**data)) + win.addnstr(row, 1, text, n_cols-1, attr) row = n_title + offset + 1 if row in valid_rows: - text = '{created} {comments} {score}'.format(**data) - win.addnstr(row, 1, clean(text), n_cols-1) + text = Symbol.clean('{score} '.format(**data)) + win.addnstr(row, 1, text, n_cols-1) + + if data['likes'] is None: + text, attr = Symbol.BULLET, curses.A_BOLD + elif data['likes']: + text, attr = Symbol.UARROW, curses.A_BOLD | Color.GREEN + else: + text, attr = Symbol.DARROW, curses.A_BOLD | Color.RED + win.addnstr(text, n_cols-win.getyx()[1], attr) + + text = Symbol.clean(' {created} {comments}'.format(**data)) + win.addnstr(text, n_cols-win.getyx()[1]) row = n_title + offset + 2 if row in valid_rows: - text = '{author}'.format(**data) - win.addnstr(row, 1, clean(text), n_cols-1, curses.A_BOLD) - text = ' {subreddit}'.format(**data) - win.addnstr(clean(text), n_cols - win.getyx()[1], Color.YELLOW) - text = ' {flair}'.format(**data) - win.addnstr(clean(text), n_cols - win.getyx()[1], Color.RED) + text = Symbol.clean('{author}'.format(**data)) + win.addnstr(row, 1, text, n_cols-1, curses.A_BOLD) + text = Symbol.clean(' {subreddit}'.format(**data)) + win.addnstr(text, n_cols-win.getyx()[1], Color.YELLOW) + text = Symbol.clean(' {flair}'.format(**data)) + win.addnstr(text, n_cols-win.getyx()[1], Color.RED) diff --git a/rtv/utils.py b/rtv/utils.py index 1986b46..df22de6 100644 --- a/rtv/utils.py +++ b/rtv/utils.py @@ -12,9 +12,6 @@ from six.moves import configparser from .errors import EscapePressed -FORCE_ASCII = True -ESCAPE = 27 - HELP = """ Global Commands `UP/DOWN` or `j/k` : Scroll to the prev/next item @@ -32,30 +29,43 @@ Submission Mode `RIGHT` or `l` : Fold the selected comment, or load additional comments """ -def clean(string): - """ - Required reading! - http://nedbatchelder.com/text/unipain.html +class Symbol(object): - Python 2 input string will be a unicode type (unicode code points). Curses - will accept that 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). However, if we encode the unicode to a utf-8 byte string and - pass that to curses, curses will render correctly. + UNICODE = False - 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. + ESCAPE = 27 - """ - if six.PY2: - string = string.encode('utf-8', 'replace') - else: - string = string.encode('utf-8', 'replace') - pass + # Curses does define constants for these (e.g. curses.ACS_BULLET) + # However, they rely on using the curses.addch() function, which has been + # found to be buggy and a PITA to work with. By defining them as unicode + # points they can be added via the more reliable curses.addstr(). + # http://bugs.python.org/issue21088 + UARROW = u'\u25b2'.encode('utf-8') + DARROW = u'\u25bc'.encode('utf-8') + BULLET = u'\u2022'.encode('utf-8') - return string + @classmethod + def clean(cls, string): + """ + Required reading! + http://nedbatchelder.com/text/unipain.html + + Python 2 input string will be a unicode type (unicode code points). Curses + will accept that 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). However, if we encode the unicode to a utf-8 byte string and + 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' + string = string.encode(encoding, 'replace') + return string class Color(object): @@ -123,7 +133,7 @@ def text_input(window): def validate(ch): "Filters characters for special key sequences" - if ch == ESCAPE: + if ch == Symbol.ESCAPE: raise EscapePressed # Fix backspace for iterm @@ -245,7 +255,7 @@ class LoadScreen(object): window.refresh() time.sleep(interval) -def open_new_tab(url): +def open_browser(url): """ Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull.