Cherry picking backwards-compatible changes from the themes branch

This commit is contained in:
Michael Lazar
2017-09-08 01:10:32 -04:00
parent 0268408f71
commit 982861560a
16 changed files with 414 additions and 248 deletions

View File

@@ -35,9 +35,10 @@ from . import docs
from . import packages
from .packages import praw
from .config import Config, copy_default_config, copy_default_mailcap
from .theme import Theme
from .oauth import OAuthHelper
from .terminal import Terminal
from .objects import curses_session, Color, patch_webbrowser
from .objects import curses_session, patch_webbrowser
from .subreddit_page import SubredditPage
from .exceptions import ConfigError
from .__version__ import __version__
@@ -169,11 +170,9 @@ def main():
try:
with curses_session() as stdscr:
# Initialize global color-pairs with curses
if not config['monochrome']:
Color.init()
theme = Theme(config['monochrome'])
term = Terminal(stdscr, config, theme)
term = Terminal(stdscr, config)
with term.loader('Initializing', catch_exception=False):
reddit = praw.Reddit(user_agent=user_agent,
decode_html_entities=False,

View File

@@ -135,6 +135,9 @@ class OrderedSet(object):
class Config(object):
"""
This class manages the loading and saving of configs and other files.
"""
def __init__(self, history_file=HISTORY, token_file=TOKEN, **kwargs):

View File

@@ -193,23 +193,23 @@ class OAuthHelper(object):
# If an exception is raised it will be seen by the thread
# so we don't need to explicitly shutdown() the server
_logger.exception(e)
self.term.show_notification('Browser Error')
self.term.show_notification('Browser Error', style='error')
else:
self.server.shutdown()
finally:
thread.join()
if self.params['error'] == 'access_denied':
self.term.show_notification('Denied access')
self.term.show_notification('Denied access', style='error')
return
elif self.params['error']:
self.term.show_notification('Authentication error')
self.term.show_notification('Authentication error', style='error')
return
elif self.params['state'] is None:
# Something went wrong but it's not clear what happened
return
elif self.params['state'] != state:
self.term.show_notification('UUID mismatch')
self.term.show_notification('UUID mismatch', style='error')
return
with self.term.loader('Logging in'):

View File

@@ -230,7 +230,8 @@ class LoadScreen(object):
for e_type, message in self.EXCEPTION_MESSAGES:
# Some exceptions we want to swallow and display a notification
if isinstance(e, e_type):
self._terminal.show_notification(message.format(e))
msg = message.format(e)
self._terminal.show_notification(msg, style='error')
return True
def animate(self, delay, interval, message, trail):
@@ -250,12 +251,16 @@ class LoadScreen(object):
return
time.sleep(0.01)
# Build the notification window
# Build the notification window. Note that we need to use
# curses.newwin() instead of stdscr.derwin() so the text below the
# notification window does not got erased when we cover it up.
message_len = len(message) + len(trail)
n_rows, n_cols = self._terminal.stdscr.getmaxyx()
s_row = (n_rows - 3) // 2
s_col = (n_cols - message_len - 1) // 2
v_offset, h_offset = self._terminal.stdscr.getbegyx()
s_row = (n_rows - 3) // 2 + v_offset
s_col = (n_cols - message_len - 1) // 2 + h_offset
window = curses.newwin(3, message_len + 2, s_row, s_col)
window.bkgd(str(' '), self._terminal.attr('notice_loading'))
# Animate the loading prompt until the stopping condition is triggered
# when the context manager exits.
@@ -285,49 +290,6 @@ class LoadScreen(object):
time.sleep(0.01)
class Color(object):
"""
Color attributes for curses.
"""
RED = curses.A_NORMAL
GREEN = curses.A_NORMAL
YELLOW = curses.A_NORMAL
BLUE = curses.A_NORMAL
MAGENTA = curses.A_NORMAL
CYAN = curses.A_NORMAL
WHITE = curses.A_NORMAL
_colors = {
'RED': (curses.COLOR_RED, -1),
'GREEN': (curses.COLOR_GREEN, -1),
'YELLOW': (curses.COLOR_YELLOW, -1),
'BLUE': (curses.COLOR_BLUE, -1),
'MAGENTA': (curses.COLOR_MAGENTA, -1),
'CYAN': (curses.COLOR_CYAN, -1),
'WHITE': (curses.COLOR_WHITE, -1),
}
@classmethod
def init(cls):
"""
Initialize color pairs inside of curses using the default background.
This should be called once during the curses initial setup. Afterwards,
curses color pairs can be accessed directly through class attributes.
"""
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))
@classmethod
def get_level(cls, level):
levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW]
return levels[level % len(levels)]
class Navigator(object):
"""
Handles the math behind cursor movement and screen paging.

View File

@@ -4,7 +4,6 @@ from __future__ import unicode_literals
import os
import sys
import time
import curses
import logging
from functools import wraps
@@ -12,7 +11,7 @@ import six
from kitchen.text.display import textual_width
from . import docs
from .objects import Controller, Color, Command
from .objects import Controller, Command
from .clipboard import copy
from .exceptions import TemporaryFileError, ProgramError
from .__version__ import __version__
@@ -158,19 +157,15 @@ class Page(object):
@PageController.register(Command('PAGE_TOP'))
def move_page_top(self):
self._remove_cursor()
self.nav.page_index = self.content.range[0]
self.nav.cursor_index = 0
self.nav.inverted = False
self._add_cursor()
@PageController.register(Command('PAGE_BOTTOM'))
def move_page_bottom(self):
self._remove_cursor()
self.nav.page_index = self.content.range[1]
self.nav.cursor_index = 0
self.nav.inverted = True
self._add_cursor()
@PageController.register(Command('UPVOTE'))
@logged_in
@@ -376,7 +371,6 @@ class Page(object):
self._draw_banner()
self._draw_content()
self._draw_footer()
self._add_cursor()
self.term.clear_screen()
self.term.stdscr.refresh()
@@ -388,8 +382,7 @@ class Page(object):
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
# curses.bkgd expects bytes in py2 and unicode in py3
ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN
window.bkgd(ch, attr)
window.bkgd(str(' '), self.term.attr('title_bar'))
sub_name = self.content.name
sub_name = sub_name.replace('/r/front', 'Front Page')
@@ -421,7 +414,7 @@ class Page(object):
sys.stdout.write(title)
sys.stdout.flush()
if self.reddit.user is not None:
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
@@ -442,8 +435,7 @@ class Page(object):
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
ch, attr = str(' '), curses.A_BOLD | Color.YELLOW
window.bkgd(ch, attr)
window.bkgd(str(' '), self.term.attr('order_bar'))
banner = docs.BANNER_SEARCH if self.content.query else docs.BANNER
items = banner.strip().split(' ')
@@ -455,7 +447,8 @@ class Page(object):
if self.content.order is not None:
order = self.content.order.split('-')[0]
col = text.find(order) - 3
window.chgat(0, col, 3, attr | curses.A_REVERSE)
attr = self.term.theme.get('order_bar', modifier='selected')
window.chgat(0, col, 3, attr)
self._row += 1
@@ -465,8 +458,7 @@ class Page(object):
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(
n_rows - self._row - 1, n_cols, self._row, 0)
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()
@@ -493,10 +485,8 @@ class Page(object):
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'])
attr = self._draw_item(subwindow, data, subwin_inverted)
self._subwindows.append((subwindow, attr))
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:
@@ -518,6 +508,25 @@ class Page(object):
self.nav.flip((len(self._subwindows) - 1))
return self._draw_content()
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 content
for index, (win, data, inverted) in enumerate(self._subwindows):
if index == self.nav.cursor_index:
# This lets the theme know to invert the cursor
modifier = 'selected'
else:
modifier = None
win.bkgd(str(' '), self.term.attr('normal'))
with self.term.theme.set_modifier(modifier):
self._draw_item(win, data, inverted)
self._row += win_n_rows
def _draw_footer(self):
@@ -525,54 +534,23 @@ class Page(object):
n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase()
ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN
window.bkgd(ch, attr)
window.bkgd(str(' '), self.term.attr('help_bar'))
text = self.FOOTER.strip()
self.term.add_line(window, text, 0, 0)
self._row += 1
def _add_cursor(self):
self._edit_cursor(curses.A_REVERSE)
def _remove_cursor(self):
self._edit_cursor(curses.A_NORMAL)
def _move_cursor(self, direction):
self._remove_cursor()
# 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()
self._add_cursor()
def _move_page(self, direction):
self._remove_cursor()
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
if not valid:
self.term.flash()
self._add_cursor()
def _edit_cursor(self, attribute):
# Don't allow the cursor to go below page index 0
if self.nav.absolute_index < 0:
return
# 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
if self.nav.cursor_index >= len(self._subwindows):
self.nav.cursor_index = len(self._subwindows) - 1
window, attr = self._subwindows[self.nav.cursor_index]
if attr is not None:
attribute |= attr
n_rows, _ = window.getmaxyx()
for row in range(n_rows):
window.chgat(row, 0, 1, attribute)
def _prompt_period(self, order):

View File

@@ -3,12 +3,11 @@ from __future__ import unicode_literals
import re
import time
import curses
from . import docs
from .content import SubmissionContent, SubredditContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Color, Command
from .objects import Navigator, Command
from .exceptions import TemporaryFileError
@@ -119,7 +118,7 @@ class SubmissionPage(Page):
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER'))
def open_link(self):
"""
Open the selected item with the webbrowser
Open the selected item with the web browser
"""
data = self.get_selected_item()
@@ -207,6 +206,10 @@ class SubmissionPage(Page):
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_URLVIEWER'))
def comment_urlview(self):
"""
Open the selected comment with the URL viewer
"""
data = self.get_selected_item()
comment = data.get('body') or data.get('text') or data.get('url_full')
if comment:
@@ -285,89 +288,114 @@ class SubmissionPage(Page):
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:
if not inverted and len(self._subwindows) == 1:
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:
attr = curses.A_BOLD
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
text = '{author} '.format(**data)
if data['is_author']:
text += '[S] '
attr = self.term.attr('comment_author_self')
text = '{author} [S]'.format(**data)
else:
attr = self.term.attr('comment_author')
text = '{author}'.format(**data)
self.term.add_line(win, text, row, 1, attr)
if data['flair']:
attr = curses.A_BOLD | Color.YELLOW
self.term.add_line(win, '{flair} '.format(**data), attr=attr)
attr = self.term.attr('user_flair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
text, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, text, attr=attr)
self.term.add_line(win, ' {score} {created} '.format(**data))
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_space(win)
self.term.add_line(win, arrow, attr=attr)
attr = self.term.attr('score')
self.term.add_space(win)
self.term.add_line(win, '{score}'.format(**data), attr=attr)
attr = self.term.attr('created')
self.term.add_space(win)
self.term.add_line(win, '{created}'.format(**data), attr=attr)
if data['gold']:
text, attr = self.term.guilded
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('gold')
self.term.add_space(win)
self.term.add_line(win, self.term.guilded, attr=attr)
if data['stickied']:
text, attr = '[stickied]', Color.GREEN
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('stickied')
self.term.add_space(win)
self.term.add_line(win, '[stickied]', attr=attr)
if data['saved']:
text, attr = '[saved]', Color.GREEN
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
for row, text in enumerate(split_body, start=offset+1):
attr = self.term.attr('comment_text')
if row in valid_rows:
self.term.add_line(win, text, row, 1)
self.term.add_line(win, text, row, 1, attr=attr)
# Unfortunately vline() doesn't support custom color so we have to
# build it one segment at a time.
attr = Color.get_level(data['level'])
x = 0
index = data['level'] % len(self.term.theme.BAR_LEVELS)
attr = self.term.attr(self.term.theme.BAR_LEVELS[index])
for y in range(n_rows):
self.term.addch(win, y, x, self.term.vline, attr)
return attr | self.term.vline
self.term.addch(win, y, 0, self.term.vline, attr)
def _draw_more_comments(self, win, data):
n_rows, n_cols = win.getmaxyx()
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)
attr = self.term.attr('hidden_comment_text')
self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr)
attr = Color.get_level(data['level'])
attr = self.term.attr('hidden_comment_expand')
self.term.add_space(win)
self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
index = data['level'] % len(self.term.theme.BAR_LEVELS)
attr = self.term.attr(self.term.theme.BAR_LEVELS[index])
self.term.addch(win, 0, 0, self.term.vline, attr)
return attr | self.term.vline
def _draw_submission(self, win, data):
n_rows, n_cols = win.getmaxyx()
n_cols -= 3 # one for each side of the border + one for offset
attr = self.term.attr('submission_title')
for row, text in enumerate(data['split_title'], start=1):
self.term.add_line(win, text, row, 1, curses.A_BOLD)
self.term.add_line(win, text, row, 1, attr)
row = len(data['split_title']) + 1
attr = curses.A_BOLD | Color.GREEN
attr = self.term.attr('submission_author')
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
attr = curses.A_BOLD | Color.YELLOW
if data['flair']:
self.term.add_line(win, ' {flair}'.format(**data), attr=attr)
self.term.add_line(win, ' {created} {subreddit}'.format(**data))
attr = self.term.attr('submission_flair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
attr = self.term.attr('created')
self.term.add_space(win)
self.term.add_line(win, '{created}'.format(**data), attr=attr)
attr = self.term.attr('submission_subreddit')
self.term.add_space(win)
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
row = len(data['split_title']) + 2
seen = (data['url_full'] in self.config.history)
link_color = Color.MAGENTA if seen else Color.BLUE
attr = curses.A_UNDERLINE | link_color
if data['url_full'] in self.config.history:
attr = self.term.attr('url_seen')
else:
attr = self.term.attr('url')
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
offset = len(data['split_title']) + 3
# Cut off text if there is not enough room to display the whole post
@@ -377,25 +405,35 @@ class SubmissionPage(Page):
split_text = split_text[:-cutoff]
split_text.append('(Not enough space to display)')
attr = self.term.attr('submission_text')
for row, text in enumerate(split_text, start=offset):
self.term.add_line(win, text, row, 1)
self.term.add_line(win, text, row, 1, attr=attr)
row = len(data['split_title']) + len(split_text) + 3
self.term.add_line(win, '{score} '.format(**data), row, 1)
text, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, text, attr=attr)
self.term.add_line(win, ' {comments} '.format(**data))
attr = self.term.attr('score')
self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr)
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_space(win)
self.term.add_line(win, arrow, attr=attr)
attr = self.term.attr('comment_count')
self.term.add_space(win)
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
if data['gold']:
text, attr = self.term.guilded
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('gold')
self.term.add_space(win)
self.term.add_line(win, self.term.guilded, attr=attr)
if data['nsfw']:
text, attr = 'NSFW', (curses.A_BOLD | Color.RED)
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('nsfw')
self.term.add_space(win)
self.term.add_line(win, 'NSFW', attr=attr)
if data['saved']:
text, attr = '[saved]', Color.GREEN
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
win.border()

View File

@@ -3,12 +3,11 @@ from __future__ import unicode_literals
import re
import time
import curses
from . import docs
from .content import SubredditContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Color, Command
from .objects import Navigator, Command
from .submission_page import SubmissionPage
from .subscription_page import SubscriptionPage
from .exceptions import TemporaryFileError
@@ -265,50 +264,75 @@ class SubredditPage(Page):
n_title = len(data['split_title'])
for row, text in enumerate(data['split_title'], start=offset):
attr = self.term.attr('submission_title')
if row in valid_rows:
self.term.add_line(win, text, row, 1, curses.A_BOLD)
self.term.add_line(win, text, row, 1, attr)
row = n_title + offset
if row in valid_rows:
seen = (data['url_full'] in self.config.history)
link_color = Color.MAGENTA if seen else Color.BLUE
attr = curses.A_UNDERLINE | link_color
if data['url_full'] in self.config.history:
attr = self.term.attr('url_seen')
else:
attr = self.term.attr('url')
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
row = n_title + offset + 1
if row in valid_rows:
self.term.add_line(win, '{score} '.format(**data), row, 1)
text, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, text, attr=attr)
self.term.add_line(win, ' {created} '.format(**data))
attr = self.term.attr('score')
self.term.add_line(win, '{score}'.format(**data), row, 1, attr)
self.term.add_space(win)
arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_line(win, arrow, attr=attr)
self.term.add_space(win)
attr = self.term.attr('created')
self.term.add_line(win, '{created}'.format(**data), attr=attr)
if data['comments'] is not None:
text, attr = '-', curses.A_BOLD
self.term.add_line(win, text, attr=attr)
self.term.add_line(win, ' {comments} '.format(**data))
attr = self.term.attr('separator')
self.term.add_space(win)
self.term.add_line(win, '-', attr=attr)
attr = self.term.attr('comment_count')
self.term.add_space(win)
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
if data['saved']:
text, attr = '[saved]', Color.GREEN
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('saved')
self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr)
if data['stickied']:
text, attr = '[stickied]', Color.GREEN
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('stickied')
self.term.add_space(win)
self.term.add_line(win, '[stickied]', attr=attr)
if data['gold']:
text, attr = self.term.guilded
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('gold')
self.term.add_space(win)
self.term.add_line(win, self.term.guilded, attr=attr)
if data['nsfw']:
text, attr = 'NSFW', (curses.A_BOLD | Color.RED)
self.term.add_line(win, text, attr=attr)
attr = self.term.attr('nsfw')
self.term.add_space(win)
self.term.add_line(win, 'NSFW', attr=attr)
row = n_title + offset + 2
if row in valid_rows:
text = '{author}'.format(**data)
self.term.add_line(win, text, row, 1, Color.GREEN)
text = ' /r/{subreddit}'.format(**data)
self.term.add_line(win, text, attr=Color.YELLOW)
attr = self.term.attr('submission_author')
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
self.term.add_space(win)
attr = self.term.attr('submission_subreddit')
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
if data['flair']:
text = ' {flair}'.format(**data)
self.term.add_line(win, text, attr=Color.RED)
attr = self.term.attr('submission_flair')
self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
attr = self.term.attr('cursor')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

View File

@@ -1,12 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import curses
from . import docs
from .page import Page, PageController
from .content import SubscriptionContent, SubredditContent
from .objects import Color, Navigator, Command
from .objects import Navigator, Command
class SubscriptionController(PageController):
@@ -95,10 +93,21 @@ class SubscriptionPage(Page):
row = offset
if row in valid_rows:
attr = curses.A_BOLD | Color.YELLOW
if data['type'] == 'Multireddit':
attr = self.term.attr('multireddit_name')
else:
attr = self.term.attr('subscription_name')
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
row = offset + 1
for row, text in enumerate(data['split_title'], start=row):
if row in valid_rows:
self.term.add_line(win, text, row, 1)
if data['type'] == 'Multireddit':
attr = self.term.attr('multireddit_text')
else:
attr = self.term.attr('subscription_text')
self.term.add_line(win, text, row, 1, attr)
attr = self.term.attr('cursor')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

View File

@@ -20,9 +20,9 @@ from tempfile import NamedTemporaryFile
import six
from kitchen.text.display import textual_width_chop
from . import exceptions
from . import mime_parsers
from .objects import LoadScreen, Color
from . import exceptions, mime_parsers
from .theme import Theme
from .objects import LoadScreen
try:
# Fix only needed for versions prior to python 3.6
@@ -46,16 +46,20 @@ class Terminal(object):
MIN_HEIGHT = 10
MIN_WIDTH = 20
# ASCII code
# ASCII codes
ESCAPE = 27
RETURN = 10
SPACE = 32
def __init__(self, stdscr, config):
def __init__(self, stdscr, config, theme=None):
self.stdscr = stdscr
self.config = config
self.loader = LoadScreen(self)
self.theme = None
self.set_theme(theme)
self._display = None
self._mailcap_dict = mailcap.getcaps()
self._term = os.environ.get('TERM')
@@ -66,27 +70,19 @@ class Terminal(object):
@property
def up_arrow(self):
symbol = '^' if self.config['ascii'] else ''
attr = curses.A_BOLD | Color.GREEN
return symbol, attr
return '^' if self.config['ascii'] else ''
@property
def down_arrow(self):
symbol = 'v' if self.config['ascii'] else ''
attr = curses.A_BOLD | Color.RED
return symbol, attr
return 'v' if self.config['ascii'] else ''
@property
def neutral_arrow(self):
symbol = 'o' if self.config['ascii'] else ''
attr = curses.A_BOLD
return symbol, attr
return 'o' if self.config['ascii'] else ''
@property
def guilded(self):
symbol = '*' if self.config['ascii'] else ''
attr = curses.A_BOLD | Color.YELLOW
return symbol, attr
return '*' if self.config['ascii'] else ''
@property
def vline(self):
@@ -197,11 +193,11 @@ class Terminal(object):
"""
if likes is None:
return self.neutral_arrow
return self.neutral_arrow, self.attr('neutral_vote')
elif likes:
return self.up_arrow
return self.up_arrow, self.attr('upvote')
else:
return self.down_arrow
return self.down_arrow, self.attr('downvote')
def clean(self, string, n_cols=None):
"""
@@ -278,7 +274,21 @@ class Terminal(object):
params = [] if attr is None else [attr]
window.addstr(row, col, text, *params)
def show_notification(self, message, timeout=None):
@staticmethod
def add_space(window):
"""
Shortcut for adding a single space to a window at the current position
"""
row, col = window.getyx()
_, max_cols = window.getmaxyx()
if max_cols - col - 1 <= 0:
# Trying to draw outside of the screen bounds
return
window.addstr(row, col, ' ')
def show_notification(self, message, timeout=None, style='info'):
"""
Overlay a message box on the center of the screen and wait for input.
@@ -286,12 +296,17 @@ class Terminal(object):
message (list or string): List of strings, one per line.
timeout (float): Optional, maximum length of time that the message
will be shown before disappearing.
style (str): The theme element that will be applied to the
notification window
"""
assert style in ('info', 'warning', 'error', 'success')
if isinstance(message, six.string_types):
message = message.splitlines()
n_rows, n_cols = self.stdscr.getmaxyx()
v_offset, h_offset = self.stdscr.getbegyx()
box_width = max(len(m) for m in message) + 2
box_height = len(message) + 2
@@ -301,10 +316,11 @@ class Terminal(object):
box_height = min(box_height, n_rows)
message = message[:box_height-2]
s_row = (n_rows - box_height) // 2
s_col = (n_cols - box_width) // 2
s_row = (n_rows - box_height) // 2 + v_offset
s_col = (n_cols - box_width) // 2 + h_offset
window = curses.newwin(box_height, box_width, s_row, s_col)
window.bkgd(str(' '), self.attr('notice_{0}'.format(style)))
window.erase()
window.border()
@@ -382,7 +398,7 @@ class Terminal(object):
_logger.warning(stderr)
self.show_notification(
'Program exited with status={0}\n{1}'.format(
code, stderr.strip()))
code, stderr.strip()), style='error')
else:
# Non-blocking, open a background process
@@ -692,18 +708,22 @@ class Terminal(object):
"""
n_rows, n_cols = self.stdscr.getmaxyx()
ch, attr = str(' '), curses.A_BOLD | curses.A_REVERSE | Color.CYAN
v_offset, h_offset = self.stdscr.getbegyx()
ch, attr = str(' '), self.attr('prompt')
prompt = self.clean(prompt, n_cols-1)
# Create a new window to draw the text at the bottom of the screen,
# so we can erase it when we're done.
prompt_win = curses.newwin(1, len(prompt)+1, n_rows-1, 0)
s_row = v_offset + n_rows - 1
s_col = h_offset
prompt_win = curses.newwin(1, len(prompt) + 1, s_row, s_col)
prompt_win.bkgd(ch, attr)
self.add_line(prompt_win, prompt)
prompt_win.refresh()
# Create a separate window for text input
input_win = curses.newwin(1, n_cols-len(prompt), n_rows-1, len(prompt))
s_col = h_offset + len(prompt)
input_win = curses.newwin(1, n_cols - len(prompt), s_row, s_col)
input_win.bkgd(ch, attr)
input_win.refresh()
@@ -802,3 +822,34 @@ class Terminal(object):
self.stdscr.touchwin()
else:
self.stdscr.clearok(True)
def attr(self, element):
"""
Shortcut for fetching the color + attribute code for an element.
"""
return self.theme.get(element)
def set_theme(self, theme=None):
"""
Set the terminal theme. This is a stub for what will eventually
support managing custom themes.
Check that the terminal supports the provided theme, and applies
the theme to the terminal if possible.
If the terminal doesn't support the theme, this falls back to the
default theme. The default theme only requires 8 colors so it
should be compatible with any terminal that supports basic colors.
"""
monochrome = (not curses.has_colors())
if theme is None:
theme = Theme(monochrome=monochrome)
theme.bind_curses()
# Apply the default color to the whole screen
self.stdscr.bkgd(str(' '), theme.get('normal'))
self.theme = theme

