diff --git a/rtv/__main__.py b/rtv/__main__.py index 9a9a575..50d6dd2 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -176,13 +176,15 @@ def main(): term = Terminal(stdscr, config) - if config['theme']: + if config['monochrome']: + theme = Theme(use_color=False) + elif config['theme']: theme = Theme.from_name(config['theme']) 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 - - term.set_theme(theme, monochrome=config['monochrome']) - + term.set_theme(theme) with term.loader('Initializing', catch_exception=False): reddit = praw.Reddit(user_agent=user_agent, diff --git a/rtv/page.py b/rtv/page.py index 03df37e..55f3891 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -11,7 +11,7 @@ import six from kitchen.text.display import textual_width from . import docs -from .theme import Theme +from .theme import ThemeList from .objects import Controller, Command from .clipboard import copy from .exceptions import TemporaryFileError, ProgramError @@ -51,6 +51,7 @@ class Page(object): self.nav = None self.controller = None self.copy_to_clipboard = copy + self.theme_list = ThemeList() self.active = True self._row = 0 @@ -93,17 +94,19 @@ class Page(object): @PageController.register(Command('PREVIOUS_THEME')) def previous_theme(self): - theme = Theme() + theme = self.theme_list.previous() self.term.set_theme(theme) 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')) def next_theme(self): - theme = Theme() + theme = self.theme_list.next() self.term.set_theme(theme) 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')) def show_help(self): @@ -582,4 +585,3 @@ class Page(object): ch = self.term.show_notification(message) ch = six.unichr(ch) return choices.get(ch) - diff --git a/rtv/terminal.py b/rtv/terminal.py index dd8641f..4dabc94 100644 --- a/rtv/terminal.py +++ b/rtv/terminal.py @@ -829,7 +829,7 @@ class Terminal(object): 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 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 default theme. The default theme only requires 8 colors so it 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 else: terminal_colors = 0 if theme is None: - theme = Theme() - - elif monochrome: - # No need to display a warning message if the user has - # explicitly turned off support for colors - pass + theme = Theme(use_color=bool(terminal_colors)) elif theme.required_color_pairs > curses.COLOR_PAIRS: _logger.warning( @@ -873,7 +863,7 @@ class Terminal(object): curses.COLORS) theme = Theme() - theme.bind_curses(use_color=bool(terminal_colors)) + theme.bind_curses() # Apply the default color to the whole screen self.stdscr.bkgd(str(' '), theme.get('@normal')) diff --git a/rtv/theme.py b/rtv/theme.py index f59c6fd..513a193 100644 --- a/rtv/theme.py +++ b/rtv/theme.py @@ -3,8 +3,11 @@ import codecs import curses import logging import configparser +from collections import OrderedDict from contextlib import contextmanager +import six + from .config import THEMES, DEFAULT_THEMES 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'] - def __init__(self, name='default', elements=None): + def __init__(self, name=None, source=None, elements=None, use_color=True): """ 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 - 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.source = source + self.use_color = use_color self._color_pair_map = None self._attribute_map = None self._modifier = None @@ -166,28 +183,33 @@ class Theme(object): self.elements = elements - # Pre-calculate how many colors / color pairs the theme will need - colors, color_pairs = set(), set() - for fg, bg, _ in self.elements.values(): - colors.add(fg) - colors.add(bg) - color_pairs.add((fg, bg)) + if self.use_color: + # Pre-calculate how many colors / color pairs the theme will need + 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 (-1, -1) as a color pair because it doesn't - # need to be initialized by curses.init_pair(). - color_pairs.discard((-1, -1)) - self.required_color_pairs = len(color_pairs) + # Don't count the default (-1, -1) as a color pair because it doesn't + # need to be initialized by curses.init_pair(). + color_pairs.discard((-1, -1)) + self.required_color_pairs = len(color_pairs) - # 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% - # of terminals follow and doesn't take into account 88 color themes. - self.required_colors = None - for marker in [0, 8, 16, 256]: - if max(colors) < marker: - self.required_colors = marker - break + # 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% + # of terminals follow and doesn't take into account 88 color themes. + self.required_colors = None + for marker in [0, 8, 16, 256]: + if max(colors) < marker: + self.required_colors = marker + 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. @@ -203,7 +225,7 @@ class Theme(object): fg, bg, attrs = item 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 # need to reuse them if there are multiple elements with the # same foreground and background. @@ -235,7 +257,20 @@ class Theme(object): @contextmanager 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 assert self._modifier is None @@ -250,28 +285,32 @@ class Theme(object): """ Compile all of the themes configuration files in the search path. """ + themes, errors = [], OrderedDict() - 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): + def load_themes(path, source): + """ + Load all themes in the given path. + """ + if os.path.isdir(path): + for filename in sorted(os.listdir(path)): if not filename.endswith('.cfg'): continue - filepath = os.path.join(theme_path, filename) + filepath = os.path.join(path, filename) name = filename[:-4] try: # Make sure the theme is valid - theme = cls.from_file(filepath) + theme = cls.from_file(filepath, source) except Exception as e: - themes['invalid'][name] = e + errors[(source, name)] = e 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 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 main curses display loop. """ - themes = cls.list_themes(path=path) + themes, errors = 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)) + installed = [t for t in themes if t.source == 'installed'] + if installed: + for theme in installed: + line = ' {0:<20}[requires {1} colors]' + print(line.format(theme.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)) + print('\nPresets:') + preset = [t for t in themes if t.source == 'preset'] + for theme in preset: + line = ' {0:<20}[requires {1} colors]' + print(line.format(theme.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)) + if errors: + print('\nWARNING: Some files encountered errors:') + for (source, name), error in errors.items(): + theme_info = '({0}) {1}'.format(source, name) + # 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('') @@ -315,21 +356,27 @@ class Theme(object): 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))] + if os.path.isfile(name): + return cls.from_file(name, 'custom') - for filename in filenames: - if os.path.isfile(filename): - return cls.from_file(filename) + filename = os.path.join(path, '{0}.cfg'.format(name)) + if os.path.isfile(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)) @classmethod - def from_file(cls, filename): + def from_file(cls, filename, source): """ 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: @@ -348,15 +395,14 @@ class Theme(object): 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) + 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) + return cls(name=theme_name, source=source, elements=elements) @classmethod def _parse_line(cls, element, line, filename=None): @@ -407,6 +453,11 @@ class Theme(object): else: 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 @staticmethod @@ -437,3 +488,56 @@ class Theme(object): except ValueError: 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 diff --git a/rtv/themes/default.cfg b/rtv/themes/default.cfg new file mode 100644 index 0000000..4fd47a6 --- /dev/null +++ b/rtv/themes/default.cfg @@ -0,0 +1,52 @@ +[theme] +; = +@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 diff --git a/scripts/demo_theme.py b/scripts/demo_theme.py index 0cf8ee5..e91fa83 100755 --- a/scripts/demo_theme.py +++ b/scripts/demo_theme.py @@ -14,7 +14,7 @@ from collections import Counter from vcr import VCR 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.packages import praw from rtv.oauth import OAuthHelper @@ -91,7 +91,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth): # Submission Page # =================================================================== 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 url = 'https://www.reddit.com/r/Python/comments/4dy7xr' @@ -129,7 +130,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth): # Subreddit Page # =================================================================== 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 with term.loader('Loading'): @@ -157,7 +159,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth): # Subscription Page # =================================================================== 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 with term.loader('Loading'): @@ -177,7 +180,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth): # Multireddit Page # =================================================================== 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 with term.loader('Loading'): @@ -193,7 +197,8 @@ def draw_screen(stdscr, reddit, config, theme, oauth): thread.start() threads.append((thread, term)) - term = Terminal(win4, config, theme) + term = Terminal(win4, config) + term.set_theme(theme) term.pause_getch = True term.getch = MethodType(prompt_getch, term) 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(): if len(sys.argv) > 1: - theme_name = sys.argv[1] + current_theme = sys.argv[1] else: - theme_name = 'default' + current_theme = None - themes = Theme.list_themes() - default_themes = sorted(themes['default'].keys()) + theme_list = ThemeList(current_theme) + theme_list.load() + theme = theme_list.current_theme vcr = initialize_vcr() 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/') term = Terminal(stdscr, config) + term.set_theme() oauth = OAuthHelper(reddit, term, config) oauth.authorize() while True: - theme = Theme.from_name(theme_name) - term = Terminal(stdscr, config, theme=theme) + term = Terminal(stdscr, config) + term.set_theme(theme) threads = draw_screen(stdscr, reddit, config, theme, oauth) try: - ch = term.show_notification(theme_name) + ch = term.show_notification(theme.display_string) except KeyboardInterrupt: ch = Terminal.ESCAPE @@ -258,11 +265,9 @@ def main(): cassette.play_counts = Counter() if ch == curses.KEY_RIGHT: - i = (default_themes.index(theme_name) + 1) - theme_name = default_themes[i % len(default_themes)] + theme = theme_list.next() elif ch == curses.KEY_LEFT: - i = (default_themes.index(theme_name) - 1) - theme_name = default_themes[i % len(default_themes)] + theme = theme_list.previous() elif ch == Terminal.ESCAPE: break