Working on theme support

This commit is contained in:
Michael Lazar
2017-07-10 17:58:48 -04:00
parent 659807d890
commit 862d0e756d
17 changed files with 7728 additions and 190 deletions

View File

@@ -27,9 +27,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
from .objects import curses_session
from .subreddit_page import SubredditPage
from .exceptions import ConfigError
from .__version__ import __version__
@@ -81,6 +82,10 @@ def main():
copy_default_mailcap()
return
if config['list_themes']:
Theme.print_themes()
return
# Load the browsing history from previous sessions
config.load_history()
@@ -148,11 +153,13 @@ def main():
try:
with curses_session() as stdscr:
# Initialize global color-pairs with curses
if not config['monochrome']:
Color.init()
if config['theme']:
theme = Theme.from_name(config['theme'], config['monochrome'])
else:
theme = Theme(monochrome=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

@@ -13,16 +13,19 @@ from six.moves import configparser
from . import docs, __version__
from .objects import KeyMap
PACKAGE = os.path.dirname(__file__)
HOME = os.path.expanduser('~')
TEMPLATES = os.path.join(PACKAGE, 'templates')
DEFAULT_CONFIG = os.path.join(TEMPLATES, 'rtv.cfg')
DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap')
DEFAULT_THEMES = os.path.join(PACKAGE, 'themes')
XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg')
MAILCAP = os.path.join(HOME, '.mailcap')
TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token')
HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log')
THEMES = os.path.join(XDG_HOME, 'rtv', 'themes')
def build_parser():
@@ -51,6 +54,12 @@ def build_parser():
parser.add_argument(
'--monochrome', action='store_const', const=True,
help='Disable color')
parser.add_argument(
'--theme', metavar='FILE', action='store',
help='Color theme to use, see --list-themes for valid options')
parser.add_argument(
'--list-themes', metavar='FILE', action='store_const', const=True,
help='List all of the available color themes')
parser.add_argument(
'--non-persistent', dest='persistent', action='store_const',
const=False,
@@ -133,6 +142,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

@@ -203,7 +203,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):
@@ -223,12 +224,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.
@@ -258,49 +263,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

@@ -12,7 +12,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__
@@ -171,19 +171,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
@@ -401,8 +397,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')
@@ -430,7 +425,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
@@ -451,8 +446,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'))
items = docs.BANNER.strip().split(' ')
distance = (n_cols - sum(len(t) for t in items) - 1) / (len(items) - 1)
@@ -462,7 +456,7 @@ 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)
window.chgat(0, col, 3, self.term.attr('order_selected'))
self._row += 1
@@ -502,6 +496,7 @@ class Page(object):
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))
available_rows -= (subwin_n_rows + 1) # Add one for the blank line
@@ -532,36 +527,25 @@ 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):
def _add_cursor(self):
# Don't allow the cursor to go below page index 0
if self.nav.absolute_index < 0:
@@ -573,10 +557,12 @@ class Page(object):
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
window, cursor_attr = self._subwindows[self.nav.cursor_index]
if cursor_attr is None:
attr = self.term.attr('cursor')
else:
attr = cursor_attr | curses.A_REVERSE
n_rows, _ = window.getmaxyx()
for row in range(n_rows):
window.chgat(row, 0, 1, attribute)
window.chgat(row, 0, 1, attr)

View File

@@ -7,7 +7,7 @@ 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
@@ -38,7 +38,9 @@ class SubmissionPage(Page):
@SubmissionController.register(Command('SUBMISSION_TOGGLE_COMMENT'))
def toggle_comment(self):
"Toggle the selected comment tree between visible and hidden"
"""
Toggle the selected comment tree between visible and hidden
"""
current_index = self.nav.absolute_index
self.content.toggle(current_index)
@@ -58,13 +60,17 @@ class SubmissionPage(Page):
@SubmissionController.register(Command('SUBMISSION_EXIT'))
def exit_submission(self):
"Close the submission and return to the subreddit page"
"""
Close the submission and return to the subreddit page
"""
self.active = False
@SubmissionController.register(Command('REFRESH'))
def refresh_content(self, order=None, name=None):
"Re-download comments and reset the page index"
"""
Re-download comments and reset the page index
"""
order = order or self.content.order
url = name or self.content.name
@@ -78,7 +84,9 @@ class SubmissionPage(Page):
@SubmissionController.register(Command('PROMPT'))
def prompt_subreddit(self):
"Open a prompt to navigate to a different subreddit"
"""
Open a prompt to navigate to a different subreddit
"""
name = self.term.prompt_input('Enter page: /')
if name is not None:
@@ -91,7 +99,9 @@ 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()
if data['type'] == 'Submission':
@@ -104,7 +114,9 @@ class SubmissionPage(Page):
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_PAGER'))
def open_pager(self):
"Open the selected item with the system's pager"
"""
Open the selected item with the system's pager
"""
data = self.get_selected_item()
if data['type'] == 'Submission':
@@ -165,7 +177,9 @@ class SubmissionPage(Page):
@SubmissionController.register(Command('DELETE'))
@logged_in
def delete_comment(self):
"Delete the selected comment"
"""
Delete the selected comment
"""
if self.get_selected_item()['type'] == 'Comment':
self.delete_item()
@@ -174,6 +188,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:
@@ -213,38 +231,52 @@ class SubmissionPage(Page):
row = offset
if row in valid_rows:
attr = curses.A_BOLD
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
self.term.add_line(win, '{author} '.format(**data), row, 1, attr)
if data['is_author']:
attr = self.term.attr('comment_author_self')
else:
attr = self.term.attr('comment_author')
self.term.add_line(win, '{author}'.format(**data), 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'])
attr = self.term.theme.get_bar_level(data['level'])
x = 0
for y in range(n_rows):
self.term.addch(win, y, x, self.term.vline, attr)
@@ -256,11 +288,14 @@ class SubmissionPage(Page):
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)
attr = self.term.theme.get_bar_level(data['level'])
self.term.addch(win, 0, 0, self.term.vline, attr)
return attr | self.term.vline
@@ -270,22 +305,34 @@ class SubmissionPage(Page):
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
@@ -295,25 +342,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