113
rtv/theme.py Normal file
View File

@@ -0,0 +1,113 @@
"""
This file is a stub that contains the default RTV theme.
This will eventually be expanded to support loading/managing custom themes.
"""
import curses
from contextlib import contextmanager
DEFAULT_THEME = {
'normal': (-1, -1, curses.A_NORMAL),
'bar_level_1': (curses.COLOR_MAGENTA, -1, curses.A_NORMAL),
'bar_level_1.selected': (curses.COLOR_MAGENTA, -1, curses.A_REVERSE),
'bar_level_2': (curses.COLOR_CYAN, -1, curses.A_NORMAL),
'bar_level_2.selected': (curses.COLOR_CYAN, -1, curses.A_REVERSE),
'bar_level_3': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
'bar_level_3.selected': (curses.COLOR_GREEN, -1, curses.A_REVERSE),
'bar_level_4': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
'bar_level_4.selected': (curses.COLOR_YELLOW, -1, curses.A_REVERSE),
'comment_author': (curses.COLOR_BLUE, -1, curses.A_BOLD),
'comment_author_self': (curses.COLOR_GREEN, -1, curses.A_BOLD),
'comment_count': (-1, -1, curses.A_NORMAL),
'comment_text': (-1, -1, curses.A_NORMAL),
'created': (-1, -1, curses.A_NORMAL),
'cursor': (-1, -1, curses.A_NORMAL),
'cursor.selected': (-1, -1, curses.A_REVERSE),
'downvote': (curses.COLOR_RED, -1, curses.A_BOLD),
'gold': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
'help_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
'hidden_comment_expand': (-1, -1, curses.A_BOLD),
'hidden_comment_text': (-1, -1, curses.A_NORMAL),
'multireddit_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
'multireddit_text': (-1, -1, curses.A_NORMAL),
'neutral_vote': (-1, -1, curses.A_BOLD),
'notice_info': (-1, -1, curses.A_NORMAL),
'notice_loading': (-1, -1, curses.A_NORMAL),
'notice_error': (-1, -1, curses.A_NORMAL),
'notice_success': (-1, -1, curses.A_NORMAL),
'nsfw': (curses.COLOR_RED, -1, curses.A_BOLD | curses.A_REVERSE),
'order_bar': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
'order_bar.selected': (curses.COLOR_YELLOW, -1, curses.A_BOLD | curses.A_REVERSE),
'prompt': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
'saved': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
'score': (-1, -1, curses.A_NORMAL),
'separator': (-1, -1, curses.A_BOLD),
'stickied': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
'subscription_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
'subscription_text': (-1, -1, curses.A_NORMAL),
'submission_author': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
'submission_flair': (curses.COLOR_RED, -1, curses.A_NORMAL),
'submission_subreddit': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
'submission_text': (-1, -1, curses.A_NORMAL),
'submission_title': (-1, -1, curses.A_BOLD),
'title_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
'upvote': (curses.COLOR_GREEN, -1, curses.A_BOLD),
'url': (curses.COLOR_BLUE, -1, curses.A_UNDERLINE),
'url_seen': (curses.COLOR_MAGENTA, -1, curses.A_UNDERLINE),
'user_flair': (curses.COLOR_YELLOW, -1, curses.A_BOLD)
}
class Theme(object):
BAR_LEVELS = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4']
def __init__(self, monochrome=True):
self.monochrome = monochrome
self._modifier = None
self._elements = {}
self._color_pairs = {}
def bind_curses(self):
if self.monochrome:
# Skip initializing the colors and just use the attributes
self._elements = {key: val[2] for key, val in DEFAULT_THEME.items()}
return
# Shortcut for the default fg/bg
self._color_pairs[(-1, -1)] = curses.A_NORMAL
for key, (fg, bg, attr) in DEFAULT_THEME.items():
# Register the color pair for the element
if (fg, bg) not in self._color_pairs:
index = len(self._color_pairs) + 1
curses.init_pair(index, fg, bg)
self._color_pairs[(fg, bg)] = curses.color_pair(index)
self._elements[key] = self._color_pairs[(fg, bg)] | attr
def get(self, element, modifier=None):
modifier = modifier or self._modifier
if modifier:
modified_element = '{0}.{1}'.format(element, modifier)
if modified_element in self._elements:
return self._elements[modified_element]
return self._elements[element]
@contextmanager
def set_modifier(self, modifier=None):
# This case is undefined if the context manager is nested
assert self._modifier is None
self._modifier = modifier
try:
yield
finally:
self._modifier = None

