diff --git a/rtv/__main__.py b/rtv/__main__.py index 0280a4f..2cc2f7b 100644 --- a/rtv/__main__.py +++ b/rtv/__main__.py @@ -35,9 +35,10 @@ from . import docs from . import packages from .packages import praw from .config import Config, copy_default_config, copy_default_mailcap +from .theme import Theme from .oauth import OAuthHelper from .terminal import Terminal -from .objects import curses_session, Color, patch_webbrowser +from .objects import curses_session, patch_webbrowser from .subreddit_page import SubredditPage from .exceptions import ConfigError from .__version__ import __version__ @@ -169,11 +170,9 @@ def main(): try: with curses_session() as stdscr: - # Initialize global color-pairs with curses - if not config['monochrome']: - Color.init() + theme = Theme(config['monochrome']) + term = Terminal(stdscr, config, theme) - term = Terminal(stdscr, config) with term.loader('Initializing', catch_exception=False): reddit = praw.Reddit(user_agent=user_agent, decode_html_entities=False, diff --git a/rtv/config.py b/rtv/config.py index 2a7e28a..ec905f4 100644 --- a/rtv/config.py +++ b/rtv/config.py @@ -135,6 +135,9 @@ class OrderedSet(object): class Config(object): + """ + This class manages the loading and saving of configs and other files. + """ def __init__(self, history_file=HISTORY, token_file=TOKEN, **kwargs): diff --git a/rtv/oauth.py b/rtv/oauth.py index 2b8a263..13c5945 100644 --- a/rtv/oauth.py +++ b/rtv/oauth.py @@ -193,23 +193,23 @@ class OAuthHelper(object): # If an exception is raised it will be seen by the thread # so we don't need to explicitly shutdown() the server _logger.exception(e) - self.term.show_notification('Browser Error') + self.term.show_notification('Browser Error', style='error') else: self.server.shutdown() finally: thread.join() if self.params['error'] == 'access_denied': - self.term.show_notification('Denied access') + self.term.show_notification('Denied access', style='error') return elif self.params['error']: - self.term.show_notification('Authentication error') + self.term.show_notification('Authentication error', style='error') return elif self.params['state'] is None: # Something went wrong but it's not clear what happened return elif self.params['state'] != state: - self.term.show_notification('UUID mismatch') + self.term.show_notification('UUID mismatch', style='error') return with self.term.loader('Logging in'): diff --git a/rtv/objects.py b/rtv/objects.py index d58b37d..40274ac 100644 --- a/rtv/objects.py +++ b/rtv/objects.py @@ -230,7 +230,8 @@ class LoadScreen(object): for e_type, message in self.EXCEPTION_MESSAGES: # Some exceptions we want to swallow and display a notification if isinstance(e, e_type): - self._terminal.show_notification(message.format(e)) + msg = message.format(e) + self._terminal.show_notification(msg, style='error') return True def animate(self, delay, interval, message, trail): @@ -250,12 +251,16 @@ class LoadScreen(object): return time.sleep(0.01) - # Build the notification window + # Build the notification window. Note that we need to use + # curses.newwin() instead of stdscr.derwin() so the text below the + # notification window does not got erased when we cover it up. message_len = len(message) + len(trail) n_rows, n_cols = self._terminal.stdscr.getmaxyx() - s_row = (n_rows - 3) // 2 - s_col = (n_cols - message_len - 1) // 2 + v_offset, h_offset = self._terminal.stdscr.getbegyx() + s_row = (n_rows - 3) // 2 + v_offset + s_col = (n_cols - message_len - 1) // 2 + h_offset window = curses.newwin(3, message_len + 2, s_row, s_col) + window.bkgd(str(' '), self._terminal.attr('notice_loading')) # Animate the loading prompt until the stopping condition is triggered # when the context manager exits. @@ -285,49 +290,6 @@ class LoadScreen(object): time.sleep(0.01) -class Color(object): - """ - Color attributes for curses. - """ - - RED = curses.A_NORMAL - GREEN = curses.A_NORMAL - YELLOW = curses.A_NORMAL - BLUE = curses.A_NORMAL - MAGENTA = curses.A_NORMAL - CYAN = curses.A_NORMAL - WHITE = curses.A_NORMAL - - _colors = { - 'RED': (curses.COLOR_RED, -1), - 'GREEN': (curses.COLOR_GREEN, -1), - 'YELLOW': (curses.COLOR_YELLOW, -1), - 'BLUE': (curses.COLOR_BLUE, -1), - 'MAGENTA': (curses.COLOR_MAGENTA, -1), - 'CYAN': (curses.COLOR_CYAN, -1), - 'WHITE': (curses.COLOR_WHITE, -1), - } - - @classmethod - def init(cls): - """ - Initialize color pairs inside of curses using the default background. - - This should be called once during the curses initial setup. Afterwards, - curses color pairs can be accessed directly through class attributes. - """ - - for index, (attr, code) in enumerate(cls._colors.items(), start=1): - curses.init_pair(index, code[0], code[1]) - setattr(cls, attr, curses.color_pair(index)) - - @classmethod - def get_level(cls, level): - - levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW] - return levels[level % len(levels)] - - class Navigator(object): """ Handles the math behind cursor movement and screen paging. diff --git a/rtv/page.py b/rtv/page.py index b773546..f64aca7 100644 --- a/rtv/page.py +++ b/rtv/page.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import os import sys import time -import curses import logging from functools import wraps @@ -12,7 +11,7 @@ import six from kitchen.text.display import textual_width from . import docs -from .objects import Controller, Color, Command +from .objects import Controller, Command from .clipboard import copy from .exceptions import TemporaryFileError, ProgramError from .__version__ import __version__ @@ -158,19 +157,15 @@ class Page(object): @PageController.register(Command('PAGE_TOP')) def move_page_top(self): - self._remove_cursor() self.nav.page_index = self.content.range[0] self.nav.cursor_index = 0 self.nav.inverted = False - self._add_cursor() @PageController.register(Command('PAGE_BOTTOM')) def move_page_bottom(self): - self._remove_cursor() self.nav.page_index = self.content.range[1] self.nav.cursor_index = 0 self.nav.inverted = True - self._add_cursor() @PageController.register(Command('UPVOTE')) @logged_in @@ -376,7 +371,6 @@ class Page(object): self._draw_banner() self._draw_content() self._draw_footer() - self._add_cursor() self.term.clear_screen() self.term.stdscr.refresh() @@ -388,8 +382,7 @@ class Page(object): window = self.term.stdscr.derwin(1, n_cols, self._row, 0) window.erase() # curses.bkgd expects bytes in py2 and unicode in py3 - ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN - window.bkgd(ch, attr) + window.bkgd(str(' '), self.term.attr('title_bar')) sub_name = self.content.name sub_name = sub_name.replace('/r/front', 'Front Page') @@ -421,7 +414,7 @@ class Page(object): sys.stdout.write(title) sys.stdout.flush() - if self.reddit.user is not None: + if self.reddit and self.reddit.user is not None: # The starting position of the name depends on if we're converting # to ascii or not width = len if self.config['ascii'] else textual_width @@ -442,8 +435,7 @@ class Page(object): n_rows, n_cols = self.term.stdscr.getmaxyx() window = self.term.stdscr.derwin(1, n_cols, self._row, 0) window.erase() - ch, attr = str(' '), curses.A_BOLD | Color.YELLOW - window.bkgd(ch, attr) + window.bkgd(str(' '), self.term.attr('order_bar')) banner = docs.BANNER_SEARCH if self.content.query else docs.BANNER items = banner.strip().split(' ') @@ -455,7 +447,8 @@ class Page(object): if self.content.order is not None: order = self.content.order.split('-')[0] col = text.find(order) - 3 - window.chgat(0, col, 3, attr | curses.A_REVERSE) + attr = self.term.theme.get('order_bar', modifier='selected') + window.chgat(0, col, 3, attr) self._row += 1 @@ -465,8 +458,7 @@ class Page(object): """ n_rows, n_cols = self.term.stdscr.getmaxyx() - window = self.term.stdscr.derwin( - n_rows - self._row - 1, n_cols, self._row, 0) + window = self.term.stdscr.derwin(n_rows - self._row - 1, n_cols, self._row, 0) window.erase() win_n_rows, win_n_cols = window.getmaxyx() @@ -493,10 +485,8 @@ class Page(object): top_item_height = None subwin_n_cols = win_n_cols - data['h_offset'] start = current_row - subwin_n_rows + 1 if inverted else current_row - subwindow = window.derwin( - subwin_n_rows, subwin_n_cols, start, data['h_offset']) - attr = self._draw_item(subwindow, data, subwin_inverted) - self._subwindows.append((subwindow, attr)) + subwindow = window.derwin(subwin_n_rows, subwin_n_cols, start, data['h_offset']) + self._subwindows.append((subwindow, data, subwin_inverted)) available_rows -= (subwin_n_rows + 1) # Add one for the blank line current_row += step * (subwin_n_rows + 1) if available_rows <= 0: @@ -518,6 +508,25 @@ class Page(object): self.nav.flip((len(self._subwindows) - 1)) return self._draw_content() + if self.nav.cursor_index >= len(self._subwindows): + # Don't allow the cursor to go over the number of subwindows + # This could happen if the window is resized and the cursor index is + # pushed out of bounds + self.nav.cursor_index = len(self._subwindows) - 1 + + # Now that the windows are setup, we can take a second pass through + # to draw the content + for index, (win, data, inverted) in enumerate(self._subwindows): + if index == self.nav.cursor_index: + # This lets the theme know to invert the cursor + modifier = 'selected' + else: + modifier = None + + win.bkgd(str(' '), self.term.attr('normal')) + with self.term.theme.set_modifier(modifier): + self._draw_item(win, data, inverted) + self._row += win_n_rows def _draw_footer(self): @@ -525,54 +534,23 @@ class Page(object): n_rows, n_cols = self.term.stdscr.getmaxyx() window = self.term.stdscr.derwin(1, n_cols, self._row, 0) window.erase() - ch, attr = str(' '), curses.A_REVERSE | curses.A_BOLD | Color.CYAN - window.bkgd(ch, attr) + window.bkgd(str(' '), self.term.attr('help_bar')) text = self.FOOTER.strip() self.term.add_line(window, text, 0, 0) self._row += 1 - def _add_cursor(self): - self._edit_cursor(curses.A_REVERSE) - - def _remove_cursor(self): - self._edit_cursor(curses.A_NORMAL) - def _move_cursor(self, direction): - self._remove_cursor() # Note: ACS_VLINE doesn't like changing the attribute, so disregard the # redraw flag and opt to always redraw valid, redraw = self.nav.move(direction, len(self._subwindows)) if not valid: self.term.flash() - self._add_cursor() def _move_page(self, direction): - self._remove_cursor() valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1) if not valid: self.term.flash() - self._add_cursor() - - def _edit_cursor(self, attribute): - - # Don't allow the cursor to go below page index 0 - if self.nav.absolute_index < 0: - return - - # Don't allow the cursor to go over the number of subwindows - # This could happen if the window is resized and the cursor index is - # pushed out of bounds - if self.nav.cursor_index >= len(self._subwindows): - self.nav.cursor_index = len(self._subwindows) - 1 - - window, attr = self._subwindows[self.nav.cursor_index] - if attr is not None: - attribute |= attr - - n_rows, _ = window.getmaxyx() - for row in range(n_rows): - window.chgat(row, 0, 1, attribute) def _prompt_period(self, order): diff --git a/rtv/submission_page.py b/rtv/submission_page.py index 826c9a6..240a6e8 100644 --- a/rtv/submission_page.py +++ b/rtv/submission_page.py @@ -3,12 +3,11 @@ from __future__ import unicode_literals import re import time -import curses from . import docs from .content import SubmissionContent, SubredditContent from .page import Page, PageController, logged_in -from .objects import Navigator, Color, Command +from .objects import Navigator, Command from .exceptions import TemporaryFileError @@ -119,7 +118,7 @@ class SubmissionPage(Page): @SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER')) def open_link(self): """ - Open the selected item with the webbrowser + Open the selected item with the web browser """ data = self.get_selected_item() @@ -207,6 +206,10 @@ class SubmissionPage(Page): @SubmissionController.register(Command('SUBMISSION_OPEN_IN_URLVIEWER')) def comment_urlview(self): + """ + Open the selected comment with the URL viewer + """ + data = self.get_selected_item() comment = data.get('body') or data.get('text') or data.get('url_full') if comment: @@ -285,89 +288,114 @@ class SubmissionPage(Page): split_body = data['split_body'] if data['n_rows'] > n_rows: # Only when there is a single comment on the page and not inverted - if not inverted and len(self._subwindows) == 0: + if not inverted and len(self._subwindows) == 1: cutoff = data['n_rows'] - n_rows + 1 split_body = split_body[:-cutoff] split_body.append('(Not enough space to display)') row = offset if row in valid_rows: - - attr = curses.A_BOLD - attr |= (Color.BLUE if not data['is_author'] else Color.GREEN) - text = '{author} '.format(**data) if data['is_author']: - text += '[S] ' + attr = self.term.attr('comment_author_self') + text = '{author} [S]'.format(**data) + else: + attr = self.term.attr('comment_author') + text = '{author}'.format(**data) self.term.add_line(win, text, row, 1, attr) if data['flair']: - attr = curses.A_BOLD | Color.YELLOW - self.term.add_line(win, '{flair} '.format(**data), attr=attr) + attr = self.term.attr('user_flair') + self.term.add_space(win) + self.term.add_line(win, '{flair}'.format(**data), attr=attr) - text, attr = self.term.get_arrow(data['likes']) - self.term.add_line(win, text, attr=attr) - self.term.add_line(win, ' {score} {created} '.format(**data)) + arrow, attr = self.term.get_arrow(data['likes']) + self.term.add_space(win) + self.term.add_line(win, arrow, attr=attr) + + attr = self.term.attr('score') + self.term.add_space(win) + self.term.add_line(win, '{score}'.format(**data), attr=attr) + + attr = self.term.attr('created') + self.term.add_space(win) + self.term.add_line(win, '{created}'.format(**data), attr=attr) if data['gold']: - text, attr = self.term.guilded - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('gold') + self.term.add_space(win) + self.term.add_line(win, self.term.guilded, attr=attr) if data['stickied']: - text, attr = '[stickied]', Color.GREEN - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('stickied') + self.term.add_space(win) + self.term.add_line(win, '[stickied]', attr=attr) if data['saved']: - text, attr = '[saved]', Color.GREEN - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('saved') + self.term.add_space(win) + self.term.add_line(win, '[saved]', attr=attr) for row, text in enumerate(split_body, start=offset+1): + attr = self.term.attr('comment_text') if row in valid_rows: - self.term.add_line(win, text, row, 1) + self.term.add_line(win, text, row, 1, attr=attr) # Unfortunately vline() doesn't support custom color so we have to # build it one segment at a time. - attr = Color.get_level(data['level']) - x = 0 + index = data['level'] % len(self.term.theme.BAR_LEVELS) + attr = self.term.attr(self.term.theme.BAR_LEVELS[index]) for y in range(n_rows): - self.term.addch(win, y, x, self.term.vline, attr) - - return attr | self.term.vline + self.term.addch(win, y, 0, self.term.vline, attr) def _draw_more_comments(self, win, data): n_rows, n_cols = win.getmaxyx() n_cols -= 1 - self.term.add_line(win, '{body}'.format(**data), 0, 1) - self.term.add_line( - win, ' [{count}]'.format(**data), attr=curses.A_BOLD) + attr = self.term.attr('hidden_comment_text') + self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr) - attr = Color.get_level(data['level']) + attr = self.term.attr('hidden_comment_expand') + self.term.add_space(win) + self.term.add_line(win, '[{count}]'.format(**data), attr=attr) + + index = data['level'] % len(self.term.theme.BAR_LEVELS) + attr = self.term.attr(self.term.theme.BAR_LEVELS[index]) self.term.addch(win, 0, 0, self.term.vline, attr) - return attr | self.term.vline - def _draw_submission(self, win, data): n_rows, n_cols = win.getmaxyx() n_cols -= 3 # one for each side of the border + one for offset + attr = self.term.attr('submission_title') for row, text in enumerate(data['split_title'], start=1): - self.term.add_line(win, text, row, 1, curses.A_BOLD) + self.term.add_line(win, text, row, 1, attr) row = len(data['split_title']) + 1 - attr = curses.A_BOLD | Color.GREEN + attr = self.term.attr('submission_author') self.term.add_line(win, '{author}'.format(**data), row, 1, attr) - attr = curses.A_BOLD | Color.YELLOW + if data['flair']: - self.term.add_line(win, ' {flair}'.format(**data), attr=attr) - self.term.add_line(win, ' {created} {subreddit}'.format(**data)) + attr = self.term.attr('submission_flair') + self.term.add_space(win) + self.term.add_line(win, '{flair}'.format(**data), attr=attr) + + attr = self.term.attr('created') + self.term.add_space(win) + self.term.add_line(win, '{created}'.format(**data), attr=attr) + + attr = self.term.attr('submission_subreddit') + self.term.add_space(win) + self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr) row = len(data['split_title']) + 2 - seen = (data['url_full'] in self.config.history) - link_color = Color.MAGENTA if seen else Color.BLUE - attr = curses.A_UNDERLINE | link_color + if data['url_full'] in self.config.history: + attr = self.term.attr('url_seen') + else: + attr = self.term.attr('url') self.term.add_line(win, '{url}'.format(**data), row, 1, attr) + offset = len(data['split_title']) + 3 # Cut off text if there is not enough room to display the whole post @@ -377,25 +405,35 @@ class SubmissionPage(Page): split_text = split_text[:-cutoff] split_text.append('(Not enough space to display)') + attr = self.term.attr('submission_text') for row, text in enumerate(split_text, start=offset): - self.term.add_line(win, text, row, 1) + self.term.add_line(win, text, row, 1, attr=attr) row = len(data['split_title']) + len(split_text) + 3 - self.term.add_line(win, '{score} '.format(**data), row, 1) - text, attr = self.term.get_arrow(data['likes']) - self.term.add_line(win, text, attr=attr) - self.term.add_line(win, ' {comments} '.format(**data)) + attr = self.term.attr('score') + self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr) + + arrow, attr = self.term.get_arrow(data['likes']) + self.term.add_space(win) + self.term.add_line(win, arrow, attr=attr) + + attr = self.term.attr('comment_count') + self.term.add_space(win) + self.term.add_line(win, '{comments}'.format(**data), attr=attr) if data['gold']: - text, attr = self.term.guilded - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('gold') + self.term.add_space(win) + self.term.add_line(win, self.term.guilded, attr=attr) if data['nsfw']: - text, attr = 'NSFW', (curses.A_BOLD | Color.RED) - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('nsfw') + self.term.add_space(win) + self.term.add_line(win, 'NSFW', attr=attr) if data['saved']: - text, attr = '[saved]', Color.GREEN - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('saved') + self.term.add_space(win) + self.term.add_line(win, '[saved]', attr=attr) win.border() diff --git a/rtv/subreddit_page.py b/rtv/subreddit_page.py index c0ad99d..1494d36 100644 --- a/rtv/subreddit_page.py +++ b/rtv/subreddit_page.py @@ -3,12 +3,11 @@ from __future__ import unicode_literals import re import time -import curses from . import docs from .content import SubredditContent from .page import Page, PageController, logged_in -from .objects import Navigator, Color, Command +from .objects import Navigator, Command from .submission_page import SubmissionPage from .subscription_page import SubscriptionPage from .exceptions import TemporaryFileError @@ -265,50 +264,75 @@ class SubredditPage(Page): n_title = len(data['split_title']) for row, text in enumerate(data['split_title'], start=offset): + attr = self.term.attr('submission_title') if row in valid_rows: - self.term.add_line(win, text, row, 1, curses.A_BOLD) + self.term.add_line(win, text, row, 1, attr) row = n_title + offset if row in valid_rows: - seen = (data['url_full'] in self.config.history) - link_color = Color.MAGENTA if seen else Color.BLUE - attr = curses.A_UNDERLINE | link_color + if data['url_full'] in self.config.history: + attr = self.term.attr('url_seen') + else: + attr = self.term.attr('url') self.term.add_line(win, '{url}'.format(**data), row, 1, attr) row = n_title + offset + 1 if row in valid_rows: - self.term.add_line(win, '{score} '.format(**data), row, 1) - text, attr = self.term.get_arrow(data['likes']) - self.term.add_line(win, text, attr=attr) - self.term.add_line(win, ' {created} '.format(**data)) + + attr = self.term.attr('score') + self.term.add_line(win, '{score}'.format(**data), row, 1, attr) + self.term.add_space(win) + + arrow, attr = self.term.get_arrow(data['likes']) + self.term.add_line(win, arrow, attr=attr) + self.term.add_space(win) + + attr = self.term.attr('created') + self.term.add_line(win, '{created}'.format(**data), attr=attr) if data['comments'] is not None: - text, attr = '-', curses.A_BOLD - self.term.add_line(win, text, attr=attr) - self.term.add_line(win, ' {comments} '.format(**data)) + attr = self.term.attr('separator') + self.term.add_space(win) + self.term.add_line(win, '-', attr=attr) + + attr = self.term.attr('comment_count') + self.term.add_space(win) + self.term.add_line(win, '{comments}'.format(**data), attr=attr) if data['saved']: - text, attr = '[saved]', Color.GREEN - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('saved') + self.term.add_space(win) + self.term.add_line(win, '[saved]', attr=attr) if data['stickied']: - text, attr = '[stickied]', Color.GREEN - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('stickied') + self.term.add_space(win) + self.term.add_line(win, '[stickied]', attr=attr) if data['gold']: - text, attr = self.term.guilded - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('gold') + self.term.add_space(win) + self.term.add_line(win, self.term.guilded, attr=attr) if data['nsfw']: - text, attr = 'NSFW', (curses.A_BOLD | Color.RED) - self.term.add_line(win, text, attr=attr) + attr = self.term.attr('nsfw') + self.term.add_space(win) + self.term.add_line(win, 'NSFW', attr=attr) row = n_title + offset + 2 if row in valid_rows: - text = '{author}'.format(**data) - self.term.add_line(win, text, row, 1, Color.GREEN) - text = ' /r/{subreddit}'.format(**data) - self.term.add_line(win, text, attr=Color.YELLOW) + attr = self.term.attr('submission_author') + self.term.add_line(win, '{author}'.format(**data), row, 1, attr) + self.term.add_space(win) + + attr = self.term.attr('submission_subreddit') + self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr) + if data['flair']: - text = ' {flair}'.format(**data) - self.term.add_line(win, text, attr=Color.RED) + attr = self.term.attr('submission_flair') + self.term.add_space(win) + self.term.add_line(win, '{flair}'.format(**data), attr=attr) + + attr = self.term.attr('cursor') + for y in range(n_rows): + self.term.addch(win, y, 0, str(' '), attr) diff --git a/rtv/subscription_page.py b/rtv/subscription_page.py index 9ff1c3e..55cca19 100644 --- a/rtv/subscription_page.py +++ b/rtv/subscription_page.py @@ -1,12 +1,10 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import curses - from . import docs from .page import Page, PageController from .content import SubscriptionContent, SubredditContent -from .objects import Color, Navigator, Command +from .objects import Navigator, Command class SubscriptionController(PageController): @@ -95,10 +93,21 @@ class SubscriptionPage(Page): row = offset if row in valid_rows: - attr = curses.A_BOLD | Color.YELLOW + if data['type'] == 'Multireddit': + attr = self.term.attr('multireddit_name') + else: + attr = self.term.attr('subscription_name') self.term.add_line(win, '{name}'.format(**data), row, 1, attr) row = offset + 1 for row, text in enumerate(data['split_title'], start=row): if row in valid_rows: - self.term.add_line(win, text, row, 1) + if data['type'] == 'Multireddit': + attr = self.term.attr('multireddit_text') + else: + attr = self.term.attr('subscription_text') + self.term.add_line(win, text, row, 1, attr) + + attr = self.term.attr('cursor') + for y in range(n_rows): + self.term.addch(win, y, 0, str(' '), attr) diff --git a/rtv/terminal.py b/rtv/terminal.py index 0ebfa08..0765c91 100644 --- a/rtv/terminal.py +++ b/rtv/terminal.py @@ -20,9 +20,9 @@ from tempfile import NamedTemporaryFile import six from kitchen.text.display import textual_width_chop -from . import exceptions -from . import mime_parsers -from .objects import LoadScreen, Color +from . import exceptions, mime_parsers +from .theme import Theme +from .objects import LoadScreen try: # Fix only needed for versions prior to python 3.6 @@ -46,16 +46,20 @@ class Terminal(object): MIN_HEIGHT = 10 MIN_WIDTH = 20 - # ASCII code + # ASCII codes ESCAPE = 27 RETURN = 10 SPACE = 32 - def __init__(self, stdscr, config): + def __init__(self, stdscr, config, theme=None): self.stdscr = stdscr self.config = config self.loader = LoadScreen(self) + + self.theme = None + self.set_theme(theme) + self._display = None self._mailcap_dict = mailcap.getcaps() self._term = os.environ.get('TERM') @@ -66,27 +70,19 @@ class Terminal(object): @property def up_arrow(self): - symbol = '^' if self.config['ascii'] else '▲' - attr = curses.A_BOLD | Color.GREEN - return symbol, attr + return '^' if self.config['ascii'] else '▲' @property def down_arrow(self): - symbol = 'v' if self.config['ascii'] else '▼' - attr = curses.A_BOLD | Color.RED - return symbol, attr + return 'v' if self.config['ascii'] else '▼' @property def neutral_arrow(self): - symbol = 'o' if self.config['ascii'] else '•' - attr = curses.A_BOLD - return symbol, attr + return 'o' if self.config['ascii'] else '•' @property def guilded(self): - symbol = '*' if self.config['ascii'] else '✪' - attr = curses.A_BOLD | Color.YELLOW - return symbol, attr + return '*' if self.config['ascii'] else '✪' @property def vline(self): @@ -197,11 +193,11 @@ class Terminal(object): """ if likes is None: - return self.neutral_arrow + return self.neutral_arrow, self.attr('neutral_vote') elif likes: - return self.up_arrow + return self.up_arrow, self.attr('upvote') else: - return self.down_arrow + return self.down_arrow, self.attr('downvote') def clean(self, string, n_cols=None): """ @@ -278,7 +274,21 @@ class Terminal(object): params = [] if attr is None else [attr] window.addstr(row, col, text, *params) - def show_notification(self, message, timeout=None): + @staticmethod + def add_space(window): + """ + Shortcut for adding a single space to a window at the current position + """ + + row, col = window.getyx() + _, max_cols = window.getmaxyx() + if max_cols - col - 1 <= 0: + # Trying to draw outside of the screen bounds + return + + window.addstr(row, col, ' ') + + def show_notification(self, message, timeout=None, style='info'): """ Overlay a message box on the center of the screen and wait for input. @@ -286,12 +296,17 @@ class Terminal(object): message (list or string): List of strings, one per line. timeout (float): Optional, maximum length of time that the message will be shown before disappearing. + style (str): The theme element that will be applied to the + notification window """ + assert style in ('info', 'warning', 'error', 'success') + if isinstance(message, six.string_types): message = message.splitlines() n_rows, n_cols = self.stdscr.getmaxyx() + v_offset, h_offset = self.stdscr.getbegyx() box_width = max(len(m) for m in message) + 2 box_height = len(message) + 2 @@ -301,10 +316,11 @@ class Terminal(object): box_height = min(box_height, n_rows) message = message[:box_height-2] - s_row = (n_rows - box_height) // 2 - s_col = (n_cols - box_width) // 2 + s_row = (n_rows - box_height) // 2 + v_offset + s_col = (n_cols - box_width) // 2 + h_offset window = curses.newwin(box_height, box_width, s_row, s_col) + window.bkgd(str(' '), self.attr('notice_{0}'.format(style))) window.erase() window.border() @@ -382,7 +398,7 @@ class Terminal(object): _logger.warning(stderr) self.show_notification( 'Program exited with status={0}\n{1}'.format( - code, stderr.strip())) + code, stderr.strip()), style='error') else: # Non-blocking, open a background process @@ -692,18 +708,22 @@ class Terminal(object): """ n_rows, n_cols = self.stdscr.getmaxyx() - ch, attr = str(' '), curses.A_BOLD | curses.A_REVERSE | Color.CYAN + v_offset, h_offset = self.stdscr.getbegyx() + ch, attr = str(' '), self.attr('prompt') prompt = self.clean(prompt, n_cols-1) # Create a new window to draw the text at the bottom of the screen, # so we can erase it when we're done. - prompt_win = curses.newwin(1, len(prompt)+1, n_rows-1, 0) + s_row = v_offset + n_rows - 1 + s_col = h_offset + prompt_win = curses.newwin(1, len(prompt) + 1, s_row, s_col) prompt_win.bkgd(ch, attr) self.add_line(prompt_win, prompt) prompt_win.refresh() # Create a separate window for text input - input_win = curses.newwin(1, n_cols-len(prompt), n_rows-1, len(prompt)) + s_col = h_offset + len(prompt) + input_win = curses.newwin(1, n_cols - len(prompt), s_row, s_col) input_win.bkgd(ch, attr) input_win.refresh() @@ -802,3 +822,34 @@ class Terminal(object): self.stdscr.touchwin() else: self.stdscr.clearok(True) + + def attr(self, element): + """ + Shortcut for fetching the color + attribute code for an element. + """ + + return self.theme.get(element) + + def set_theme(self, theme=None): + """ + Set the terminal theme. This is a stub for what will eventually + support managing custom themes. + + Check that the terminal supports the provided theme, and applies + the theme to the terminal if possible. + + If the terminal doesn't support the theme, this falls back to the + default theme. The default theme only requires 8 colors so it + should be compatible with any terminal that supports basic colors. + """ + monochrome = (not curses.has_colors()) + + if theme is None: + theme = Theme(monochrome=monochrome) + + theme.bind_curses() + + # Apply the default color to the whole screen + self.stdscr.bkgd(str(' '), theme.get('normal')) + + self.theme = theme diff --git a/rtv/theme.py b/rtv/theme.py new file mode 100644 index 0000000..19a8f7c --- /dev/null +++ b/rtv/theme.py @@ -0,0 +1,113 @@ +""" +This file is a stub that contains the default RTV theme. + +This will eventually be expanded to support loading/managing custom themes. +""" + +import curses +from contextlib import contextmanager + + +DEFAULT_THEME = { + 'normal': (-1, -1, curses.A_NORMAL), + 'bar_level_1': (curses.COLOR_MAGENTA, -1, curses.A_NORMAL), + 'bar_level_1.selected': (curses.COLOR_MAGENTA, -1, curses.A_REVERSE), + 'bar_level_2': (curses.COLOR_CYAN, -1, curses.A_NORMAL), + 'bar_level_2.selected': (curses.COLOR_CYAN, -1, curses.A_REVERSE), + 'bar_level_3': (curses.COLOR_GREEN, -1, curses.A_NORMAL), + 'bar_level_3.selected': (curses.COLOR_GREEN, -1, curses.A_REVERSE), + 'bar_level_4': (curses.COLOR_YELLOW, -1, curses.A_NORMAL), + 'bar_level_4.selected': (curses.COLOR_YELLOW, -1, curses.A_REVERSE), + 'comment_author': (curses.COLOR_BLUE, -1, curses.A_BOLD), + 'comment_author_self': (curses.COLOR_GREEN, -1, curses.A_BOLD), + 'comment_count': (-1, -1, curses.A_NORMAL), + 'comment_text': (-1, -1, curses.A_NORMAL), + 'created': (-1, -1, curses.A_NORMAL), + 'cursor': (-1, -1, curses.A_NORMAL), + 'cursor.selected': (-1, -1, curses.A_REVERSE), + 'downvote': (curses.COLOR_RED, -1, curses.A_BOLD), + 'gold': (curses.COLOR_YELLOW, -1, curses.A_BOLD), + 'help_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE), + 'hidden_comment_expand': (-1, -1, curses.A_BOLD), + 'hidden_comment_text': (-1, -1, curses.A_NORMAL), + 'multireddit_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD), + 'multireddit_text': (-1, -1, curses.A_NORMAL), + 'neutral_vote': (-1, -1, curses.A_BOLD), + 'notice_info': (-1, -1, curses.A_NORMAL), + 'notice_loading': (-1, -1, curses.A_NORMAL), + 'notice_error': (-1, -1, curses.A_NORMAL), + 'notice_success': (-1, -1, curses.A_NORMAL), + 'nsfw': (curses.COLOR_RED, -1, curses.A_BOLD | curses.A_REVERSE), + 'order_bar': (curses.COLOR_YELLOW, -1, curses.A_BOLD), + 'order_bar.selected': (curses.COLOR_YELLOW, -1, curses.A_BOLD | curses.A_REVERSE), + 'prompt': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE), + 'saved': (curses.COLOR_GREEN, -1, curses.A_NORMAL), + 'score': (-1, -1, curses.A_NORMAL), + 'separator': (-1, -1, curses.A_BOLD), + 'stickied': (curses.COLOR_GREEN, -1, curses.A_NORMAL), + 'subscription_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD), + 'subscription_text': (-1, -1, curses.A_NORMAL), + 'submission_author': (curses.COLOR_GREEN, -1, curses.A_NORMAL), + 'submission_flair': (curses.COLOR_RED, -1, curses.A_NORMAL), + 'submission_subreddit': (curses.COLOR_YELLOW, -1, curses.A_NORMAL), + 'submission_text': (-1, -1, curses.A_NORMAL), + 'submission_title': (-1, -1, curses.A_BOLD), + 'title_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE), + 'upvote': (curses.COLOR_GREEN, -1, curses.A_BOLD), + 'url': (curses.COLOR_BLUE, -1, curses.A_UNDERLINE), + 'url_seen': (curses.COLOR_MAGENTA, -1, curses.A_UNDERLINE), + 'user_flair': (curses.COLOR_YELLOW, -1, curses.A_BOLD) +} + + +class Theme(object): + + BAR_LEVELS = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4'] + + def __init__(self, monochrome=True): + + self.monochrome = monochrome + self._modifier = None + self._elements = {} + self._color_pairs = {} + + def bind_curses(self): + + if self.monochrome: + # Skip initializing the colors and just use the attributes + self._elements = {key: val[2] for key, val in DEFAULT_THEME.items()} + return + + # Shortcut for the default fg/bg + self._color_pairs[(-1, -1)] = curses.A_NORMAL + + for key, (fg, bg, attr) in DEFAULT_THEME.items(): + # Register the color pair for the element + if (fg, bg) not in self._color_pairs: + index = len(self._color_pairs) + 1 + curses.init_pair(index, fg, bg) + self._color_pairs[(fg, bg)] = curses.color_pair(index) + + self._elements[key] = self._color_pairs[(fg, bg)] | attr + + def get(self, element, modifier=None): + + modifier = modifier or self._modifier + if modifier: + modified_element = '{0}.{1}'.format(element, modifier) + if modified_element in self._elements: + return self._elements[modified_element] + + return self._elements[element] + + @contextmanager + def set_modifier(self, modifier=None): + + # This case is undefined if the context manager is nested + assert self._modifier is None + + self._modifier = modifier + try: + yield + finally: + self._modifier = None diff --git a/tests/conftest.py b/tests/conftest.py index c14864e..a42a4ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,6 +52,9 @@ class MockStdscr(mock.MagicMock): def getyx(self): return self.y, self.x + def getbegyx(self): + return 0, 0 + def getmaxyx(self): return self.nlines, self.ncols @@ -154,12 +157,14 @@ def stdscr(): patch('curses.curs_set'), \ patch('curses.init_pair'), \ patch('curses.color_pair'), \ + patch('curses.has_colors'), \ patch('curses.start_color'), \ patch('curses.use_default_colors'): out = MockStdscr(nlines=40, ncols=80, x=0, y=0) curses.initscr.return_value = out curses.newwin.side_effect = lambda *args: out.derwin(*args) curses.color_pair.return_value = 23 + curses.has_colors.return_value = True curses.ACS_VLINE = 0 yield out diff --git a/tests/test_objects.py b/tests/test_objects.py index 411fd7c..dde5c3f 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -12,7 +12,7 @@ import requests from six.moves import reload_module from rtv import exceptions -from rtv.objects import Color, Controller, Navigator, Command, KeyMap, \ +from rtv.objects import Controller, Navigator, Command, KeyMap, \ curses_session, patch_webbrowser try: @@ -189,21 +189,6 @@ def test_objects_load_screen_nested_complex(terminal, stdscr, use_ascii): stdscr.subwin.addstr.assert_called_once_with(1, 1, error_message) -def test_objects_color(stdscr): - - colors = ['RED', 'GREEN', 'YELLOW', 'BLUE', 'MAGENTA', 'CYAN', 'WHITE'] - - # Check that all colors start with the default value - for color in colors: - assert getattr(Color, color) == curses.A_NORMAL - - Color.init() - - # Check that all colors are populated - for color in colors: - assert getattr(Color, color) == 23 - - def test_objects_curses_session(stdscr): # Normal setup and cleanup diff --git a/tests/test_submission.py b/tests/test_submission.py index 698c726..bf8030d 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -79,15 +79,16 @@ def test_submission_page_construct(reddit, terminal, config, oauth): # Comment comment_data = page.content.get(0) text = comment_data['split_body'][0].encode('utf-8') - window.subwin.addstr.assert_any_call(1, 1, text) + window.subwin.addstr.assert_any_call(1, 1, text, curses.A_NORMAL) # More Comments comment_data = page.content.get(1) text = comment_data['body'].encode('utf-8') - window.subwin.addstr.assert_any_call(0, 1, text) + window.subwin.addstr.assert_any_call(0, 1, text, curses.A_NORMAL) # Cursor should not be drawn when the page is first opened - assert not window.subwin.chgat.called + # TODO: Add a new test for this + # assert not window.subwin.chgat.called # Reload with a smaller terminal window terminal.stdscr.ncols = 20 @@ -264,7 +265,7 @@ def test_submission_comment_not_enough_space(submission_page, terminal): text = '(Not enough space to display)'.encode('ascii') window = terminal.stdscr.subwin - window.subwin.addstr.assert_any_call(6, 1, text) + window.subwin.addstr.assert_any_call(6, 1, text, curses.A_NORMAL) def test_submission_vote(submission_page, refresh_token): diff --git a/tests/test_subreddit.py b/tests/test_subreddit.py index 09d1a1a..b5fba6f 100644 --- a/tests/test_subreddit.py +++ b/tests/test_subreddit.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import curses + import six from rtv import __version__ @@ -38,7 +40,7 @@ def test_subreddit_page_construct(reddit, terminal, config, oauth): window.subwin.addstr.assert_any_call(0, 1, text, 2097152) # Cursor should have been drawn - assert window.subwin.chgat.called + window.subwin.addch.assert_any_call(0, 0, ' ', curses.A_REVERSE) # Reload with a smaller terminal window terminal.stdscr.ncols = 20 diff --git a/tests/test_subscription.py b/tests/test_subscription.py index 1cd635c..239a291 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -45,8 +45,8 @@ def test_subscription_page_construct(reddit, terminal, config, oauth, window.addstr.assert_any_call(0, 0, menu) # Cursor - 2 lines - window.subwin.chgat.assert_any_call(0, 0, 1, 262144) - window.subwin.chgat.assert_any_call(1, 0, 1, 262144) + window.subwin.addch.assert_any_call(0, 0, ' ', 262144) + window.subwin.addch.assert_any_call(1, 0, ' ', 262144) # Reload with a smaller terminal window terminal.stdscr.ncols = 20 diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 15fd42e..9df6710 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -20,14 +20,10 @@ except ImportError: def test_terminal_properties(terminal, config): - assert len(terminal.up_arrow) == 2 - assert isinstance(terminal.up_arrow[0], six.text_type) - assert len(terminal.down_arrow) == 2 - assert isinstance(terminal.down_arrow[0], six.text_type) - assert len(terminal.neutral_arrow) == 2 - assert isinstance(terminal.neutral_arrow[0], six.text_type) - assert len(terminal.guilded) == 2 - assert isinstance(terminal.guilded[0], six.text_type) + assert isinstance(terminal.up_arrow, six.text_type) + assert isinstance(terminal.down_arrow, six.text_type) + assert isinstance(terminal.neutral_arrow, six.text_type) + assert isinstance(terminal.guilded, six.text_type) terminal._display = None with mock.patch('rtv.terminal.sys') as sys, \