sync with 1.10.0
This commit is contained in:
@@ -1,4 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
________ __________________________
|
||||
___ __ \__________ /_____ /__(_)_ /_
|
||||
__ /_/ / _ \ __ /_ __ /__ /_ __/
|
||||
_ _, _// __/ /_/ / / /_/ / _ / / /_
|
||||
/_/ |_| \___/\__,_/ \__,_/ /_/ \__/
|
||||
|
||||
|
||||
________ _____ ______
|
||||
___ __/__________________ ______(_)____________ ___ /
|
||||
__ / _ _ \_ ___/_ __ `__ \_ /__ __ \ __ `/_ /
|
||||
_ / / __/ / _ / / / / / / _ / / / /_/ /_ /
|
||||
/_/ \___//_/ /_/ /_/ /_//_/ /_/ /_/\__,_/ /_/
|
||||
|
||||
|
||||
___ ______
|
||||
__ | / /__(_)_______ ______________
|
||||
__ | / /__ /_ _ \_ | /| / / _ \_ ___/
|
||||
__ |/ / _ / / __/_ |/ |/ // __/ /
|
||||
_____/ /_/ \___/____/|__/ \___//_/
|
||||
|
||||
|
||||
(RTV)
|
||||
"""
|
||||
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from .__version__ import __version__
|
||||
@@ -6,4 +32,4 @@ from .__version__ import __version__
|
||||
__title__ = 'Reddit Terminal Viewer'
|
||||
__author__ = 'Michael Lazar'
|
||||
__license__ = 'The MIT License (MIT)'
|
||||
__copyright__ = '(c) 2015 Michael Lazar'
|
||||
__copyright__ = '(c) 2016 Michael Lazar'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import sys
|
||||
import locale
|
||||
import logging
|
||||
@@ -12,7 +13,7 @@ from . import docs
|
||||
from .config import Config, copy_default_config
|
||||
from .oauth import OAuthHelper
|
||||
from .terminal import Terminal
|
||||
from .objects import curses_session
|
||||
from .objects import curses_session, Color
|
||||
from .subreddit import SubredditPage
|
||||
from .exceptions import ConfigError
|
||||
from .__version__ import __version__
|
||||
@@ -29,15 +30,17 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
"Main entry point"
|
||||
"""Main entry point"""
|
||||
|
||||
# Squelch SSL warnings
|
||||
logging.captureWarnings(True)
|
||||
locale.setlocale(locale.LC_ALL, '')
|
||||
|
||||
# Set the terminal title
|
||||
title = 'rtv {0}'.format(__version__)
|
||||
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
|
||||
if os.getenv('DISPLAY'):
|
||||
title = 'rtv {0}'.format(__version__)
|
||||
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
|
||||
sys.stdout.flush()
|
||||
|
||||
args = Config.get_args()
|
||||
fargs, bindings = Config.get_file(args.get('config'))
|
||||
@@ -73,7 +76,19 @@ def main():
|
||||
# if args[0] != "header:":
|
||||
# _http_logger.info(' '.join(args))
|
||||
# client.print = print_to_file
|
||||
logging.basicConfig(level=logging.DEBUG, filename=config['log'])
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
filename=config['log'],
|
||||
format='%(asctime)s:%(levelname)s:%(filename)s:%(lineno)d:%(message)s')
|
||||
_logger.info('Starting new session, RTV v%s', __version__)
|
||||
env = [
|
||||
('$DISPLAY', os.getenv('DISPLAY')),
|
||||
('$XDG_CONFIG_HOME', os.getenv('XDG_CONFIG_HOME')),
|
||||
('$BROWSER', os.getenv('BROWSER')),
|
||||
('$PAGER', os.getenv('PAGER')),
|
||||
('$RTV_EDITOR', os.getenv('RTV_EDITOR')),
|
||||
('$RTV_URLVIEWER', os.getenv('RTV_URLVIEWER'))]
|
||||
_logger.info('Environment: %s', env)
|
||||
else:
|
||||
# Add an empty handler so the logger doesn't complain
|
||||
logging.root.addHandler(logging.NullHandler())
|
||||
@@ -83,6 +98,11 @@ def main():
|
||||
|
||||
try:
|
||||
with curses_session() as stdscr:
|
||||
|
||||
# Initialize global color-pairs with curses
|
||||
if not config['monochrome']:
|
||||
Color.init()
|
||||
|
||||
term = Terminal(stdscr, config['ascii'])
|
||||
with term.loader('Initializing', catch_exception=False):
|
||||
reddit = praw.Reddit(user_agent=user_agent,
|
||||
@@ -124,4 +144,4 @@ def main():
|
||||
# Explicitly close file descriptors opened by Tornado's IOLoop
|
||||
tornado.ioloop.IOLoop.current().close(all_fds=True)
|
||||
|
||||
sys.exit(main())
|
||||
sys.exit(main())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
__version__ = '1.8.1'
|
||||
__version__ = '1.10.0'
|
||||
|
||||
@@ -33,19 +33,22 @@ def build_parser():
|
||||
'-V', '--version', action='version', version='rtv '+__version__)
|
||||
parser.add_argument(
|
||||
'-s', dest='subreddit',
|
||||
help='name of the subreddit that will be opened on start')
|
||||
help='Name of the subreddit that will be opened on start')
|
||||
parser.add_argument(
|
||||
'-l', dest='link',
|
||||
help='full URL of a submission that will be opened on start')
|
||||
help='Full URL of a submission that will be opened on start')
|
||||
parser.add_argument(
|
||||
'--log', metavar='FILE', action='store',
|
||||
help='log HTTP requests to the given file')
|
||||
help='Log HTTP requests to the given file')
|
||||
parser.add_argument(
|
||||
'--config', metavar='FILE', action='store',
|
||||
help='Load configuration settings from the given file')
|
||||
parser.add_argument(
|
||||
'--ascii', action='store_const', const=True,
|
||||
help='enable ascii-only mode')
|
||||
help='Enable ascii-only mode')
|
||||
parser.add_argument(
|
||||
'--monochrome', action='store_const', const=True,
|
||||
help='Disable color')
|
||||
parser.add_argument(
|
||||
'--non-persistent', dest='persistent', action='store_const',
|
||||
const=False,
|
||||
@@ -79,6 +82,7 @@ def copy_default_config(filename=CONFIG):
|
||||
|
||||
print('Copying default settings to %s' % filename)
|
||||
shutil.copy(DEFAULT_CONFIG, filename)
|
||||
os.chmod(filename, 0o664)
|
||||
|
||||
|
||||
class OrderedSet(object):
|
||||
@@ -208,6 +212,7 @@ class Config(object):
|
||||
|
||||
params = {
|
||||
'ascii': partial(config.getboolean, 'rtv'),
|
||||
'monochrome': partial(config.getboolean, 'rtv'),
|
||||
'clear_auth': partial(config.getboolean, 'rtv'),
|
||||
'persistent': partial(config.getboolean, 'rtv'),
|
||||
'history_size': partial(config.getint, 'rtv'),
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
|
||||
import six
|
||||
import praw
|
||||
from praw.errors import InvalidSubreddit
|
||||
from kitchen.text.display import wrap
|
||||
|
||||
from . import exceptions
|
||||
@@ -96,6 +97,7 @@ class Content(object):
|
||||
data['type'] = 'MoreComments'
|
||||
data['count'] = comment.count
|
||||
data['body'] = 'More comments'
|
||||
data['hidden'] = True
|
||||
else:
|
||||
author = getattr(comment, 'author', '[deleted]')
|
||||
name = getattr(author, 'name', '[deleted]')
|
||||
@@ -118,6 +120,7 @@ class Content(object):
|
||||
data['gold'] = comment.gilded > 0
|
||||
data['permalink'] = permalink
|
||||
data['stickied'] = stickied
|
||||
data['hidden'] = False
|
||||
data['saved'] = comment.saved
|
||||
|
||||
return data
|
||||
@@ -129,10 +132,11 @@ class Content(object):
|
||||
displayed through the terminal.
|
||||
|
||||
Definitions:
|
||||
permalink - Full URL to the submission comments.
|
||||
url_full - Link that the submission points to.
|
||||
url - URL that is displayed on the subreddit page, may be
|
||||
"selfpost" or "x-post" or a link.
|
||||
permalink - URL to the reddit page with submission comments.
|
||||
url_full - URL that the submission points to.
|
||||
url - URL that will be displayed on the subreddit page, may be
|
||||
"selfpost", "x-post submission", "x-post subreddit", or an
|
||||
external link.
|
||||
"""
|
||||
|
||||
reddit_link = re.compile(
|
||||
@@ -148,8 +152,7 @@ class Content(object):
|
||||
data['text'] = sub.selftext
|
||||
data['created'] = cls.humanize_timestamp(sub.created_utc)
|
||||
data['comments'] = '{0} comments'.format(sub.num_comments)
|
||||
data['score'] = '{0} pts'.format(
|
||||
'-' if sub.hide_score else sub.score)
|
||||
data['score'] = '{0} pts'.format('-' if sub.hide_score else sub.score)
|
||||
data['author'] = name
|
||||
data['permalink'] = sub.permalink
|
||||
data['subreddit'] = six.text_type(sub.subreddit)
|
||||
@@ -159,6 +162,8 @@ class Content(object):
|
||||
data['gold'] = sub.gilded > 0
|
||||
data['nsfw'] = sub.over_18
|
||||
data['stickied'] = sub.stickied
|
||||
data['hidden'] = False
|
||||
data['xpost_subreddit'] = None
|
||||
data['index'] = None # This is filled in later by the method caller
|
||||
data['saved'] = sub.saved
|
||||
|
||||
@@ -168,8 +173,13 @@ class Content(object):
|
||||
elif reddit_link.match(sub.url):
|
||||
# Strip the subreddit name from the permalink to avoid having
|
||||
# submission.subreddit.url make a separate API call
|
||||
data['url'] = 'self.{0}'.format(sub.url.split('/')[4])
|
||||
data['url_type'] = 'x-post'
|
||||
url_parts = sub.url.split('/')
|
||||
data['xpost_subreddit'] = url_parts[4]
|
||||
data['url'] = 'self.{0}'.format(url_parts[4])
|
||||
if 'comments' in url_parts:
|
||||
data['url_type'] = 'x-post submission'
|
||||
else:
|
||||
data['url_type'] = 'x-post subreddit'
|
||||
else:
|
||||
data['url'] = sub.url
|
||||
data['url_type'] = 'external'
|
||||
@@ -324,7 +334,8 @@ class SubmissionContent(Content):
|
||||
'cache': cache,
|
||||
'count': count,
|
||||
'level': data['level'],
|
||||
'body': 'Hidden'}
|
||||
'body': 'Hidden',
|
||||
'hidden': True}
|
||||
|
||||
self._comment_data[index:index + len(cache)] = [comment]
|
||||
|
||||
@@ -409,6 +420,11 @@ class SubredditContent(Content):
|
||||
submissions = reddit.search(query, subreddit=name, sort=order)
|
||||
|
||||
else:
|
||||
if name == '':
|
||||
# Praw does not correctly handle empty strings
|
||||
# https://github.com/praw-dev/praw/issues/615
|
||||
raise InvalidSubreddit()
|
||||
|
||||
if name == 'front':
|
||||
dispatch = {
|
||||
None: reddit.get_front_page,
|
||||
@@ -420,6 +436,9 @@ class SubredditContent(Content):
|
||||
}
|
||||
else:
|
||||
subreddit = reddit.get_subreddit(name)
|
||||
# For special subreddits like /r/random we want to replace the
|
||||
# display name with the one returned by the request.
|
||||
display_name = '/r/{0}'.format(subreddit.display_name)
|
||||
dispatch = {
|
||||
None: subreddit.get_hot,
|
||||
'hot': subreddit.get_hot,
|
||||
@@ -456,9 +475,10 @@ class SubredditContent(Content):
|
||||
data = self.strip_praw_submission(submission)
|
||||
except:
|
||||
continue
|
||||
data['index'] = index
|
||||
|
||||
data['index'] = len(self._submission_data) + 1
|
||||
# Add the post number to the beginning of the title
|
||||
data['title'] = '{0}. {1}'.format(index+1, data.get('title'))
|
||||
data['title'] = '{0}. {1}'.format(data['index'], data['title'])
|
||||
self._submission_data.append(data)
|
||||
|
||||
# Modifies the original dict, faster than copying
|
||||
|
||||
@@ -45,6 +45,7 @@ HELP = """
|
||||
`h` or `LEFT` : Return to subreddit mode
|
||||
`l` or `RIGHT` : Open the selected comment in a new window
|
||||
`SPACE` : Fold the selected comment, or load additional comments
|
||||
`b` : Display URLs with urlview
|
||||
"""
|
||||
|
||||
COMMENT_FILE = """
|
||||
|
||||
@@ -35,4 +35,8 @@ class ProgramError(RTVError):
|
||||
|
||||
|
||||
class BrowserError(RTVError):
|
||||
"Could not open a web browser tab"
|
||||
"Could not open a web browser tab"
|
||||
|
||||
|
||||
class TemporaryFileError(RTVError):
|
||||
"Indicates that an error has occurred and the file should not be deleted"
|
||||
@@ -62,7 +62,8 @@ def curses_session():
|
||||
# Hide the blinking cursor
|
||||
curses.curs_set(0)
|
||||
|
||||
Color.init()
|
||||
# Assign the terminal's default (background) color to code -1
|
||||
curses.use_default_colors()
|
||||
|
||||
yield stdscr
|
||||
|
||||
@@ -254,7 +255,6 @@ class LoadScreen(object):
|
||||
|
||||
|
||||
class Color(object):
|
||||
|
||||
"""
|
||||
Color attributes for curses.
|
||||
"""
|
||||
@@ -286,9 +286,6 @@ class Color(object):
|
||||
curses color pairs can be accessed directly through class attributes.
|
||||
"""
|
||||
|
||||
# Assign the terminal's default (background) color to code -1
|
||||
curses.use_default_colors()
|
||||
|
||||
for index, (attr, code) in enumerate(cls._colors.items(), start=1):
|
||||
curses.init_pair(index, code[0], code[1])
|
||||
setattr(cls, attr, curses.color_pair(index))
|
||||
@@ -318,7 +315,8 @@ class Navigator(object):
|
||||
valid_page_cb,
|
||||
page_index=0,
|
||||
cursor_index=0,
|
||||
inverted=False):
|
||||
inverted=False,
|
||||
top_item_height=None):
|
||||
"""
|
||||
Params:
|
||||
valid_page_callback (func): This function, usually `Content.get`,
|
||||
@@ -334,11 +332,16 @@ class Navigator(object):
|
||||
inverted - The page is drawn from the bottom of the screen,
|
||||
starting with the page index, up to the top of the
|
||||
screen.
|
||||
top_item_height (int): If this is set to a non-null value
|
||||
The number of columns that the top-most item
|
||||
should utilize if non-inverted. This is used for a special mode
|
||||
where all items are drawn non-inverted except for the top one.
|
||||
"""
|
||||
|
||||
self.page_index = page_index
|
||||
self.cursor_index = cursor_index
|
||||
self.inverted = inverted
|
||||
self.top_item_height = top_item_height
|
||||
self._page_cb = valid_page_cb
|
||||
|
||||
@property
|
||||
@@ -399,15 +402,21 @@ class Navigator(object):
|
||||
# Flip the orientation and reset the cursor
|
||||
self.flip(self.cursor_index)
|
||||
self.cursor_index = 0
|
||||
self.top_item_height = None
|
||||
redraw = True
|
||||
else:
|
||||
if self.cursor_index > 0:
|
||||
self.cursor_index -= 1
|
||||
if self.top_item_height and self.cursor_index == 0:
|
||||
# Selecting the partially displayed item
|
||||
self.top_item_height = None
|
||||
redraw = True
|
||||
else:
|
||||
self.page_index -= self.step
|
||||
if self._is_valid(self.absolute_index):
|
||||
# We have reached the beginning of the page - move the
|
||||
# index
|
||||
self.top_item_height = None
|
||||
redraw = True
|
||||
else:
|
||||
self.page_index += self.step
|
||||
@@ -481,6 +490,7 @@ class Navigator(object):
|
||||
self.page_index += (self.step * n_windows)
|
||||
self.cursor_index = n_windows
|
||||
self.inverted = not self.inverted
|
||||
self.top_item_height = None
|
||||
|
||||
def _is_valid(self, page_index):
|
||||
"""
|
||||
|
||||
68
rtv/page.py
68
rtv/page.py
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import curses
|
||||
@@ -10,6 +11,8 @@ from kitchen.text.display import textual_width
|
||||
|
||||
from . import docs
|
||||
from .objects import Controller, Color, Command
|
||||
from .exceptions import TemporaryFileError
|
||||
from .__version__ import __version__
|
||||
|
||||
|
||||
def logged_in(f):
|
||||
@@ -234,16 +237,19 @@ class Page(object):
|
||||
self.term.flash()
|
||||
return
|
||||
|
||||
text = self.term.open_editor(info)
|
||||
if text == content:
|
||||
self.term.show_notification('Canceled')
|
||||
return
|
||||
with self.term.open_editor(info) as text:
|
||||
if text == content:
|
||||
self.term.show_notification('Canceled')
|
||||
return
|
||||
|
||||
with self.term.loader('Editing', delay=0):
|
||||
data['object'].edit(text)
|
||||
time.sleep(2.0)
|
||||
if self.term.loader.exception is None:
|
||||
self.refresh_content()
|
||||
with self.term.loader('Editing', delay=0):
|
||||
data['object'].edit(text)
|
||||
time.sleep(2.0)
|
||||
|
||||
if self.term.loader.exception is None:
|
||||
self.refresh_content()
|
||||
else:
|
||||
raise TemporaryFileError()
|
||||
|
||||
@PageController.register(Command('INBOX'))
|
||||
@logged_in
|
||||
@@ -291,9 +297,22 @@ class Page(object):
|
||||
ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN
|
||||
window.bkgd(ch, attr)
|
||||
|
||||
sub_name = self.content.name.replace('/r/front', 'Front Page')
|
||||
sub_name = self.content.name
|
||||
sub_name = sub_name.replace('/r/front', 'Front Page')
|
||||
sub_name = sub_name.replace('/r/me', 'My Submissions')
|
||||
self.term.add_line(window, sub_name, 0, 0)
|
||||
|
||||
# Set the terminal title
|
||||
if len(sub_name) > 50:
|
||||
title = sub_name.strip('/').rsplit('/', 1)[1].replace('_', ' ')
|
||||
else:
|
||||
title = sub_name
|
||||
|
||||
if os.getenv('DISPLAY'):
|
||||
title += ' - rtv {0}'.format(__version__)
|
||||
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
|
||||
sys.stdout.flush()
|
||||
|
||||
if self.reddit.user is not None:
|
||||
# The starting position of the name depends on if we're converting
|
||||
# to ascii or not
|
||||
@@ -344,29 +363,46 @@ class Page(object):
|
||||
# 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.
|
||||
cancel_inverted = True
|
||||
current_row = (win_n_rows - 1) if inverted else 0
|
||||
available_rows = (win_n_rows - 1) if inverted else win_n_rows
|
||||
top_item_height = None if inverted else self.nav.top_item_height
|
||||
for data in self.content.iterate(page_index, step, win_n_cols - 2):
|
||||
subwin_n_rows = min(available_rows, data['n_rows'])
|
||||
subwin_inverted = inverted
|
||||
if top_item_height is not None:
|
||||
# Special case: draw the page as non-inverted, except for the
|
||||
# top element. This element will be drawn as inverted with a
|
||||
# restricted height
|
||||
subwin_n_rows = min(subwin_n_rows, top_item_height)
|
||||
subwin_inverted = True
|
||||
top_item_height = None
|
||||
subwin_n_cols = win_n_cols - data['offset']
|
||||
start = current_row - subwin_n_rows if inverted else current_row
|
||||
subwindow = window.derwin(
|
||||
subwin_n_rows, subwin_n_cols, start, data['offset'])
|
||||
attr = self._draw_item(subwindow, data, inverted)
|
||||
attr = self._draw_item(subwindow, data, subwin_inverted)
|
||||
self._subwindows.append((subwindow, attr))
|
||||
available_rows -= (subwin_n_rows + 1) # Add one for the blank line
|
||||
current_row += step * (subwin_n_rows + 1)
|
||||
if available_rows <= 0:
|
||||
# Indicate the page is full and we can keep the inverted screen.
|
||||
cancel_inverted = False
|
||||
break
|
||||
else:
|
||||
# If the page is not full we need to make sure that it is NOT
|
||||
|
||||
if len(self._subwindows) == 1:
|
||||
# Never draw inverted if only one subwindow. The top of the
|
||||
# subwindow should always be aligned with the top of the screen.
|
||||
cancel_inverted = True
|
||||
|
||||
if cancel_inverted and self.nav.inverted:
|
||||
# In some cases we need to make sure that the screen is NOT
|
||||
# inverted. Unfortunately, this currently means drawing the whole
|
||||
# page over again. Could not think of a better way to pre-determine
|
||||
# if the content will fill up the page, given that it is dependent
|
||||
# on the size of the terminal.
|
||||
if self.nav.inverted:
|
||||
self.nav.flip((len(self._subwindows) - 1))
|
||||
self._draw_content()
|
||||
self.nav.flip((len(self._subwindows) - 1))
|
||||
self._draw_content()
|
||||
|
||||
self._row = n_rows
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
; This may be necessary for compatibility with some terminal browsers.
|
||||
ascii = False
|
||||
|
||||
; Turn on monochrome mode to disable color.
|
||||
monochrome = False
|
||||
|
||||
; Enable debugging by logging all HTTP requests and errors to the given file.
|
||||
;log = /tmp/rtv.log
|
||||
|
||||
@@ -110,6 +113,7 @@ SUBMISSION_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
|
||||
SUBMISSION_POST = c
|
||||
SUBMISSION_EXIT = h, <KEY_LEFT>
|
||||
SUBMISSION_OPEN_IN_PAGER = l, <KEY_RIGHT>
|
||||
SUBMISSION_OPEN_IN_URLVIEWER = b
|
||||
|
||||
; Subreddit page
|
||||
SUBREDDIT_SEARCH = f
|
||||
|
||||
@@ -8,6 +8,7 @@ from . import docs
|
||||
from .content import SubmissionContent
|
||||
from .page import Page, PageController, logged_in
|
||||
from .objects import Navigator, Color, Command
|
||||
from .exceptions import TemporaryFileError
|
||||
|
||||
|
||||
class SubmissionController(PageController):
|
||||
@@ -33,11 +34,19 @@ class SubmissionPage(Page):
|
||||
|
||||
current_index = self.nav.absolute_index
|
||||
self.content.toggle(current_index)
|
||||
|
||||
# This logic handles a display edge case after a comment toggle. We
|
||||
# want to make sure that when we re-draw the page, the cursor stays at
|
||||
# its current absolute position on the screen. In order to do this,
|
||||
# apply a fixed offset if, while inverted, we either try to hide the
|
||||
# bottom comment or toggle any of the middle comments.
|
||||
if self.nav.inverted:
|
||||
# Reset the navigator so that the cursor is at the bottom of the
|
||||
# page. This is a workaround to handle if folding the comment
|
||||
# causes the cursor index to go out of bounds.
|
||||
self.nav.page_index, self.nav.cursor_index = current_index, 0
|
||||
data = self.content.get(current_index)
|
||||
if data['hidden'] or self.nav.cursor_index != 0:
|
||||
window = self._subwindows[-1][0]
|
||||
n_rows, _ = window.getmaxyx()
|
||||
self.nav.flip(len(self._subwindows) - 1)
|
||||
self.nav.top_item_height = n_rows
|
||||
|
||||
@SubmissionController.register(Command('SUBMISSION_EXIT'))
|
||||
def exit_submission(self):
|
||||
@@ -113,17 +122,20 @@ class SubmissionPage(Page):
|
||||
type=data['type'].lower(),
|
||||
content=content)
|
||||
|
||||
comment = self.term.open_editor(comment_info)
|
||||
if not comment:
|
||||
self.term.show_notification('Canceled')
|
||||
return
|
||||
with self.term.open_editor(comment_info) as comment:
|
||||
if not comment:
|
||||
self.term.show_notification('Canceled')
|
||||
return
|
||||
|
||||
with self.term.loader('Posting', delay=0):
|
||||
reply(comment)
|
||||
# Give reddit time to process the submission
|
||||
time.sleep(2.0)
|
||||
if not self.term.loader.exception:
|
||||
self.refresh_content()
|
||||
with self.term.loader('Posting', delay=0):
|
||||
reply(comment)
|
||||
# Give reddit time to process the submission
|
||||
time.sleep(2.0)
|
||||
|
||||
if self.term.loader.exception is None:
|
||||
self.refresh_content()
|
||||
else:
|
||||
raise TemporaryFileError()
|
||||
|
||||
@SubmissionController.register(Command('DELETE'))
|
||||
@logged_in
|
||||
@@ -135,6 +147,15 @@ class SubmissionPage(Page):
|
||||
else:
|
||||
self.term.flash()
|
||||
|
||||
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_URLVIEWER'))
|
||||
def comment_urlview(self):
|
||||
data = self.content.get(self.nav.absolute_index)
|
||||
comment = data.get('body', '')
|
||||
if comment:
|
||||
self.term.open_urlview(comment)
|
||||
else:
|
||||
self.term.flash()
|
||||
|
||||
def _draw_item(self, win, data, inverted):
|
||||
|
||||
if data['type'] == 'MoreComments':
|
||||
@@ -155,6 +176,16 @@ class SubmissionPage(Page):
|
||||
valid_rows = range(0, n_rows)
|
||||
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
|
||||
|
||||
# If there isn't enough space to fit the comment body on the screen,
|
||||
# replace the last line with a notification.
|
||||
split_body = data['split_body']
|
||||
if data['n_rows'] > n_rows:
|
||||
# Only when there is a single comment on the page and not inverted
|
||||
if not inverted and len(self._subwindows) == 0:
|
||||
cutoff = data['n_rows'] - n_rows + 1
|
||||
split_body = split_body[:-cutoff]
|
||||
split_body.append('(Not enough space to display)')
|
||||
|
||||
row = offset
|
||||
if row in valid_rows:
|
||||
|
||||
@@ -182,7 +213,7 @@ class SubmissionPage(Page):
|
||||
text, attr = self.term.saved
|
||||
self.term.add_line(win, text, attr=attr)
|
||||
|
||||
for row, text in enumerate(data['split_body'], start=offset+1):
|
||||
for row, text in enumerate(split_body, start=offset+1):
|
||||
if row in valid_rows:
|
||||
self.term.add_line(win, text, row, 1)
|
||||
|
||||
@@ -201,7 +232,8 @@ class SubmissionPage(Page):
|
||||
n_cols -= 1
|
||||
|
||||
self.term.add_line(win, '{body}'.format(**data), 0, 1)
|
||||
self.term.add_line(win, ' [{count}]'.format(**data), attr=curses.A_BOLD)
|
||||
self.term.add_line(
|
||||
win, ' [{count}]'.format(**data), attr=curses.A_BOLD)
|
||||
|
||||
attr = Color.get_level(data['level'])
|
||||
self.term.addch(win, 0, 0, self.term.vline, attr)
|
||||
|
||||
@@ -10,6 +10,7 @@ from .page import Page, PageController, logged_in
|
||||
from .objects import Navigator, Color, Command
|
||||
from .submission import SubmissionPage
|
||||
from .subscription import SubscriptionPage
|
||||
from .exceptions import TemporaryFileError
|
||||
|
||||
|
||||
class SubredditController(PageController):
|
||||
@@ -99,7 +100,9 @@ class SubredditPage(Page):
|
||||
data = self.content.get(self.nav.absolute_index)
|
||||
if data['url_type'] == 'selfpost':
|
||||
self.open_submission()
|
||||
elif data['url_type'] == 'x-post':
|
||||
elif data['url_type'] == 'x-post subreddit':
|
||||
self.refresh_content(order='ignore', name=data['xpost_subreddit'])
|
||||
elif data['url_type'] == 'x-post submission':
|
||||
self.open_submission(url=data['url_full'])
|
||||
self.config.history.add(data['url_full'])
|
||||
else:
|
||||
@@ -118,31 +121,35 @@ class SubredditPage(Page):
|
||||
return
|
||||
|
||||
submission_info = docs.SUBMISSION_FILE.format(name=name)
|
||||
text = self.term.open_editor(submission_info)
|
||||
if not text or '\n' not in text:
|
||||
self.term.show_notification('Canceled')
|
||||
return
|
||||
with self.term.open_editor(submission_info) as text:
|
||||
if not text:
|
||||
self.term.show_notification('Canceled')
|
||||
return
|
||||
elif '\n' not in text:
|
||||
self.term.show_notification('Missing body')
|
||||
return
|
||||
|
||||
title, content = text.split('\n', 1)
|
||||
with self.term.loader('Posting', delay=0):
|
||||
submission = self.reddit.submit(name, title, text=content,
|
||||
raise_captcha_exception=True)
|
||||
# Give reddit time to process the submission
|
||||
time.sleep(2.0)
|
||||
if self.term.loader.exception:
|
||||
return
|
||||
title, content = text.split('\n', 1)
|
||||
with self.term.loader('Posting', delay=0):
|
||||
submission = self.reddit.submit(name, title, text=content,
|
||||
raise_captcha_exception=True)
|
||||
# Give reddit time to process the submission
|
||||
time.sleep(2.0)
|
||||
if self.term.loader.exception:
|
||||
raise TemporaryFileError()
|
||||
|
||||
# Open the newly created post
|
||||
with self.term.loader('Loading submission'):
|
||||
page = SubmissionPage(
|
||||
self.reddit, self.term, self.config, self.oauth,
|
||||
submission=submission)
|
||||
if self.term.loader.exception:
|
||||
return
|
||||
if not self.term.loader.exception:
|
||||
# Open the newly created post
|
||||
with self.term.loader('Loading submission'):
|
||||
page = SubmissionPage(
|
||||
self.reddit, self.term, self.config, self.oauth,
|
||||
submission=submission)
|
||||
if self.term.loader.exception:
|
||||
return
|
||||
|
||||
page.loop()
|
||||
page.loop()
|
||||
|
||||
self.refresh_content()
|
||||
self.refresh_content()
|
||||
|
||||
@SubredditController.register(Command('SUBREDDIT_OPEN_SUBSCRIPTIONS'))
|
||||
@logged_in
|
||||
|
||||
105
rtv/terminal.py
105
rtv/terminal.py
@@ -6,12 +6,14 @@ import sys
|
||||
import time
|
||||
import codecs
|
||||
import curses
|
||||
import logging
|
||||
import tempfile
|
||||
import webbrowser
|
||||
import subprocess
|
||||
import curses.ascii
|
||||
from curses import textpad
|
||||
from datetime import datetime
|
||||
from contextlib import contextmanager
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import six
|
||||
from kitchen.text.display import textual_width_chop
|
||||
@@ -27,6 +29,9 @@ except ImportError:
|
||||
unescape = html_parser.HTMLParser().unescape
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Terminal(object):
|
||||
|
||||
MIN_HEIGHT = 10
|
||||
@@ -58,13 +63,13 @@ class Terminal(object):
|
||||
|
||||
@property
|
||||
def neutral_arrow(self):
|
||||
symbol = '>' if self.ascii else '‣'
|
||||
symbol = 'o' if self.ascii else '•'
|
||||
attr = curses.A_BOLD
|
||||
return symbol, attr
|
||||
|
||||
@property
|
||||
def timestamp_sep(self):
|
||||
symbol = 'o' if self.ascii else '•'
|
||||
symbol = '-'
|
||||
attr = curses.A_BOLD
|
||||
return symbol, attr
|
||||
|
||||
@@ -100,7 +105,14 @@ class Terminal(object):
|
||||
if self._display is None:
|
||||
if sys.platform == 'darwin':
|
||||
# OSX doesn't always set DISPLAY so we can't use this to check
|
||||
display = True
|
||||
# Note: Disabling for now, with the hope that if this
|
||||
# is a widespread issue then people will complain and we can
|
||||
# come up with a better solution. Checking for $DISPLAY is
|
||||
# used extensively in mailcap files, so it really *should* be
|
||||
# set properly. I don't have a mac anymore so I can't test.
|
||||
|
||||
# display = True
|
||||
display = bool(os.environ.get("DISPLAY"))
|
||||
else:
|
||||
display = bool(os.environ.get("DISPLAY"))
|
||||
|
||||
@@ -350,7 +362,8 @@ class Terminal(object):
|
||||
'Browser exited with status=%s' % code)
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
raise exceptions.BrowserError('Timeout opening browser')
|
||||
raise exceptions.BrowserError(
|
||||
'Timeout opening browser')
|
||||
finally:
|
||||
# Can't check the loader exception because the oauth module
|
||||
# supersedes this loader and we need to always kill the
|
||||
@@ -383,37 +396,75 @@ class Terminal(object):
|
||||
except OSError:
|
||||
self.show_notification('Could not open pager %s' % pager)
|
||||
|
||||
@contextmanager
|
||||
def open_editor(self, data=''):
|
||||
"""
|
||||
Open a temporary file using the system's default editor.
|
||||
Open a file for editing using the system's default editor.
|
||||
|
||||
The data string will be written to the file before opening. This
|
||||
function will block until the editor has closed. At that point the file
|
||||
will be read and and lines starting with '#' will be stripped.
|
||||
After the file has been altered, the text will be read back and lines
|
||||
starting with '#' will be stripped. If an error occurs inside of the
|
||||
context manager, the file will be preserved. Otherwise, the file will
|
||||
be deleted when the context manager closes.
|
||||
|
||||
Params:
|
||||
data (str): If provided, text will be written to the file before
|
||||
opening it with the editor.
|
||||
|
||||
Returns:
|
||||
text (str): The text that the user entered into the editor.
|
||||
"""
|
||||
|
||||
with NamedTemporaryFile(prefix='rtv-', suffix='.txt', mode='wb') as fp:
|
||||
fp.write(self.clean(data))
|
||||
fp.flush()
|
||||
editor = os.getenv('RTV_EDITOR') or os.getenv('EDITOR') or 'nano'
|
||||
filename = 'rtv_{:%Y%m%d_%H%M%S}.txt'.format(datetime.now())
|
||||
filepath = os.path.join(tempfile.gettempdir(), filename)
|
||||
|
||||
with codecs.open(filepath, 'w', 'utf-8') as fp:
|
||||
fp.write(data)
|
||||
_logger.info('File created: %s', filepath)
|
||||
|
||||
editor = os.getenv('RTV_EDITOR') or os.getenv('EDITOR') or 'nano'
|
||||
try:
|
||||
with self.suspend():
|
||||
p = subprocess.Popen([editor, filepath])
|
||||
try:
|
||||
p.wait()
|
||||
except KeyboardInterrupt:
|
||||
p.terminate()
|
||||
except OSError:
|
||||
self.show_notification('Could not open file with %s' % editor)
|
||||
|
||||
with codecs.open(filepath, 'r', 'utf-8') as fp:
|
||||
text = ''.join(line for line in fp if not line.startswith('#'))
|
||||
text = text.rstrip()
|
||||
|
||||
try:
|
||||
yield text
|
||||
except exceptions.TemporaryFileError:
|
||||
# All exceptions will cause the file to *not* be removed, but these
|
||||
# ones should also be swallowed
|
||||
_logger.info('Caught TemporaryFileError')
|
||||
self.show_notification('Post saved as: %s', filepath)
|
||||
else:
|
||||
# If no errors occurred, try to remove the file
|
||||
try:
|
||||
with self.suspend():
|
||||
p = subprocess.Popen([editor, fp.name])
|
||||
try:
|
||||
p.wait()
|
||||
except KeyboardInterrupt:
|
||||
p.terminate()
|
||||
os.remove(filepath)
|
||||
except OSError:
|
||||
self.show_notification('Could not open file with %s' % editor)
|
||||
_logger.warning('Could not delete: %s', filepath)
|
||||
else:
|
||||
_logger.info('File deleted: %s', filepath)
|
||||
|
||||
# Open a second file object to read. This appears to be necessary
|
||||
# in order to read the changes made by some editors (gedit). w+
|
||||
# mode does not work!
|
||||
with codecs.open(fp.name, 'r', 'utf-8') as fp2:
|
||||
text = ''.join(line for line in fp2 if not line.startswith('#'))
|
||||
text = text.rstrip()
|
||||
return text
|
||||
def open_urlview(self, data):
|
||||
urlview = os.getenv('RTV_URLVIEWER') or 'urlview'
|
||||
try:
|
||||
with self.suspend():
|
||||
p = subprocess.Popen([urlview],
|
||||
stdin=subprocess.PIPE)
|
||||
try:
|
||||
p.communicate(input=six.b(data))
|
||||
except KeyboardInterrupt:
|
||||
p.terminate()
|
||||
except OSError:
|
||||
self.show_notification(
|
||||
'Could not open urls with {}'.format(urlview))
|
||||
|
||||
def text_input(self, window, allow_resize=False):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user