View File

@@ -52,6 +52,9 @@ class MockStdscr(mock.MagicMock):
def getyx(self):
return self.y, self.x
def getbegyx(self):
return 0, 0
def getmaxyx(self):
return self.nlines, self.ncols
@@ -154,12 +157,14 @@ def stdscr():
patch('curses.curs_set'), \
patch('curses.init_pair'), \
patch('curses.color_pair'), \
patch('curses.has_colors'), \
patch('curses.start_color'), \
patch('curses.use_default_colors'):
out = MockStdscr(nlines=40, ncols=80, x=0, y=0)
curses.initscr.return_value = out
curses.newwin.side_effect = lambda *args: out.derwin(*args)
curses.color_pair.return_value = 23
curses.has_colors.return_value = True
curses.ACS_VLINE = 0
yield out

View File

@@ -12,7 +12,7 @@ import requests
from six.moves import reload_module
from rtv import exceptions
from rtv.objects import Color, Controller, Navigator, Command, KeyMap, \
from rtv.objects import Controller, Navigator, Command, KeyMap, \
curses_session, patch_webbrowser
try:
@@ -189,21 +189,6 @@ def test_objects_load_screen_nested_complex(terminal, stdscr, use_ascii):
stdscr.subwin.addstr.assert_called_once_with(1, 1, error_message)
def test_objects_color(stdscr):
colors = ['RED', 'GREEN', 'YELLOW', 'BLUE', 'MAGENTA', 'CYAN', 'WHITE']
# Check that all colors start with the default value
for color in colors:
assert getattr(Color, color) == curses.A_NORMAL
Color.init()
# Check that all colors are populated
for color in colors:
assert getattr(Color, color) == 23
def test_objects_curses_session(stdscr):
# Normal setup and cleanup

