@@ -17,6 +17,8 @@ Basic Commands
|
||||
:``q``/``Q``: Quit/Force quit
|
||||
:``y``: Copy submission permalink to clipboard
|
||||
:``Y``: Copy submission link to clipboard
|
||||
:``F2``: Cycle to the previous color theme
|
||||
:``F3``: Cycle to the next color theme
|
||||
|
||||
----------------------
|
||||
Authenticated Commands
|
||||
|
||||
@@ -5,3 +5,4 @@ include README.md
|
||||
include LICENSE
|
||||
include rtv.1
|
||||
include rtv/templates/*
|
||||
include rtv/themes/*
|
||||
|
||||
48
README.md
@@ -40,7 +40,8 @@ RTV is built in python using the curses library.
|
||||
* [Demo](#demo)
|
||||
* [Installation](#installation)
|
||||
* [Usage](#usage)
|
||||
* [Settings](#settings)
|
||||
* [Settings](#settings)
|
||||
* [Themes](#themes)
|
||||
* [FAQ](#faq)
|
||||
* [Contributing](#contributing)
|
||||
* [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``
|
||||
- ``/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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
$ rtv --copy-config
|
||||
@@ -134,7 +135,7 @@ A mailcap file allows you to associate different MIME media types, like ``image/
|
||||
$ 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**)
|
||||
|
||||
@@ -161,10 +162,41 @@ The default programs that RTV interacts with can be configured through environme
|
||||
</table>
|
||||
|
||||
### 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/).
|
||||
|
||||
## 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
|
||||
|
||||
<details>
|
||||
@@ -213,8 +245,8 @@ On Linux systems you will need to install either [xsel](http://www.vergenet.net/
|
||||
## Contributing
|
||||
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
|
||||
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
@@ -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.
|
||||
BIN
resources/iterm_preferences.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
resources/terminal_colors.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
resources/theme_default.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
resources/theme_modifiers.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
resources/theme_modifiers_2.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
resources/theme_molokai.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
resources/theme_monochrome.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
resources/theme_papercolor.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
resources/theme_solarized_dark.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
resources/theme_solarized_light.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
@@ -91,6 +91,10 @@ def main():
|
||||
copy_default_mailcap()
|
||||
return
|
||||
|
||||
if config['list_themes']:
|
||||
Theme.print_themes()
|
||||
return
|
||||
|
||||
# Load the browsing history from previous sessions
|
||||
config.load_history()
|
||||
|
||||
@@ -171,8 +175,19 @@ def main():
|
||||
try:
|
||||
with curses_session() as stdscr:
|
||||
|
||||
theme = Theme(config['monochrome'])
|
||||
term = Terminal(stdscr, config, theme)
|
||||
term = Terminal(stdscr, config)
|
||||
|
||||
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):
|
||||
reddit = praw.Reddit(user_agent=user_agent,
|
||||
|
||||
@@ -13,16 +13,19 @@ from six.moves import configparser
|
||||
from . import docs, __version__
|
||||
from .objects import KeyMap
|
||||
|
||||
|
||||
PACKAGE = os.path.dirname(__file__)
|
||||
HOME = os.path.expanduser('~')
|
||||
TEMPLATES = os.path.join(PACKAGE, 'templates')
|
||||
DEFAULT_CONFIG = os.path.join(TEMPLATES, 'rtv.cfg')
|
||||
DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap')
|
||||
DEFAULT_THEMES = os.path.join(PACKAGE, 'themes')
|
||||
XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
|
||||
CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg')
|
||||
MAILCAP = os.path.join(HOME, '.mailcap')
|
||||
TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token')
|
||||
HISTORY = os.path.join(XDG_HOME, 'rtv', 'history.log')
|
||||
THEMES = os.path.join(XDG_HOME, 'rtv', 'themes')
|
||||
|
||||
|
||||
def build_parser():
|
||||
@@ -52,6 +55,12 @@ def build_parser():
|
||||
parser.add_argument(
|
||||
'--monochrome', action='store_const', const=True,
|
||||
help='Disable color')
|
||||
parser.add_argument(
|
||||
'--theme', metavar='FILE', action='store',
|
||||
help='Color theme to use, see --list-themes for valid options')
|
||||
parser.add_argument(
|
||||
'--list-themes', metavar='FILE', action='store_const', const=True,
|
||||
help='List all of the available color themes')
|
||||
parser.add_argument(
|
||||
'--non-persistent', dest='persistent', action='store_const', const=False,
|
||||
help='Forget the authenticated user when the program exits')
|
||||
|
||||
@@ -217,6 +217,7 @@ class Content(object):
|
||||
data['title'] = sub.title
|
||||
data['text'] = sub.selftext
|
||||
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['score'] = '{0} pts'.format('-' if sub.hide_score else sub.score)
|
||||
data['author'] = name
|
||||
|
||||
@@ -60,6 +60,8 @@ https://github.com/michael-lazar/rtv
|
||||
b : Display urls with urlview
|
||||
y : Copy submission permalink to clipboard
|
||||
Y : Copy submission link to clipboard
|
||||
F2 : Cycle to previous theme
|
||||
F3 : Cycle to next theme
|
||||
|
||||
[Prompt]
|
||||
The `/` prompt accepts subreddits in the following formats
|
||||
@@ -87,7 +89,6 @@ BANNER_SEARCH = """
|
||||
[1]relevance [2]top [3]comments [4]new
|
||||
"""
|
||||
|
||||
|
||||
FOOTER_SUBREDDIT = """
|
||||
[?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote
|
||||
"""
|
||||
|
||||
@@ -193,23 +193,23 @@ class OAuthHelper(object):
|
||||
# If an exception is raised it will be seen by the thread
|
||||
# so we don't need to explicitly shutdown() the server
|
||||
_logger.exception(e)
|
||||
self.term.show_notification('Browser Error', style='error')
|
||||
self.term.show_notification('Browser Error', style='Error')
|
||||
else:
|
||||
self.server.shutdown()
|
||||
finally:
|
||||
thread.join()
|
||||
|
||||
if self.params['error'] == 'access_denied':
|
||||
self.term.show_notification('Denied access', style='error')
|
||||
self.term.show_notification('Denied access', style='Error')
|
||||
return
|
||||
elif self.params['error']:
|
||||
self.term.show_notification('Authentication error', style='error')
|
||||
self.term.show_notification('Authentication error', style='Error')
|
||||
return
|
||||
elif self.params['state'] is None:
|
||||
# Something went wrong but it's not clear what happened
|
||||
return
|
||||
elif self.params['state'] != state:
|
||||
self.term.show_notification('UUID mismatch', style='error')
|
||||
self.term.show_notification('UUID mismatch', style='Error')
|
||||
return
|
||||
|
||||
with self.term.loader('Logging in'):
|
||||
|
||||
@@ -238,7 +238,7 @@ class LoadScreen(object):
|
||||
# Some exceptions we want to swallow and display a notification
|
||||
if isinstance(e, e_type):
|
||||
msg = message.format(e)
|
||||
self._terminal.show_notification(msg, style='error')
|
||||
self._terminal.show_notification(msg, style='Error')
|
||||
return True
|
||||
|
||||
def animate(self, delay, interval, message, trail):
|
||||
@@ -267,7 +267,7 @@ class LoadScreen(object):
|
||||
s_row = (n_rows - 3) // 2 + v_offset
|
||||
s_col = (n_cols - message_len - 1) // 2 + h_offset
|
||||
window = curses.newwin(3, message_len + 2, s_row, s_col)
|
||||
window.bkgd(str(' '), self._terminal.attr('notice_loading'))
|
||||
window.bkgd(str(' '), self._terminal.attr('NoticeLoading'))
|
||||
|
||||
# Animate the loading prompt until the stopping condition is triggered
|
||||
# when the context manager exits.
|
||||
|
||||
47
rtv/page.py
@@ -95,6 +95,30 @@ class Page(object):
|
||||
def force_exit(self):
|
||||
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'))
|
||||
def show_help(self):
|
||||
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.erase()
|
||||
# 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 = sub_name.replace('/r/front', 'Front Page')
|
||||
@@ -402,7 +426,7 @@ class Page(object):
|
||||
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
||||
window.erase()
|
||||
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
|
||||
items = banner.strip().split(' ')
|
||||
@@ -414,7 +438,7 @@ class Page(object):
|
||||
if self.content.order is not None:
|
||||
order = self.content.order.split('-')[0]
|
||||
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)
|
||||
|
||||
self._row += 1
|
||||
@@ -482,16 +506,14 @@ class Page(object):
|
||||
self.nav.cursor_index = len(self._subwindows) - 1
|
||||
|
||||
# Now that the windows are setup, we can take a second pass through
|
||||
# to draw the content
|
||||
# to draw the text onto each subwindow
|
||||
for index, (win, data, inverted) in enumerate(self._subwindows):
|
||||
if index == self.nav.cursor_index:
|
||||
# This lets the theme know to invert the cursor
|
||||
modifier = 'selected'
|
||||
if self.nav.absolute_index >= 0 and index == self.nav.cursor_index:
|
||||
win.bkgd(str(' '), self.term.attr('Selected'))
|
||||
with self.term.theme.turn_on_selected():
|
||||
self._draw_item(win, data, inverted)
|
||||
else:
|
||||
modifier = None
|
||||
|
||||
win.bkgd(str(' '), self.term.attr('normal'))
|
||||
with self.term.theme.set_modifier(modifier):
|
||||
win.bkgd(str(' '), self.term.attr('Normal'))
|
||||
self._draw_item(win, data, inverted)
|
||||
|
||||
self._row += win_n_rows
|
||||
@@ -501,7 +523,7 @@ class Page(object):
|
||||
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
||||
window.erase()
|
||||
window.bkgd(str(' '), self.term.attr('help_bar'))
|
||||
window.bkgd(str(' '), self.term.attr('HelpBar'))
|
||||
|
||||
text = self.FOOTER.strip()
|
||||
self.term.add_line(window, text, 0, 0)
|
||||
@@ -534,4 +556,3 @@ class Page(object):
|
||||
ch = self.term.show_notification(message)
|
||||
ch = six.unichr(ch)
|
||||
return choices.get(ch)
|
||||
|
||||
|
||||
@@ -317,15 +317,15 @@ class SubmissionPage(Page):
|
||||
row = offset
|
||||
if row in valid_rows:
|
||||
if data['is_author']:
|
||||
attr = self.term.attr('comment_author_self')
|
||||
attr = self.term.attr('CommentAuthorSelf')
|
||||
text = '{author} [S]'.format(**data)
|
||||
else:
|
||||
attr = self.term.attr('comment_author')
|
||||
attr = self.term.attr('CommentAuthor')
|
||||
text = '{author}'.format(**data)
|
||||
self.term.add_line(win, text, row, 1, attr)
|
||||
|
||||
if data['flair']:
|
||||
attr = self.term.attr('user_flair')
|
||||
attr = self.term.attr('UserFlair')
|
||||
self.term.add_space(win)
|
||||
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_line(win, arrow, attr=attr)
|
||||
|
||||
attr = self.term.attr('score')
|
||||
attr = self.term.attr('Score')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '{score}'.format(**data), attr=attr)
|
||||
|
||||
attr = self.term.attr('created')
|
||||
attr = self.term.attr('Created')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
||||
|
||||
if data['gold']:
|
||||
attr = self.term.attr('gold')
|
||||
attr = self.term.attr('Gold')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||
|
||||
if data['stickied']:
|
||||
attr = self.term.attr('stickied')
|
||||
attr = self.term.attr('Stickied')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '[stickied]', attr=attr)
|
||||
|
||||
if data['saved']:
|
||||
attr = self.term.attr('saved')
|
||||
attr = self.term.attr('Saved')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '[saved]', attr=attr)
|
||||
|
||||
for row, text in enumerate(split_body, start=offset+1):
|
||||
attr = self.term.attr('comment_text')
|
||||
attr = self.term.attr('CommentText')
|
||||
if row in valid_rows:
|
||||
self.term.add_line(win, text, row, 1, attr=attr)
|
||||
|
||||
# Unfortunately vline() doesn't support custom color so we have to
|
||||
# build it one segment at a time.
|
||||
index = data['level'] % len(self.term.theme.BAR_LEVELS)
|
||||
attr = self.term.attr(self.term.theme.BAR_LEVELS[index])
|
||||
# curses.vline() doesn't support custom colors so need to build the
|
||||
# cursor bar on the left of the comment one character at a time
|
||||
index = data['level'] % len(self.term.theme.CURSOR_BARS)
|
||||
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
|
||||
for y in range(n_rows):
|
||||
self.term.addch(win, y, 0, self.term.vline, attr)
|
||||
|
||||
@@ -373,15 +373,15 @@ class SubmissionPage(Page):
|
||||
n_rows, n_cols = win.getmaxyx()
|
||||
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)
|
||||
|
||||
attr = self.term.attr('hidden_comment_expand')
|
||||
attr = self.term.attr('HiddenCommentExpand')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
|
||||
|
||||
index = data['level'] % len(self.term.theme.BAR_LEVELS)
|
||||
attr = self.term.attr(self.term.theme.BAR_LEVELS[index])
|
||||
index = data['level'] % len(self.term.theme.CURSOR_BARS)
|
||||
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
|
||||
self.term.addch(win, 0, 0, self.term.vline, attr)
|
||||
|
||||
def _draw_submission(self, win, data):
|
||||
@@ -389,32 +389,32 @@ class SubmissionPage(Page):
|
||||
n_rows, n_cols = win.getmaxyx()
|
||||
n_cols -= 3 # one for each side of the border + one for offset
|
||||
|
||||
attr = self.term.attr('submission_title')
|
||||
attr = self.term.attr('SubmissionTitle')
|
||||
for row, text in enumerate(data['split_title'], start=1):
|
||||
self.term.add_line(win, text, row, 1, attr)
|
||||
|
||||
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)
|
||||
|
||||
if data['flair']:
|
||||
attr = self.term.attr('submission_flair')
|
||||
attr = self.term.attr('SubmissionFlair')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||
|
||||
attr = self.term.attr('created')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '{created}'.format(**data), attr=attr)
|
||||
|
||||
attr = self.term.attr('submission_subreddit')
|
||||
attr = self.term.attr('SubmissionSubreddit')
|
||||
self.term.add_space(win)
|
||||
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
|
||||
if data['url_full'] in self.config.history:
|
||||
attr = self.term.attr('url_seen')
|
||||
attr = self.term.attr('LinkSeen')
|
||||
else:
|
||||
attr = self.term.attr('url')
|
||||
attr = self.term.attr('Link')
|
||||
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
|
||||
|
||||
offset = len(data['split_title']) + 3
|
||||
@@ -426,34 +426,34 @@ class SubmissionPage(Page):
|
||||
split_text = split_text[:-cutoff]
|
||||
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):
|
||||
self.term.add_line(win, text, row, 1, attr=attr)
|
||||
|
||||
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)
|
||||
|
||||
arrow, attr = self.term.get_arrow(data['likes'])
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, arrow, attr=attr)
|
||||
|
||||
attr = self.term.attr('comment_count')
|
||||
attr = self.term.attr('CommentCount')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
||||
|
||||
if data['gold']:
|
||||
attr = self.term.attr('gold')
|
||||
attr = self.term.attr('Gold')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||
|
||||
if data['nsfw']:
|
||||
attr = self.term.attr('nsfw')
|
||||
attr = self.term.attr('NSFW')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, 'NSFW', attr=attr)
|
||||
|
||||
if data['saved']:
|
||||
attr = self.term.attr('saved')
|
||||
attr = self.term.attr('Saved')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '[saved]', attr=attr)
|
||||
|
||||
|
||||
@@ -304,22 +304,22 @@ class SubredditPage(Page):
|
||||
|
||||
n_title = len(data['split_title'])
|
||||
for row, text in enumerate(data['split_title'], start=offset):
|
||||
attr = self.term.attr('submission_title')
|
||||
attr = self.term.attr('SubmissionTitle')
|
||||
if row in valid_rows:
|
||||
self.term.add_line(win, text, row, 1, attr)
|
||||
|
||||
row = n_title + offset
|
||||
if row in valid_rows:
|
||||
if data['url_full'] in self.config.history:
|
||||
attr = self.term.attr('url_seen')
|
||||
attr = self.term.attr('LinkSeen')
|
||||
else:
|
||||
attr = self.term.attr('url')
|
||||
attr = self.term.attr('Link')
|
||||
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
|
||||
|
||||
row = n_title + offset + 1
|
||||
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_space(win)
|
||||
|
||||
@@ -327,52 +327,52 @@ class SubredditPage(Page):
|
||||
self.term.add_line(win, arrow, attr=attr)
|
||||
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)
|
||||
|
||||
if data['comments'] is not None:
|
||||
attr = self.term.attr('separator')
|
||||
attr = self.term.attr('Separator')
|
||||
self.term.add_space(win)
|
||||
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_line(win, '{comments}'.format(**data), attr=attr)
|
||||
|
||||
if data['saved']:
|
||||
attr = self.term.attr('saved')
|
||||
attr = self.term.attr('Saved')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '[saved]', attr=attr)
|
||||
|
||||
if data['stickied']:
|
||||
attr = self.term.attr('stickied')
|
||||
attr = self.term.attr('Stickied')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, '[stickied]', attr=attr)
|
||||
|
||||
if data['gold']:
|
||||
attr = self.term.attr('gold')
|
||||
attr = self.term.attr('Gold')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, self.term.guilded, attr=attr)
|
||||
|
||||
if data['nsfw']:
|
||||
attr = self.term.attr('nsfw')
|
||||
attr = self.term.attr('NSFW')
|
||||
self.term.add_space(win)
|
||||
self.term.add_line(win, 'NSFW', attr=attr)
|
||||
|
||||
row = n_title + offset + 2
|
||||
if row in valid_rows:
|
||||
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_space(win)
|
||||
|
||||
attr = self.term.attr('submission_subreddit')
|
||||
attr = self.term.attr('SubmissionSubreddit')
|
||||
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
||||
|
||||
if data['flair']:
|
||||
attr = self.term.attr('submission_flair')
|
||||
attr = self.term.attr('SubmissionFlair')
|
||||
self.term.add_space(win)
|
||||
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):
|
||||
self.term.addch(win, y, 0, str(' '), attr)
|
||||
|
||||
@@ -93,20 +93,20 @@ class SubscriptionPage(Page):
|
||||
row = offset
|
||||
if row in valid_rows:
|
||||
if data['type'] == 'Multireddit':
|
||||
attr = self.term.attr('multireddit_name')
|
||||
attr = self.term.attr('MultiredditName')
|
||||
else:
|
||||
attr = self.term.attr('subscription_name')
|
||||
attr = self.term.attr('SubscriptionName')
|
||||
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
|
||||
|
||||
row = offset + 1
|
||||
for row, text in enumerate(data['split_title'], start=row):
|
||||
if row in valid_rows:
|
||||
if data['type'] == 'Multireddit':
|
||||
attr = self.term.attr('multireddit_text')
|
||||
attr = self.term.attr('MultiredditText')
|
||||
else:
|
||||
attr = self.term.attr('subscription_text')
|
||||
attr = self.term.attr('SubscriptionText')
|
||||
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):
|
||||
self.term.addch(win, y, 0, str(' '), attr)
|
||||
|
||||
@@ -43,6 +43,11 @@ max_comment_cols = 120
|
||||
; Hide username if logged in, display "Logged in" instead
|
||||
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
|
||||
################
|
||||
@@ -110,6 +115,8 @@ SORT_NEW = 4
|
||||
SORT_CONTROVERSIAL = 5
|
||||
MOVE_UP = k, <KEY_UP>
|
||||
MOVE_DOWN = j, <KEY_DOWN>
|
||||
PREVIOUS_THEME = <KEY_F2>
|
||||
NEXT_THEME = <KEY_F3>
|
||||
PAGE_UP = m, <KEY_PPAGE>, <NAK>
|
||||
PAGE_DOWN = n, <KEY_NPAGE>, <EOT>
|
||||
PAGE_TOP = gg
|
||||
|
||||
@@ -21,7 +21,7 @@ import six
|
||||
from kitchen.text.display import textual_width_chop
|
||||
|
||||
from . import exceptions, mime_parsers, content
|
||||
from .theme import Theme
|
||||
from .theme import Theme, ThemeList
|
||||
from .objects import LoadScreen
|
||||
|
||||
try:
|
||||
@@ -51,14 +51,13 @@ class Terminal(object):
|
||||
RETURN = 10
|
||||
SPACE = 32
|
||||
|
||||
def __init__(self, stdscr, config, theme=None):
|
||||
def __init__(self, stdscr, config):
|
||||
|
||||
self.stdscr = stdscr
|
||||
self.config = config
|
||||
self.loader = LoadScreen(self)
|
||||
|
||||
self.theme = None
|
||||
self.set_theme(theme)
|
||||
self.theme = None # Initialized by term.set_theme()
|
||||
self.theme_list = ThemeList()
|
||||
|
||||
self._display = None
|
||||
self._mailcap_dict = mailcap.getcaps()
|
||||
@@ -193,11 +192,11 @@ class Terminal(object):
|
||||
"""
|
||||
|
||||
if likes is None:
|
||||
return self.neutral_arrow, self.attr('neutral_vote')
|
||||
return self.neutral_arrow, self.attr('NeutralVote')
|
||||
elif likes:
|
||||
return self.up_arrow, self.attr('upvote')
|
||||
return self.up_arrow, self.attr('Upvote')
|
||||
else:
|
||||
return self.down_arrow, self.attr('downvote')
|
||||
return self.down_arrow, self.attr('Downvote')
|
||||
|
||||
def clean(self, string, n_cols=None):
|
||||
"""
|
||||
@@ -293,7 +292,7 @@ class Terminal(object):
|
||||
|
||||
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.
|
||||
|
||||
@@ -305,7 +304,7 @@ class Terminal(object):
|
||||
notification window
|
||||
"""
|
||||
|
||||
assert style in ('info', 'warning', 'error', 'success')
|
||||
assert style in ('Info', 'Warning', 'Error', 'Success')
|
||||
|
||||
if isinstance(message, six.string_types):
|
||||
message = message.splitlines()
|
||||
@@ -325,7 +324,7 @@ class Terminal(object):
|
||||
s_col = (n_cols - box_width) // 2 + h_offset
|
||||
|
||||
window = curses.newwin(box_height, box_width, s_row, s_col)
|
||||
window.bkgd(str(' '), self.attr('notice_{0}'.format(style)))
|
||||
window.bkgd(str(' '), self.attr('Notice{0}'.format(style)))
|
||||
window.erase()
|
||||
window.border()
|
||||
|
||||
@@ -403,7 +402,7 @@ class Terminal(object):
|
||||
_logger.warning(stderr)
|
||||
self.show_notification(
|
||||
'Program exited with status={0}\n{1}'.format(
|
||||
code, stderr.strip()), style='error')
|
||||
code, stderr.strip()), style='Error')
|
||||
|
||||
else:
|
||||
# Non-blocking, open a background process
|
||||
@@ -731,7 +730,7 @@ class Terminal(object):
|
||||
|
||||
n_rows, n_cols = self.stdscr.getmaxyx()
|
||||
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)
|
||||
|
||||
# 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.
|
||||
"""
|
||||
# The theme must be initialized before calling this
|
||||
assert self.theme is not None
|
||||
|
||||
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):
|
||||
"""
|
||||
Set the terminal theme. This is a stub for what will eventually
|
||||
support managing custom themes.
|
||||
|
||||
Check that the terminal supports the provided theme, and applies
|
||||
the theme to the terminal if possible.
|
||||
|
||||
@@ -864,14 +876,31 @@ class Terminal(object):
|
||||
default theme. The default theme only requires 8 colors so it
|
||||
should be compatible with any terminal that supports basic colors.
|
||||
"""
|
||||
monochrome = (not curses.has_colors())
|
||||
|
||||
if theme is None or monochrome:
|
||||
theme = Theme(monochrome=monochrome)
|
||||
terminal_colors = curses.COLORS if curses.has_colors() else 0
|
||||
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()
|
||||
self.theme = theme
|
||||
|
||||
# Apply the default color to the whole screen
|
||||
self.stdscr.bkgd(str(' '), theme.get('normal'))
|
||||
|
||||
self.theme = theme
|
||||
self.stdscr.bkgd(str(' '), self.attr('Normal'))
|
||||
|
||||
616
rtv/theme.py
@@ -1,113 +1,555 @@
|
||||
"""
|
||||
This file is a stub that contains the default RTV theme.
|
||||
|
||||
This will eventually be expanded to support loading/managing custom themes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import codecs
|
||||
import curses
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from contextlib import contextmanager
|
||||
|
||||
import six
|
||||
from six.moves import configparser
|
||||
|
||||
DEFAULT_THEME = {
|
||||
'normal': (-1, -1, curses.A_NORMAL),
|
||||
'bar_level_1': (curses.COLOR_MAGENTA, -1, curses.A_NORMAL),
|
||||
'bar_level_1.selected': (curses.COLOR_MAGENTA, -1, curses.A_REVERSE),
|
||||
'bar_level_2': (curses.COLOR_CYAN, -1, curses.A_NORMAL),
|
||||
'bar_level_2.selected': (curses.COLOR_CYAN, -1, curses.A_REVERSE),
|
||||
'bar_level_3': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||
'bar_level_3.selected': (curses.COLOR_GREEN, -1, curses.A_REVERSE),
|
||||
'bar_level_4': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
|
||||
'bar_level_4.selected': (curses.COLOR_YELLOW, -1, curses.A_REVERSE),
|
||||
'comment_author': (curses.COLOR_BLUE, -1, curses.A_BOLD),
|
||||
'comment_author_self': (curses.COLOR_GREEN, -1, curses.A_BOLD),
|
||||
'comment_count': (-1, -1, curses.A_NORMAL),
|
||||
'comment_text': (-1, -1, curses.A_NORMAL),
|
||||
'created': (-1, -1, curses.A_NORMAL),
|
||||
'cursor': (-1, -1, curses.A_NORMAL),
|
||||
'cursor.selected': (-1, -1, curses.A_REVERSE),
|
||||
'downvote': (curses.COLOR_RED, -1, curses.A_BOLD),
|
||||
'gold': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||
'help_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||
'hidden_comment_expand': (-1, -1, curses.A_BOLD),
|
||||
'hidden_comment_text': (-1, -1, curses.A_NORMAL),
|
||||
'multireddit_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||
'multireddit_text': (-1, -1, curses.A_NORMAL),
|
||||
'neutral_vote': (-1, -1, curses.A_BOLD),
|
||||
'notice_info': (-1, -1, curses.A_NORMAL),
|
||||
'notice_loading': (-1, -1, curses.A_NORMAL),
|
||||
'notice_error': (-1, -1, curses.A_NORMAL),
|
||||
'notice_success': (-1, -1, curses.A_NORMAL),
|
||||
'nsfw': (curses.COLOR_RED, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||
'order_bar': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||
'order_bar.selected': (curses.COLOR_YELLOW, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||
'prompt': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||
'saved': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||
'score': (-1, -1, curses.A_NORMAL),
|
||||
'separator': (-1, -1, curses.A_BOLD),
|
||||
'stickied': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||
'subscription_name': (curses.COLOR_YELLOW, -1, curses.A_BOLD),
|
||||
'subscription_text': (-1, -1, curses.A_NORMAL),
|
||||
'submission_author': (curses.COLOR_GREEN, -1, curses.A_NORMAL),
|
||||
'submission_flair': (curses.COLOR_RED, -1, curses.A_NORMAL),
|
||||
'submission_subreddit': (curses.COLOR_YELLOW, -1, curses.A_NORMAL),
|
||||
'submission_text': (-1, -1, curses.A_NORMAL),
|
||||
'submission_title': (-1, -1, curses.A_BOLD),
|
||||
'title_bar': (curses.COLOR_CYAN, -1, curses.A_BOLD | curses.A_REVERSE),
|
||||
'upvote': (curses.COLOR_GREEN, -1, curses.A_BOLD),
|
||||
'url': (curses.COLOR_BLUE, -1, curses.A_UNDERLINE),
|
||||
'url_seen': (curses.COLOR_MAGENTA, -1, curses.A_UNDERLINE),
|
||||
'user_flair': (curses.COLOR_YELLOW, -1, curses.A_BOLD)
|
||||
}
|
||||
from .config import THEMES, DEFAULT_THEMES
|
||||
from .exceptions import ConfigError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
self._modifier = None
|
||||
self._elements = {}
|
||||
self._color_pairs = {}
|
||||
for i in range(256):
|
||||
COLOR_CODES['ansi_{0}'.format(i)] = i
|
||||
|
||||
# For compatibility with as many terminals as possible, the default theme
|
||||
# can only use the 8 basic colors with the default 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):
|
||||
"""
|
||||
Bind the theme's colors to curses's internal color pair map.
|
||||
|
||||
if self.monochrome:
|
||||
# Skip initializing the colors and just use the attributes
|
||||
self._elements = {key: val[2] for key, val in DEFAULT_THEME.items()}
|
||||
return
|
||||
This method must be called once (after curses has been initialized)
|
||||
before any element attributes can be accessed. Color codes and other
|
||||
special attributes will be mixed bitwise into a single value that
|
||||
can be passed into curses draw functions.
|
||||
"""
|
||||
self._color_pair_map = {}
|
||||
self._attribute_map = {}
|
||||
|
||||
# Shortcut for the default fg/bg
|
||||
self._color_pairs[(-1, -1)] = curses.A_NORMAL
|
||||
for element, item in self.elements.items():
|
||||
fg, bg, attrs = item
|
||||
|
||||
for key, (fg, bg, attr) in DEFAULT_THEME.items():
|
||||
# Register the color pair for the element
|
||||
if (fg, bg) not in self._color_pairs:
|
||||
index = len(self._color_pairs) + 1
|
||||
curses.init_pair(index, fg, bg)
|
||||
self._color_pairs[(fg, bg)] = curses.color_pair(index)
|
||||
color_pair = (fg, bg)
|
||||
if self.use_color and color_pair != (-1, -1):
|
||||
# Curses limits the number of available color pairs, so we
|
||||
# need to reuse them if there are multiple elements with the
|
||||
# same foreground and background.
|
||||
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 modifier:
|
||||
modified_element = '{0}.{1}'.format(element, modifier)
|
||||
if modified_element in self._elements:
|
||||
return self._elements[modified_element]
|
||||
if selected or self._selected:
|
||||
element = '@{0}'.format(element)
|
||||
|
||||
return self._elements[element]
|
||||
return self._attribute_map[element]
|
||||
|
||||
@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
|
||||
assert self._modifier is None
|
||||
For example:
|
||||
>>> 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:
|
||||
yield
|
||||
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)
|
||||
|
||||
50
rtv/themes/default.cfg.example
Normal 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
@@ -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
@@ -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
|
||||
69
rtv/themes/solarized-dark.cfg
Normal 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
|
||||
69
rtv/themes/solarized-light.cfg
Normal 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
|
||||
6363
scripts/cassettes/demo_theme.yaml
Normal file
282
scripts/demo_theme.py
Executable 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())
|
||||
|
||||
2
setup.py
@@ -59,7 +59,7 @@ setuptools.setup(
|
||||
'rtv.packages.praw'
|
||||
],
|
||||
package_data={
|
||||
'rtv': ['templates/*'],
|
||||
'rtv': ['templates/*', 'themes/*'],
|
||||
'rtv.packages.praw': ['praw.ini']
|
||||
},
|
||||
data_files=[("share/man/man1", ["rtv.1"])],
|
||||
|
||||
@@ -167,6 +167,8 @@ def stdscr():
|
||||
curses.color_pair.return_value = 23
|
||||
curses.has_colors.return_value = True
|
||||
curses.ACS_VLINE = 0
|
||||
curses.COLORS = 256
|
||||
curses.COLOR_PAIRS = 256
|
||||
yield out
|
||||
|
||||
|
||||
@@ -199,6 +201,7 @@ def reddit(vcr, request):
|
||||
@pytest.fixture()
|
||||
def terminal(stdscr, config):
|
||||
term = Terminal(stdscr, config=config)
|
||||
term.set_theme()
|
||||
# Disable the python 3.4 addch patch so that the mock stdscr calls are
|
||||
# always made the same way
|
||||
term.addch = lambda window, *args: window.addch(*args)
|
||||
|
||||
@@ -89,7 +89,9 @@ def test_config_get_args():
|
||||
'--non-persistent',
|
||||
'--clear-auth',
|
||||
'--copy-config',
|
||||
'--enable-media']
|
||||
'--enable-media',
|
||||
'--theme', 'molokai',
|
||||
'--list-themes']
|
||||
|
||||
with mock.patch('sys.argv', ['rtv']):
|
||||
config_dict = Config.get_args()
|
||||
@@ -111,6 +113,8 @@ def test_config_get_args():
|
||||
assert config['config'] == 'configfile.cfg'
|
||||
assert config['copy_config'] is True
|
||||
assert config['enable_media'] is True
|
||||
assert config['theme'] == 'molokai'
|
||||
assert config['list_themes'] is True
|
||||
|
||||
|
||||
def test_config_link_deprecated():
|
||||
@@ -143,7 +147,8 @@ def test_config_from_file():
|
||||
'subreddit': 'cfb',
|
||||
'enable_media': True,
|
||||
'max_comment_cols': 150,
|
||||
'hide_username': True}
|
||||
'hide_username': True,
|
||||
'theme': 'molokai'}
|
||||
|
||||
bindings = {
|
||||
'REFRESH': 'r, <KEY_F5>',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import curses
|
||||
|
||||
import pytest
|
||||
|
||||
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')
|
||||
page.controller.trigger('u')
|
||||
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
|
||||
|
||||
|
||||
@@ -575,27 +575,81 @@ def test_add_space(terminal, stdscr):
|
||||
|
||||
def test_attr(terminal):
|
||||
|
||||
assert terminal.attr('cursor') == 0
|
||||
assert terminal.attr('cursor.selected') == curses.A_REVERSE
|
||||
assert terminal.attr('neutral_vote') == curses.A_BOLD
|
||||
assert terminal.attr('CursorBlock') == 0
|
||||
assert terminal.attr('@CursorBlock') == curses.A_REVERSE
|
||||
assert terminal.attr('NeutralVote') == curses.A_BOLD
|
||||
|
||||
with terminal.theme.set_modifier('selected'):
|
||||
assert terminal.attr('cursor') == curses.A_REVERSE
|
||||
assert terminal.attr('neutral_vote') == curses.A_BOLD
|
||||
with terminal.theme.turn_on_selected():
|
||||
assert terminal.attr('CursorBlock') == curses.A_REVERSE
|
||||
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):
|
||||
|
||||
# Default with color enabled
|
||||
stdscr.reset_mock()
|
||||
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.reset_mock()
|
||||
theme = Theme(monochrome=True)
|
||||
terminal.set_theme(theme=theme)
|
||||
assert terminal.theme.monochrome
|
||||
stdscr.bkgd.assert_called_once_with(' ', 0)
|
||||
# When the user passes in the --monochrome flag
|
||||
terminal.theme = None
|
||||
terminal.set_theme(Theme(use_color=False))
|
||||
assert not terminal.theme.use_color
|
||||
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):
|
||||
@@ -605,8 +659,7 @@ def test_set_theme_no_colors(terminal, stdscr):
|
||||
has_colors.return_value = False
|
||||
|
||||
terminal.set_theme()
|
||||
assert terminal.theme.monochrome
|
||||
assert not terminal.theme.use_color
|
||||
|
||||
theme = Theme(monochrome=False)
|
||||
terminal.set_theme(theme=theme)
|
||||
assert terminal.theme.monochrome
|
||||
terminal.set_theme(Theme(use_color=True))
|
||||
assert not terminal.theme.use_color
|
||||
276
tests/test_theme.py
Normal 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
|
||||