Initial commit.
This commit is contained in:
0
rtv/__init__.py
Normal file
0
rtv/__init__.py
Normal file
0
rtv/rtv.py
Normal file
0
rtv/rtv.py
Normal file
243
rtv/submission.py
Normal file
243
rtv/submission.py
Normal file
@@ -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)
|
||||||
98
rtv/utils.py
Normal file
98
rtv/utils.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user