View File

@@ -79,15 +79,16 @@ def test_submission_page_construct(reddit, terminal, config, oauth):
# Comment
comment_data = page.content.get(0)
text = comment_data['split_body'][0].encode('utf-8')
window.subwin.addstr.assert_any_call(1, 1, text)
window.subwin.addstr.assert_any_call(1, 1, text, curses.A_NORMAL)
# More Comments
comment_data = page.content.get(1)
text = comment_data['body'].encode('utf-8')
window.subwin.addstr.assert_any_call(0, 1, text)
window.subwin.addstr.assert_any_call(0, 1, text, curses.A_NORMAL)
# Cursor should not be drawn when the page is first opened
assert not window.subwin.chgat.called
# TODO: Add a new test for this
# assert not window.subwin.chgat.called
# Reload with a smaller terminal window
terminal.stdscr.ncols = 20
@@ -264,7 +265,7 @@ def test_submission_comment_not_enough_space(submission_page, terminal):
text = '(Not enough space to display)'.encode('ascii')
window = terminal.stdscr.subwin
window.subwin.addstr.assert_any_call(6, 1, text)
window.subwin.addstr.assert_any_call(6, 1, text, curses.A_NORMAL)
def test_submission_vote(submission_page, refresh_token):

View File

@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import curses
import six
from rtv import __version__
@@ -38,7 +40,7 @@ def test_subreddit_page_construct(reddit, terminal, config, oauth):
window.subwin.addstr.assert_any_call(0, 1, text, 2097152)
# Cursor should have been drawn
assert window.subwin.chgat.called
window.subwin.addch.assert_any_call(0, 0, ' ', curses.A_REVERSE)
# Reload with a smaller terminal window
terminal.stdscr.ncols = 20

