Adding theme list and enabling F2 / F3

This commit is contained in:
Michael Lazar
2017-09-12 01:48:03 -04:00
parent 9af4b8c709
commit fb2ede8df7
6 changed files with 263 additions and 108 deletions

View File

@@ -176,13 +176,15 @@ def main():
term = Terminal(stdscr, config) term = Terminal(stdscr, config)
if config['theme']: if config['monochrome']:
theme = Theme(use_color=False)
elif config['theme']:
theme = Theme.from_name(config['theme']) theme = Theme.from_name(config['theme'])
else: else:
# Set to None to let the terminal figure out which default
# theme to use depending on if colors are supported or not
theme = None theme = None
term.set_theme(theme)
term.set_theme(theme, monochrome=config['monochrome'])
with term.loader('Initializing', catch_exception=False): with term.loader('Initializing', catch_exception=False):
reddit = praw.Reddit(user_agent=user_agent, reddit = praw.Reddit(user_agent=user_agent,

View File

@@ -11,7 +11,7 @@ import six
from kitchen.text.display import textual_width from kitchen.text.display import textual_width
from . import docs from . import docs
from .theme import Theme from .theme import ThemeList
from .objects import Controller, Command from .objects import Controller, Command
from .clipboard import copy from .clipboard import copy
from .exceptions import TemporaryFileError, ProgramError from .exceptions import TemporaryFileError, ProgramError
@@ -51,6 +51,7 @@ class Page(object):
self.nav = None self.nav = None
self.controller = None self.controller = None
self.copy_to_clipboard = copy self.copy_to_clipboard = copy
self.theme_list = ThemeList()
self.active = True self.active = True
self._row = 0 self._row = 0
@@ -93,17 +94,19 @@ class Page(object):
@PageController.register(Command('PREVIOUS_THEME')) @PageController.register(Command('PREVIOUS_THEME'))
def previous_theme(self): def previous_theme(self):
theme = Theme() theme = self.theme_list.previous()
self.term.set_theme(theme) self.term.set_theme(theme)
self.draw() self.draw()
self.term.show_notification(theme.name, timeout=1) message = self.term.theme.display_string
self.term.show_notification(message, timeout=1)
@PageController.register(Command('NEXT_THEME')) @PageController.register(Command('NEXT_THEME'))
def next_theme(self): def next_theme(self):
theme = Theme() theme = self.theme_list.next()
self.term.set_theme(theme) self.term.set_theme(theme)
self.draw() self.draw()
self.term.show_notification(theme.name, timeout=1) message = self.term.theme.display_string
self.term.show_notification(message, timeout=1)
@PageController.register(Command('HELP')) @PageController.register(Command('HELP'))
def show_help(self): def show_help(self):
@@ -582,4 +585,3 @@ class Page(object):
ch = self.term.show_notification(message) ch = self.term.show_notification(message)
ch = six.unichr(ch) ch = six.unichr(ch)
return choices.get(ch) return choices.get(ch)

View File

@@ -829,7 +829,7 @@ class Terminal(object):
return self.theme.get(element) return self.theme.get(element)
def set_theme(self, theme=None, monochrome=False): def set_theme(self, theme=None):
""" """
Check that the terminal supports the provided theme, and applies Check that the terminal supports the provided theme, and applies
the theme to the terminal if possible. the theme to the terminal if possible.
@@ -837,25 +837,15 @@ class Terminal(object):
If the terminal doesn't support the theme, this falls back to the If the terminal doesn't support the theme, this falls back to the
default theme. The default theme only requires 8 colors so it default theme. The default theme only requires 8 colors so it
should be compatible with any terminal that supports basic colors. should be compatible with any terminal that supports basic colors.
Using ``monochrome=True`` will force loading the current theme
without any color support. The intention is that this be used as
a fallback for the default theme to support the old --monochrome
command line flag.
""" """
if not monochrome and curses.has_colors(): if curses.has_colors():
terminal_colors = curses.COLORS terminal_colors = curses.COLORS
else: else:
terminal_colors = 0 terminal_colors = 0
if theme is None: if theme is None:
theme = Theme() theme = Theme(use_color=bool(terminal_colors))
elif monochrome:
# No need to display a warning message if the user has
# explicitly turned off support for colors
pass
elif theme.required_color_pairs > curses.COLOR_PAIRS: elif theme.required_color_pairs > curses.COLOR_PAIRS:
_logger.warning( _logger.warning(
@@ -873,7 +863,7 @@ class Terminal(object):
curses.COLORS) curses.COLORS)
theme = Theme() theme = Theme()
theme.bind_curses(use_color=bool(terminal_colors)) theme.bind_curses()
# Apply the default color to the whole screen # Apply the default color to the whole screen
self.stdscr.bkgd(str(' '), theme.get('@normal')) self.stdscr.bkgd(str(' '), theme.get('@normal'))

View File

@@ -3,8 +3,11 @@ import codecs
import curses import curses
import logging import logging
import configparser import configparser
from collections import OrderedDict
from contextlib import contextmanager from contextlib import contextmanager
import six
from .config import THEMES, DEFAULT_THEMES from .config import THEMES, DEFAULT_THEMES
from .exceptions import ConfigError from .exceptions import ConfigError
@@ -105,15 +108,29 @@ class Theme(object):
BAR_LEVELS = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4'] BAR_LEVELS = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4']
def __init__(self, name='default', elements=None): def __init__(self, name=None, source=None, elements=None, use_color=True):
""" """
Params: Params:
name (str): A unique string that describes the theme name (str): A unique string that describes the theme
source (str): A string that describes the source of the theme:
built-in - Should only be used when Theme() is called directly
preset - Themes packaged with rtv
installed - Themes in ~/.config/rtv/themes/
custom - When a filepath is explicitly provided, e.g.
``rtv --theme=/path/to/theme_file.cfg``
elements (dict): The theme's element map, should be in the same elements (dict): The theme's element map, should be in the same
format as Theme.DEFAULT_THEME. format as Theme.DEFAULT_THEME.
""" """
if name is None and source is None:
name = 'default' if use_color else 'monochrome'
source = 'built-in'
elif name is None or source is None:
raise ValueError('Must specify both `name` and `source`, or neither one')
self.name = name self.name = name
self.source = source
self.use_color = use_color
self._color_pair_map = None self._color_pair_map = None
self._attribute_map = None self._attribute_map = None
self._modifier = None self._modifier = None
@@ -166,28 +183,33 @@ class Theme(object):
self.elements = elements self.elements = elements
# Pre-calculate how many colors / color pairs the theme will need if self.use_color:
colors, color_pairs = set(), set() # Pre-calculate how many colors / color pairs the theme will need
for fg, bg, _ in self.elements.values(): colors, color_pairs = set(), set()
colors.add(fg) for fg, bg, _ in self.elements.values():
colors.add(bg) colors.add(fg)
color_pairs.add((fg, bg)) colors.add(bg)
color_pairs.add((fg, bg))
# Don't count the default (-1, -1) as a color pair because it doesn't # Don't count the default (-1, -1) as a color pair because it doesn't
# need to be initialized by curses.init_pair(). # need to be initialized by curses.init_pair().
color_pairs.discard((-1, -1)) color_pairs.discard((-1, -1))
self.required_color_pairs = len(color_pairs) self.required_color_pairs = len(color_pairs)
# Determine how many colors the terminal needs to support in order to # Determine how many colors the terminal needs to support in order to
# be able to use the theme. This uses the common breakpoints that 99% # be able to use the theme. This uses the common breakpoints that 99%
# of terminals follow and doesn't take into account 88 color themes. # of terminals follow and doesn't take into account 88 color themes.
self.required_colors = None self.required_colors = None
for marker in [0, 8, 16, 256]: for marker in [0, 8, 16, 256]:
if max(colors) < marker: if max(colors) < marker:
self.required_colors = marker self.required_colors = marker
break break
def bind_curses(self, use_color=True): @property
def display_string(self):
return '{0} ({1})'.format(self.name, self.source)
def bind_curses(self):
""" """
Bind the theme's colors to curses's internal color pair map. Bind the theme's colors to curses's internal color pair map.
@@ -203,7 +225,7 @@ class Theme(object):
fg, bg, attrs = item fg, bg, attrs = item
color_pair = (fg, bg) color_pair = (fg, bg)
if use_color and color_pair != (-1, -1): if self.use_color and color_pair != (-1, -1):
# Curses limits the number of available color pairs, so we # Curses limits the number of available color pairs, so we
# need to reuse them if there are multiple elements with the # need to reuse them if there are multiple elements with the
# same foreground and background. # same foreground and background.
@@ -235,7 +257,20 @@ class Theme(object):
@contextmanager @contextmanager
def set_modifier(self, modifier=None): def set_modifier(self, modifier=None):
"""
Sets the active modifier inside of context block.
For example:
>>> with theme.set_modifier('highlight'):
>>> attr = theme.get('cursor')
Is the same as:
>>> attr = theme.get('cursor', modifier='highlight')
Is also the same as:
>>> attr = theme.get('cursor.highlight')
"""
# This case is undefined if the context manager is nested # This case is undefined if the context manager is nested
assert self._modifier is None assert self._modifier is None
@@ -250,28 +285,32 @@ class Theme(object):
""" """
Compile all of the themes configuration files in the search path. Compile all of the themes configuration files in the search path.
""" """
themes, errors = [], OrderedDict()
themes = {'invalid': {}, 'custom': {}, 'default': {}} def load_themes(path, source):
for container, theme_path in [ """
(themes['custom'], path), Load all themes in the given path.
(themes['default'], DEFAULT_THEMES)]: """
if os.path.isdir(path):
if os.path.isdir(theme_path): for filename in sorted(os.listdir(path)):
for filename in os.listdir(theme_path):
if not filename.endswith('.cfg'): if not filename.endswith('.cfg'):
continue continue
filepath = os.path.join(theme_path, filename) filepath = os.path.join(path, filename)
name = filename[:-4] name = filename[:-4]
try: try:
# Make sure the theme is valid # Make sure the theme is valid
theme = cls.from_file(filepath) theme = cls.from_file(filepath, source)
except Exception as e: except Exception as e:
themes['invalid'][name] = e errors[(source, name)] = e
else: else:
container[name] = theme themes.append(theme)
return themes themes.extend([Theme(use_color=True), Theme(use_color=False)])
load_themes(DEFAULT_THEMES, 'preset')
load_themes(path, 'installed')
return themes, errors
@classmethod @classmethod
def print_themes(cls, path=THEMES): def print_themes(cls, path=THEMES):
@@ -281,28 +320,30 @@ class Theme(object):
This is intended to be used as a command-line utility, outside of the This is intended to be used as a command-line utility, outside of the
main curses display loop. main curses display loop.
""" """
themes = cls.list_themes(path=path) themes, errors = cls.list_themes(path=path)
print('\nInstalled ({0}):'.format(path)) print('\nInstalled ({0}):'.format(path))
custom_themes = sorted(themes['custom'].items()) installed = [t for t in themes if t.source == 'installed']
if custom_themes: if installed:
for name, theme in custom_themes: for theme in installed:
print(' {0:<20}[requires {1} colors]'.format( line = ' {0:<20}[requires {1} colors]'
name, theme.required_colors)) print(line.format(theme.name, theme.required_colors))
else: else:
print(' (empty)') print(' (empty)')
print('\nBuilt-in:') print('\nPresets:')
default_themes = sorted(themes['default'].items()) preset = [t for t in themes if t.source == 'preset']
for name, theme in default_themes: for theme in preset:
print(' {0:<20}[requires {1} colors]'.format( line = ' {0:<20}[requires {1} colors]'
name, theme.required_colors)) print(line.format(theme.name, theme.required_colors))
invalid_themes = sorted(themes['invalid'].items()) if errors:
if invalid_themes: print('\nWARNING: Some files encountered errors:')
print('\nWARNING: Some themes had problems loading:') for (source, name), error in errors.items():
for name, error in invalid_themes: theme_info = '({0}) {1}'.format(source, name)
print(' {0:<20}{1!r}'.format(name, error)) # Align multi-line error messages with the right column
err_message = six.text_type(error).replace('\n', '\n' + ' ' * 20)
print(' {0:<20}{1}'.format(theme_info, err_message))
print('') print('')
@@ -315,21 +356,27 @@ class Theme(object):
provided as an absolute file path, it will be loaded directly. provided as an absolute file path, it will be loaded directly.
""" """
filenames = [ if os.path.isfile(name):
name, return cls.from_file(name, 'custom')
os.path.join(path, '{0}.cfg'.format(name)),
os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))]
for filename in filenames: filename = os.path.join(path, '{0}.cfg'.format(name))
if os.path.isfile(filename): if os.path.isfile(filename):
return cls.from_file(filename) return cls.from_file(name, 'installed')
filename = os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))
if os.path.isfile(filename):
return cls.from_file(name, 'preset')
raise ConfigError('Could not find theme named "{0}"'.format(name)) raise ConfigError('Could not find theme named "{0}"'.format(name))
@classmethod @classmethod
def from_file(cls, filename): def from_file(cls, filename, source):
""" """
Load a theme from the specified configuration file. Load a theme from the specified configuration file.
Parameters:
filename: The name of the filename to load.
source: A description of where the theme was loaded from.
""" """
try: try:
@@ -348,15 +395,14 @@ class Theme(object):
theme_name, _ = os.path.splitext(theme_name) theme_name, _ = os.path.splitext(theme_name)
elements = {} elements = {}
if config.has_section('theme'): for element, line in config.items('theme'):
for element, line in config.items('theme'): if element not in cls.DEFAULT_THEME:
if element not in cls.DEFAULT_THEME: # Could happen if using a new config with an older version
# Could happen if using a new config with an older version # of the software
# of the software continue
continue elements[element] = cls._parse_line(element, line, filename)
elements[element] = cls._parse_line(element, line, filename)
return cls(theme_name, elements) return cls(name=theme_name, source=source, elements=elements)
@classmethod @classmethod
def _parse_line(cls, element, line, filename=None): def _parse_line(cls, element, line, filename=None):
@@ -407,6 +453,11 @@ class Theme(object):
else: else:
attrs_code |= attr_code attrs_code |= attr_code
if element.startswith('@') and None in (fg_code, bg_code, attrs_code):
raise ConfigError(
'Error loading {0}, {1} cannot have unspecified attributes:\n'
' {1} = {2}'.format(filename, element, line))
return fg_code, bg_code, attrs_code return fg_code, bg_code, attrs_code
@staticmethod @staticmethod
@@ -437,3 +488,56 @@ class Theme(object):
except ValueError: except ValueError:
return None return None
class ThemeList(object):
"""
This is a small container around Theme.list_themes() that can be used
to cycle through all of the available themes.
"""
def __init__(self, current_theme=None):
self.index = 0
self.current_theme = current_theme
self.themes = None
self.errors = None
def load(self):
"""
This acts as a lazy load, it won't read all of the theme files from
disk until the first time somebody tries to access the theme list.
"""
self.themes, self.errors = Theme.list_themes()
if self.current_theme is not None:
# Try to find the starting index
key = (self.current_theme.source, self.current_theme.name)
for i, theme in enumerate(self.themes):
if (theme.source, theme.name) == key:
self.index = i
break
self.current_theme = self.themes[self.index]
def next(self):
"""
Retrieve the next theme in the list
"""
if not self.themes:
self.load()
self.index = (self.index + 1) % len(self.themes)
self.current_theme = self.themes[self.index]
return self.current_theme
def previous(self):
"""
Retrieve the previous theme in the list
"""
if not self.themes:
self.load()
self.index = (self.index - 1) % len(self.themes)
self.current_theme = self.themes[self.index]
return self.current_theme

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

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

