Merge pull request #443 from michael-lazar/themes

Themes
This commit is contained in:
Michael Lazar
2017-12-09 22:22:52 -05:00
committed by GitHub
40 changed files with 8341 additions and 211 deletions

View File

@@ -17,6 +17,8 @@ Basic Commands
:``q``/``Q``: Quit/Force quit :``q``/``Q``: Quit/Force quit
:``y``: Copy submission permalink to clipboard :``y``: Copy submission permalink to clipboard
:``Y``: Copy submission link to clipboard :``Y``: Copy submission link to clipboard
:``F2``: Cycle to the previous color theme
:``F3``: Cycle to the next color theme
---------------------- ----------------------
Authenticated Commands Authenticated Commands

View File

@@ -5,3 +5,4 @@ include README.md
include LICENSE include LICENSE
include rtv.1 include rtv.1
include rtv/templates/* include rtv/templates/*
include rtv/themes/*

View File

@@ -40,7 +40,8 @@ RTV is built in python using the curses library.
* [Demo](#demo) * [Demo](#demo)
* [Installation](#installation) * [Installation](#installation)
* [Usage](#usage) * [Usage](#usage)
* [Settings](#settings) * [Settings](#settings)
* [Themes](#themes)
* [FAQ](#faq) * [FAQ](#faq)
* [Contributing](#contributing) * [Contributing](#contributing)
* [License](#license) * [License](#license)
@@ -106,7 +107,7 @@ Press <kbd>/</kbd> to open the navigation prompt, where you can type things like
- ``/u/multi-mod/m/art`` - ``/u/multi-mod/m/art``
- ``/domain/github.com`` - ``/domain/github.com``
See [CONTROLS](https://github.com/michael-lazar/rtv/blob/master/CONTROLS.rst) for the full list of commands. See [CONTROLS](CONTROLS.rst) for the full list of commands.
## Settings ## Settings
@@ -114,7 +115,7 @@ See [CONTROLS](https://github.com/michael-lazar/rtv/blob/master/CONTROLS.rst) fo
Configuration files are stored in the ``{HOME}/.config/rtv/`` directory. Configuration files are stored in the ``{HOME}/.config/rtv/`` directory.
Check out [rtv.cfg](https://github.com/michael-lazar/rtv/blob/master/rtv/templates/rtv.cfg) for the full list of configurable options. You can clone this file into your home directory by running: Check out [rtv.cfg](rtv/templates/rtv.cfg) for the full list of configurable options. You can clone this file into your home directory by running:
```bash ```bash
$ rtv --copy-config $ rtv --copy-config
@@ -134,7 +135,7 @@ A mailcap file allows you to associate different MIME media types, like ``image/
$ rtv --copy-mailcap $ rtv --copy-mailcap
``` ```
This template contains examples for common MIME types that work with popular reddit websites like *imgur*, *youtube*, and *gfycat*. Open the mailcap template and follow the [instructions](https://github.com/michael-lazar/rtv/blob/master/rtv/templates/mailcap) listed inside. This template contains examples for common MIME types that work with popular reddit websites like *imgur*, *youtube*, and *gfycat*. Open the mailcap template and follow the [instructions](rtv/templates/mailcap) listed inside.
Once you've setup your mailcap file, enable it by launching rtv with the ``rtv --enable-media`` flag (or set it in your **rtv.cfg**) Once you've setup your mailcap file, enable it by launching rtv with the ``rtv --enable-media`` flag (or set it in your **rtv.cfg**)
@@ -161,10 +162,41 @@ The default programs that RTV interacts with can be configured through environme
</table> </table>
### Clipboard ### Clipboard
RTV supports copying submission links to the OS clipboard.
On macOS this is supported out of the box. RTV supports copying submission links to the OS clipboard. On macOS this is supported out of the box.
On Linux systems you will need to install either [xsel](http://www.vergenet.net/~conrad/software/xsel/) or [xclip](https://sourceforge.net/projects/xclip/). On Linux systems you will need to install either [xsel](http://www.vergenet.net/~conrad/software/xsel/) or [xclip](https://sourceforge.net/projects/xclip/).
## Themes
Themes can be used to customize the look and feel of RTV
<table>
<tr>
<td align="center">
<p><strong>Solarized Dark</strong></p>
<img src="resources/theme_solarized_dark.png"></img>
</td>
<td align="center">
<p><strong>Solarized Light</strong></p>
<img src="resources/theme_solarized_light.png"></img>
</td>
</tr>
<tr>
<td align="center">
<p><strong>Papercolor</strong></p>
<img src="resources/theme_papercolor.png"></img>
</td>
<td align="center">
<p><strong>Molokai</strong></p>
<img src="resources/theme_molokai.png"></img>
</td>
</tr>
</table>
List installed themes with ``--list-themes`` command, and select one with ``--theme``. You can also set the theme permenantly in your [rtv.cfg](rtv/templates/rtv.cfg) file. While inside of RTV, you can use the <kbd>F2</kbd> & <kbd>F3</kbd> keys for a live preview.
For instructions on writing and installing your own themes, see [THEMES.md](THEMES.md).
## FAQ ## FAQ
<details> <details>
@@ -213,8 +245,8 @@ On Linux systems you will need to install either [xsel](http://www.vergenet.net/
## Contributing ## Contributing
All feedback and suggestions are welcome, just post an issue! All feedback and suggestions are welcome, just post an issue!
Before writing any code, please read the [Contributor Guidelines](https://github.com/michael-lazar/rtv/blob/master/CONTRIBUTING.rst). Before writing any code, please read the [Contributor Guidelines](CONTRIBUTING.rst).
## License ## License
This project is distributed under the [MIT](https://github.com/michael-lazar/rtv/blob/master/LICENSE) license. This project is distributed under the [MIT](LICENSE) license.

217
THEMES.md Normal file
View File

@@ -0,0 +1,217 @@
# Themes
## Installing Themes
You can install custom themes by copying them into your **~/.config/rtv/themes/**
directory. The name of the theme will match the name of the file.
```
$ cp my-custom-theme.cfg ~/.config/rtv/themes/
$ rtv --theme my-custom-theme
```
If you've created a cool theme and would like to share it with the community,
please submit a pull request!
## A quick primer on ANSI colors
Color support on modern terminals can be split into 4 categories:
1. No support for colors
2. 8 system colors - Black, Red, Green, Yellow, Blue, Magenta,
Cyan, and White
3. 16 system colors - Everything above + bright variations
4. 256 extended colors - Everything above + 6x6x6 color palette + 24 greyscale colors
<p align="center">
<img alt="terminal colors" src="resources/terminal_colors.png"/>
<br><i>The 256 terminal color codes, image from <a href=https://github.com/eikenb/terminal_colors>https://github.com/eikenb/terminal_colors</a></i>
</p>
The 16 system colors, along with the default foreground and background,
can usually be customized through your terminal's profile settings. The
6x6x6 color palette and grayscale colors are constant RGB values across
all terminals. RTV's default theme only uses the 8 primary system colors,
which is why it matches the "look and feel" of the terminal that you're
running it in.
<p align="center">
<img alt="iTerm preferences" src="resources/iterm_preferences.png"/>
<br><i>Setting the 16 system colors in iTerm preferences</i>
</p>
The curses library determines your terminal's color support by reading your
environment's ``$TERM`` variable, and looking up your terminal's
capabilities in the [terminfo](https://linux.die.net/man/5/terminfo)
database. You can emulate this behavior by using the ``tput`` command:
```
bash$ export TERM=xterm
bash$ tput colors
8
bash$ export TERM=xterm-256color
bash$ tput colors
256
bash$ export TERM=vt220
bash$ tput colors
-1
```
In general you should not be setting your ``$TERM`` variable manually,
it will be set automatically by you terminal. Often, problems with
terminal colors can be traced back to somebody hardcoding
``TERM=xterm-256color`` in their .bashrc file.
## Understanding RTV Themes
Here's an example of what an RTV theme file looks like:
```
[theme]
;<element> = <foreground> <background> <attributes>
Normal = default default normal
Selected = default default normal
SelectedCursor = default default reverse
TitleBar = cyan - bold+reverse
OrderBar = yellow - bold
OrderBarHighlight = yellow - bold+reverse
HelpBar = cyan - bold+reverse
Prompt = cyan - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = - - bold
NoticeSuccess = - - bold
CursorBlock = - - -
CursorBar1 = magenta - -
CursorBar2 = cyan - -
CursorBar3 = green - -
CursorBar4 = yellow - -
CommentAuthor = blue - bold
CommentAuthorSelf = green - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = red - bold
Gold = yellow - bold
HiddenCommentExpand = - - bold
HiddenCommentText = - - -
MultiredditName = yellow - bold
MultiredditText = - - -
NeutralVote = - - bold
NSFW = red - bold+reverse
Saved = green - -
Score = - - -
Separator = - - bold
Stickied = green - -
SubscriptionName = yellow - bold
SubscriptionText = - - -
SubmissionAuthor = green - bold
SubmissionFlair = red - -
SubmissionSubreddit = yellow - -
SubmissionText = - - -
SubmissionTitle = - - bold
Upvote = green - bold
Link = blue - underline
LinkSeen = magenta - underline
UserFlair = yellow - bold
```
Every piece of text drawn on the screen is assigned to an ``<element>``,
which has three properties:
- ``<foreground>``: The text color
- ``<background>``: The background color
- ``<attributes>``: Additional text attributes, like bold or underlined
### Colors
The ``<foreground>`` and ``<background>`` properties can be set to any the following values:
- ``default``, which means use the terminal's default foreground or background color.
- The 16 system colors:
<p>
<table>
<tr><td>black</td><td>dark_gray</td></tr>
<tr><td>red</td></td><td>bright_red</td></tr>
<tr><td>green</td></td><td>bright_green</td></tr>
<tr><td>yellow</td></td><td>bright_yellow</td></tr>
<tr><td>blue</td></td><td>bright_blue</td></tr>
<tr><td>magenta</td></td><td>bright_magenta</td></tr>
<tr><td>cyan</td></td><td>bright_cyan</td></tr>
<tr><td>light_gray</td></td><td>white</td></tr>
</table>
</p>
- ``ansi_{n}``, where n is between 0 and 255. These will map to their
corresponding ANSI colors (see the figure above).
- Hex RGB codes, like ``#0F0F0F``, which will be converted to their nearest
ANSI color. This is generally not recommended because the conversion process
downscales the color resolution and the resulting colors will look "off".
### Attributes
The ``<attributes>`` property can be set to any of the following values:
- ``normal``, ``bold``, ``underline``, or ``standout``.
- ``reverse`` will swap the foreground and background colors.
Attributes can be mixed together using the + symbol. For example,
``bold+underline`` will make the text bold and underlined.
### Modifiers
RTV themes use special "modifer" elements to define the default
application style. This allows you to do things like set the default
background color without needing to set ``<background>`` on every
single element. The three modifier elements are:
- ``Normal`` - The default modifier that applies to all text elements.
- ``Selected`` - Applies to text elements that are highlighted on the page.
- ``SelectedCursor`` - Like ``Selected``, but only applies to ``CursorBlock``
and ``CursorBar{n}`` elements.
When an element is marked with a ``-`` token, it means inherit the
attribute value from the relevant modifier. This is best explained
through an example:
```
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_241 ansi_230 normal
Selected = ansi_241 ansi_254 normal
SelectedCursor = ansi_241 ansi_254 bold+reverse
Link = ansi_33 - underline
```
<p align="center">
<img src="resources/theme_modifiers.png"/>
<br><i>The default solarized-light theme</i>
</p>
In the snippet above, the ``Link`` element has its background color set
to the ``-`` token. This means that it will inherit it's background
from either the ``Normal`` (light yellow) or the ``Selected`` (light grey)
element, depending on if it's selected or not.
Compare this to with what happens when the ``Link`` background is explicitly set:
```
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_241 ansi_230 normal
Selected = ansi_241 ansi_254 normal
SelectedCursor = ansi_241 ansi_254 bold+reverse
Link = ansi_33 ansi_230 underline
```
<p align="center">
<img src="resources/theme_modifiers_2.png"/>
<br><i>A modified solarized-light theme, with the Link background set to ansi_230</i>
</p>
In this case, the ``Link`` background stays ansi_230 (yellow) even the link is
selected by the cursor.

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
resources/theme_default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
resources/theme_molokai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -91,6 +91,10 @@ def main():
copy_default_mailcap() copy_default_mailcap()
return return
if config['list_themes']:
Theme.print_themes()
return
# Load the browsing history from previous sessions # Load the browsing history from previous sessions
config.load_history() config.load_history()
@@ -171,8 +175,19 @@ def main():
try: try:
with curses_session() as stdscr: with curses_session() as stdscr:
theme = Theme(config['monochrome']) term = Terminal(stdscr, config)
term = Terminal(stdscr, config, theme)
if config['monochrome'] or config['theme'] == 'monochrome':
_logger.info('Using monochrome theme')
theme = Theme(use_color=False)
elif config['theme'] and config['theme'] != 'default':
_logger.info('Loading theme: %s', config['theme'])
theme = Theme.from_name(config['theme'])
else:
# Set to None to let the terminal figure out which theme
# to use depending on if colors are supported or not
theme = None
term.set_theme(theme)
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

@@ -13,16 +13,19 @@ from six.moves import configparser
from . import docs, __version__ from . import docs, __version__
from .objects import KeyMap from .objects import KeyMap
PACKAGE = os.path.dirname(__file__) PACKAGE = os.path.dirname(__file__)
HOME = os.path.expanduser('~') HOME = os.path.expanduser('~')
TEMPLATES = os.path.join(PACKAGE, 'templates') TEMPLATES = os.path.join(PACKAGE, 'templates')
DEFAULT_CONFIG = os.path.join(TEMPLATES, 'rtv.cfg') DEFAULT_CONFIG = os.path.join(TEMPLATES, 'rtv.cfg')
DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap') 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')) XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg') CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg')
MAILCAP = os.path.join(HOME, '.mailcap') MAILCAP = os.path.join(HOME, '.mailcap')
TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token') TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token')
HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log') HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log')
THEMES = os.path.join(XDG_HOME, 'rtv', 'themes')
def build_parser(): def build_parser():
@@ -52,6 +55,12 @@ def build_parser():
parser.add_argument( parser.add_argument(
'--monochrome', action='store_const', const=True, '--monochrome', action='store_const', const=True,
help='Disable color') 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( parser.add_argument(
'--non-persistent', dest='persistent', action='store_const', const=False, '--non-persistent', dest='persistent', action='store_const', const=False,
help='Forget the authenticated user when the program exits') help='Forget the authenticated user when the program exits')

View File

@@ -217,6 +217,7 @@ class Content(object):
data['title'] = sub.title data['title'] = sub.title
data['text'] = sub.selftext data['text'] = sub.selftext
data['created'] = cls.humanize_timestamp(sub.created_utc) data['created'] = cls.humanize_timestamp(sub.created_utc)
data['created_long'] = cls.humanize_timestamp(sub.created_utc, True)
data['comments'] = '{0} comments'.format(sub.num_comments) data['comments'] = '{0} comments'.format(sub.num_comments)
data['score'] = '{0} pts'.format('-' if sub.hide_score else sub.score) data['score'] = '{0} pts'.format('-' if sub.hide_score else sub.score)
data['author'] = name data['author'] = name

View File

@@ -60,6 +60,8 @@ https://github.com/michael-lazar/rtv
b : Display urls with urlview b : Display urls with urlview
y : Copy submission permalink to clipboard y : Copy submission permalink to clipboard
Y : Copy submission link to clipboard Y : Copy submission link to clipboard
F2 : Cycle to previous theme
F3 : Cycle to next theme
[Prompt] [Prompt]
The `/` prompt accepts subreddits in the following formats The `/` prompt accepts subreddits in the following formats
@@ -87,7 +89,6 @@ BANNER_SEARCH = """
[1]relevance [2]top [3]comments [4]new [1]relevance [2]top [3]comments [4]new
""" """
FOOTER_SUBREDDIT = """ FOOTER_SUBREDDIT = """
[?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote [?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote
""" """

View File

@@ -193,23 +193,23 @@ class OAuthHelper(object):
# If an exception is raised it will be seen by the thread # If an exception is raised it will be seen by the thread
# so we don't need to explicitly shutdown() the server # so we don't need to explicitly shutdown() the server
_logger.exception(e) _logger.exception(e)
self.term.show_notification('Browser Error', style='error') self.term.show_notification('Browser Error', style='Error')
else: else:
self.server.shutdown() self.server.shutdown()
finally: finally:
thread.join() thread.join()
if self.params['error'] == 'access_denied': if self.params['error'] == 'access_denied':
self.term.show_notification('Denied access', style='error') self.term.show_notification('Denied access', style='Error')
return return
elif self.params['error']: elif self.params['error']:
self.term.show_notification('Authentication error', style='error') self.term.show_notification('Authentication error', style='Error')
return return
elif self.params['state'] is None: elif self.params['state'] is None:
# Something went wrong but it's not clear what happened # Something went wrong but it's not clear what happened
return return
elif self.params['state'] != state: elif self.params['state'] != state:
self.term.show_notification('UUID mismatch', style='error') self.term.show_notification('UUID mismatch', style='Error')
return return
with self.term.loader('Logging in'): with self.term.loader('Logging in'):

View File

@@ -238,7 +238,7 @@ class LoadScreen(object):
# Some exceptions we want to swallow and display a notification # Some exceptions we want to swallow and display a notification
if isinstance(e, e_type): if isinstance(e, e_type):
msg = message.format(e) msg = message.format(e)
self._terminal.show_notification(msg, style='error') self._terminal.show_notification(msg, style='Error')
return True return True
def animate(self, delay, interval, message, trail): def animate(self, delay, interval, message, trail):
@@ -267,7 +267,7 @@ class LoadScreen(object):
s_row = (n_rows - 3) // 2 + v_offset s_row = (n_rows - 3) // 2 + v_offset
s_col = (n_cols - message_len - 1) // 2 + h_offset s_col = (n_cols - message_len - 1) // 2 + h_offset
window = curses.newwin(3, message_len + 2, s_row, s_col) window = curses.newwin(3, message_len + 2, s_row, s_col)
window.bkgd(str(' '), self._terminal.attr('notice_loading')) window.bkgd(str(' '), self._terminal.attr('NoticeLoading'))
# Animate the loading prompt until the stopping condition is triggered # Animate the loading prompt until the stopping condition is triggered
# when the context manager exits. # when the context manager exits.

View File

@@ -95,6 +95,30 @@ class Page(object):
def force_exit(self): def force_exit(self):
sys.exit() sys.exit()
@PageController.register(Command('PREVIOUS_THEME'))
def previous_theme(self):
theme = self.term.theme_list.previous(self.term.theme)
while not self.term.check_theme(theme):
theme = self.term.theme_list.previous(theme)
self.term.set_theme(theme)
self.draw()
message = self.term.theme.display_string
self.term.show_notification(message, timeout=1)
@PageController.register(Command('NEXT_THEME'))
def next_theme(self):
theme = self.term.theme_list.next(self.term.theme)
while not self.term.check_theme(theme):
theme = self.term.theme_list.next(theme)
self.term.set_theme(theme)
self.draw()
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):
self.term.open_pager(docs.HELP.strip()) self.term.open_pager(docs.HELP.strip())
@@ -347,7 +371,7 @@ class Page(object):
window = self.term.stdscr.derwin(1, n_cols, self._row, 0) window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase() window.erase()
# curses.bkgd expects bytes in py2 and unicode in py3 # curses.bkgd expects bytes in py2 and unicode in py3
window.bkgd(str(' '), self.term.attr('title_bar')) window.bkgd(str(' '), self.term.attr('TitleBar'))
sub_name = self.content.name sub_name = self.content.name
sub_name = sub_name.replace('/r/front', 'Front Page') sub_name = sub_name.replace('/r/front', 'Front Page')
@@ -402,7 +426,7 @@ class Page(object):
n_rows, n_cols = self.term.stdscr.getmaxyx() n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(1, n_cols, self._row, 0) window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase() window.erase()
window.bkgd(str(' '), self.term.attr('order_bar')) window.bkgd(str(' '), self.term.attr('OrderBar'))
banner = docs.BANNER_SEARCH if self.content.query else docs.BANNER banner = docs.BANNER_SEARCH if self.content.query else docs.BANNER
items = banner.strip().split(' ') items = banner.strip().split(' ')
@@ -414,7 +438,7 @@ class Page(object):
if self.content.order is not None: if self.content.order is not None:
order = self.content.order.split('-')[0] order = self.content.order.split('-')[0]
col = text.find(order) - 3 col = text.find(order) - 3
attr = self.term.theme.get('order_bar', modifier='selected') attr = self.term.attr('OrderBarHighlight')
window.chgat(0, col, 3, attr) window.chgat(0, col, 3, attr)
self._row += 1 self._row += 1
@@ -482,16 +506,14 @@ class Page(object):
self.nav.cursor_index = len(self._subwindows) - 1 self.nav.cursor_index = len(self._subwindows) - 1
# Now that the windows are setup, we can take a second pass through # Now that the windows are setup, we can take a second pass through
# to draw the content # to draw the text onto each subwindow
for index, (win, data, inverted) in enumerate(self._subwindows): for index, (win, data, inverted) in enumerate(self._subwindows):
if index == self.nav.cursor_index: if self.nav.absolute_index >= 0 and index == self.nav.cursor_index:
# This lets the theme know to invert the cursor win.bkgd(str(' '), self.term.attr('Selected'))
modifier = 'selected' with self.term.theme.turn_on_selected():
self._draw_item(win, data, inverted)
else: else:
modifier = None win.bkgd(str(' '), self.term.attr('Normal'))
win.bkgd(str(' '), self.term.attr('normal'))
with self.term.theme.set_modifier(modifier):
self._draw_item(win, data, inverted) self._draw_item(win, data, inverted)
self._row += win_n_rows self._row += win_n_rows
@@ -501,7 +523,7 @@ class Page(object):
n_rows, n_cols = self.term.stdscr.getmaxyx() n_rows, n_cols = self.term.stdscr.getmaxyx()
window = self.term.stdscr.derwin(1, n_cols, self._row, 0) window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
window.erase() window.erase()
window.bkgd(str(' '), self.term.attr('help_bar')) window.bkgd(str(' '), self.term.attr('HelpBar'))
text = self.FOOTER.strip() text = self.FOOTER.strip()
self.term.add_line(window, text, 0, 0) self.term.add_line(window, text, 0, 0)
@@ -534,4 +556,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

@@ -317,15 +317,15 @@ class SubmissionPage(Page):
row = offset row = offset
if row in valid_rows: if row in valid_rows:
if data['is_author']: if data['is_author']:
attr = self.term.attr('comment_author_self') attr = self.term.attr('CommentAuthorSelf')
text = '{author} [S]'.format(**data) text = '{author} [S]'.format(**data)
else: else:
attr = self.term.attr('comment_author') attr = self.term.attr('CommentAuthor')
text = '{author}'.format(**data) text = '{author}'.format(**data)
self.term.add_line(win, text, row, 1, attr) self.term.add_line(win, text, row, 1, attr)
if data['flair']: if data['flair']:
attr = self.term.attr('user_flair') attr = self.term.attr('UserFlair')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr) self.term.add_line(win, '{flair}'.format(**data), attr=attr)
@@ -333,38 +333,38 @@ class SubmissionPage(Page):
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, arrow, attr=attr) self.term.add_line(win, arrow, attr=attr)
attr = self.term.attr('score') attr = self.term.attr('Score')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '{score}'.format(**data), attr=attr) self.term.add_line(win, '{score}'.format(**data), attr=attr)
attr = self.term.attr('created') attr = self.term.attr('Created')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '{created}'.format(**data), attr=attr) self.term.add_line(win, '{created}'.format(**data), attr=attr)
if data['gold']: if data['gold']:
attr = self.term.attr('gold') attr = self.term.attr('Gold')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, self.term.guilded, attr=attr) self.term.add_line(win, self.term.guilded, attr=attr)
if data['stickied']: if data['stickied']:
attr = self.term.attr('stickied') attr = self.term.attr('Stickied')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '[stickied]', attr=attr) self.term.add_line(win, '[stickied]', attr=attr)
if data['saved']: if data['saved']:
attr = self.term.attr('saved') attr = self.term.attr('Saved')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr) self.term.add_line(win, '[saved]', attr=attr)
for row, text in enumerate(split_body, start=offset+1): for row, text in enumerate(split_body, start=offset+1):
attr = self.term.attr('comment_text') attr = self.term.attr('CommentText')
if row in valid_rows: if row in valid_rows:
self.term.add_line(win, text, row, 1, attr=attr) self.term.add_line(win, text, row, 1, attr=attr)
# Unfortunately vline() doesn't support custom color so we have to # curses.vline() doesn't support custom colors so need to build the
# build it one segment at a time. # cursor bar on the left of the comment one character at a time
index = data['level'] % len(self.term.theme.BAR_LEVELS) index = data['level'] % len(self.term.theme.CURSOR_BARS)
attr = self.term.attr(self.term.theme.BAR_LEVELS[index]) attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
for y in range(n_rows): for y in range(n_rows):
self.term.addch(win, y, 0, self.term.vline, attr) self.term.addch(win, y, 0, self.term.vline, attr)
@@ -373,15 +373,15 @@ class SubmissionPage(Page):
n_rows, n_cols = win.getmaxyx() n_rows, n_cols = win.getmaxyx()
n_cols -= 1 n_cols -= 1
attr = self.term.attr('hidden_comment_text') attr = self.term.attr('HiddenCommentText')
self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr) self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr)
attr = self.term.attr('hidden_comment_expand') attr = self.term.attr('HiddenCommentExpand')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '[{count}]'.format(**data), attr=attr) self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
index = data['level'] % len(self.term.theme.BAR_LEVELS) index = data['level'] % len(self.term.theme.CURSOR_BARS)
attr = self.term.attr(self.term.theme.BAR_LEVELS[index]) attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
self.term.addch(win, 0, 0, self.term.vline, attr) self.term.addch(win, 0, 0, self.term.vline, attr)
def _draw_submission(self, win, data): def _draw_submission(self, win, data):
@@ -389,32 +389,32 @@ class SubmissionPage(Page):
n_rows, n_cols = win.getmaxyx() n_rows, n_cols = win.getmaxyx()
n_cols -= 3 # one for each side of the border + one for offset n_cols -= 3 # one for each side of the border + one for offset
attr = self.term.attr('submission_title') attr = self.term.attr('SubmissionTitle')
for row, text in enumerate(data['split_title'], start=1): for row, text in enumerate(data['split_title'], start=1):
self.term.add_line(win, text, row, 1, attr) self.term.add_line(win, text, row, 1, attr)
row = len(data['split_title']) + 1 row = len(data['split_title']) + 1
attr = self.term.attr('submission_author') attr = self.term.attr('SubmissionAuthor')
self.term.add_line(win, '{author}'.format(**data), row, 1, attr) self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
if data['flair']: if data['flair']:
attr = self.term.attr('submission_flair') attr = self.term.attr('SubmissionFlair')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr) self.term.add_line(win, '{flair}'.format(**data), attr=attr)
attr = self.term.attr('created') attr = self.term.attr('SubmissionSubreddit')
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_space(win)
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr) self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
attr = self.term.attr('Created')
self.term.add_space(win)
self.term.add_line(win, '{created_long}'.format(**data), attr=attr)
row = len(data['split_title']) + 2 row = len(data['split_title']) + 2
if data['url_full'] in self.config.history: if data['url_full'] in self.config.history:
attr = self.term.attr('url_seen') attr = self.term.attr('LinkSeen')
else: else:
attr = self.term.attr('url') attr = self.term.attr('Link')
self.term.add_line(win, '{url}'.format(**data), row, 1, attr) self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
offset = len(data['split_title']) + 3 offset = len(data['split_title']) + 3
@@ -426,34 +426,34 @@ class SubmissionPage(Page):
split_text = split_text[:-cutoff] split_text = split_text[:-cutoff]
split_text.append('(Not enough space to display)') split_text.append('(Not enough space to display)')
attr = self.term.attr('submission_text') attr = self.term.attr('SubmissionText')
for row, text in enumerate(split_text, start=offset): for row, text in enumerate(split_text, start=offset):
self.term.add_line(win, text, row, 1, attr=attr) self.term.add_line(win, text, row, 1, attr=attr)
row = len(data['split_title']) + len(split_text) + 3 row = len(data['split_title']) + len(split_text) + 3
attr = self.term.attr('score') attr = self.term.attr('Score')
self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr) self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr)
arrow, attr = self.term.get_arrow(data['likes']) arrow, attr = self.term.get_arrow(data['likes'])
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, arrow, attr=attr) self.term.add_line(win, arrow, attr=attr)
attr = self.term.attr('comment_count') attr = self.term.attr('CommentCount')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '{comments}'.format(**data), attr=attr) self.term.add_line(win, '{comments}'.format(**data), attr=attr)
if data['gold']: if data['gold']:
attr = self.term.attr('gold') attr = self.term.attr('Gold')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, self.term.guilded, attr=attr) self.term.add_line(win, self.term.guilded, attr=attr)
if data['nsfw']: if data['nsfw']:
attr = self.term.attr('nsfw') attr = self.term.attr('NSFW')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, 'NSFW', attr=attr) self.term.add_line(win, 'NSFW', attr=attr)
if data['saved']: if data['saved']:
attr = self.term.attr('saved') attr = self.term.attr('Saved')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr) self.term.add_line(win, '[saved]', attr=attr)

View File

@@ -304,22 +304,22 @@ class SubredditPage(Page):
n_title = len(data['split_title']) n_title = len(data['split_title'])
for row, text in enumerate(data['split_title'], start=offset): for row, text in enumerate(data['split_title'], start=offset):
attr = self.term.attr('submission_title') attr = self.term.attr('SubmissionTitle')
if row in valid_rows: if row in valid_rows:
self.term.add_line(win, text, row, 1, attr) self.term.add_line(win, text, row, 1, attr)
row = n_title + offset row = n_title + offset
if row in valid_rows: if row in valid_rows:
if data['url_full'] in self.config.history: if data['url_full'] in self.config.history:
attr = self.term.attr('url_seen') attr = self.term.attr('LinkSeen')
else: else:
attr = self.term.attr('url') attr = self.term.attr('Link')
self.term.add_line(win, '{url}'.format(**data), row, 1, attr) self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
row = n_title + offset + 1 row = n_title + offset + 1
if row in valid_rows: if row in valid_rows:
attr = self.term.attr('score') attr = self.term.attr('Score')
self.term.add_line(win, '{score}'.format(**data), row, 1, attr) self.term.add_line(win, '{score}'.format(**data), row, 1, attr)
self.term.add_space(win) self.term.add_space(win)
@@ -327,52 +327,52 @@ class SubredditPage(Page):
self.term.add_line(win, arrow, attr=attr) self.term.add_line(win, arrow, attr=attr)
self.term.add_space(win) self.term.add_space(win)
attr = self.term.attr('created') attr = self.term.attr('Created')
self.term.add_line(win, '{created}'.format(**data), attr=attr) self.term.add_line(win, '{created}'.format(**data), attr=attr)
if data['comments'] is not None: if data['comments'] is not None:
attr = self.term.attr('separator') attr = self.term.attr('Separator')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '-', attr=attr) self.term.add_line(win, '-', attr=attr)
attr = self.term.attr('comment_count') attr = self.term.attr('CommentCount')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '{comments}'.format(**data), attr=attr) self.term.add_line(win, '{comments}'.format(**data), attr=attr)
if data['saved']: if data['saved']:
attr = self.term.attr('saved') attr = self.term.attr('Saved')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '[saved]', attr=attr) self.term.add_line(win, '[saved]', attr=attr)
if data['stickied']: if data['stickied']:
attr = self.term.attr('stickied') attr = self.term.attr('Stickied')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '[stickied]', attr=attr) self.term.add_line(win, '[stickied]', attr=attr)
if data['gold']: if data['gold']:
attr = self.term.attr('gold') attr = self.term.attr('Gold')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, self.term.guilded, attr=attr) self.term.add_line(win, self.term.guilded, attr=attr)
if data['nsfw']: if data['nsfw']:
attr = self.term.attr('nsfw') attr = self.term.attr('NSFW')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, 'NSFW', attr=attr) self.term.add_line(win, 'NSFW', attr=attr)
row = n_title + offset + 2 row = n_title + offset + 2
if row in valid_rows: if row in valid_rows:
attr = self.term.attr('submission_author') attr = self.term.attr('SubmissionAuthor')
self.term.add_line(win, '{author}'.format(**data), row, 1, attr) self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
self.term.add_space(win) self.term.add_space(win)
attr = self.term.attr('submission_subreddit') attr = self.term.attr('SubmissionSubreddit')
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr) self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
if data['flair']: if data['flair']:
attr = self.term.attr('submission_flair') attr = self.term.attr('SubmissionFlair')
self.term.add_space(win) self.term.add_space(win)
self.term.add_line(win, '{flair}'.format(**data), attr=attr) self.term.add_line(win, '{flair}'.format(**data), attr=attr)
attr = self.term.attr('cursor') attr = self.term.attr('CursorBlock')
for y in range(n_rows): for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr) self.term.addch(win, y, 0, str(' '), attr)

View File

@@ -93,20 +93,20 @@ class SubscriptionPage(Page):
row = offset row = offset
if row in valid_rows: if row in valid_rows:
if data['type'] == 'Multireddit': if data['type'] == 'Multireddit':
attr = self.term.attr('multireddit_name') attr = self.term.attr('MultiredditName')
else: else:
attr = self.term.attr('subscription_name') attr = self.term.attr('SubscriptionName')
self.term.add_line(win, '{name}'.format(**data), row, 1, attr) self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
row = offset + 1 row = offset + 1
for row, text in enumerate(data['split_title'], start=row): for row, text in enumerate(data['split_title'], start=row):
if row in valid_rows: if row in valid_rows:
if data['type'] == 'Multireddit': if data['type'] == 'Multireddit':
attr = self.term.attr('multireddit_text') attr = self.term.attr('MultiredditText')
else: else:
attr = self.term.attr('subscription_text') attr = self.term.attr('SubscriptionText')
self.term.add_line(win, text, row, 1, attr) self.term.add_line(win, text, row, 1, attr)
attr = self.term.attr('cursor') attr = self.term.attr('CursorBlock')
for y in range(n_rows): for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr) self.term.addch(win, y, 0, str(' '), attr)

View File

@@ -43,6 +43,11 @@ max_comment_cols = 120
; Hide username if logged in, display "Logged in" instead ; Hide username if logged in, display "Logged in" instead
hide_username = False hide_username = False
; Color theme, use "rtv --list-themes" to view a list of valid options.
; This can be an absolute filepath, or the name of a theme file that has
; been installed into either the custom of default theme paths.
;theme = monokai
################ ################
# OAuth Settings # OAuth Settings
################ ################
@@ -110,6 +115,8 @@ SORT_NEW = 4
SORT_CONTROVERSIAL = 5 SORT_CONTROVERSIAL = 5
MOVE_UP = k, <KEY_UP> MOVE_UP = k, <KEY_UP>
MOVE_DOWN = j, <KEY_DOWN> MOVE_DOWN = j, <KEY_DOWN>
PREVIOUS_THEME = <KEY_F2>
NEXT_THEME = <KEY_F3>
PAGE_UP = m, <KEY_PPAGE>, <NAK> PAGE_UP = m, <KEY_PPAGE>, <NAK>
PAGE_DOWN = n, <KEY_NPAGE>, <EOT> PAGE_DOWN = n, <KEY_NPAGE>, <EOT>
PAGE_TOP = gg PAGE_TOP = gg

View File

@@ -21,7 +21,7 @@ import six
from kitchen.text.display import textual_width_chop from kitchen.text.display import textual_width_chop
from . import exceptions, mime_parsers, content from . import exceptions, mime_parsers, content
from .theme import Theme from .theme import Theme, ThemeList
from .objects import LoadScreen from .objects import LoadScreen
try: try:
@@ -51,14 +51,13 @@ class Terminal(object):
RETURN = 10 RETURN = 10
SPACE = 32 SPACE = 32
def __init__(self, stdscr, config, theme=None): def __init__(self, stdscr, config):
self.stdscr = stdscr self.stdscr = stdscr
self.config = config self.config = config
self.loader = LoadScreen(self) self.loader = LoadScreen(self)
self.theme = None # Initialized by term.set_theme()
self.theme = None self.theme_list = ThemeList()
self.set_theme(theme)
self._display = None self._display = None
self._mailcap_dict = mailcap.getcaps() self._mailcap_dict = mailcap.getcaps()
@@ -193,11 +192,11 @@ class Terminal(object):
""" """
if likes is None: if likes is None:
return self.neutral_arrow, self.attr('neutral_vote') return self.neutral_arrow, self.attr('NeutralVote')
elif likes: elif likes:
return self.up_arrow, self.attr('upvote') return self.up_arrow, self.attr('Upvote')
else: else:
return self.down_arrow, self.attr('downvote') return self.down_arrow, self.attr('Downvote')
def clean(self, string, n_cols=None): def clean(self, string, n_cols=None):
""" """
@@ -293,7 +292,7 @@ class Terminal(object):
window.addstr(row, col, ' ') window.addstr(row, col, ' ')
def show_notification(self, message, timeout=None, style='info'): def show_notification(self, message, timeout=None, style='Info'):
""" """
Overlay a message box on the center of the screen and wait for input. Overlay a message box on the center of the screen and wait for input.
@@ -305,7 +304,7 @@ class Terminal(object):
notification window notification window
""" """
assert style in ('info', 'warning', 'error', 'success') assert style in ('Info', 'Warning', 'Error', 'Success')
if isinstance(message, six.string_types): if isinstance(message, six.string_types):
message = message.splitlines() message = message.splitlines()
@@ -325,7 +324,7 @@ class Terminal(object):
s_col = (n_cols - box_width) // 2 + h_offset s_col = (n_cols - box_width) // 2 + h_offset
window = curses.newwin(box_height, box_width, s_row, s_col) window = curses.newwin(box_height, box_width, s_row, s_col)
window.bkgd(str(' '), self.attr('notice_{0}'.format(style))) window.bkgd(str(' '), self.attr('Notice{0}'.format(style)))
window.erase() window.erase()
window.border() window.border()
@@ -403,7 +402,7 @@ class Terminal(object):
_logger.warning(stderr) _logger.warning(stderr)
self.show_notification( self.show_notification(
'Program exited with status={0}\n{1}'.format( 'Program exited with status={0}\n{1}'.format(
code, stderr.strip()), style='error') code, stderr.strip()), style='Error')
else: else:
# Non-blocking, open a background process # Non-blocking, open a background process
@@ -731,7 +730,7 @@ class Terminal(object):
n_rows, n_cols = self.stdscr.getmaxyx() n_rows, n_cols = self.stdscr.getmaxyx()
v_offset, h_offset = self.stdscr.getbegyx() v_offset, h_offset = self.stdscr.getbegyx()
ch, attr = str(' '), self.attr('prompt') ch, attr = str(' '), self.attr('Prompt')
prompt = self.clean(prompt, n_cols-1) prompt = self.clean(prompt, n_cols-1)
# Create a new window to draw the text at the bottom of the screen, # Create a new window to draw the text at the bottom of the screen,
@@ -849,14 +848,27 @@ class Terminal(object):
""" """
Shortcut for fetching the color + attribute code for an element. Shortcut for fetching the color + attribute code for an element.
""" """
# The theme must be initialized before calling this
assert self.theme is not None
return self.theme.get(element) return self.theme.get(element)
@staticmethod
def check_theme(theme):
"""
Check if the given theme is compatible with the terminal
"""
terminal_colors = curses.COLORS if curses.has_colors() else 0
if theme.required_colors > terminal_colors:
return False
elif theme.required_color_pairs > curses.COLOR_PAIRS:
return False
else:
return True
def set_theme(self, theme=None): 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 Check that the terminal supports the provided theme, and applies
the theme to the terminal if possible. the theme to the terminal if possible.
@@ -864,14 +876,31 @@ class Terminal(object):
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.
""" """
monochrome = (not curses.has_colors())
if theme is None or monochrome: terminal_colors = curses.COLORS if curses.has_colors() else 0
theme = Theme(monochrome=monochrome) default_theme = Theme(use_color=bool(terminal_colors))
if theme is None:
theme = default_theme
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 = default_theme
elif theme.required_colors > terminal_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 = default_theme
theme.bind_curses() theme.bind_curses()
self.theme = theme
# 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(' '), self.attr('Normal'))
self.theme = theme

View File

@@ -1,113 +1,555 @@
""" import os
This file is a stub that contains the default RTV theme. import codecs
This will eventually be expanded to support loading/managing custom themes.
"""
import curses import curses
import logging
from collections import OrderedDict
from contextlib import contextmanager from contextlib import contextmanager
import six
from six.moves import configparser
DEFAULT_THEME = { from .config import THEMES, DEFAULT_THEMES
'normal': (-1, -1, curses.A_NORMAL), from .exceptions import ConfigError
'bar_level_1': (curses.COLOR_MAGENTA, -1, curses.A_NORMAL),
'bar_level_1.selected': (curses.COLOR_MAGENTA, -1, curses.A_REVERSE), _logger = logging.getLogger(__name__)
'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): class Theme(object):
BAR_LEVELS = ['bar_level_1', 'bar_level_2', 'bar_level_3', 'bar_level_4'] ATTRIBUTE_CODES = {
'-': None,
'': None,
'normal': curses.A_NORMAL,
'bold': curses.A_BOLD,
'reverse': curses.A_REVERSE,
'underline': curses.A_UNDERLINE,
'standout': curses.A_STANDOUT
}
def __init__(self, monochrome=True): COLOR_CODES = {
'-': None,
'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,
}
self.monochrome = monochrome for i in range(256):
self._modifier = None COLOR_CODES['ansi_{0}'.format(i)] = i
self._elements = {}
self._color_pairs = {} # For compatibility with as many terminals as possible, the default theme
# can only use the 8 basic colors with the default color as the background
DEFAULT_THEME = {
'modifiers': {
'Normal': (-1, -1, curses.A_NORMAL),
'Selected': (-1, -1, curses.A_NORMAL),
'SelectedCursor': (-1, -1, curses.A_REVERSE),
},
'page': {
'TitleBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'OrderBar': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'OrderBarHighlight': (curses.COLOR_YELLOW, None, curses.A_BOLD | curses.A_REVERSE),
'HelpBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'Prompt': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
'NoticeInfo': (None, None, curses.A_BOLD),
'NoticeLoading': (None, None, curses.A_BOLD),
'NoticeError': (None, None, curses.A_BOLD),
'NoticeSuccess': (None, None, curses.A_BOLD),
},
# Fields that might be highlighted by the "SelectedCursor" element
'cursor': {
'CursorBlock': (None, None, None),
'CursorBar1': (curses.COLOR_MAGENTA, None, None),
'CursorBar2': (curses.COLOR_CYAN, None, None),
'CursorBar3': (curses.COLOR_GREEN, None, None),
'CursorBar4': (curses.COLOR_YELLOW, None, None),
},
# Fields that might be highlighted by the "Selected" element
'normal': {
'CommentAuthor': (curses.COLOR_BLUE, None, curses.A_BOLD),
'CommentAuthorSelf': (curses.COLOR_GREEN, None, curses.A_BOLD),
'CommentCount': (None, None, None),
'CommentText': (None, None, None),
'Created': (None, None, None),
'Downvote': (curses.COLOR_RED, None, curses.A_BOLD),
'Gold': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'HiddenCommentExpand': (None, None, curses.A_BOLD),
'HiddenCommentText': (None, None, None),
'MultiredditName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'MultiredditText': (None, None, None),
'NeutralVote': (None, None, curses.A_BOLD),
'NSFW': (curses.COLOR_RED, None, curses.A_BOLD | curses.A_REVERSE),
'Saved': (curses.COLOR_GREEN, None, None),
'Score': (None, None, None),
'Separator': (None, None, curses.A_BOLD),
'Stickied': (curses.COLOR_GREEN, None, None),
'SubscriptionName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
'SubscriptionText': (None, None, None),
'SubmissionAuthor': (curses.COLOR_GREEN, None, curses.A_BOLD),
'SubmissionFlair': (curses.COLOR_RED, None, None),
'SubmissionSubreddit': (curses.COLOR_YELLOW, None, None),
'SubmissionText': (None, None, None),
'SubmissionTitle': (None, None, curses.A_BOLD),
'Upvote': (curses.COLOR_GREEN, None, curses.A_BOLD),
'Link': (curses.COLOR_BLUE, None, curses.A_UNDERLINE),
'LinkSeen': (curses.COLOR_MAGENTA, None, curses.A_UNDERLINE),
'UserFlair': (curses.COLOR_YELLOW, None, curses.A_BOLD)
}
}
DEFAULT_ELEMENTS = {k: v for group in DEFAULT_THEME.values()
for k, v in group.items()}
# The SubmissionPage uses this to determine which color bar to use
CURSOR_BARS = ['CursorBar1', 'CursorBar2', 'CursorBar3', 'CursorBar4']
def __init__(self, name=None, source=None, elements=None, use_color=True):
"""
Params:
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.
"""
if source not in (None, 'built-in', 'preset', 'installed', 'custom'):
raise ValueError('Invalid source')
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._selected = None
self.required_color_pairs = 0
self.required_colors = 0
if elements is None:
elements = self.DEFAULT_ELEMENTS.copy()
# Set any elements that weren't defined by the config to fallback to
# the default color and attributes
for key in self.DEFAULT_ELEMENTS.keys():
if key not in elements:
elements[key] = (None, None, None)
self._set_fallback(elements, 'Normal', (-1, -1, curses.A_NORMAL))
self._set_fallback(elements, 'Selected', 'Normal')
self._set_fallback(elements, 'SelectedCursor', 'Normal')
# Create the "Selected" versions of elements, which are prefixed with
# the @ symbol. For example, "@CommentText" represents how comment
# text is formatted when it is highlighted by the cursor.
for name in self.DEFAULT_THEME['normal']:
dest = '@{0}'.format(name)
self._set_fallback(elements, name, 'Selected', dest)
for name in self.DEFAULT_THEME['cursor']:
dest = '@{0}'.format(name)
self._set_fallback(elements, name, 'SelectedCursor', dest)
# Fill in the ``None`` values for all of the elements with normal text
for name in self.DEFAULT_THEME['normal']:
self._set_fallback(elements, name, 'Normal')
for name in self.DEFAULT_THEME['cursor']:
self._set_fallback(elements, name, 'Normal')
for name in self.DEFAULT_THEME['page']:
self._set_fallback(elements, name, 'Normal')
self.elements = elements
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)
# 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
@property
def display_string(self):
return '{0} ({1})'.format(self.name, self.source)
def bind_curses(self): def bind_curses(self):
"""
Bind the theme's colors to curses's internal color pair map.
if self.monochrome: This method must be called once (after curses has been initialized)
# Skip initializing the colors and just use the attributes before any element attributes can be accessed. Color codes and other
self._elements = {key: val[2] for key, val in DEFAULT_THEME.items()} special attributes will be mixed bitwise into a single value that
return can be passed into curses draw functions.
"""
self._color_pair_map = {}
self._attribute_map = {}
# Shortcut for the default fg/bg for element, item in self.elements.items():
self._color_pairs[(-1, -1)] = curses.A_NORMAL fg, bg, attrs = item
for key, (fg, bg, attr) in DEFAULT_THEME.items(): color_pair = (fg, bg)
# Register the color pair for the element if self.use_color and color_pair != (-1, -1):
if (fg, bg) not in self._color_pairs: # Curses limits the number of available color pairs, so we
index = len(self._color_pairs) + 1 # need to reuse them if there are multiple elements with the
curses.init_pair(index, fg, bg) # same foreground and background.
self._color_pairs[(fg, bg)] = curses.color_pair(index) 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._elements[key] = self._color_pairs[(fg, bg)] | attr self._attribute_map[element] = attrs
def get(self, element, modifier=None): def get(self, element, selected=False):
"""
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()')
modifier = modifier or self._modifier if selected or self._selected:
if modifier: element = '@{0}'.format(element)
modified_element = '{0}.{1}'.format(element, modifier)
if modified_element in self._elements:
return self._elements[modified_element]
return self._elements[element] return self._attribute_map[element]
@contextmanager @contextmanager
def set_modifier(self, modifier=None): def turn_on_selected(self):
"""
Sets the selected modifier inside of context block.
# This case is undefined if the context manager is nested For example:
assert self._modifier is None >>> with theme.turn_on_selected():
>>> attr = theme.get('CursorBlock')
self._modifier = modifier Is the same as:
>>> attr = theme.get('CursorBlock', selected=True)
Is also the same as:
>>> attr = theme.get('@CursorBlock')
"""
# This context manager should never be nested
assert self._selected is None
self._selected = True
try: try:
yield yield
finally: finally:
self._modifier = None self._selected = None
@classmethod
def list_themes(cls, path=THEMES):
"""
Compile all of the themes configuration files in the search path.
"""
themes, errors = [], OrderedDict()
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(path, filename)
name = filename[:-4]
try:
# Make sure the theme is valid
theme = cls.from_file(filepath, source)
except Exception as e:
errors[(source, name)] = e
else:
themes.append(theme)
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):
"""
Prints a human-readable summary 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, errors = cls.list_themes(path=path + '/')
print('\nInstalled ({0}):'.format(path))
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('\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))
print('\nBuilt-in:')
built_in = [t for t in themes if t.source == 'built-in']
for theme in built_in:
line = ' {0:<20}[requires {1} colors]'
print(line.format(theme.name, theme.required_colors))
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('')
@classmethod
def from_name(cls, name, 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.
"""
if os.path.isfile(name):
return cls.from_file(name, 'custom')
filename = os.path.join(path, '{0}.cfg'.format(name))
if os.path.isfile(filename):
return cls.from_file(filename, 'installed')
filename = os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))
if os.path.isfile(filename):
return cls.from_file(filename, 'preset')
raise ConfigError('Could not find theme named "{0}"'.format(name))
@classmethod
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.
"""
_logger.info('Loading theme %s', filename)
try:
config = configparser.ConfigParser()
config.optionxform = six.text_type # Preserve case
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 = {}
for element, line in config.items('theme'):
if element not in cls.DEFAULT_ELEMENTS:
# Could happen if using a new config with an older version
# of the software
_logger.info('Skipping element %s', element)
continue
elements[element] = cls._parse_line(element, line, filename)
return cls(name=theme_name, source=source, elements=elements)
@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)
if fg not in cls.COLOR_CODES:
raise ConfigError(
'Error loading {0}, invalid <foreground>:\n'
' {1} = {2}'.format(filename, element, line))
fg_code = cls.COLOR_CODES[fg]
if bg not in cls.COLOR_CODES:
raise ConfigError(
'Error loading {0}, invalid <background>:\n'
' {1} = {2}'.format(filename, element, line))
bg_code = cls.COLOR_CODES[bg]
attrs_code = curses.A_NORMAL
for attr in attrs.split('+'):
if attr not in cls.ATTRIBUTE_CODES:
raise ConfigError(
'Error loading {0}, invalid <attributes>:\n'
' {1} = {2}'.format(filename, element, line))
attr_code = cls.ATTRIBUTE_CODES[attr]
if attr_code is None:
attrs_code = None
break
else:
attrs_code |= attr_code
return fg_code, bg_code, attrs_code
@staticmethod
def _set_fallback(elements, src_field, fallback, dest_field=None):
"""
Helper function used to set the fallback attributes of an element when
they are defined by the configuration as "None" or "-".
"""
if dest_field is None:
dest_field = src_field
if isinstance(fallback, six.string_types):
fallback = elements[fallback]
attrs = elements[src_field]
elements[dest_field] = (
attrs[0] if attrs[0] is not None else fallback[0],
attrs[1] if attrs[1] is not None else fallback[1],
attrs[2] if attrs[2] is not None else fallback[2])
@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 = int(36 * r + 6 * g + b + 16)
return 'ansi_{0:d}'.format(n)
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):
self.themes = None
self.errors = None
def reload(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()
def _step(self, theme, direction):
"""
Traverse the list in the given direction and return the next theme
"""
if not self.themes:
self.reload()
# Try to find the starting index
key = (theme.source, theme.name)
for i, val in enumerate(self.themes):
if (val.source, val.name) == key:
index = i
break
else:
# If the theme was set from a custom source it might
# not be a part of the list returned by list_themes().
self.themes.insert(0, theme)
index = 0
index = (index + direction) % len(self.themes)
new_theme = self.themes[index]
return new_theme
def next(self, theme):
return self._step(theme, 1)
def previous(self, theme):
return self._step(theme, -1)

View File

@@ -0,0 +1,50 @@
[theme]
;<element> = <foreground> <background> <attributes>
Normal = default default normal
Selected = default default normal
SelectedCursor = default default reverse
TitleBar = cyan - bold+reverse
OrderBar = yellow - bold
OrderBarHighlight = yellow - bold+reverse
HelpBar = cyan - bold+reverse
Prompt = cyan - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = - - bold
NoticeSuccess = - - bold
CursorBlock = - - -
CursorBar1 = magenta - -
CursorBar2 = cyan - -
CursorBar3 = green - -
CursorBar4 = yellow - -
CommentAuthor = blue - bold
CommentAuthorSelf = green - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = red - bold
Gold = yellow - bold
HiddenCommentExpand = - - bold
HiddenCommentText = - - -
MultiredditName = yellow - bold
MultiredditText = - - -
NeutralVote = - - bold
NSFW = red - bold+reverse
Saved = green - -
Score = - - -
Separator = - - bold
Stickied = green - -
SubscriptionName = yellow - bold
SubscriptionText = - - -
SubmissionAuthor = green - bold
SubmissionFlair = red - -
SubmissionSubreddit = yellow - -
SubmissionText = - - -
SubmissionTitle = - - bold
Upvote = green - bold
Link = blue - underline
LinkSeen = magenta - underline
UserFlair = yellow - bold

73
rtv/themes/molokai.cfg Normal file
View File

@@ -0,0 +1,73 @@
# https://github.com/tomasr/molokai
# normal ansi_252, ansi_234
# line number ansi_239, ansi_235
# cursor ansi_252, ansi_236
# pmenusel ansi_255, ansi_242
# text - normal ansi_252
# text - dim ansi_244
# text - ultra dim ansi_241
# purple ansi_141
# green ansi_154
# magenta ansi_199, ansi_16
# gold ansi_222, ansi_233
# red ansi_197
# red - dim ansi_203
# orange ansi_208
# blue ansi_81
# blue - dim ansi_67, ansi_16
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_252 ansi_234 normal
Selected = ansi_252 ansi_236 normal
SelectedCursor = ansi_252 ansi_234 bold+reverse
TitleBar = ansi_81 - bold+reverse
OrderBar = ansi_244 ansi_235 -
OrderBarHighlight = ansi_244 ansi_235 bold+reverse
HelpBar = ansi_81 - bold+reverse
Prompt = ansi_208 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_199 - bold
NoticeSuccess = ansi_154 - bold
CursorBlock = ansi_252 - -
CursorBar1 = ansi_141 - -
CursorBar2 = ansi_197 - -
CursorBar3 = ansi_154 - -
CursorBar4 = ansi_208 - -
CommentAuthor = ansi_81 - -
CommentAuthorSelf = ansi_154 - -
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_197 - bold
Gold = ansi_222 - bold
HiddenCommentExpand = ansi_244 - bold
HiddenCommentText = ansi_244 - -
MultiredditName = - - bold
MultiredditText = ansi_244 - -
NeutralVote = - - bold
NSFW = ansi_197 - bold+reverse
Saved = ansi_199 - -
Score = - - bold
Separator = ansi_241 - bold
Stickied = ansi_208 - -
SubscriptionName = - - bold
SubscriptionText = ansi_244 - -
SubmissionAuthor = ansi_154 - -
SubmissionFlair = ansi_197 - -
SubmissionSubreddit = ansi_222 - -
SubmissionText = - - -
SubmissionTitle = - - bold
Upvote = ansi_154 - bold
Link = ansi_67 - underline
LinkSeen = ansi_141 - underline
UserFlair = ansi_222 - bold

71
rtv/themes/papercolor.cfg Normal file
View File

@@ -0,0 +1,71 @@
# https://github.com/NLKNguyen/papercolor-theme
# background ansi_255
# negative ansi_124
# positive ansi_28
# olive ansi_64
# neutral ansi_31
# comment ansi_102
# navy ansi_24
# foreground ansi_238
# nontext ansi_250
# red ansi_160
# pink ansi_162
# purple ansi_91
# accent ansi_166
# orange ansi_166
# blue ansi_25
# highlight ansi_24
# aqua ansi_31
# green ansi_28
[theme]
;<element> = <foreground> <background> <attributes>
Normal = ansi_238 ansi_255 normal
Selected = ansi_238 ansi_254 normal
SelectedCursor = ansi_238 ansi_255 bold+reverse
TitleBar = ansi_24 - bold+reverse
OrderBar = ansi_25 - bold
OrderBarHighlight = ansi_25 - bold+reverse
HelpBar = ansi_24 - bold+reverse
Prompt = ansi_31 - bold+reverse
NoticeInfo = ansi_238 ansi_252 bold
NoticeLoading = ansi_238 ansi_252 bold
NoticeError = ansi_124 ansi_225 bold
NoticeSuccess = ansi_28 ansi_157 bold
CursorBlock = ansi_102 - -
CursorBar1 = ansi_162 - -
CursorBar2 = ansi_166 - -
CursorBar3 = ansi_25 - -
CursorBar4 = ansi_91 - -
CommentAuthor = ansi_25 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_124 - bold
Gold = ansi_166 - bold
HiddenCommentExpand = ansi_102 - bold
HiddenCommentText = ansi_102 - -
MultiredditName = - - bold
MultiredditText = ansi_102 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_31 - bold
Score = - - bold
Separator = - - bold
Stickied = ansi_166 - bold
SubscriptionName = - - bold
SubscriptionText = ansi_102 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_162 - bold
SubmissionSubreddit = ansi_166 - bold
SubmissionText = - - -
SubmissionTitle = - - bold
Upvote = ansi_28 - bold
Link = ansi_24 - underline
LinkSeen = ansi_91 - underline
UserFlair = ansi_162 - bold

View File

@@ -0,0 +1,69 @@
# 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>
Normal = ansi_244 ansi_234 normal
Selected = ansi_244 ansi_235 normal
SelectedCursor = ansi_244 ansi_235 bold+reverse
TitleBar = ansi_37 - bold+reverse
OrderBar = ansi_245 - bold
OrderBarHighlight = ansi_245 - bold+reverse
HelpBar = ansi_37 - bold+reverse
Prompt = ansi_33 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_160 - bold
NoticeSuccess = ansi_64 - bold
CursorBlock = ansi_240 - -
CursorBar1 = ansi_125 - -
CursorBar2 = ansi_160 - -
CursorBar3 = ansi_61 - -
CursorBar4 = ansi_37 - -
CommentAuthor = ansi_33 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_160 - bold
Gold = ansi_136 - bold
HiddenCommentExpand = ansi_240 - bold
HiddenCommentText = ansi_240 - -
MultiredditName = ansi_245 - bold
MultiredditText = ansi_240 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_125 - -
Score = - - -
Separator = - - bold
Stickied = ansi_136 - -
SubscriptionName = ansi_245 - bold
SubscriptionText = ansi_240 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_160 - -
SubmissionSubreddit = ansi_166 - -
SubmissionText = - - -
SubmissionTitle = ansi_245 - bold
Upvote = ansi_64 - bold
Link = ansi_33 - underline
LinkSeen = ansi_61 - underline
UserFlair = ansi_136 - bold

View File

@@ -0,0 +1,69 @@
# 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>
Normal = ansi_241 ansi_230 normal
Selected = ansi_241 ansi_254 normal
SelectedCursor = ansi_241 ansi_254 bold+reverse
TitleBar = ansi_37 - bold+reverse
OrderBar = ansi_245 - bold
OrderBarHighlight = ansi_245 - bold+reverse
HelpBar = ansi_37 - bold+reverse
Prompt = ansi_33 - bold+reverse
NoticeInfo = - - bold
NoticeLoading = - - bold
NoticeError = ansi_160 - bold
NoticeSuccess = ansi_64 - bold
CursorBlock = ansi_245 - -
CursorBar1 = ansi_125 - -
CursorBar2 = ansi_160 - -
CursorBar3 = ansi_61 - -
CursorBar4 = ansi_37 - -
CommentAuthor = ansi_33 - bold
CommentAuthorSelf = ansi_64 - bold
CommentCount = - - -
CommentText = - - -
Created = - - -
Downvote = ansi_160 - bold
Gold = ansi_136 - bold
HiddenCommentExpand = ansi_245 - bold
HiddenCommentText = ansi_245 - -
MultiredditName = ansi_240 - bold
MultiredditText = ansi_245 - -
NeutralVote = - - bold
NSFW = ansi_160 - bold+reverse
Saved = ansi_125 - bold
Score = - - -
Separator = - - bold
Stickied = ansi_136 - bold
SubscriptionName = ansi_240 - bold
SubscriptionText = ansi_245 - -
SubmissionAuthor = ansi_64 - bold
SubmissionFlair = ansi_160 - bold
SubmissionSubreddit = ansi_166 - bold
SubmissionText = - - -
SubmissionTitle = ansi_240 - bold
Upvote = ansi_64 - bold
Link = ansi_33 - underline
LinkSeen = ansi_61 - underline
UserFlair = ansi_136 - bold

File diff suppressed because it is too large Load Diff

282
scripts/demo_theme.py Executable file
View File

@@ -0,0 +1,282 @@
#!/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 locale
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, ThemeList
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)
term.set_theme(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)
term.set_theme(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)
term.set_theme(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)
term.set_theme(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)
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: ',))
thread.start()
threads.append((thread, term))
time.sleep(0.5)
curses.curs_set(0)
return threads
def main():
locale.setlocale(locale.LC_ALL, '')
if len(sys.argv) > 1:
theme = Theme.from_name(sys.argv[1])
else:
theme = Theme()
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)
reddit.config.api_request_delay = 0
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)
term.set_theme()
oauth = OAuthHelper(reddit, term, config)
oauth.authorize()
theme_list = ThemeList()
while True:
term = Terminal(stdscr, config)
term.set_theme(theme)
threads = draw_screen(stdscr, reddit, config, theme, oauth)
try:
ch = term.show_notification(theme.display_string)
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()
theme_list.reload()
if ch == curses.KEY_RIGHT:
theme = theme_list.next(theme)
elif ch == curses.KEY_LEFT:
theme = theme_list.previous(theme)
elif ch == Terminal.ESCAPE:
break
else:
# Force the theme to reload
theme = theme_list.next(theme)
theme = theme_list.previous(theme)
sys.exit(main())

View File

@@ -59,7 +59,7 @@ setuptools.setup(
'rtv.packages.praw' 'rtv.packages.praw'
], ],
package_data={ package_data={
'rtv': ['templates/*'], 'rtv': ['templates/*', 'themes/*'],
'rtv.packages.praw': ['praw.ini'] 'rtv.packages.praw': ['praw.ini']
}, },
data_files=[("share/man/man1", ["rtv.1"])], data_files=[("share/man/man1", ["rtv.1"])],

View File

@@ -167,6 +167,8 @@ def stdscr():
curses.color_pair.return_value = 23 curses.color_pair.return_value = 23
curses.has_colors.return_value = True curses.has_colors.return_value = True
curses.ACS_VLINE = 0 curses.ACS_VLINE = 0
curses.COLORS = 256
curses.COLOR_PAIRS = 256
yield out yield out
@@ -199,6 +201,7 @@ def reddit(vcr, request):
@pytest.fixture() @pytest.fixture()
def terminal(stdscr, config): def terminal(stdscr, config):
term = Terminal(stdscr, config=config) term = Terminal(stdscr, config=config)
term.set_theme()
# Disable the python 3.4 addch patch so that the mock stdscr calls are # Disable the python 3.4 addch patch so that the mock stdscr calls are
# always made the same way # always made the same way
term.addch = lambda window, *args: window.addch(*args) term.addch = lambda window, *args: window.addch(*args)

View File

@@ -89,7 +89,9 @@ def test_config_get_args():
'--non-persistent', '--non-persistent',
'--clear-auth', '--clear-auth',
'--copy-config', '--copy-config',
'--enable-media'] '--enable-media',
'--theme', 'molokai',
'--list-themes']
with mock.patch('sys.argv', ['rtv']): with mock.patch('sys.argv', ['rtv']):
config_dict = Config.get_args() config_dict = Config.get_args()
@@ -111,6 +113,8 @@ def test_config_get_args():
assert config['config'] == 'configfile.cfg' assert config['config'] == 'configfile.cfg'
assert config['copy_config'] is True assert config['copy_config'] is True
assert config['enable_media'] is True assert config['enable_media'] is True
assert config['theme'] == 'molokai'
assert config['list_themes'] is True
def test_config_link_deprecated(): def test_config_link_deprecated():
@@ -143,7 +147,8 @@ def test_config_from_file():
'subreddit': 'cfb', 'subreddit': 'cfb',
'enable_media': True, 'enable_media': True,
'max_comment_cols': 150, 'max_comment_cols': 150,
'hide_username': True} 'hide_username': True,
'theme': 'molokai'}
bindings = { bindings = {
'REFRESH': 'r, <KEY_F5>', 'REFRESH': 'r, <KEY_F5>',

View File

@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import curses
import pytest import pytest
from rtv.page import Page, PageController, logged_in from rtv.page import Page, PageController, logged_in
@@ -112,3 +114,40 @@ def test_page_authenticated(reddit, terminal, config, oauth, refresh_token):
terminal.stdscr.getch.return_value = ord('y') terminal.stdscr.getch.return_value = ord('y')
page.controller.trigger('u') page.controller.trigger('u')
assert not reddit.is_oauth_session() assert not reddit.is_oauth_session()
def test_page_cycle_theme(reddit, terminal, config, oauth):
page = Page(reddit, terminal, config, oauth)
page.controller = PageController(page, keymap=config.keymap)
page.term.set_theme()
assert page.term.theme.name == 'default'
with mock.patch.object(terminal, 'show_notification'), \
mock.patch.object(page, 'draw'):
# Next theme
page.controller.trigger(curses.KEY_F3)
assert page.term.theme.name == 'monochrome'
terminal.show_notification.assert_called_with(
'monochrome (built-in)', timeout=1)
# Previous theme
page.controller.trigger(curses.KEY_F2)
assert page.term.theme.name == 'default'
terminal.show_notification.assert_called_with(
'default (built-in)', timeout=1)
# Previous - will loop to one of the 256 color themes
page.controller.trigger(curses.KEY_F2)
assert page.term.theme.source in ('preset', 'installed')
# Reset
page.term.set_theme()
# Will skip over any installed themes that aren't supported
curses.has_colors.return_value = False
page.controller.trigger(curses.KEY_F2)
assert page.term.theme.required_colors == 0

View File

@@ -575,27 +575,81 @@ def test_add_space(terminal, stdscr):
def test_attr(terminal): def test_attr(terminal):
assert terminal.attr('cursor') == 0 assert terminal.attr('CursorBlock') == 0
assert terminal.attr('cursor.selected') == curses.A_REVERSE assert terminal.attr('@CursorBlock') == curses.A_REVERSE
assert terminal.attr('neutral_vote') == curses.A_BOLD assert terminal.attr('NeutralVote') == curses.A_BOLD
with terminal.theme.set_modifier('selected'): with terminal.theme.turn_on_selected():
assert terminal.attr('cursor') == curses.A_REVERSE assert terminal.attr('CursorBlock') == curses.A_REVERSE
assert terminal.attr('neutral_vote') == curses.A_BOLD assert terminal.attr('NeutralVote') == curses.A_BOLD
def test_check_theme(terminal):
monochrome = Theme(use_color=False)
default = Theme()
color256 = Theme.from_name('molokai')
curses.has_colors.return_value = False
assert terminal.check_theme(monochrome)
assert not terminal.check_theme(default)
assert not terminal.check_theme(color256)
curses.has_colors.return_value = True
curses.COLORS = 0
assert terminal.check_theme(monochrome)
assert not terminal.check_theme(default)
assert not terminal.check_theme(color256)
curses.COLORS = 8
assert terminal.check_theme(monochrome)
assert terminal.check_theme(default)
assert not terminal.check_theme(color256)
curses.COLORS = 256
assert terminal.check_theme(monochrome)
assert terminal.check_theme(default)
assert terminal.check_theme(color256)
curses.COLOR_PAIRS = 8
assert terminal.check_theme(monochrome)
assert terminal.check_theme(default)
assert not terminal.check_theme(color256)
def test_set_theme(terminal, stdscr): def test_set_theme(terminal, stdscr):
# Default with color enabled
stdscr.reset_mock() stdscr.reset_mock()
terminal.set_theme() terminal.set_theme()
assert not terminal.theme.monochrome assert terminal.theme.use_color
assert terminal.theme.display_string == 'default (built-in)'
stdscr.bkgd.assert_called_once_with(' ', 0) stdscr.bkgd.assert_called_once_with(' ', 0)
stdscr.reset_mock() # When the user passes in the --monochrome flag
theme = Theme(monochrome=True) terminal.theme = None
terminal.set_theme(theme=theme) terminal.set_theme(Theme(use_color=False))
assert terminal.theme.monochrome assert not terminal.theme.use_color
stdscr.bkgd.assert_called_once_with(' ', 0) assert terminal.theme.display_string == 'monochrome (built-in)'
# When the terminal doesn't support colors
curses.COLORS = 0
terminal.theme = None
terminal.set_theme()
assert terminal.theme.display_string == 'monochrome (built-in)'
# When the terminal doesn't support 256 colors so it falls back to the
# built-in default theme
curses.COLORS = 8
terminal.theme = None
terminal.set_theme(Theme.from_name('molokai'))
assert terminal.theme.display_string == 'default (built-in)'
# When the terminal does support the 256 color theme
curses.COLORS = 256
terminal.theme = None
terminal.set_theme(Theme.from_name('molokai'))
assert terminal.theme.display_string == 'molokai (preset)'
def test_set_theme_no_colors(terminal, stdscr): def test_set_theme_no_colors(terminal, stdscr):
@@ -605,8 +659,7 @@ def test_set_theme_no_colors(terminal, stdscr):
has_colors.return_value = False has_colors.return_value = False
terminal.set_theme() terminal.set_theme()
assert terminal.theme.monochrome assert not terminal.theme.use_color
theme = Theme(monochrome=False) terminal.set_theme(Theme(use_color=True))
terminal.set_theme(theme=theme) assert not terminal.theme.use_color
assert terminal.theme.monochrome

276
tests/test_theme.py Normal file
View File

@@ -0,0 +1,276 @@
import os
import shutil
import curses
from collections import OrderedDict
from contextlib import contextmanager
from tempfile import mkdtemp, 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')
])
@contextmanager
def _ephemeral_directory():
# All of the temporary files for the theme tests must
# be initialized in separate directories, so the tests
# can run in parallel without accidentally loading theme
# files from each other
dirname = None
try:
dirname = mkdtemp()
yield dirname
finally:
if dirname:
shutil.rmtree(dirname, ignore_errors=True)
def test_theme_invalid_source():
with pytest.raises(ValueError):
Theme(name='default', source=None)
with pytest.raises(ValueError):
Theme(name=None, source='installed')
def test_theme_default_construct():
theme = Theme()
assert theme.name == 'default'
assert theme.source == 'built-in'
assert theme.required_colors == 8
assert theme.required_color_pairs == 6
for fg, bg, attr in theme.elements.values():
assert isinstance(fg, int)
assert isinstance(bg, int)
assert isinstance(attr, int)
def test_theme_monochrome_construct():
theme = Theme(use_color=False)
assert theme.name == 'monochrome'
assert theme.source == 'built-in'
assert theme.required_colors == 0
assert theme.required_color_pairs == 0
def test_theme_256_construct():
elements = {'CursorBar1': (None, 101, curses.A_UNDERLINE)}
theme = Theme(elements=elements)
assert theme.elements['CursorBar1'] == (-1, 101, curses.A_UNDERLINE)
assert theme.required_colors == 256
def test_theme_element_selected_attributes():
elements = {
'Normal': (1, 2, curses.A_REVERSE),
'Selected': (2, 3, None),
'TitleBar': (4, None, curses.A_BOLD),
'Link': (5, None, None)}
theme = Theme(elements=elements)
assert theme.elements['Normal'] == (1, 2, curses.A_REVERSE)
# All of the normal elements fallback to the attributes of "Normal"
assert theme.elements['Selected'] == (2, 3, curses.A_REVERSE)
assert theme.elements['TitleBar'] == (4, 2, curses.A_BOLD)
assert theme.elements['Link'] == (5, 2, curses.A_REVERSE)
# The @Selected mode will overwrite any other attributes with
# the ones defined in "Selected". Because "Selected" defines
# a foreground and a background color, they will override the
# ones that "Link" had defined.
# assert theme.elements['@Link'] == (2, 3, curses.A_REVERSE)
# I can't remember why the above rule was implemented, so I reverted it
assert theme.elements['@Link'] == (5, 3, curses.A_REVERSE)
assert '@Normal' not in theme.elements
assert '@Selected' not in theme.elements
assert '@TitleBar' not in theme.elements
def test_theme_default_cfg_matches_builtin():
filename = os.path.join(DEFAULT_THEMES, 'default.cfg.example')
default_theme = Theme.from_file(filename, 'built-in')
# The default theme file should match the hardcoded values
assert default_theme.elements == Theme().elements
# Make sure that the elements passed into the constructor exactly match
# up with the hardcoded elements
class MockTheme(Theme):
def __init__(self, name=None, source=None, elements=None):
assert name == 'default.cfg'
assert source == 'preset'
assert elements == Theme.DEFAULT_ELEMENTS
MockTheme.from_file(filename, 'preset')
args, ids = INVALID_ELEMENTS.values(), list(INVALID_ELEMENTS)
@pytest.mark.parametrize('line', args, ids=ids)
def test_theme_from_file_invalid(line):
with _ephemeral_directory() as dirname:
with NamedTemporaryFile(mode='w+', dir=dirname) as fp:
fp.write('[theme]\n')
fp.write(line)
fp.flush()
with pytest.raises(ConfigError):
Theme.from_file(fp.name, 'installed')
def test_theme_from_file():
with _ephemeral_directory() as dirname:
with NamedTemporaryFile(mode='w+', dir=dirname) as fp:
with pytest.raises(ConfigError):
Theme.from_file(fp.name, 'installed')
fp.write('[theme]\n')
fp.write('Unknown = - -\n')
fp.write('Upvote = - red\n')
fp.write('Downvote = ansi_255 default bold\n')
fp.write('NeutralVote = #000000 #ffffff bold+reverse\n')
fp.flush()
theme = Theme.from_file(fp.name, 'installed')
assert theme.source == 'installed'
assert 'Unknown' not in theme.elements
assert theme.elements['Upvote'] == (
-1, curses.COLOR_RED, curses.A_NORMAL)
assert theme.elements['Downvote'] == (
255, -1, curses.A_BOLD)
assert theme.elements['NeutralVote'] == (
16, 231, curses.A_BOLD | curses.A_REVERSE)
def test_theme_from_name():
with _ephemeral_directory() as dirname:
with NamedTemporaryFile(mode='w+', suffix='.cfg', dir=dirname) 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.source == 'custom'
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.source == 'installed'
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()
with pytest.raises(RuntimeError):
theme.get('Upvote')
theme.bind_curses()
assert len(theme._color_pair_map) == theme.required_color_pairs
for element in Theme.DEFAULT_ELEMENTS:
assert isinstance(theme.get(element), int)
theme = Theme(use_color=False)
theme.bind_curses()
def test_theme_initialize_attributes_monochrome(stdscr):
theme = Theme(use_color=False)
theme.bind_curses()
theme.get('Upvote')
# Avoid making these curses calls if colors aren't initialized
assert not curses.init_pair.called
assert not curses.color_pair.called
def test_theme_list_themes():
with _ephemeral_directory() as dirname:
with NamedTemporaryFile(mode='w+', suffix='.cfg', dir=dirname) as fp:
path, filename = os.path.split(fp.name)
theme_name = filename[:-4]
fp.write('[theme]\n')
fp.flush()
Theme.print_themes(path)
themes, errors = Theme.list_themes(path)
assert not errors
theme_strings = [t.display_string for t in themes]
assert theme_name + ' (installed)' in theme_strings
assert 'default (built-in)' in theme_strings
assert 'monochrome (built-in)' in theme_strings
assert 'molokai (preset)' in theme_strings
def test_theme_list_themes_invalid():
with _ephemeral_directory() as dirname:
with NamedTemporaryFile(mode='w+', suffix='.cfg', dir=dirname) 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, errors = Theme.list_themes(path)
assert ('installed', theme_name) in errors
def test_theme_presets_define_all_elements():
# The themes in the preset themes/ folder should have all of the valid
# elements defined in their configuration.
class MockTheme(Theme):
def __init__(self, name=None, source=None, elements=None, use_color=True):
if source == 'preset':
assert set(elements.keys()) == set(Theme.DEFAULT_ELEMENTS.keys())
super(MockTheme, self).__init__(name, source, elements, use_color)
themes, errors = MockTheme.list_themes()
assert sum([theme.source == 'preset' for theme in themes]) >= 4