From 93a7c3a099f10ce7ca86754cd9a1855c0d490e24 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Mon, 26 Jan 2015 23:57:23 -0800 Subject: [PATCH] Submission view up and running. --- rtv/content_generators.py | 178 +++++++++++++++------- rtv/submission_viewer.py | 128 ++++++++++++++++ rtv/{subreddit.py => subreddit_viewer.py} | 62 +------- rtv/viewer.py | 79 ++++++++-- 4 files changed, 331 insertions(+), 116 deletions(-) create mode 100644 rtv/submission_viewer.py rename rtv/{subreddit.py => subreddit_viewer.py} (52%) diff --git a/rtv/content_generators.py b/rtv/content_generators.py index 316d860..d352bc2 100644 --- a/rtv/content_generators.py +++ b/rtv/content_generators.py @@ -1,28 +1,70 @@ import textwrap +import praw from utils import clean, strip_subreddit_url, humanize_timestamp -class SubmissionContent(object): - """ - Facilitates navigating through the comments in a PRAW submission. - """ - - def __init__(self, submission): - - self.submission = submission - self._comments = [] - +class BaseContent(object): @staticmethod - def flatten_comments(submission): + def strip_praw_comment(comment): + """ + Parse through a submission comment and return a dict with data ready to + be displayed through the terminal. + """ + + data = {} + data['object'] = comment + data['level'] = comment.nested_level + + + if isinstance(comment, praw.objects.MoreComments): + data['type'] = 'MoreComments' + data['body'] = 'More comments [{}]'.format(comment.count) + else: + data['type'] = 'Comment' + data['body'] = clean(comment.body) + data['created'] = humanize_timestamp(comment.created_utc) + data['score'] = '{} pts'.format(comment.score) + data['author'] = (clean(comment.author.name) if + getattr(comment, 'author') else '[deleted]') + + return data + + @staticmethod + def strip_praw_submission(sub): + """ + Parse through a submission and return a dict with data ready to be + displayed through the terminal. + """ + + is_selfpost = lambda s: s.startswith('http://www.reddit.com/r/') + + data = {} + data['object'] = sub + data['type'] = 'Submission' + data['title'] = clean(sub.title) + data['text'] = clean(sub.selftext) + data['created'] = humanize_timestamp(sub.created_utc) + data['comments'] = '{} comments'.format(sub.num_comments) + data['score'] = '{} pts'.format(sub.score) + data['author'] = (clean(sub.author.name) if getattr(sub, 'author') + else '[deleted]') + data['permalink'] = clean(sub.permalink) + data['subreddit'] = strip_subreddit_url(sub.permalink) + data['url'] = ('(selfpost)' if is_selfpost(sub.url) else clean(sub.url)) + + return data + + @staticmethod + def flatten_comments(comments, initial_level=0): """ Flatten a PRAW comment tree while preserving the nested level of each comment via the `nested_level` attribute. """ - stack = submission[:] + stack = comments[:] for item in stack: - item.nested_level = 0 + item.nested_level = initial_level retval = [] while stack: @@ -35,24 +77,8 @@ class SubmissionContent(object): retval.append(item) return retval - @staticmethod - def strip_praw_comment(comment): - """ - Parse through a submission comment and return a dict with data ready to - be displayed through the terminal. - """ - data = {} - data['body'] = clean(comment.body) - data['created'] = humanize_timestamp(comment.created_utc) - data['score'] = '{} pts'.format(comment.score) - data['author'] = (clean(comment.author.name) if - getattr(comment, 'author') else '[deleted]') - - return data - - -class SubredditContent(object): +class SubredditContent(BaseContent): """ Grabs a subreddit from PRAW and lazily stores submissions to an internal list for repeat access. @@ -74,28 +100,6 @@ class SubredditContent(object): self.reset(subreddit=subreddit) - @staticmethod - def strip_praw_submission(sub): - """ - Parse through a submission and return a dict with data ready to be - displayed through the terminal. - """ - - is_selfpost = lambda s: s.startswith('http://www.reddit.com/r/') - - data = {} - data['title'] = clean(sub.title) - data['text'] = clean(sub.selftext) - data['created'] = humanize_timestamp(sub.created_utc) - data['comments'] = '{} comments'.format(sub.num_comments) - data['score'] = '{} pts'.format(sub.score) - data['author'] = (clean(sub.author.name) if getattr(sub, 'author') - else '[deleted]') - data['subreddit'] = strip_subreddit_url(sub.permalink) - data['url'] = ('(selfpost)' if is_selfpost(sub.url) else clean(sub.url)) - - return data - def get(self, index, n_cols=70): """ Grab the `i`th submission, with the title field formatted to fit inside @@ -119,6 +123,7 @@ class SubredditContent(object): data = self._submission_data[index] data['split_title'] = textwrap.wrap(data['title'], width=n_cols) data['n_rows'] = len(data['split_title']) + 3 + data['offset'] = 0 return data @@ -143,4 +148,71 @@ class SubredditContent(object): self.display_name = 'Front Page' else: self._submissions = self.r.get_subreddit(self.subreddit, limit=None) - self.display_name = self._submissions.display_name \ No newline at end of file + self.display_name = self._submissions.display_name + + +class SubmissionContent(BaseContent): + """ + Grabs a submission from PRAW and lazily store comments to an internal + list for repeat access and to allow expanding and hiding comments. + """ + + def __init__(self, submission, indent_size=2, max_indent_level=4): + + self.submission = submission + self.indent_size = indent_size + self.max_indent_level = max_indent_level + + self.display_name = None + self._submission_data = None + self._comments = None + self._comment_data = None + + self.reset() + + def get(self, index, n_cols=70): + """ + Grab the `i`th submission, with the title field formatted to fit inside + of a window of width `n` + """ + + if index < -1: + raise IndexError + + elif index == -1: + data = self._submission_data + data['split_title'] = textwrap.wrap(data['title'], width=n_cols) + data['n_rows'] = len(data['split_title']) + 3 + data['offset'] = 0 + + else: + data = self._comment_data[index] + indent_level = min(data['level'], self.max_indent_level) + data['offset'] = indent_level * self.indent_size + + if data['type'] == 'Comment': + data['split_body'] = textwrap.wrap( + data['body'], width=n_cols-data['offset']) + data['n_rows'] = len(data['split_body']) + 1 + else: + data['n_rows'] = 1 + + return data + + def iterate(self, index, step, n_cols): + + while True: + yield self.get(index, n_cols) + index += step + + def reset(self): + """ + Fetch changes to the submission from PRAW and clear the internal list. + """ + + self.submission.refresh() + self._submission_data = self.strip_praw_submission(self.submission) + + self.display_name = self._submission_data['permalink'] + self._comments = self.flatten_comments(self.submission.comments) + self._comment_data = [self.strip_praw_comment(c) for c in self._comments] \ No newline at end of file diff --git a/rtv/submission_viewer.py b/rtv/submission_viewer.py new file mode 100644 index 0000000..a151b7d --- /dev/null +++ b/rtv/submission_viewer.py @@ -0,0 +1,128 @@ +import praw +import curses + +from content_generators import SubmissionContent, SubredditContent +from utils import curses_session +from viewer import BaseViewer + +class SubmissionViewer(BaseViewer): + + def __init__(self, stdscr, content): + + page_index, cursor_index = -1, 1 + super(SubmissionViewer, self).__init__( + stdscr, content, + page_index=page_index, cursor_index=cursor_index) + + def loop(self): + + self.draw() + while True: + cmd = self.stdscr.getch() + + if cmd == curses.KEY_UP: + self.move_cursor_up() + self.clear_input_queue() + + elif cmd == curses.KEY_DOWN: + self.move_cursor_down() + self.clear_input_queue() + + # Refresh page + elif cmd in (curses.KEY_F5, ord('r')): + self.content.reset() + self.stdscr.clear() + self.draw() + + elif cmd == curses.KEY_RESIZE: + self.draw() + + # Quit + elif cmd == ord('q'): + break + + else: + curses.beep() + + def draw(self): + + n_rows, n_cols = self.stdscr.getmaxyx() + self._header_window = self.stdscr.derwin(1, n_cols, 0, 0) + self._content_window = self.stdscr.derwin(1, 0) + + self.draw_header() + self.draw_content() + self.add_cursor() + + def draw_item(self, win, data, inverted=False): + + if data['type'] == 'MoreComments': + self.draw_more_comments(win, data) + + elif data['type'] == 'Comment': + self.draw_comment(win, data, inverted=inverted) + + else: + self.draw_submission(win, data) + + @staticmethod + def draw_comment(win, data, inverted=False): + + n_rows, n_cols = win.getmaxyx() + n_cols -= 2 + + # Handle the case where the window is not large enough to fit the data. + valid_rows = range(0, n_rows) + offset = 0 if not inverted else -(data['n_rows'] - n_rows) + + row = offset + text = '{} {} {}'.format(data['author'], data['score'], data['created']) + if row in valid_rows: + win.addnstr(row, 1, text, n_cols) + + 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, text, n_cols) + + # Vertical line, unfortunately vline() doesn't support custom color so + # we have to build it one chr at a time. + for y in xrange(n_rows): + win.addch(y, 0, curses.ACS_VLINE) + + @staticmethod + def draw_more_comments(win, data): + + n_rows, n_cols = win.getmaxyx() + n_cols -= 2 + win.addnstr(0, 1, data['body'], n_cols) + + for y in xrange(n_rows): + win.addch(y, 0, curses.ACS_VLINE) + + @staticmethod + def draw_submission(win, data): + + n_rows, n_cols = win.getmaxyx() + n_cols -= 1 + + # Don't print anything if there is not enough room + if data['n_rows'] > n_rows: + return + + win.border() + + +def main(): + + with curses_session() as stdscr: + r = praw.Reddit(user_agent='reddit terminal viewer (rtv) v0.0') + submission = SubredditContent(r).get(0)['object'] + generator = SubmissionContent(submission) + + viewer = SubmissionViewer(stdscr, generator) + viewer.loop() + +if __name__ == '__main__': + + main() \ No newline at end of file diff --git a/rtv/subreddit.py b/rtv/subreddit_viewer.py similarity index 52% rename from rtv/subreddit.py rename to rtv/subreddit_viewer.py index dd48c16..3c1b648 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit_viewer.py @@ -8,14 +8,6 @@ from viewer import BaseViewer class SubredditViewer(BaseViewer): - def __init__(self, stdscr, subreddit_content): - - self.stdscr = stdscr - self._title_window = None - self._content_window = None - - super(SubredditViewer, self).__init__(subreddit_content) - def loop(self): self.draw() @@ -24,9 +16,11 @@ class SubredditViewer(BaseViewer): if cmd == curses.KEY_UP: self.move_cursor_up() + self.clear_input_queue() elif cmd == curses.KEY_DOWN: self.move_cursor_down() + self.clear_input_queue() # View submission elif cmd in (curses.KEY_RIGHT, ord(' ')): @@ -54,59 +48,19 @@ class SubredditViewer(BaseViewer): def draw(self): - # Refresh window bounds incase the screen has been resized n_rows, n_cols = self.stdscr.getmaxyx() - self._title_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(1, 0) self.draw_header() self.draw_content() self.add_cursor() - def draw_content(self): - """ - Loop through submissions and fill up the content page. - """ - - n_rows, n_cols = self._content_window.getmaxyx() - self._content_window.erase() - self._subwindows = [] - - page_index, cursor_index, inverted = self.nav.position - step = self.nav.step - - # If not inverted, align the first submission with the top and draw - # downwards. If inverted, align the first submission with the bottom - # and draw upwards. - current_row = n_rows if inverted else 0 - available_rows = n_rows - for data in self.content.iterate(page_index, step, n_cols-2): - window_rows = min(available_rows, data['n_rows']) - start = current_row - window_rows if inverted else current_row - window = self._content_window.derwin(window_rows, n_cols, start, 0) - self.draw_submission(window, data, inverted) - self._subwindows.append(window) - available_rows -= (window_rows + 1) # Add one for the blank line - current_row += step * (window_rows + 1) - if available_rows <= 0: - break - - self._content_window.refresh() - - def draw_header(self): - - n_rows, n_cols = self._title_window.getmaxyx() - sub_name = self.content.display_name - - self._title_window.erase() - self._title_window.addnstr(0, 0, sub_name, n_cols) - self._title_window.refresh() - @staticmethod - def draw_submission(win, data, inverted=False): + def draw_item(win, data, inverted=False): n_rows, n_cols = win.getmaxyx() - n_cols -= 1 # Leave space for the cursor in the first column + n_cols -= 2 # Leave space for the cursor in the first column # Handle the case where the window is not large enough to fit the data. valid_rows = range(0, n_rows) @@ -119,15 +73,15 @@ class SubredditViewer(BaseViewer): row = n_title + offset if row in valid_rows: - win.addnstr(row, 1, '{url}'.format(**data), n_cols-1) + win.addnstr(row, 1, '{url}'.format(**data), n_cols) row = n_title + offset + 1 if row in valid_rows: - win.addnstr(row, 1, '{created} {comments} {score}'.format(**data), n_cols-1) + win.addnstr(row, 1, '{created} {comments} {score}'.format(**data), n_cols) row = n_title + offset + 2 if row in valid_rows: - win.addnstr(row, 1, '{author} {subreddit}'.format(**data), n_cols-1) + win.addnstr(row, 1, '{author} {subreddit}'.format(**data), n_cols) def main(): diff --git a/rtv/viewer.py b/rtv/viewer.py index 6ce56a6..2ed517d 100644 --- a/rtv/viewer.py +++ b/rtv/viewer.py @@ -12,11 +12,12 @@ class Navigator(object): cursor_index=0, inverted=False): - self._page_cb = valid_page_cb - self.page_index = page_index self.cursor_index = cursor_index self.inverted = inverted + self._page_cb = valid_page_cb + self._header_window = None + self._content_window = None @property def step(self): @@ -26,6 +27,10 @@ class Navigator(object): def position(self): return (self.page_index, self.cursor_index, self.inverted) + @property + def absolute_index(self): + return self.page_index + (self.step * self.cursor_index) + def move(self, direction, n_windows): "Move the cursor down (positive direction) or up (negative direction)" @@ -70,10 +75,12 @@ class BaseViewer(object): Base terminal viewer incorperates a cursor to navigate content """ - def __init__(self, content): + def __init__(self, stdscr, content, **kwargs): + self.stdscr = stdscr self.content = content - self.nav = Navigator(self.content.get) + + self.nav = Navigator(self.content.get, **kwargs) self._subwindows = None @@ -87,13 +94,61 @@ class BaseViewer(object): self._move_cursor(1) def add_cursor(self): - curses.curs_set(2) self._edit_cursor(curses.A_REVERSE) def remove_cursor(self): - curses.curs_set(0) self._edit_cursor(curses.A_NORMAL) + def clear_input_queue(self): + "Clear excessive input caused by the scroll wheel or holding down a key" + self.stdscr.nodelay(1) + while self.stdscr.getch() != -1: + continue + self.stdscr.nodelay(0) + + def draw_header(self): + + n_rows, n_cols = self._header_window.getmaxyx() + + self._header_window.erase() + self._header_window.addnstr(0, 0, self.content.display_name, n_cols-1) + self._header_window.refresh() + + def draw_content(self): + """ + Loop through submissions and fill up the content page. + """ + + n_rows, n_cols = self._content_window.getmaxyx() + self._content_window.erase() + self._subwindows = [] + + page_index, cursor_index, inverted = self.nav.position + step = self.nav.step + + # If not inverted, align the first submission with the top and draw + # downwards. If inverted, align the first submission with the bottom + # and draw upwards. + current_row = n_rows if inverted else 0 + available_rows = n_rows + 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 + subwindow = self._content_window.derwin( + window_rows, window_cols, start, data['offset']) + self.draw_item(subwindow, data, inverted) + self._subwindows.append(subwindow) + available_rows -= (window_rows + 1) # Add one for the blank line + current_row += step * (window_rows + 1) + if available_rows <= 0: + break + + self._content_window.refresh() + + def draw_item(self, window, data, inverted): + raise NotImplementedError + def _move_cursor(self, direction): self.remove_cursor() @@ -101,18 +156,24 @@ class BaseViewer(object): valid, redraw = self.nav.move(direction, len(self._subwindows)) if not valid: curses.flash() - if redraw: + + # 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() self.add_cursor() def _edit_cursor(self, attribute): - window = self._subwindows[self.nav.cursor_index] + # Don't alow the cursor to go below page index 0 + if self.nav.absolute_index == -1: + window = self._subwindows[self.nav.cursor_index + 1] + else: + window = self._subwindows[self.nav.cursor_index] n_rows, _ = window.getmaxyx() for row in xrange(n_rows): window.chgat(row, 0, 1, attribute) - window.move(0, 0) window.refresh() \ No newline at end of file