View File

@@ -14,7 +14,7 @@ from collections import Counter
from vcr import VCR from vcr import VCR
from six.moves.urllib.parse import urlparse, parse_qs from six.moves.urllib.parse import urlparse, parse_qs
from rtv.theme import Theme from rtv.theme import ThemeList
from rtv.config import Config from rtv.config import Config
from rtv.packages import praw from rtv.packages import praw
from rtv.oauth import OAuthHelper from rtv.oauth import OAuthHelper
@@ -91,7 +91,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth):
# Submission Page # Submission Page
# =================================================================== # ===================================================================
win1 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, 0) win1 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, 0)
term = Terminal(win1, config, theme) term = Terminal(win1, config)
term.set_theme(theme)
oauth.term = term oauth.term = term
url = 'https://www.reddit.com/r/Python/comments/4dy7xr' url = 'https://www.reddit.com/r/Python/comments/4dy7xr'
@@ -129,7 +130,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth):
# Subreddit Page # Subreddit Page
# =================================================================== # ===================================================================
win2 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, mid_x + 1) win2 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, mid_x + 1)
term = Terminal(win2, config, theme) term = Terminal(win2, config)
term.set_theme(theme)
oauth.term = term oauth.term = term
with term.loader('Loading'): with term.loader('Loading'):
@@ -157,7 +159,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth):
# Subscription Page # Subscription Page
# =================================================================== # ===================================================================
win3 = stdscr.derwin(short_y, mid_x - 1, tall_y, 0) win3 = stdscr.derwin(short_y, mid_x - 1, tall_y, 0)
term = Terminal(win3, config, theme) term = Terminal(win3, config)
term.set_theme(theme)
oauth.term = term oauth.term = term
with term.loader('Loading'): with term.loader('Loading'):
@@ -177,7 +180,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth):
# Multireddit Page # Multireddit Page
# =================================================================== # ===================================================================
win4 = stdscr.derwin(short_y, mid_x - 1, tall_y, mid_x + 1) win4 = stdscr.derwin(short_y, mid_x - 1, tall_y, mid_x + 1)
term = Terminal(win4, config, theme) term = Terminal(win4, config)
term.set_theme(theme)
oauth.term = term oauth.term = term
with term.loader('Loading'): with term.loader('Loading'):
@@ -193,7 +197,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth):
thread.start() thread.start()
threads.append((thread, term)) threads.append((thread, term))
term = Terminal(win4, config, theme) term = Terminal(win4, config)
term.set_theme(theme)
term.pause_getch = True term.pause_getch = True
term.getch = MethodType(prompt_getch, term) term.getch = MethodType(prompt_getch, term)
thread = threading.Thread(target=term.prompt_y_or_n, args=('Prompt: ',)) thread = threading.Thread(target=term.prompt_y_or_n, args=('Prompt: ',))
@@ -208,12 +213,13 @@ def draw_screen(stdscr, reddit, config, theme, oauth):
def main(): def main():
if len(sys.argv) > 1: if len(sys.argv) > 1:
theme_name = sys.argv[1] current_theme = sys.argv[1]
else: else:
theme_name = 'default' current_theme = None
themes = Theme.list_themes() theme_list = ThemeList(current_theme)
default_themes = sorted(themes['default'].keys()) theme_list.load()
theme = theme_list.current_theme
vcr = initialize_vcr() vcr = initialize_vcr()
with vcr.use_cassette('demo_theme.yaml') as cassette, \ with vcr.use_cassette('demo_theme.yaml') as cassette, \
@@ -234,17 +240,18 @@ def main():
config.history.add('https://www.reddit.com/r/Python/comments/6302cj/rpython_official_job_board/') config.history.add('https://www.reddit.com/r/Python/comments/6302cj/rpython_official_job_board/')
term = Terminal(stdscr, config) term = Terminal(stdscr, config)
term.set_theme()
oauth = OAuthHelper(reddit, term, config) oauth = OAuthHelper(reddit, term, config)
oauth.authorize() oauth.authorize()
while True: while True:
theme = Theme.from_name(theme_name) term = Terminal(stdscr, config)
term = Terminal(stdscr, config, theme=theme) term.set_theme(theme)
threads = draw_screen(stdscr, reddit, config, theme, oauth) threads = draw_screen(stdscr, reddit, config, theme, oauth)
try: try:
ch = term.show_notification(theme_name) ch = term.show_notification(theme.display_string)
except KeyboardInterrupt: except KeyboardInterrupt:
ch = Terminal.ESCAPE ch = Terminal.ESCAPE
@@ -258,11 +265,9 @@ def main():
cassette.play_counts = Counter() cassette.play_counts = Counter()
if ch == curses.KEY_RIGHT: if ch == curses.KEY_RIGHT:
i = (default_themes.index(theme_name) + 1) theme = theme_list.next()
theme_name = default_themes[i % len(default_themes)]
elif ch == curses.KEY_LEFT: elif ch == curses.KEY_LEFT:
i = (default_themes.index(theme_name) - 1) theme = theme_list.previous()
theme_name = default_themes[i % len(default_themes)]
elif ch == Terminal.ESCAPE: elif ch == Terminal.ESCAPE:
break break