diff --git a/rtv/curses_helpers.py b/rtv/curses_helpers.py index 3c504ff..1b14b62 100644 --- a/rtv/curses_helpers.py +++ b/rtv/curses_helpers.py @@ -10,7 +10,8 @@ from .helpers import strip_textpad from .exceptions import EscapeInterrupt __all__ = ['ESCAPE', 'UARROW', 'DARROW', 'BULLET', 'show_notification', - 'show_help', 'LoadScreen', 'Color', 'text_input', 'curses_session'] + 'show_help', 'LoadScreen', 'Color', 'text_input', 'curses_session', + 'prompt_input'] ESCAPE = 27 @@ -235,6 +236,31 @@ def text_input(window, allow_resize=True): return strip_textpad(out) +def prompt_input(window, prompt, hide=False): + """ + Display a prompt where the user can enter text at the bottom of the screen + + Set hide to True to make the input text invisible. + """ + + attr = curses.A_BOLD | Color.CYAN + n_rows, n_cols = window.getmaxyx() + + if hide: + prompt += ' ' * (n_cols - len(prompt) - 1) + window.addstr(n_rows-1, 0, prompt, attr) + out = window.getstr(n_rows-1, 1) + else: + window.addstr(n_rows - 1, 0, prompt, attr) + window.refresh() + subwin = window.derwin(1, n_cols - len(prompt), + n_rows - 1, len(prompt)) + subwin.attrset(attr) + out = text_input(subwin) + + return out + + @contextmanager def curses_session(): """ diff --git a/rtv/page.py b/rtv/page.py index f7f2872..f76b638 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -3,11 +3,14 @@ import time import six import sys import logging +from contextlib import contextmanager import praw.errors +import requests from .helpers import clean, open_editor -from .curses_helpers import Color, show_notification, show_help, text_input +from .curses_helpers import (Color, show_notification, show_help, text_input, + prompt_input) from .docs import COMMENT_EDIT_FILE, SUBMISSION_FILE __all__ = ['Navigator', 'BaseController', 'BasePage'] @@ -173,6 +176,13 @@ class BasePage(object): self._content_window = None self._subwindows = None + def refresh_content(self): + raise NotImplementedError + + @staticmethod + def draw_item(window, data, inverted): + raise NotImplementedError + @BaseController.register('q') def exit(self): sys.exit() @@ -191,14 +201,6 @@ class BasePage(object): self._move_cursor(1) self.clear_input_queue() - 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) - @BaseController.register('a') def upvote(self): data = self.content.get(self.nav.absolute_index) @@ -240,14 +242,14 @@ class BasePage(object): self.logout() return - username = self.prompt_input('Enter username:') - password = self.prompt_input('Enter password:', hide=True) + username = prompt_input(self.stdscr, 'Enter username:') + password = prompt_input(self.stdscr, 'Enter password:', hide=True) if not username or not password: curses.flash() return try: - with self.loader(): + with self.loader(message='Logging in'): self.reddit.login(username, password) except praw.errors.InvalidUserPass: show_notification(self.stdscr, ['Invalid user/pass']) @@ -259,8 +261,9 @@ class BasePage(object): """ Delete a submission or comment. """ + if not self.reddit.is_logged_in(): - show_notification(self.stdscr, ['Login to delete']) + show_notification(self.stdscr, ['Not logged in']) return data = self.content.get(self.nav.absolute_index) @@ -269,32 +272,25 @@ class BasePage(object): return prompt = 'Are you sure you want to delete this? (y/n):' - char = self.prompt_input(prompt) + char = prompt_input(self.stdscr, prompt) if char != 'y': - show_notification(self.stdscr, ['Delete canceled']) + show_notification(self.stdscr, ['Canceled']) return - try: - data['object'].delete() - except praw.errors.APIException as e: - message = ['Error: {}'.format(e.error_type), e.message] - show_notification(self.stdscr, message) - _logger.exception(e) - except requests.HTTPError as e: - show_notification(self.stdscr, ['Unexpected Error']) - _logger.exception(e) - else: - with self.loader(delay=0, message='Deleting'): + with self.safe_call(): + with self.loader(message='Deleting', delay=0): + data['object'].delete() time.sleep(2.0) - self.refresh_content() + self.refresh_content() @BaseController.register('e') def edit(self): """ Edit a submission or comment. """ + if not self.reddit.is_logged_in(): - show_notification(self.stdscr, ['Login to edit']) + show_notification(self.stdscr, ['Not logged in']) return data = self.content.get(self.nav.absolute_index) @@ -316,54 +312,69 @@ class BasePage(object): curses.endwin() text = open_editor(info) curses.doupdate() - if text == content: - show_notification(self.stdscr, ['Edit canceled']) + show_notification(self.stdscr, ['Canceled']) return - try: - data['object'].edit(text) - except praw.errors.APIException as e: - message = ['Error: {}'.format(e.error_type), e.message] - show_notification(self.stdscr, message) - _logger.exception(e) - except requests.HTTPError as e: - show_notification(self.stdscr, ['Unexpected Error']) - _logger.exception(e) - else: - with self.loader(delay=0, message='Posting'): + with self.safe_call(): + with self.loader(message='Editing', delay=0): + data['object'].edit() time.sleep(2.0) - self.refresh_content() + self.refresh_content() + + 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 logout(self): "Prompt to log out of the user's account." - ch = self.prompt_input("Log out? (y/n):") + ch = prompt_input(self.stdscr, "Log out? (y/n):") if ch == 'y': self.reddit.clear_authentication() show_notification(self.stdscr, ['Logged out']) elif ch != 'n': curses.flash() - def prompt_input(self, prompt, hide=False): - "Prompt the user for input" + @contextmanager + def safe_call(self): + """ + Wrap praw calls with extended error handling. + If a PRAW related error occurs inside of this context manager, a + notification will be displayed on the screen instead of the entire + application shutting down. This function will return a callback that + can be used to check the status of the call. - attr = curses.A_BOLD | Color.CYAN - n_rows, n_cols = self.stdscr.getmaxyx() + Usage: + #>>> with self.safe_call() as check_status: + #>>> self.reddit.submit(...) + #>>> success = check_status() + """ - if hide: - prompt += ' ' * (n_cols - len(prompt) - 1) - self.stdscr.addstr(n_rows-1, 0, prompt, attr) - out = self.stdscr.getstr(n_rows-1, 1) + success = None + check_status = lambda: success + try: + yield check_status + except praw.errors.APIException as e: + message = ['Error: {}'.format(e.error_type), e.message] + show_notification(self.stdscr, message) + _logger.exception(e) + success = False + except praw.errors.ClientException as e: + message = ['Error: Client Exception', e.message] + show_notification(self.stdscr, message) + _logger.exception(e) + success = False + except (requests.HTTPError, requests.ConnectionError) as e: + show_notification(self.stdscr, ['Unexpected Error']) + _logger.exception(e) + success = False else: - 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) - out = text_input(window) - - return out + success = True def draw(self): @@ -380,10 +391,6 @@ class BasePage(object): self._draw_content() self._add_cursor() - @staticmethod - def draw_item(window, data, inverted): - raise NotImplementedError - def _draw_header(self): n_rows, n_cols = self._header_window.getmaxyx() diff --git a/rtv/submission.py b/rtv/submission.py index 22c54e6..15f33da 100644 --- a/rtv/submission.py +++ b/rtv/submission.py @@ -89,7 +89,7 @@ class SubmissionPage(BasePage): """ if not self.reddit.is_logged_in(): - show_notification(self.stdscr, ['Login to post']) + show_notification(self.stdscr, ['Not logged in']) return data = self.content.get(self.nav.absolute_index) @@ -112,25 +112,26 @@ class SubmissionPage(BasePage): comment_text = open_editor(comment_info) curses.doupdate() if not comment_text: - show_notification(self.stdscr, ['Comment canceled']) + show_notification(self.stdscr, ['Canceled']) return - try: - if data['type'] == 'Submission': - data['object'].add_comment(comment_text) - else: - data['object'].reply(comment_text) - except praw.errors.APIException as e: - message = ['Error: {}'.format(e.error_type), e.message] - show_notification(self.stdscr, message) - _logger.exception(e) - except requests.HTTPError as e: - show_notification(self.stdscr, ['Unexpected Error']) - _logger.exception(e) - else: - with self.loader(delay=0, message='Posting'): + with self.safe_call(): + with self.loader(message='Posting', delay=0): + if data['type'] == 'Submission': + data['object'].add_comment(comment_text) + else: + data['object'].reply(comment_text) time.sleep(2.0) - self.refresh_content() + self.refresh_content() + + @SubmissionController.register('d') + def delete_comment(self): + "Delete a comment as long as it is not the current submission" + + if self.nav.absolute_index != -1: + self.delete() + else: + curses.flash() def draw_item(self, win, data, inverted=False): diff --git a/rtv/subreddit.py b/rtv/subreddit.py index 490c09a..8945248 100644 --- a/rtv/subreddit.py +++ b/rtv/subreddit.py @@ -12,7 +12,7 @@ from .content import SubredditContent from .helpers import clean, open_browser, open_editor from .docs import SUBMISSION_FILE from .curses_helpers import (BULLET, UARROW, DARROW, GOLD, Color, - LoadScreen, show_notification) + LoadScreen, show_notification, prompt_input) __all__ = ['opened_links', 'SubredditController', 'SubredditPage'] @@ -67,7 +67,7 @@ class SubredditPage(BasePage): name = name or self.content.name prompt = 'Search {}:'.format(name) - query = self.prompt_input(prompt) + query = prompt_input(self.stdscr, prompt) if query is None: return @@ -83,7 +83,7 @@ class SubredditPage(BasePage): def prompt_subreddit(self): "Open a prompt to navigate to a different subreddit" prompt = 'Enter Subreddit: /r/' - name = self.prompt_input(prompt) + name = prompt_input(self.stdscr, prompt) if name is not None: self.refresh_content(name=name) @@ -114,7 +114,7 @@ class SubredditPage(BasePage): "Post a new submission to the given subreddit" if not self.reddit.is_logged_in(): - show_notification(self.stdscr, ['Login to post']) + show_notification(self.stdscr, ['Not logged in']) return # Strips the subreddit to just the name @@ -132,27 +132,20 @@ class SubredditPage(BasePage): curses.doupdate() # Validate the submission content - if not submission_text: - show_notification(self.stdscr, ['Post canceled']) + if not submission_text or '\n' not in submission_text: + show_notification(self.stdscr, ['Canceled']) return - if '\n' not in submission_text: - show_notification(self.stdscr, ['No content']) - return - - try: - title, content = submission_text.split('\n', 1) - self.reddit.submit(sub, title, text=content) - except praw.errors.APIException as e: - message = ['Error: {}'.format(e.error_type), e.message] - show_notification(self.stdscr, message) - _logger.exception(e) - except requests.HTTPError as e: - show_notification(self.stdscr, ['Unexpected Error']) - _logger.exception(e) - else: - with self.loader(delay=0, message='Posting'): + title, content = submission_text.split('\n', 1) + with self.safe_call() as check_status: + with self.loader(message='Posting', delay=0): + post = self.reddit.submit(sub, title, text=content) time.sleep(2.0) + + if check_status(): + # Open the newly created post + page = SubmissionPage(self.stdscr, self.reddit, submission=post) + page.loop() self.refresh_content() @staticmethod