Working on theme support
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
42
rtv/page.py
42
rtv/page.py
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
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']:
|
||||
attr = self.term.attr('submission_flair')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||
self.term.add_line(win, ' {created} {subreddit}'.format(**data))
|
||||
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
113
rtv/terminal.py
113
rtv/terminal.py
@@ -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
370
rtv/theme.py
Normal 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
47
rtv/themes/default.cfg
Normal 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
48
rtv/themes/monochrome.cfg
Normal 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
|
||||
65
rtv/themes/solarized-dark.cfg
Normal file
65
rtv/themes/solarized-dark.cfg
Normal 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
|
||||
65
rtv/themes/solarized-light.cfg
Normal file
65
rtv/themes/solarized-light.cfg
Normal 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
|
||||
6363
scripts/cassettes/demo_theme.yaml
Normal file
6363
scripts/cassettes/demo_theme.yaml
Normal file
File diff suppressed because it is too large
Load Diff
270
scripts/demo_theme.py
Executable file
270
scripts/demo_theme.py
Executable 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())
|
||||
191
tests/test_theme.py
Normal file
191
tests/test_theme.py
Normal 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']
|
||||
Reference in New Issue
Block a user