From 06633e27eec235e21d6c49ddcae82c05b916f8a5 Mon Sep 17 00:00:00 2001 From: Michael Lazar Date: Sun, 18 Jan 2015 23:34:50 -0800 Subject: [PATCH] Initial commit. --- rtv/__init__.py | 0 rtv/rtv.py | 0 rtv/submission.py | 243 ++++++++++++++++++++++++++++++++++++++++++++++ rtv/utils.py | 98 +++++++++++++++++++ 4 files changed, 341 insertions(+) create mode 100644 rtv/__init__.py create mode 100644 rtv/rtv.py create mode 100644 rtv/submission.py create mode 100644 rtv/utils.py diff --git a/rtv/__init__.py b/rtv/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rtv/rtv.py b/rtv/rtv.py new file mode 100644 index 0000000..e69de29 diff --git a/rtv/submission.py b/rtv/submission.py new file mode 100644 index 0000000..eb1ec3f --- /dev/null +++ b/rtv/submission.py @@ -0,0 +1,243 @@ +import textwrap +import curses + +import praw + +import utils + +class OOBError(Exception): + pass + +class SubmissionDisplay(object): + + DEFAULT_REPLY_COLORS = [ + curses.COLOR_MAGENTA, + curses.COLOR_GREEN, + curses.COLOR_CYAN, + curses.COLOR_YELLOW, + ] + + def __init__( + self, + stdscr, + max_indent_level=5, + indent_size=1, + reply_colors=None, + ): + + self.stdscr = stdscr + self.line = 0 + + self._max_indent_level = max_indent_level + self._indent_size = indent_size + self._reply_colors = (reply_colors if reply_colors is not None + else self.DEFAULT_REPLY_COLORS) + + @staticmethod + def clean(unicode_string): + "Convert unicode string into ascii-safe characters." + return unicode_string.encode('ascii', 'replace').replace('\\', '') + + def _get_reply_color(self, nested_level): + return self._reply_colors[nested_level % len(self._reply_colors)] + + def _draw_post(self, submission): + "Draw the sumbission author's post" + + n_rows, n_cols = self.stdscr.getmaxyx() + n_cols -= 1 + + title = textwrap.wrap(self.clean(submission.title), n_cols-1) + indents = {'initial_indent':' ', 'subsequent_indent':' '} + text = textwrap.wrap(self.clean(submission.selftext), n_cols-1, **indents) + url = ([self.clean(submission.url)] if + getattr(submission, 'url') else []) + + required_rows = 4 + len(text) + len(url) + len(title) + if self.line + required_rows > n_rows: + raise OOBError() + + win = self.stdscr.derwin(required_rows, n_cols, self.line, 0) + submission.window = win + win_y = 0 + self.line += required_rows + + win.hline(win_y, 0, curses.ACS_HLINE, n_cols) + win_y += 1 + + # Submission title + color_attr = curses.color_pair(curses.COLOR_CYAN) + win.addstr(win_y, 0, '\n'.join(title), color_attr|curses.A_BOLD) + win_y += len(title) + + # Author / Date / Subreddit + author = (self.clean(submission.author.name) if + getattr(submission, 'author') else '[deleted]') + date = utils.humanize_timestamp(submission.created_utc) + subreddit = self.clean(submission.subreddit.url) + color_attr = curses.color_pair(curses.COLOR_GREEN) + win.addstr(win_y, 0, author, curses.A_UNDERLINE|color_attr) + win.addstr(' {} {}'.format(date, subreddit), curses.A_BOLD) + win_y += 1 + + if url: + color_attr = curses.color_pair(curses.COLOR_MAGENTA) + win.addstr(win_y, 0, url[0], curses.A_BOLD|color_attr) + win_y += len(url) + + if text: + win.addstr(win_y + len(url), 0, '\n'.join(text)) + win_y += len(text) + + # Score / Comments + score = submission.score + num_comments = submission.num_comments + info = '{} points {} comments'.format(score, num_comments) + win.addstr(win_y, 0, info, curses.A_BOLD) + win_y += 1 + + win.hline(win_y, 0, curses.ACS_HLINE, n_cols) + + + def _draw_more_comments(self, comment): + "Indicate that more comments can be loaded" + + n_rows, n_cols = self.stdscr.getmaxyx() + n_cols -= 1 + + required_rows = 2 + if self.line + required_rows > n_rows: + raise OOBError() + + # Determine the indent level of the comment + indent_level = min(self._max_indent_level, comment.nested_level) + indent = indent_level * self._indent_size + n_cols -= indent + + win = self.stdscr.derwin(required_rows, n_cols, self.line, indent) + comment.window = win + self.line += required_rows + win.addnstr(0, indent, '[+] More comments', curses.A_BOLD) + + + def _draw_comment(self, comment): + "Draw a single comment" + + n_rows, n_cols = self.stdscr.getmaxyx() + n_cols -= 1 + + # Determine the indent level of the comment + indent_level = min(self._max_indent_level, comment.nested_level) + indent = indent_level * self._indent_size + n_cols -= indent + + indents = {'initial_indent':' ', 'subsequent_indent':' '} + text = textwrap.wrap(self.clean(comment.body), n_cols-1, **indents) + + required_rows = 2 + len(text) + if self.line + required_rows > n_rows: + raise OOBError() + + win = self.stdscr.derwin(required_rows, n_cols, self.line, indent) + comment.window = win + self.line += required_rows + + # Author / Score / Date + author = (self.clean(comment.author.name) if + getattr(comment, 'author') else '[deleted]') + date = utils.humanize_timestamp(comment.created_utc) + score = submission.score + color_attr = curses.color_pair(curses.COLOR_BLUE) + win.addstr(0, 1, author, curses.A_UNDERLINE|color_attr) + win.addstr(' {} points {}'.format(score, date), curses.A_BOLD) + + # Body + win.addstr(1, 0, '\n'.join(text)) + + # Vertical line, unfortunately vline() doesn't support custom color so + # we have to build it one chr at a time. + reply_color = self._get_reply_color(comment.nested_level) + color_attr = curses.color_pair(reply_color) + for y in xrange(required_rows-1): + win.addch(y, 0, curses.ACS_VLINE, color_attr) + + def _draw_url(self, submission): + "Draw the submission url" + + n_rows, n_cols = self.stdscr.getmaxyx() + + color_attr = curses.color_pair(curses.COLOR_RED) + url = self.clean(submission.permalink) + self.stdscr.addnstr(self.line, 0, url, n_cols-1, color_attr|curses.A_STANDOUT) + + self.line += 1 + + return True + + def draw_page(self, submission, index=-1): + """ + Draw the comments page starting at the given index. + """ + + # Initialize screen + self.stdscr.erase() + self.line = 0 + + # URL is always drawn + self._draw_url(submission) + + if index == -1: + self._draw_post(submission) + index += 1 + + comments = utils.flatten_tree(submission.comments) + for comment in comments[index:]: + try: + if isinstance(comment, praw.objects.MoreComments): + self._draw_more_comments(comment) + else: + self._draw_comment(comment) + except OOBError: + break + + self.stdscr.refresh() + +class SubmissionController(object): + + def __init__(self, display): + + self.display = display + + self._index = -1 + self._cursor = 0 + + def loop(self, submission): + + self.display.draw_page(submission, self._index) + + while True: + + key = self.display.stdscr.getch() + if key == curses.KEY_DOWN: + self._index += 1 + elif key == curses.KEY_UP and self._index > -1: + self._index -= 1 + elif key == curses.KEY_RESIZE: + pass + else: + continue + + self.display.draw_page(submission, self._index) + + +if __name__ == '__main__': + + r = praw.Reddit(user_agent='reddit terminal viewer (rtv) v0.0') + submissions = r.get_subreddit('all').get_hot(limit=5) + submission = submissions.next() + + with utils.curses_session() as stdscr: + + display = SubmissionDisplay(stdscr) + controller = SubmissionController(display) + controller.loop(submission) \ No newline at end of file diff --git a/rtv/utils.py b/rtv/utils.py new file mode 100644 index 0000000..9f009e1 --- /dev/null +++ b/rtv/utils.py @@ -0,0 +1,98 @@ +from datetime import datetime, timedelta +import curses +from contextlib import contextmanager + +@contextmanager +def curses_session(): + + try: + # Initialize curses + stdscr = curses.initscr() + + # Turn off echoing of keys, and enter cbreak mode, + # where no buffering is performed on keyboard input + curses.noecho() + curses.cbreak() + + # In keypad mode, escape sequences for special keys + # (like the cursor keys) will be interpreted and + # a special value like curses.KEY_LEFT will be returned + stdscr.keypad(1) + + # Start color, too. Harmless if the terminal doesn't have + # color; user can test with has_color() later on. The try/catch + # works around a minor bit of over-conscientiousness in the curses + # module -- the error return from C start_color() is ignorable. + try: + curses.start_color() + + # Assign the terminal's default (background) color to code -1 + curses.use_default_colors() + except: + pass + + # Hide blinking cursor + curses.curs_set(0) + + # Initialize color pairs - colored text on the default background + curses.init_pair(1, curses.COLOR_RED, -1) + curses.init_pair(2, curses.COLOR_GREEN, -1) + curses.init_pair(3, curses.COLOR_YELLOW, -1) + curses.init_pair(4, curses.COLOR_BLUE, -1) + curses.init_pair(5, curses.COLOR_MAGENTA, -1) + curses.init_pair(6, curses.COLOR_CYAN, -1) + + yield stdscr + + finally: + + if stdscr is not None: + stdscr.keypad(0) + curses.echo() + curses.nocbreak() + curses.endwin() + +def humanize_timestamp(utc_timestamp): + """ + Convert a utc timestamp into a human readable time relative to now. + """ + timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp) + seconds = int(timedelta.total_seconds()) + if seconds < 60: + return 'moments ago' + minutes = seconds / 60 + if minutes < 60: + return '{} minutes ago'.format(minutes) + hours = minutes / 60 + if hours < 24: + return '{} hours ago'.format(hours) + days = hours / 24 + if days < 30: + return '{} days ago'.format(days) + months = days / 30.4 + if months < 12: + return '{} months ago'.format(months) + years = months / 12 + return '{} years ago'.format(years) + + +def flatten_tree(tree): + """ + Flatten a PRAW comment tree while preserving the nested level of each + comment via the `nested_level` attribute. + """ + + stack = tree[:] + for item in stack: + item.nested_level = 0 + + retval = [] + while stack: + item = stack.pop(0) + nested = getattr(item, 'replies', None) + if nested: + for n in nested: + n.nested_level = item.nested_level + 1 + stack[0:0] = nested + retval.append(item) + return retval \ No newline at end of file