View File

@@ -45,8 +45,8 @@ def test_subscription_page_construct(reddit, terminal, config, oauth,
window.addstr.assert_any_call(0, 0, menu)
# Cursor - 2 lines
window.subwin.chgat.assert_any_call(0, 0, 1, 262144)
window.subwin.chgat.assert_any_call(1, 0, 1, 262144)
window.subwin.addch.assert_any_call(0, 0, ' ', 262144)
window.subwin.addch.assert_any_call(1, 0, ' ', 262144)
# Reload with a smaller terminal window
terminal.stdscr.ncols = 20

View File

@@ -20,14 +20,10 @@ except ImportError:
def test_terminal_properties(terminal, config):
assert len(terminal.up_arrow) == 2
assert isinstance(terminal.up_arrow[0], six.text_type)
assert len(terminal.down_arrow) == 2
assert isinstance(terminal.down_arrow[0], six.text_type)
assert len(terminal.neutral_arrow) == 2
assert isinstance(terminal.neutral_arrow[0], six.text_type)
assert len(terminal.guilded) == 2
assert isinstance(terminal.guilded[0], six.text_type)
assert isinstance(terminal.up_arrow, six.text_type)
assert isinstance(terminal.down_arrow, six.text_type)
assert isinstance(terminal.neutral_arrow, six.text_type)
assert isinstance(terminal.guilded, six.text_type)
terminal._display = None
with mock.patch('rtv.terminal.sys') as sys, \