@@ -8,7 +8,7 @@ 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
@@ -244,50 +244,71 @@ 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)

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):
@@ -29,7 +27,9 @@ class SubscriptionPage(Page):
@SubscriptionController.register(Command('REFRESH'))
def refresh_content(self, order=None, name=None):
"Re-download all subscriptions and reset the page index"
"""
Re-download all subscriptions and reset the page index
"""
# reddit.get_my_subreddits() does not support sorting by order
if order:
@@ -44,7 +44,9 @@ class SubscriptionPage(Page):
@SubscriptionController.register(Command('PROMPT'))
def prompt_subreddit(self):
"Open a prompt to navigate to a different subreddit"
"""
Open a prompt to navigate to a different subreddit
"""
name = self.term.prompt_input('Enter page: /')
if name is not None:
@@ -57,7 +59,9 @@ class SubscriptionPage(Page):
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
def select_subreddit(self):
"Store the selected subreddit and return to the subreddit page"
"""
Store the selected subreddit and return to the subreddit page
"""
name = self.get_selected_item()['name']
with self.term.loader('Loading page'):
@@ -69,7 +73,9 @@ class SubscriptionPage(Page):
@SubscriptionController.register(Command('SUBSCRIPTION_EXIT'))
def close_subscriptions(self):
"Close subscriptions and return to the subreddit page"
"""
Close subscriptions and return to the subreddit page
"""
self.active = False
@@ -87,10 +93,17 @@ 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)

View File

