887 lines
31 KiB
Python
887 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
|
from __future__ import unicode_literals
|
|
|
|
import re
|
|
import os
|
|
import sys
|
|
import time
|
|
import logging
|
|
from functools import wraps
|
|
|
|
import six
|
|
from kitchen.text.display import textual_width
|
|
|
|
from . import docs
|
|
from .clipboard import copy as clipboard_copy
|
|
from .objects import Controller, Command
|
|
from .exceptions import TemporaryFileError, ProgramError
|
|
from .__version__ import __version__
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def logged_in(f):
|
|
"""
|
|
Decorator for Page methods that require the user to be authenticated.
|
|
"""
|
|
|
|
@wraps(f)
|
|
def wrapped_method(self, *args, **kwargs):
|
|
if not self.reddit.is_oauth_session():
|
|
self.term.show_notification('Not logged in')
|
|
return None
|
|
return f(self, *args, **kwargs)
|
|
return wrapped_method
|
|
|
|
|
|
class PageController(Controller):
|
|
character_map = {}
|
|
|
|
|
|
class Page(object):
|
|
|
|
BANNER = None
|
|
FOOTER = None
|
|
|
|
def __init__(self, reddit, term, config, oauth):
|
|
self.reddit = reddit
|
|
self.term = term
|
|
self.config = config
|
|
self.oauth = oauth
|
|
self.content = None
|
|
self.nav = None
|
|
self.controller = None
|
|
|
|
self.active = True
|
|
self.selected_page = None
|
|
self._row = 0
|
|
self._subwindows = None
|
|
|
|
def refresh_content(self, order=None, name=None):
|
|
raise NotImplementedError
|
|
|
|
def _draw_item(self, win, data, inverted):
|
|
raise NotImplementedError
|
|
|
|
def get_selected_item(self):
|
|
"""
|
|
Return the content dictionary that is currently selected by the cursor.
|
|
"""
|
|
return self.content.get(self.nav.absolute_index)
|
|
|
|
def loop(self):
|
|
"""
|
|
Main control loop runs the following steps:
|
|
1. Re-draw the screen
|
|
2. Wait for user to press a key (includes terminal resizing)
|
|
3. Trigger the method registered to the input key
|
|
4. Check if there are any nested pages that need to be looped over
|
|
|
|
The loop will run until self.active is set to False from within one of
|
|
the methods.
|
|
"""
|
|
self.active = True
|
|
|
|
# This needs to be called once before the main loop, in case a subpage
|
|
# was pre-selected before the loop started. This happens in __main__.py
|
|
# with ``page.open_submission(url=url)``
|
|
while self.selected_page and self.active:
|
|
self.handle_selected_page()
|
|
|
|
while self.active:
|
|
self.draw()
|
|
ch = self.term.stdscr.getch()
|
|
self.controller.trigger(ch)
|
|
|
|
while self.selected_page and self.active:
|
|
self.handle_selected_page()
|
|
|
|
return self.selected_page
|
|
|
|
def handle_selected_page(self):
|
|
"""
|
|
Some commands will result in an action that causes a new page to open.
|
|
Examples include selecting a submission, viewing subscribed subreddits,
|
|
or opening the user's inbox. With these commands, the newly selected
|
|
page will be pre-loaded and stored in ``self.selected_page`` variable.
|
|
It's up to each page type to determine what to do when another page is
|
|
selected.
|
|
|
|
- It can start a nested page.loop(). This would allow the user to
|
|
return to their previous screen after exiting the sub-page. For
|
|
example, this is what happens when opening an individual submission
|
|
from within a subreddit page. When the submission is closed, the
|
|
user resumes the subreddit that they were previously viewing.
|
|
|
|
- It can close the current self.loop() and bubble the selected page up
|
|
one level in the loop stack. For example, this is what happens when
|
|
the user opens their subscriptions and selects a subreddit. The
|
|
subscription page loop is closed and the selected subreddit is
|
|
bubbled up to the root level loop.
|
|
|
|
Care should be taken to ensure the user can never enter an infinite
|
|
nested loop, as this could lead to memory leaks and recursion errors.
|
|
|
|
# Example of an unsafe nested loop
|
|
subreddit_page.loop()
|
|
-> submission_page.loop()
|
|
-> subreddit_page.loop()
|
|
-> submission_page.loop()
|
|
...
|
|
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@PageController.register(Command('REFRESH'))
|
|
def reload_page(self):
|
|
"""
|
|
Clear the PRAW cache to force the page the re-fetch content from reddit.
|
|
"""
|
|
self.reddit.handler.clear_cache()
|
|
self.refresh_content()
|
|
|
|
@PageController.register(Command('EXIT'))
|
|
def exit(self):
|
|
"""
|
|
Prompt and exit the application.
|
|
"""
|
|
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
|
|
sys.exit()
|
|
|
|
@PageController.register(Command('FORCE_EXIT'))
|
|
def force_exit(self):
|
|
"""
|
|
Immediately exit the application.
|
|
"""
|
|
sys.exit()
|
|
|
|
@PageController.register(Command('PREVIOUS_THEME'))
|
|
def previous_theme(self):
|
|
"""
|
|
Cycle to preview the previous theme from the internal list of themes.
|
|
"""
|
|
theme = self.term.theme_list.previous(self.term.theme)
|
|
while not self.term.check_theme(theme):
|
|
theme = self.term.theme_list.previous(theme)
|
|
|
|
self.term.set_theme(theme)
|
|
self.draw()
|
|
message = self.term.theme.display_string
|
|
self.term.show_notification(message, timeout=1)
|
|
|
|
@PageController.register(Command('NEXT_THEME'))
|
|
def next_theme(self):
|
|
"""
|
|
Cycle to preview the next theme from the internal list of themes.
|
|
"""
|
|
theme = self.term.theme_list.next(self.term.theme)
|
|
while not self.term.check_theme(theme):
|
|
theme = self.term.theme_list.next(theme)
|
|
|
|
self.term.set_theme(theme)
|
|
self.draw()
|
|
message = self.term.theme.display_string
|
|
self.term.show_notification(message, timeout=1)
|
|
|
|
@PageController.register(Command('HELP'))
|
|
def show_help(self):
|
|
"""
|
|
Open the help documentation in the system pager.
|
|
"""
|
|
self.term.open_pager(docs.HELP.strip())
|
|
|
|
@PageController.register(Command('MOVE_UP'))
|
|
def move_cursor_up(self):
|
|
"""
|
|
Move the cursor up one selection.
|
|
"""
|
|
self._move_cursor(-1)
|
|
self.clear_input_queue()
|
|
|
|
@PageController.register(Command('MOVE_DOWN'))
|
|
def move_cursor_down(self):
|
|
"""
|
|
Move the cursor down one selection.
|
|
"""
|
|
self._move_cursor(1)
|
|
self.clear_input_queue()
|
|
|
|
@PageController.register(Command('PAGE_UP'))
|
|
def move_page_up(self):
|
|
"""
|
|
Move the cursor up approximately the number of entries on the page.
|
|
"""
|
|
self._move_page(-1)
|
|
self.clear_input_queue()
|
|
|
|
@PageController.register(Command('PAGE_DOWN'))
|
|
def move_page_down(self):
|
|
"""
|
|
Move the cursor down approximately the number of entries on the page.
|
|
"""
|
|
self._move_page(1)
|
|
self.clear_input_queue()
|
|
|
|
@PageController.register(Command('PAGE_TOP'))
|
|
def move_page_top(self):
|
|
"""
|
|
Move the cursor to the first item on the page.
|
|
"""
|
|
self.nav.page_index = self.content.range[0]
|
|
self.nav.cursor_index = 0
|
|
self.nav.inverted = False
|
|
|
|
@PageController.register(Command('PAGE_BOTTOM'))
|
|
def move_page_bottom(self):
|
|
"""
|
|
Move the cursor to the last item on the page.
|
|
"""
|
|
self.nav.page_index = self.content.range[1]
|
|
self.nav.cursor_index = 0
|
|
self.nav.inverted = True
|
|
|
|
@PageController.register(Command('UPVOTE'))
|
|
@logged_in
|
|
def upvote(self):
|
|
"""
|
|
Upvote the currently selected item.
|
|
"""
|
|
data = self.get_selected_item()
|
|
if 'likes' not in data:
|
|
self.term.flash()
|
|
elif getattr(data['object'], 'archived'):
|
|
self.term.show_notification("Voting disabled for archived post", style='Error')
|
|
elif data['likes']:
|
|
with self.term.loader('Clearing vote'):
|
|
data['object'].clear_vote()
|
|
if not self.term.loader.exception:
|
|
data['likes'] = None
|
|
else:
|
|
with self.term.loader('Voting'):
|
|
data['object'].upvote()
|
|
if not self.term.loader.exception:
|
|
data['likes'] = True
|
|
|
|
@PageController.register(Command('DOWNVOTE'))
|
|
@logged_in
|
|
def downvote(self):
|
|
"""
|
|
Downvote the currently selected item.
|
|
"""
|
|
data = self.get_selected_item()
|
|
if 'likes' not in data:
|
|
self.term.flash()
|
|
elif getattr(data['object'], 'archived'):
|
|
self.term.show_notification("Voting disabled for archived post", style='Error')
|
|
elif data['likes'] or data['likes'] is None:
|
|
with self.term.loader('Voting'):
|
|
data['object'].downvote()
|
|
if not self.term.loader.exception:
|
|
data['likes'] = False
|
|
else:
|
|
with self.term.loader('Clearing vote'):
|
|
data['object'].clear_vote()
|
|
if not self.term.loader.exception:
|
|
data['likes'] = None
|
|
|
|
@PageController.register(Command('SAVE'))
|
|
@logged_in
|
|
def save(self):
|
|
"""
|
|
Mark the currently selected item as saved through the reddit API.
|
|
"""
|
|
data = self.get_selected_item()
|
|
if 'saved' not in data:
|
|
self.term.flash()
|
|
elif not data['saved']:
|
|
with self.term.loader('Saving'):
|
|
data['object'].save()
|
|
if not self.term.loader.exception:
|
|
data['saved'] = True
|
|
else:
|
|
with self.term.loader('Unsaving'):
|
|
data['object'].unsave()
|
|
if not self.term.loader.exception:
|
|
data['saved'] = False
|
|
|
|
@PageController.register(Command('LOGIN'))
|
|
def login(self):
|
|
"""
|
|
Prompt to log into the user's account, or log out of the current
|
|
account.
|
|
"""
|
|
if self.reddit.is_oauth_session():
|
|
ch = self.term.show_notification('Log out? (y/n)')
|
|
if ch in (ord('y'), ord('Y')):
|
|
self.oauth.clear_oauth_data()
|
|
self.term.show_notification('Logged out')
|
|
else:
|
|
self.oauth.authorize()
|
|
|
|
def reply(self):
|
|
"""
|
|
Reply to the selected item. This is a utility method and should not
|
|
be bound to a key directly.
|
|
|
|
Item type:
|
|
Submission - add a top level comment
|
|
Comment - add a comment reply
|
|
Message - reply to a private message
|
|
"""
|
|
data = self.get_selected_item()
|
|
|
|
if data['type'] == 'Submission':
|
|
body = data['text']
|
|
description = 'submission'
|
|
reply = data['object'].add_comment
|
|
elif data['type'] in ('Comment', 'InboxComment'):
|
|
body = data['body']
|
|
description = 'comment'
|
|
reply = data['object'].reply
|
|
elif data['type'] == 'Message':
|
|
body = data['body']
|
|
description = 'private message'
|
|
reply = data['object'].reply
|
|
else:
|
|
self.term.flash()
|
|
return
|
|
|
|
# Construct the text that will be displayed in the editor file.
|
|
# The post body will be commented out and added for reference
|
|
lines = [' |' + line for line in body.split('\n')]
|
|
content = '\n'.join(lines)
|
|
comment_info = docs.REPLY_FILE.format(
|
|
author=data['author'],
|
|
type=description,
|
|
content=content)
|
|
|
|
with self.term.open_editor(comment_info) as comment:
|
|
if not comment:
|
|
self.term.show_notification('Canceled')
|
|
return
|
|
|
|
with self.term.loader('Posting {}'.format(description), delay=0):
|
|
reply(comment)
|
|
# Give reddit time to process the submission
|
|
time.sleep(2.0)
|
|
|
|
if self.term.loader.exception is None:
|
|
self.reload_page()
|
|
else:
|
|
raise TemporaryFileError()
|
|
|
|
@PageController.register(Command('DELETE'))
|
|
@logged_in
|
|
def delete_item(self):
|
|
"""
|
|
Delete a submission or comment.
|
|
"""
|
|
data = self.get_selected_item()
|
|
if data.get('author') != self.reddit.user.name:
|
|
self.term.flash()
|
|
return
|
|
|
|
prompt = 'Are you sure you want to delete this? (y/n): '
|
|
if not self.term.prompt_y_or_n(prompt):
|
|
self.term.show_notification('Canceled')
|
|
return
|
|
|
|
with self.term.loader('Deleting', delay=0):
|
|
data['object'].delete()
|
|
# Give reddit time to process the request
|
|
time.sleep(2.0)
|
|
|
|
if self.term.loader.exception is None:
|
|
self.reload_page()
|
|
|
|
@PageController.register(Command('EDIT'))
|
|
@logged_in
|
|
def edit(self):
|
|
"""
|
|
Edit a submission or comment.
|
|
"""
|
|
data = self.get_selected_item()
|
|
if data.get('author') != self.reddit.user.name:
|
|
self.term.flash()
|
|
return
|
|
|
|
if data['type'] == 'Submission':
|
|
content = data['text']
|
|
info = docs.SUBMISSION_EDIT_FILE.format(
|
|
content=content, id=data['object'].id)
|
|
elif data['type'] == 'Comment':
|
|
content = data['body']
|
|
info = docs.COMMENT_EDIT_FILE.format(
|
|
content=content, id=data['object'].id)
|
|
else:
|
|
self.term.flash()
|
|
return
|
|
|
|
with self.term.open_editor(info) as text:
|
|
if not text or 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.reload_page()
|
|
else:
|
|
raise TemporaryFileError()
|
|
|
|
@PageController.register(Command('PRIVATE_MESSAGE'))
|
|
@logged_in
|
|
def send_private_message(self):
|
|
"""
|
|
Send a new private message to another user.
|
|
"""
|
|
message_info = docs.MESSAGE_FILE
|
|
with self.term.open_editor(message_info) as text:
|
|
if not text:
|
|
self.term.show_notification('Canceled')
|
|
return
|
|
|
|
parts = text.split('\n', 2)
|
|
if len(parts) == 1:
|
|
self.term.show_notification('Missing message subject')
|
|
return
|
|
elif len(parts) == 2:
|
|
self.term.show_notification('Missing message body')
|
|
return
|
|
|
|
recipient, subject, message = parts
|
|
recipient = recipient.strip()
|
|
subject = subject.strip()
|
|
message = message.rstrip()
|
|
|
|
if not recipient:
|
|
self.term.show_notification('Missing recipient')
|
|
return
|
|
elif not subject:
|
|
self.term.show_notification('Missing message subject')
|
|
return
|
|
elif not message:
|
|
self.term.show_notification('Missing message body')
|
|
return
|
|
|
|
with self.term.loader('Sending message', delay=0):
|
|
self.reddit.send_message(
|
|
recipient, subject, message, raise_captcha_exception=True)
|
|
# Give reddit time to process the message
|
|
time.sleep(2.0)
|
|
|
|
if self.term.loader.exception:
|
|
raise TemporaryFileError()
|
|
else:
|
|
self.term.show_notification('Message sent!')
|
|
self.selected_page = self.open_inbox_page('sent')
|
|
|
|
def prompt_and_select_link(self):
|
|
"""
|
|
Prompt the user to select a link from a list to open.
|
|
|
|
Return the link that was selected, or ``None`` if no link was selected.
|
|
"""
|
|
data = self.get_selected_item()
|
|
url_full = data.get('url_full')
|
|
permalink = data.get('permalink')
|
|
|
|
if url_full and url_full != permalink:
|
|
# The item is a link-only submission that won't contain text
|
|
link = url_full
|
|
else:
|
|
html = data.get('html')
|
|
if html:
|
|
extracted_links = self.content.extract_links(html)
|
|
if not extracted_links:
|
|
# Only one selection to choose from, so just pick it
|
|
link = permalink
|
|
else:
|
|
# Let the user decide which link to open
|
|
links = []
|
|
if permalink:
|
|
links += [{'text': 'Permalink', 'href': permalink}]
|
|
links += extracted_links
|
|
link = self.term.prompt_user_to_select_link(links)
|
|
else:
|
|
# Some items like hidden comments don't have any HTML to parse
|
|
link = permalink
|
|
|
|
return link
|
|
|
|
@PageController.register(Command('COPY_PERMALINK'))
|
|
def copy_permalink(self):
|
|
"""
|
|
Copy the submission permalink to OS clipboard
|
|
"""
|
|
url = self.get_selected_item().get('permalink')
|
|
self.copy_to_clipboard(url)
|
|
|
|
@PageController.register(Command('COPY_URL'))
|
|
def copy_url(self):
|
|
"""
|
|
Copy a link to OS clipboard
|
|
"""
|
|
url = self.prompt_and_select_link()
|
|
self.copy_to_clipboard(url)
|
|
|
|
def copy_to_clipboard(self, url):
|
|
"""
|
|
Attempt to copy the selected URL to the user's clipboard
|
|
"""
|
|
if url is None:
|
|
self.term.flash()
|
|
return
|
|
|
|
try:
|
|
clipboard_copy(url)
|
|
except (ProgramError, OSError) as e:
|
|
_logger.exception(e)
|
|
self.term.show_notification(
|
|
'Failed to copy url: {0}'.format(e))
|
|
else:
|
|
self.term.show_notification(
|
|
['Copied to clipboard:', url], timeout=1)
|
|
|
|
@PageController.register(Command('SUBSCRIPTIONS'))
|
|
@logged_in
|
|
def subscriptions(self):
|
|
"""
|
|
View a list of the user's subscribed subreddits
|
|
"""
|
|
self.selected_page = self.open_subscription_page('subreddit')
|
|
|
|
@PageController.register(Command('MULTIREDDITS'))
|
|
@logged_in
|
|
def multireddits(self):
|
|
"""
|
|
View a list of the user's subscribed multireddits
|
|
"""
|
|
self.selected_page = self.open_subscription_page('multireddit')
|
|
|
|
@PageController.register(Command('PROMPT'))
|
|
def prompt(self):
|
|
"""
|
|
Open a prompt to navigate to a different subreddit or comment"
|
|
"""
|
|
name = self.term.prompt_input('Enter page: /')
|
|
if name:
|
|
# Check if opening a submission url or a subreddit url
|
|
# Example patterns for submissions:
|
|
# comments/571dw3
|
|
# /comments/571dw3
|
|
# /r/pics/comments/571dw3/
|
|
# https://www.reddit.com/r/pics/comments/571dw3/at_disneyland
|
|
submission_pattern = re.compile(r'(^|/)comments/(?P<id>.+?)($|/)')
|
|
|
|
match = submission_pattern.search(name)
|
|
if match:
|
|
url = 'https://www.reddit.com/comments/{0}'.format(match.group('id'))
|
|
self.selected_page = self.open_submission_page(url)
|
|
else:
|
|
self.selected_page = self.open_subreddit_page(name)
|
|
|
|
@PageController.register(Command('INBOX'))
|
|
@logged_in
|
|
def inbox(self):
|
|
"""
|
|
View the user's inbox.
|
|
"""
|
|
self.selected_page = self.open_inbox_page('all')
|
|
|
|
def open_inbox_page(self, content_type):
|
|
"""
|
|
Open an instance of the inbox page for the logged in user.
|
|
"""
|
|
from .inbox_page import InboxPage
|
|
|
|
with self.term.loader('Loading inbox'):
|
|
page = InboxPage(self.reddit, self.term, self.config, self.oauth,
|
|
content_type=content_type)
|
|
if not self.term.loader.exception:
|
|
return page
|
|
|
|
def open_subscription_page(self, content_type):
|
|
"""
|
|
Open an instance of the subscriptions page with the selected content.
|
|
"""
|
|
from .subscription_page import SubscriptionPage
|
|
|
|
with self.term.loader('Loading {0}s'.format(content_type)):
|
|
page = SubscriptionPage(self.reddit, self.term, self.config,
|
|
self.oauth, content_type=content_type)
|
|
if not self.term.loader.exception:
|
|
return page
|
|
|
|
def open_submission_page(self, url=None, submission=None):
|
|
"""
|
|
Open an instance of the submission page for the given submission URL.
|
|
"""
|
|
from .submission_page import SubmissionPage
|
|
|
|
with self.term.loader('Loading submission'):
|
|
page = SubmissionPage(self.reddit, self.term, self.config,
|
|
self.oauth, url=url, submission=submission)
|
|
if not self.term.loader.exception:
|
|
return page
|
|
|
|
def open_subreddit_page(self, name):
|
|
"""
|
|
Open an instance of the subreddit page for the given subreddit name.
|
|
"""
|
|
from .subreddit_page import SubredditPage
|
|
|
|
with self.term.loader('Loading subreddit'):
|
|
page = SubredditPage(self.reddit, self.term, self.config,
|
|
self.oauth, name)
|
|
if not self.term.loader.exception:
|
|
return page
|
|
|
|
def clear_input_queue(self):
|
|
"""
|
|
Clear excessive input caused by the scroll wheel or holding down a key
|
|
"""
|
|
with self.term.no_delay():
|
|
while self.term.getch() != -1:
|
|
continue
|
|
|
|
def draw(self):
|
|
"""
|
|
Clear the terminal screen and redraw all of the sub-windows
|
|
"""
|
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
|
if n_rows < self.term.MIN_HEIGHT or n_cols < self.term.MIN_WIDTH:
|
|
# TODO: Will crash when you try to navigate if the terminal is too
|
|
# small at startup because self._subwindows will never be populated
|
|
return
|
|
|
|
self._row = 0
|
|
self._draw_header()
|
|
self._draw_banner()
|
|
self._draw_content()
|
|
self._draw_footer()
|
|
self.term.clear_screen()
|
|
self.term.stdscr.refresh()
|
|
|
|
def _draw_header(self):
|
|
"""
|
|
Draw the title bar at the top of the screen
|
|
"""
|
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
|
|
|
# Note: 2 argument form of derwin breaks PDcurses on Windows 7!
|
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
|
window.erase()
|
|
# curses.bkgd expects bytes in py2 and unicode in py3
|
|
window.bkgd(str(' '), self.term.attr('TitleBar'))
|
|
|
|
sub_name = self.content.name
|
|
sub_name = sub_name.replace('/r/front', 'Front Page')
|
|
|
|
parts = sub_name.split('/')
|
|
if len(parts) == 1:
|
|
pass
|
|
elif '/m/' in sub_name:
|
|
_, _, user, _, multi = parts
|
|
sub_name = '{} Curated by {}'.format(multi, user)
|
|
elif parts[1] == 'u':
|
|
noun = 'My' if parts[2] == 'me' else parts[2] + "'s"
|
|
user_room = parts[3] if len(parts) == 4 else 'overview'
|
|
title_lookup = {
|
|
'overview': 'Overview',
|
|
'submitted': 'Submissions',
|
|
'comments': 'Comments',
|
|
'saved': 'Saved Content',
|
|
'hidden': 'Hidden Content',
|
|
'upvoted': 'Upvoted Content',
|
|
'downvoted': 'Downvoted Content'
|
|
}
|
|
sub_name = "{} {}".format(noun, title_lookup[user_room])
|
|
|
|
query = self.content.query
|
|
if query:
|
|
sub_name = 'Searching {0}: {1}'.format(sub_name, query)
|
|
self.term.add_line(window, sub_name, 0, 0)
|
|
|
|
# Set the terminal title
|
|
if len(sub_name) > 50:
|
|
title = sub_name.strip('/')
|
|
title = title.replace('_', ' ')
|
|
try:
|
|
title = title.rsplit('/', 1)[1]
|
|
except IndexError:
|
|
pass
|
|
else:
|
|
title = sub_name
|
|
|
|
# Setting the terminal title will break emacs or systems without
|
|
# X window.
|
|
if os.getenv('DISPLAY') and not os.getenv('INSIDE_EMACS'):
|
|
title += ' - rtv {0}'.format(__version__)
|
|
title = self.term.clean(title)
|
|
if six.PY3:
|
|
# In py3 you can't write bytes to stdout
|
|
title = title.decode('utf-8')
|
|
title = '\x1b]2;{0}\x07'.format(title)
|
|
else:
|
|
title = b'\x1b]2;{0}\x07'.format(title)
|
|
sys.stdout.write(title)
|
|
sys.stdout.flush()
|
|
|
|
if self.reddit and self.reddit.user is not None:
|
|
# The starting position of the name depends on if we're converting
|
|
# to ascii or not
|
|
width = len if self.config['ascii'] else textual_width
|
|
|
|
if self.config['hide_username']:
|
|
username = "Logged in"
|
|
else:
|
|
username = self.reddit.user.name
|
|
s_col = (n_cols - width(username) - 1)
|
|
# Only print username if it fits in the empty space on the right
|
|
if (s_col - 1) >= width(sub_name):
|
|
self.term.add_line(window, username, 0, s_col)
|
|
|
|
self._row += 1
|
|
|
|
def _draw_banner(self):
|
|
"""
|
|
Draw the banner with sorting options at the top of the page
|
|
"""
|
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
|
window.erase()
|
|
window.bkgd(str(' '), self.term.attr('OrderBar'))
|
|
|
|
banner = docs.BANNER_SEARCH if self.content.query else self.BANNER
|
|
items = banner.strip().split(' ')
|
|
|
|
distance = (n_cols - sum(len(t) for t in items) - 1) / (len(items) - 1)
|
|
spacing = max(1, int(distance)) * ' '
|
|
text = spacing.join(items)
|
|
self.term.add_line(window, text, 0, 0)
|
|
if self.content.order is not None:
|
|
order = self.content.order.split('-')[0]
|
|
col = text.find(order) - 3
|
|
attr = self.term.attr('OrderBarHighlight')
|
|
window.chgat(0, col, 3, attr)
|
|
|
|
self._row += 1
|
|
|
|
def _draw_content(self):
|
|
"""
|
|
Loop through submissions and fill up the content page.
|
|
"""
|
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
|
window = self.term.stdscr.derwin(n_rows - self._row - 1, n_cols, self._row, 0)
|
|
window.erase()
|
|
win_n_rows, win_n_cols = window.getmaxyx()
|
|
|
|
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.
|
|
cancel_inverted = True
|
|
current_row = (win_n_rows - 1) if inverted else 0
|
|
available_rows = 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['h_offset']
|
|
start = current_row - subwin_n_rows + 1 if inverted else current_row
|
|
subwindow = window.derwin(subwin_n_rows, subwin_n_cols, start, data['h_offset'])
|
|
self._subwindows.append((subwindow, data, subwin_inverted))
|
|
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
|
|
|
|
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.
|
|
self.nav.flip((len(self._subwindows) - 1))
|
|
self._draw_content()
|
|
return
|
|
|
|
if self.nav.cursor_index >= len(self._subwindows):
|
|
# Don't allow the cursor to go over the number of subwindows
|
|
# This could happen if the window is resized and the cursor index is
|
|
# pushed out of bounds
|
|
self.nav.cursor_index = len(self._subwindows) - 1
|
|
|
|
# Now that the windows are setup, we can take a second pass through
|
|
# to draw the text onto each subwindow
|
|
for index, (win, data, inverted) in enumerate(self._subwindows):
|
|
if self.nav.absolute_index >= 0 and index == self.nav.cursor_index:
|
|
win.bkgd(str(' '), self.term.attr('Selected'))
|
|
with self.term.theme.turn_on_selected():
|
|
self._draw_item(win, data, inverted)
|
|
else:
|
|
win.bkgd(str(' '), self.term.attr('Normal'))
|
|
self._draw_item(win, data, inverted)
|
|
|
|
self._row += win_n_rows
|
|
|
|
def _draw_footer(self):
|
|
"""
|
|
Draw the key binds help bar at the bottom of the screen
|
|
"""
|
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
|
window.erase()
|
|
window.bkgd(str(' '), self.term.attr('HelpBar'))
|
|
|
|
text = self.FOOTER.strip()
|
|
self.term.add_line(window, text, 0, 0)
|
|
self._row += 1
|
|
|
|
def _move_cursor(self, direction):
|
|
# Note: ACS_VLINE doesn't like changing the attribute, so disregard the
|
|
# redraw flag and opt to always redraw
|
|
valid, redraw = self.nav.move(direction, len(self._subwindows))
|
|
if not valid:
|
|
self.term.flash()
|
|
|
|
def _move_page(self, direction):
|
|
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
|
|
if not valid:
|
|
self.term.flash()
|
|
|
|
def _prompt_period(self, order):
|
|
choices = {
|
|
'\n': order,
|
|
'1': '{0}-hour'.format(order),
|
|
'2': '{0}-day'.format(order),
|
|
'3': '{0}-week'.format(order),
|
|
'4': '{0}-month'.format(order),
|
|
'5': '{0}-year'.format(order),
|
|
'6': '{0}-all'.format(order)}
|
|
|
|
message = docs.TIME_ORDER_MENU.strip().splitlines()
|
|
ch = self.term.show_notification(message)
|
|
ch = six.unichr(ch)
|
|
return choices.get(ch)
|