@@ -21,9 +21,9 @@ import six
from six.moves.urllib.parse import quote
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
@@ -47,43 +47,39 @@ 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['TERM']
@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):
@@ -194,11 +190,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):
"""
@@ -275,7 +271,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.
@@ -283,12 +293,15 @@ 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
"""
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
@@ -298,10 +311,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()
@@ -687,18 +701,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()
@@ -797,3 +815,46 @@ 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):
"""
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)
elif theme.required_color_pairs > curses.COLOR_PAIRS:
_logger.warning(
'Theme %s requires %s color pairs, but TERM %s only '
'supports %s color pairs, switching to default theme',
theme.name, theme.required_color_pairs, self._term,
curses.COLOR_PAIRS)
theme = Theme(monochrome=monochrome)
elif theme.required_colors > curses.COLORS:
_logger.warning(
'Theme %s requires %s colors, but TERM %s only '
'supports %s colors, switching to default theme',
theme.name, theme.required_colors, self._term,
curses.COLORS)
theme = Theme(monochrome=monochrome)
theme.bind_curses()
# Apply the default color to the whole screen
self.stdscr.bkgd(str(' '), theme.get('default'))
self.theme = theme

370
rtv/theme.py Normal file
View File

@@ -0,0 +1,370 @@
import codecs
import configparser
import curses
import logging
import os
from .config import THEMES, DEFAULT_THEMES
from .exceptions import ConfigError
_logger = logging.getLogger(__name__)
class Theme(object):
ATTRIBUTE_CODES = {
'': curses.A_NORMAL,
'bold': curses.A_BOLD,
'reverse': curses.A_REVERSE,
'underline': curses.A_UNDERLINE,
'standout': curses.A_STANDOUT
}
COLOR_CODES = {
'default': -1,
'black': curses.COLOR_BLACK,
'red': curses.COLOR_RED,
'green': curses.COLOR_GREEN,
'yellow': curses.COLOR_YELLOW,
'blue': curses.COLOR_BLUE,
'magenta': curses.COLOR_MAGENTA,
'cyan': curses.COLOR_CYAN,
'light_gray': curses.COLOR_WHITE,
'dark_gray': 8,
'bright_red': 9,
'bright_green': 10,
'bright_yellow': 11,
'bright_blue': 12,
'bright_magenta': 13,
'bright_cyan': 14,
'white': 15,
}
# Add keywords for the 256 ansi color codes
for i in range(256):
COLOR_CODES['ansi_{0}'.format(i)] = i
# For compatibility with as many terminals as possible, the default theme
# can only use the 8 basic colors with the default background.
DEFAULT_THEME = {
'bar_level_1': (curses.COLOR_MAGENTA, -1, curses.A_NORMAL),
'bar_level_2': (curses.COLOR_CYAN, -1, curses.A_NORMAL),
'bar_level_3': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
'bar_level_4': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
'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_REVERSE),
'default': (-1, -1, curses.A_NORMAL),
'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': (curses.COLOR_RED, -1, curses.A_NORMAL),
'notice_success': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
'nsfw': (curses.COLOR_RED, -1, curses.A_BOLD),
'order_bar': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
'order_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)
}
def __init__(self, name='default', elements=None, monochrome=False):
"""
Params:
name (str): A unique string that describes the theme
elements (dict): The theme's element map, should be in the same
format as Theme.DEFAULT_THEME.
monochrome (bool): If true, force all color pairs to use the
terminal's default foreground/background color.
"""
self.elements = self.DEFAULT_THEME.copy()
if elements:
self.elements.update(elements)
self.name = name
self.monochrome = monochrome
self._color_pair_map = None
self._attribute_map = None
self.required_color_pairs = 0
self.required_colors = 0
if not self.monochrome:
colors, color_pairs = set(), set()
for fg, bg, _ in self.elements.values():
colors.add(fg)
colors.add(bg)
color_pairs.add((fg, bg))
# Don't count the default fg/bg as a color pair
color_pairs.discard((-1, -1))
self.required_color_pairs = len(color_pairs)
# Determine which color set the terminal needs to
# support in order to be able to use the theme
self.required_colors = None
for marker in [0, 8, 16, 256]:
if max(colors) < marker:
self.required_colors = marker
break
def bind_curses(self):
"""
Bind the theme's colors to curses's internal color pair map.
This method must be called once (after curses has been initialized)
before any element attributes can be accessed. Color codes and other
special attributes will be mixed bitwise into a single value that
can be understood by curses.
"""
self._color_pair_map = {}
self._attribute_map = {}
for element, item in self.elements.items():
fg, bg, attrs = item
color_pair = (fg, bg)
if not self.monochrome and color_pair != (-1, -1):
# Curses limits the number of available color pairs, so we
# need to reuse them if there are multiple elements with the
# same foreground and background.
if color_pair not in self._color_pair_map:
# Index 0 is reserved by curses for the default color
index = len(self._color_pair_map) + 1
curses.init_pair(index, color_pair[0], color_pair[1])
self._color_pair_map[color_pair] = curses.color_pair(index)
attrs |= self._color_pair_map[color_pair]
self._attribute_map[element] = attrs
def get(self, val):
"""
Returns the curses attribute code for the given element.
"""
if self._attribute_map is None:
raise RuntimeError('Attempted to access theme attribute before '
'calling initialize_curses_theme()')
return self._attribute_map[val]
def get_bar_level(self, indentation_level):
"""
Helper method for loading the bar format given the indentation level.
"""
levels = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4']
level = levels[indentation_level % len(levels)]
return self.get(level)
@classmethod
def list_themes(cls, path=THEMES):
"""
Compile all of the themes configuration files in the search path.
"""
themes = {'invalid': {}, 'custom': {}, 'default': {}}
for container, theme_path in [
(themes['custom'], path),
(themes['default'], DEFAULT_THEMES)]:
if os.path.isdir(theme_path):
for filename in os.listdir(theme_path):
if not filename.endswith('.cfg'):
continue
filepath = os.path.join(theme_path, filename)
name = filename[:-4]
try:
# Make sure the theme is valid
theme = cls.from_file(filepath)
except Exception as e:
themes['invalid'][name] = e
else:
container[name] = theme
return themes
@classmethod
def print_themes(cls, path=THEMES):
"""
Prints a human-readable summary of all of the installed themes to stdout.
This is intended to be used as a command-line utility, outside of the
main curses display loop.
"""
themes = cls.list_themes(path=path)
print('\nInstalled ({0}):'.format(path))
custom_themes = sorted(themes['custom'].items())
if custom_themes:
for name, theme in custom_themes:
print(' {0:<20}[requires {1} colors]'.format(
name, theme.required_colors))
else:
print(' (empty)')
print('\nBuilt-in:')
default_themes = sorted(themes['default'].items())
for name, theme in default_themes:
print(' {0:<20}[requires {1} colors]'.format(
name, theme.required_colors))
invalid_themes = sorted(themes['invalid'].items())
if invalid_themes:
print('\nWARNING: Some themes had problems loading:')
for name, error in invalid_themes:
print(' {0:<20}{1!r}'.format(name, error))
print('')
@classmethod
def from_name(cls, name, monochrome=False, path=THEMES):
"""
Search for the given theme on the filesystem and attempt to load it.
Directories will be checked in a pre-determined order. If the name is
provided as an absolute file path, it will be loaded directly.
"""
filenames = [
name,
os.path.join(path, '{0}.cfg'.format(name)),
os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))]
for filename in filenames:
if os.path.isfile(filename):
return cls.from_file(filename, monochrome)
raise ConfigError('Could not find theme named "{0}"'.format(name))
@classmethod
def from_file(cls, filename, monochrome=False):
"""
Load a theme from the specified configuration file.
"""
try:
config = configparser.ConfigParser()
with codecs.open(filename, encoding='utf-8') as fp:
config.readfp(fp)
except configparser.ParsingError as e:
raise ConfigError(e.message)
if not config.has_section('theme'):
raise ConfigError(
'Error loading {0}:\n'
' missing [theme] section'.format(filename))
theme_name = os.path.basename(filename)
theme_name, _ = os.path.splitext(theme_name)
elements = {}
if config.has_section('theme'):
for element, line in config.items('theme'):
if element not in cls.DEFAULT_THEME:
# Could happen if using a new config with an older version
# of the software
continue
elements[element] = cls._parse_line(element, line, filename)
return cls(theme_name, elements, monochrome)
@classmethod
def _parse_line(cls, element, line, filename=None):
"""
Parse a single line from a theme file.
Format:
<element>: <foreground> <background> <attributes>
"""
items = line.split()
if len(items) == 2:
fg, bg, attrs = items[0], items[1], ''
elif len(items) == 3:
fg, bg, attrs = items
else:
raise ConfigError(
'Error loading {0}, invalid line:\n'
' {1} = {2}'.format(filename, element, line))
if fg.startswith('#'):
fg = cls.rgb_to_ansi(fg)
if bg.startswith('#'):
bg = cls.rgb_to_ansi(bg)
fg_code = cls.COLOR_CODES.get(fg)
if fg_code is None:
raise ConfigError(
'Error loading {0}, invalid <foreground>:\n'
' {1} = {2}'.format(filename, element, line))
bg_code = cls.COLOR_CODES.get(bg)
if bg_code is None:
raise ConfigError(
'Error loading {0}, invalid <background>:\n'
' {1} = {2}'.format(filename, element, line))
attrs_code = curses.A_NORMAL
for attr in attrs.split('+'):
attr_code = cls.ATTRIBUTE_CODES.get(attr)
if attr_code is None:
raise ConfigError(
'Error loading {0}, invalid <attributes>:\n'
' {1} = {2}'.format(filename, element, line))
attrs_code |= attr_code
return fg_code, bg_code, attrs_code
@staticmethod
def rgb_to_ansi(color):
"""
Converts hex RGB to the 6x6x6 xterm color space
Args:
color (str): RGB color string in the format "#RRGGBB"
Returns:
str: ansi color string in the format "ansi_n", where n
is between 16 and 230
Reference:
https://github.com/chadj2/bash-ui/blob/master/COLORS.md
"""
if color[0] != '#' or len(color) != 7:
return None
try:
r = round(int(color[1:3], 16) / 51.0) # Normalize between 0-5
g = round(int(color[3:5], 16) / 51.0)
b = round(int(color[5:7], 16) / 51.0)
n = 36 * r + 6 * g + b + 16
return 'ansi_{0}'.format(n)
except ValueError:
return None

47
rtv/themes/default.cfg Normal file
View File

@@ -0,0 +1,47 @@
# RTV theme
[theme]
;<element> = <foreground> <background> <attributes>
bar_level_1 = magenta default
bar_level_2 = cyan default
bar_level_3 = green default
bar_level_4 = yellow default
comment_author = blue default bold
comment_author_self = green default bold
comment_count = default default
comment_text = default default
created = default default
cursor = default default reverse
default = default default
downvote = red default bold
gold = yellow default bold
help_bar = cyan default bold+reverse
hidden_comment_expand = default default bold
hidden_comment_text = default default
multireddit_name = yellow default bold
multireddit_text = default default
neutral_vote = default default bold
notice_info = default default
notice_loading = default default
notice_error = red default
notice_success = green default
nsfw = red default bold
order_bar = yellow default bold
order_selected = yellow default bold+reverse
prompt = cyan default bold+reverse
saved = green default
score = default default
separator = default default bold
stickied = green default
subscription_name = yellow default bold
subscription_text = default default
submission_author = green default
submission_flair = red default
submission_subreddit = yellow default
submission_text = default default
submission_title = default default bold
title_bar = cyan default bold+reverse
upvote = green default bold
url = blue default underline
url_seen = magenta default underline
user_flair = yellow default bold

48
rtv/themes/monochrome.cfg Normal file
View File

@@ -0,0 +1,48 @@
# RTV theme
[theme]
;<element> = <foreground> <background> <attributes>
bar_level_1 = default default
bar_level_2 = default default
bar_level_3 = default default
bar_level_4 = default default
comment_author = default default bold
comment_author_self = default default bold
comment_count = default default
comment_text = default default
created = default default
cursor = default default reverse
default = default default
downvote = default default bold
gold = default default bold
help_bar = default default bold+reverse
hidden_comment_expand = default default bold
hidden_comment_text = default default
multireddit_name = default default bold
multireddit_text = default default
neutral_vote = default default bold
notice_info = default default
notice_loading = default default
notice_error = default default
notice_success = default default
nsfw = default default bold
order_bar = default default bold
order_selected = default default bold+reverse
prompt = default default bold+reverse
saved = default default
score = default default
separator = default default bold
stickied = default default
subscription_name = default default bold
subscription_text = default default
submission_author = default default
submission_flair = default default bold
submission_subreddit = default default
submission_text = default default
submission_title = default default bold
title_bar = default default bold+reverse
upvote = default default bold
url = default default underline
url_seen = default default underline
user_flair = default default bold

View File

@@ -0,0 +1,65 @@
# http://ethanschoonover.com/solarized
# base3 ansi_230
# base2 ansi_254
# base1 ansi_245 (optional emphasized content)
# base0 ansi_244 (body text / primary content)
# base00 ansi_241
# base01 ansi_240 (comments / secondary content)
# base02 ansi_235 (background highlights)
# base03 ansi_234 (background)
# yellow ansi_136
# orange ansi_166
# red ansi_160
# magenta ansi_125
# violet ansi_61
# blue ansi_33
# cyan ansi_37
# green ansi_64
[theme]
;<element> = <foreground> <background> <attributes>
bar_level_1 = ansi_125 ansi_234
bar_level_2 = ansi_160 ansi_234
bar_level_3 = ansi_61 ansi_234
bar_level_4 = ansi_37 ansi_234
comment_author = ansi_33 ansi_234 bold
comment_author_self = ansi_64 ansi_234 bold
comment_count = ansi_244 ansi_234
comment_text = ansi_244 ansi_234
created = ansi_244 ansi_234
cursor = ansi_244 ansi_234 reverse
default = ansi_244 ansi_234
downvote = ansi_160 ansi_234 bold
gold = ansi_136 ansi_234 bold
help_bar = ansi_37 ansi_234 bold+reverse
hidden_comment_expand = ansi_240 ansi_234 bold
hidden_comment_text = ansi_240 ansi_234
multireddit_name = ansi_245 ansi_234 bold
multireddit_text = ansi_240 ansi_234
neutral_vote = ansi_244 ansi_234 bold
notice_info = ansi_244 ansi_234 bold
notice_loading = ansi_244 ansi_234 bold
notice_error = ansi_160 ansi_234 bold
notice_success = ansi_64 ansi_234 bold
nsfw = ansi_125 ansi_234 bold+reverse
order_bar = ansi_240 ansi_235 bold
order_selected = ansi_240 ansi_235 bold+reverse
prompt = ansi_33 ansi_234 bold+reverse
saved = ansi_125 ansi_234
score = ansi_244 ansi_234
separator = ansi_244 ansi_234 bold
stickied = ansi_136 ansi_234
subscription_name = ansi_245 ansi_234 bold
subscription_text = ansi_240 ansi_234
submission_author = ansi_64 ansi_234 bold
submission_flair = ansi_160 ansi_234
submission_subreddit = ansi_166 ansi_234
submission_text = ansi_244 ansi_234
submission_title = ansi_245 ansi_234 bold
title_bar = ansi_37 ansi_234 bold+reverse
upvote = ansi_64 ansi_234 bold
url = ansi_33 ansi_234 underline
url_seen = ansi_61 ansi_234 underline
user_flair = ansi_136 ansi_234 bold

View File

@@ -0,0 +1,65 @@
# http://ethanschoonover.com/solarized
# base03 ansi_234
# base02 ansi_235
# base01 ansi_240 (optional emphasized content)
# base00 ansi_241 (body text / primary content)
# base0 ansi_244
# base1 ansi_245 (comments / secondary content)
# base2 ansi_254 (background highlights)
# base3 ansi_230 (background)
# yellow ansi_136
# orange ansi_166
# red ansi_160
# magenta ansi_125
# violet ansi_61
# blue ansi_33
# cyan ansi_37
# green ansi_64
[theme]
;<element> = <foreground> <background> <attributes>
bar_level_1 = ansi_125 ansi_230
bar_level_2 = ansi_160 ansi_230
bar_level_3 = ansi_61 ansi_230
bar_level_4 = ansi_37 ansi_230
comment_author = ansi_33 ansi_230 bold
comment_author_self = ansi_64 ansi_230 bold
comment_count = ansi_241 ansi_230
comment_text = ansi_241 ansi_230
created = ansi_241 ansi_230
cursor = ansi_244 ansi_230 reverse
default = ansi_241 ansi_230
downvote = ansi_160 ansi_230 bold
gold = ansi_136 ansi_230 bold
help_bar = ansi_37 ansi_230 bold+reverse
hidden_comment_expand = ansi_245 ansi_230 bold
hidden_comment_text = ansi_245 ansi_230
multireddit_name = ansi_240 ansi_230 bold
multireddit_text = ansi_245 ansi_230
neutral_vote = ansi_241 ansi_230 bold
notice_info = ansi_241 ansi_230 bold
notice_loading = ansi_241 ansi_230 bold
notice_error = ansi_160 ansi_230 bold
notice_success = ansi_64 ansi_230 bold
nsfw = ansi_125 ansi_230 bold+reverse
order_bar = ansi_245 ansi_254 bold
order_selected = ansi_245 ansi_254 bold+reverse
prompt = ansi_33 ansi_230 bold+reverse
saved = ansi_125 ansi_230
score = ansi_241 ansi_230
separator = ansi_241 ansi_230 bold
stickied = ansi_136 ansi_230
subscription_name = ansi_240 ansi_230 bold
subscription_text = ansi_245 ansi_230
submission_author = ansi_64 ansi_230 bold
submission_flair = ansi_160 ansi_230
submission_subreddit = ansi_166 ansi_230
submission_text = ansi_241 ansi_230
submission_title = ansi_240 ansi_230 bold
title_bar = ansi_37 ansi_230 bold+reverse
upvote = ansi_64 ansi_230 bold
url = ansi_33 ansi_230 underline
url_seen = ansi_61 ansi_230 underline
user_flair = ansi_136 ansi_230 bold

File diff suppressed because it is too large Load Diff

270
scripts/demo_theme.py Executable file
View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from __future__ import print_function
import os
import sys
import time
import curses
import threading
from types import MethodType
from collections import Counter
from vcr import VCR
from six.moves.urllib.parse import urlparse, parse_qs
from rtv.theme import Theme
from rtv.config import Config
from rtv.packages import praw
from rtv.oauth import OAuthHelper
from rtv.terminal import Terminal
from rtv.objects import curses_session
from rtv.subreddit_page import SubredditPage
from rtv.submission_page import SubmissionPage
from rtv.subscription_page import SubscriptionPage
try:
from unittest import mock
except ImportError:
import mock
def initialize_vcr():
def auth_matcher(r1, r2):
return (r1.headers.get('authorization') ==
r2.headers.get('authorization'))
def uri_with_query_matcher(r1, r2):
p1, p2 = urlparse(r1.uri), urlparse(r2.uri)
return (p1[:3] == p2[:3] and
parse_qs(p1.query, True) == parse_qs(p2.query, True))
cassette_dir = os.path.join(os.path.dirname(__file__), 'cassettes')
if not os.path.exists(cassette_dir):
os.makedirs(cassette_dir)
filename = os.path.join(cassette_dir, 'demo_theme.yaml')
if os.path.exists(filename):
record_mode = 'none'
else:
record_mode = 'once'
vcr = VCR(
record_mode=record_mode,
filter_headers=[('Authorization', '**********')],
filter_post_data_parameters=[('refresh_token', '**********')],
match_on=['method', 'uri_with_query', 'auth', 'body'],
cassette_library_dir=cassette_dir)
vcr.register_matcher('auth', auth_matcher)
vcr.register_matcher('uri_with_query', uri_with_query_matcher)
return vcr
# Patch the getch method so we can display multiple notifications or
# other elements that require a keyboard input on the screen at the
# same time without blocking the main thread.
def notification_getch(self):
if self.pause_getch:
return -1
return 0
def prompt_getch(self):
while self.pause_getch:
time.sleep(1)
return 0
def draw_screen(stdscr, reddit, config, theme, oauth):
threads = []
max_y, max_x = stdscr.getmaxyx()
mid_x = int(max_x / 2)
tall_y, short_y = int(max_y / 3 * 2), int(max_y / 3)
stdscr.clear()
stdscr.refresh()
# ===================================================================
# Submission Page
# ===================================================================
win1 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, 0)
term = Terminal(win1, config, theme)
oauth.term = term
url = 'https://www.reddit.com/r/Python/comments/4dy7xr'
with term.loader('Loading'):
page = SubmissionPage(reddit, term, config, oauth, url=url)
# Tweak the data in order to demonstrate the full range of settings
data = page.content.get(-1)
data['object'].link_flair_text = 'flair'
data['object'].guilded = 1
data['object'].over_18 = True
data['object'].saved = True
data.update(page.content.strip_praw_submission(data['object']))
data = page.content.get(0)
data['object'].author.name = 'kafoozalum'
data['object'].stickied = True
data['object'].author_flair_text = 'flair'
data['object'].likes = True
data.update(page.content.strip_praw_comment(data['object']))
data = page.content.get(1)
data['object'].saved = True
data['object'].likes = False
data['object'].score_hidden = True
data['object'].guilded = 1
data.update(page.content.strip_praw_comment(data['object']))
data = page.content.get(2)
data['object'].author.name = 'kafoozalum'
data['object'].body = data['object'].body[:100]
data.update(page.content.strip_praw_comment(data['object']))
page.content.toggle(9)
page.content.toggle(5)
page.draw()
# ===================================================================
# Subreddit Page
# ===================================================================
win2 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, mid_x + 1)
term = Terminal(win2, config, theme)
oauth.term = term
with term.loader('Loading'):
page = SubredditPage(reddit, term, config, oauth, '/u/saved')
# Tweak the data in order to demonstrate the full range of settings
data = page.content.get(3)
data['object'].hide_score = True
data['object'].author = None
data['object'].saved = False
data.update(page.content.strip_praw_submission(data['object']))
page.content.order = 'rising'
page.nav.cursor_index = 1
page.draw()
term.pause_getch = True
term.getch = MethodType(notification_getch, term)
thread = threading.Thread(target=term.show_notification,
args=('Success',),
kwargs={'style': 'success'})
thread.start()
threads.append((thread, term))
# ===================================================================
# Subscription Page
# ===================================================================
win3 = stdscr.derwin(short_y, mid_x - 1, tall_y, 0)
term = Terminal(win3, config, theme)
oauth.term = term
with term.loader('Loading'):
page = SubscriptionPage(reddit, term, config, oauth, 'popular')
page.nav.cursor_index = 1
page.draw()
term.pause_getch = True
term.getch = MethodType(notification_getch, term)
thread = threading.Thread(target=term.show_notification,
args=('Error',),
kwargs={'style': 'error'})
thread.start()
threads.append((thread, term))
# ===================================================================
# Multireddit Page
# ===================================================================
win4 = stdscr.derwin(short_y, mid_x - 1, tall_y, mid_x + 1)
term = Terminal(win4, config, theme)
oauth.term = term
with term.loader('Loading'):
page = SubscriptionPage(reddit, term, config, oauth, 'multireddit')
page.nav.cursor_index = 1
page.draw()
term.pause_getch = True
term.getch = MethodType(notification_getch, term)
thread = threading.Thread(target=term.show_notification,
args=('Info',),
kwargs={'style': 'info'})
thread.start()
threads.append((thread, term))
term = Terminal(win4, config, theme)
term.pause_getch = True
term.getch = MethodType(prompt_getch, term)
thread = threading.Thread(target=term.prompt_y_or_n, args=('Prompt: ',))
thread.start()
threads.append((thread, term))
time.sleep(0.5)
curses.curs_set(0)
return threads
def main():
if len(sys.argv) > 1:
theme_name = sys.argv[1]
else:
theme_name = 'default'
themes = Theme.list_themes()
default_themes = sorted(themes['default'].keys())
vcr = initialize_vcr()
with vcr.use_cassette('demo_theme.yaml') as cassette, \
curses_session() as stdscr:
config = Config()
if vcr.record_mode == 'once':
config.load_refresh_token()
else:
config.refresh_token = 'mock_refresh_token'
reddit = praw.Reddit(user_agent='RTV Theme Demo',
decode_html_entities=False,
disable_update_check=True)
config.history.add('https://api.reddit.com/comments/6llvsl/_/djutc3s')
config.history.add('http://i.imgur.com/Z9iGKWv.gifv')
config.history.add('https://www.reddit.com/r/Python/comments/6302cj/rpython_official_job_board/')
term = Terminal(stdscr, config)
oauth = OAuthHelper(reddit, term, config)
oauth.authorize()
while True:
theme = Theme.from_name(theme_name)
term = Terminal(stdscr, config, theme=theme)
threads = draw_screen(stdscr, reddit, config, theme, oauth)
try:
ch = term.show_notification(theme_name)
except KeyboardInterrupt:
ch = Terminal.ESCAPE
for thread, term in threads:
term.pause_getch = False
thread.join()
if vcr.record_mode == 'once':
break
else:
cassette.play_counts = Counter()
if ch == curses.KEY_RIGHT:
i = (default_themes.index(theme_name) + 1)
theme_name = default_themes[i % len(default_themes)]
elif ch == curses.KEY_LEFT:
i = (default_themes.index(theme_name) - 1)
theme_name = default_themes[i % len(default_themes)]
elif ch == Terminal.ESCAPE:
break
sys.exit(main())

View File

@@ -224,4 +224,4 @@ def test_config_history():
config.delete_history()
assert len(config.history) == 0
assert not os.path.exists(fp.name)
assert not os.path.exists(fp.name)

191
tests/test_theme.py Normal file
View File

@@ -0,0 +1,191 @@
import os
import curses
from collections import OrderedDict
from tempfile import NamedTemporaryFile
import pytest
from rtv.theme import Theme
from rtv.config import DEFAULT_THEMES
from rtv.exceptions import ConfigError
try:
from unittest import mock
except ImportError:
import mock
INVALID_ELEMENTS = OrderedDict([
('too_few_items', 'upvote = blue\n'),
('too_many_items', 'upvote = blue blue bold underline\n'),
('invalid_fg', 'upvote = invalid blue\n'),
('invalid_bg', 'upvote = blue invalid\n'),
('invalid_attr', 'upvote = blue blue bold+invalid\n'),
('invalid_hex', 'upvote = #fffff blue\n'),
('invalid_hex2', 'upvote = #gggggg blue\n'),
('out_of_range', 'upvote = ansi_256 blue\n')
])
def test_theme_construct():
theme = Theme()
assert theme.name == 'default'
assert theme.elements == Theme.DEFAULT_THEME
assert theme.required_colors == 8
assert theme.required_color_pairs == 6
theme = Theme(name='monochrome', monochrome=True)
assert theme.name == 'monochrome'
assert theme.monochrome is True
assert theme.required_colors == 0
assert theme.required_color_pairs == 0
elements = {'bar_level_1': (100, 101, curses.A_UNDERLINE)}
theme = Theme(elements=elements)
assert theme.elements['bar_level_1'] == elements['bar_level_1']
assert theme.required_colors == 256
def test_theme_default_cfg_matches_builtin():
filename = os.path.join(DEFAULT_THEMES, 'default.cfg')
default_theme = Theme.from_file(filename)
# The default theme file should match the hardcoded values
assert default_theme.elements == Theme().elements
class MockTheme(Theme):
def __init__(self, name=None, elements=None, monochrome=False):
assert name == 'default'
assert elements == Theme.DEFAULT_THEME
assert monochrome is False
# Make sure that the config file elements exactly match the defaults
MockTheme.from_file(filename)
args, ids = INVALID_ELEMENTS.values(), list(INVALID_ELEMENTS)
@pytest.mark.parametrize('line', args, ids=ids)
def test_theme_from_file_invalid(line):
with NamedTemporaryFile(mode='w+') as fp:
fp.write('[theme]\n')
fp.write(line)
fp.flush()
with pytest.raises(ConfigError):
Theme.from_file(fp.name)
def test_theme_from_file():
with NamedTemporaryFile(mode='w+') as fp:
# Needs a [theme] section
with pytest.raises(ConfigError):
Theme.from_file(fp.name)
fp.write('[theme]\n')
fp.write('unknown = neutral neutral\n')
fp.write('upvote = default red\n')
fp.write('downvote = ansi_0 ansi_255 bold\n')
fp.write('neutral_vote = #000000 #ffffff bold+reverse\n')
fp.flush()
theme = Theme.from_file(fp.name)
assert 'unknown' not in theme.elements
assert theme.elements['upvote'] == (
-1, curses.COLOR_RED, curses.A_NORMAL)
assert theme.elements['downvote'] == (
0, 255, curses.A_BOLD)
assert theme.elements['neutral_vote'] == (
16, 231, curses.A_BOLD | curses.A_REVERSE)
def test_theme_from_name():
with NamedTemporaryFile(mode='w+', suffix='.cfg') as fp:
path, filename = os.path.split(fp.name)
theme_name = filename[:-4]
fp.write('[theme]\n')
fp.write('upvote = default default\n')
fp.flush()
# Full file path
theme = Theme.from_name(fp.name, path=path)
assert theme.name == theme_name
assert theme.elements['upvote'] == (-1, -1, curses.A_NORMAL)
# Relative to the directory
theme = Theme.from_name(theme_name, path=path)
assert theme.name == theme_name
assert theme.elements['upvote'] == (-1, -1, curses.A_NORMAL)
# Invalid theme name
with pytest.raises(ConfigError, path=path):
theme.from_name('invalid_theme_name')
def test_theme_initialize_attributes(stdscr):
theme = Theme()
# Can't access elements before initializing curses
with pytest.raises(RuntimeError):
theme.get('upvote')
theme.bind_curses()
# Our pre-computed required color pairs should have been correct
assert len(theme._color_pair_map) == theme.required_color_pairs
for element in Theme.DEFAULT_THEME:
assert isinstance(theme.get(element), int)
assert theme.get_bar_level(0) == theme.get_bar_level(4)
def test_theme_initialize_attributes_monochrome(stdscr):
theme = Theme(monochrome=True)
theme.bind_curses()
# Avoid making these curses calls if colors aren't initialized
curses.init_pair.assert_not_called()
curses.color_pair.assert_not_called()
def test_theme_list_themes():
with NamedTemporaryFile(mode='w+', suffix='.cfg') as fp:
path, filename = os.path.split(fp.name)
theme_name = filename[:-4]
fp.write('[theme]\n')
fp.flush()
Theme.print_themes(path)
themes = Theme.list_themes(path)
assert themes['custom'][theme_name].name == theme_name
assert themes['default']['monochrome'].name == 'monochrome'
# This also checks that all of the default themes are valid
assert not themes['invalid']
def test_theme_list_themes_invalid():
with NamedTemporaryFile(mode='w+', suffix='.cfg') as fp:
path, filename = os.path.split(fp.name)
theme_name = filename[:-4]
fp.write('[theme]\n')
fp.write('upvote = invalid value\n')
fp.flush()
Theme.print_themes(path)
themes = Theme.list_themes(path)
assert theme_name in themes['invalid']
assert not themes['custom']